diff --git a/design/pap-14557-monitor-visibility/.paperclip-page/state.json b/design/pap-14557-monitor-visibility/.paperclip-page/state.json new file mode 100644 index 0000000000..1e3344d4d9 --- /dev/null +++ b/design/pap-14557-monitor-visibility/.paperclip-page/state.json @@ -0,0 +1,10 @@ +{ + "bucket": "pages.paperclip.ing", + "prefix": "pap-14557-monitor-visibility/", + "slug": "pap-14557-monitor-visibility", + "url": "https://pages.paperclip.ing/pap-14557-monitor-visibility/", + "baseUrl": "https://pages.paperclip.ing", + "publishedAt": "2026-07-17T15:51:31Z", + "sourceHash": "4d276364eb4e122d782247da6f1f0996763c12175ec0d7efa8ac7a0568fb6f2c", + "version": 1 +} diff --git a/design/pap-14557-monitor-visibility/index.html b/design/pap-14557-monitor-visibility/index.html new file mode 100644 index 0000000000..28963350dd --- /dev/null +++ b/design/pap-14557-monitor-visibility/index.html @@ -0,0 +1,420 @@ + + + + + +PAP-14557 — Monitor visibility wireframes + + + +
+
+ + + PAP-14557 · Monitor visibility
+ Jump to a screen +
+ +
+ +
+ +
+
+
PAP-14557 · Wireframes
+

Make a waiting monitor obvious

+

When an issue has a monitor armed, nothing on the page says so unless you squint at a truncated properties row. These wireframes surface the same monitor state in three places — a banner at the top of the task page, a strip directly above the reply composer, and a readable two-line properties row with a hover tooltip for full details. Every surface shows both the exact time and how far away it is from now.

+
+ 4 screens + 2 mobile + Desktop + mobile + Lo-fi · monochrome + Click any wireframe to zoom +
+
+ +
+
Flow
+

One monitor state, three surfaces

+

The banner, the composer strip, and the properties row are all fed by the same state + copy rules (screen 04), so they can never disagree. Red dashed marks are annotation callouts, not UI.

+
+
flow.svg1280×304
+ Flow diagram of the three monitor surfaces +
+
+ +
+
Step 1 · Task page top
+

1.Monitor banner under the title

+

A full-width banner sits between the issue title and the description whenever a monitor is scheduled, due, or overdue. Headline leads with the relative time ("resumes in 2h 12m"); the second line gives the exact local time, the attempt count, and what is being watched. "Check now" fires the monitor immediately; "Edit" opens the existing monitor editor.

+
+
+
01-issue-top.svg1280×800 · desktop
+ Task page top with monitor banner (desktop) wireframe +
+
+
mobile375×812
+ Task page with monitor banner (mobile) wireframe +
+
+
+

Annotations

+
    +
  • 1 — Monitor banner: bold relative time first, exact time + attempt + watched service second, actions on the right. On mobile the actions drop below the text.
  • +
  • 2 — Properties Monitor row switches to the two-line layout (detail on screen 3). On mobile (callouts 2–3 on the phone frame) the collapsed properties bar carries a compact "in 2h 12m" chip and the strip repeats above the composer.
  • +
+
Why this changes: today the only always-visible signal is the truncated properties row; the existing "Monitor scheduled" card renders in the description area where it scrolls away and is easy to miss. The banner is pinned to the page header area so the very first glance answers "why is nothing happening, and when will it?"
+
+
+
+
+ +
+
Step 2 · Thread bottom
+

2.Strip above the reply composer

+

The bottom of the thread is where you go to ask "what's happening?" — so the answer sits right there. A compact strip directly above the composer repeats the resume time (exact + relative + attempt) with a "Check now" action, and a one-line hint under the composer explains that replying wakes the agent before the scheduled check.

+
+
+
02-composer.svg1280×800 · desktop
+ Monitor strip above composer (desktop) wireframe +
+
+
mobile375×812
+ Monitor strip above composer (mobile) wireframe +
+
+
+
Referencecurrent thread bottom
+ Current thread bottom with no monitor indication +
+
+

Annotations

+
    +
  • 1 — Monitor strip: anchored to the composer (scrolls with it), not part of the comment list. Same copy system as the banner.
  • +
  • 2 — Wake hint under the composer: sets the expectation that sending a message preempts the scheduled check.
  • +
+
Why this changes: in the reference screenshot the last comment says a monitor is armed only in prose ("issue monitor fires 16:08Z"), and once more comments land even that disappears. The strip keeps the resume time visible at the exact spot where a human decides whether to wait or to nudge.
+
+
+
+
+ +
+
Step 3 · Properties pane
+

3.Readable Monitor row + details tooltip

+

The properties row stops cramming label, date, and attempt onto one colliding line. Collapsed: two lines — bold relative time, then muted "absolute time · Attempt N". Hover (tap on mobile) opens a details card with the full timestamp incl. timezone, watched service, notes, last-trigger info, and quick actions. Click still opens the existing editor popover.

+
+
+
03-properties-monitor.svg1280×800 · component sheet
+ Properties monitor row redesign wireframe +
+
+
+
Referencecurrent properties row
+ Current cramped monitor properties row +
+
+

Annotations

+
    +
  • 1 — Current failure: "Next check" truncates to "Ne…", the date shows the year but no time, and "Attempt 1" wraps into a two-line crumb.
  • +
  • 2 — Proposed collapsed row: relative time is the headline; absolute time and attempt live on a second muted line sized for the 352px pane.
  • +
  • 3 — Hover/tap tooltip: exact timestamp with timezone, watched service, notes, last-trigger state, and Check now / Edit / Clear without opening the editor.
  • +
+
Why this changes: the row currently tries to show four facts in one 200px slot, so none of them are readable. Two lines fit the pane; everything else moves to a tooltip you summon only when you care about specifics.
+
+
+
+
+ +
+
Step 4 · System
+

4.State matrix and copy rules

+

All three surfaces share one formatter. Relative time always leads and uses two units under 24h ("in 2h 12m", not "in 2h" — today's single-unit rounding is why the current UI feels vague). Overdue and due-now states get explicit copy instead of silently showing a past date.

+
+
+
04-states.svg1280×800 · component sheet
+ Monitor state matrix wireframe +
+
+
+

States covered

+
    +
  • Scheduled — under 1h / under 24h / beyond today.
  • +
  • Retrying — attempt count > 1 gets visual weight.
  • +
  • Due now / overdue — explicit copy, never a stale-looking past date.
  • +
  • Cleared / none — muted, no clock icon, banner and strip hidden.
  • +
+
Why this changes: a single copy system means the banner, strip, and row can never disagree about when the task resumes, and edge states (overdue, cleared) stop looking like bugs.
+
+
+
+
+ +
+
For reviewer
+

Open questions

+
+
    +
  • Banner vs. existing card — there is already an "IssueMonitorActivityCard" that renders in the description area. Proposal: promote/replace it with the pinned banner (screen 1) rather than showing both. OK?
  • +
  • Strip stickiness — should the composer strip scroll with the thread (inline, as drawn) or stick to the composer even mid-scroll? Drawn as anchored to the composer.
  • +
  • Relative-time precision — adopt the two-unit format ("in 2h 12m") everywhere, including the existing scheduled-retry row which uses the same formatter?
  • +
  • Hover vs. click — the properties row currently opens the editor on click. Proposal keeps click = editor, hover/tap = read-only details tooltip. Fine, or should tap open the editor directly on mobile?
  • +
  • Live countdown — should the relative time tick live (re-render every minute) or is on-load accuracy enough?
  • +
+
+
+ + +
+
+ + + + + + diff --git a/design/pap-14557-monitor-visibility/screenshots/current-properties-monitor.png b/design/pap-14557-monitor-visibility/screenshots/current-properties-monitor.png new file mode 100644 index 0000000000..f3255e33fe Binary files /dev/null and b/design/pap-14557-monitor-visibility/screenshots/current-properties-monitor.png differ diff --git a/design/pap-14557-monitor-visibility/screenshots/current-thread-bottom.png b/design/pap-14557-monitor-visibility/screenshots/current-thread-bottom.png new file mode 100644 index 0000000000..9e87ca9546 Binary files /dev/null and b/design/pap-14557-monitor-visibility/screenshots/current-thread-bottom.png differ diff --git a/design/pap-14557-monitor-visibility/wireframes/01-issue-top-mobile.svg b/design/pap-14557-monitor-visibility/wireframes/01-issue-top-mobile.svg new file mode 100644 index 0000000000..aeb3f5f851 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/01-issue-top-mobile.svg @@ -0,0 +1,105 @@ + + + + + + + + PAP-14557 + + + + Nightly model comparison sweep + + + In Progress + + + + + + + + + Waiting on monitor — in 2h 12m + Thu Jul 17, 4:08 PM · Attempt 1 + + + Check now + + + + + + + + + + + + + + + Senior Engineer Fable + 15m ago + + + + + + + + + Properties + + + + in 2h 12m + + + + + + + + + + Resumes in 2h 12m + Jul 17, 4:08 PM · Attempt 1 + Check now + + + + + + Reply… + + + + + + + Send + + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + diff --git a/design/pap-14557-monitor-visibility/wireframes/01-issue-top.svg b/design/pap-14557-monitor-visibility/wireframes/01-issue-top.svg new file mode 100644 index 0000000000..cbf09bab1e --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/01-issue-top.svg @@ -0,0 +1,100 @@ + + + + + + + Issues / + PAP-14557 + + + + + + + Nightly model comparison sweep + + + In Progress + + + + + + + + + + Waiting on monitor — resumes in 2h 12m + Thu Jul 17, 4:08 PM (your time) · Attempt 1 · Watching: vercel-deploy + + + Check now + + + + Edit + + + + + + + + + + + + Activity + + + + + + Senior Engineer Fable + 15m ago + + + + + + ⌄ thread continues — see screen 02 for the composer + + + PROPERTIES + Status + In Progress + Priority + Medium + Assignee + + Fable + Approvers + None + + + Monitor + + + + In 2h 12m + Jul 17, 4:08 PM · Attempt 1 + + Watchdog + None + + + + + + + 1 + + + + + 2 + + + diff --git a/design/pap-14557-monitor-visibility/wireframes/02-composer-mobile.svg b/design/pap-14557-monitor-visibility/wireframes/02-composer-mobile.svg new file mode 100644 index 0000000000..895d8be526 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/02-composer-mobile.svg @@ -0,0 +1,74 @@ + + + + + + + + PAP-14557 + + + ⌃ earlier comments + + + + Senior Engineer Fable + 15m ago + + + + Next action (armed): + + + + + + + + Previous · PAP-14544 + Phase 3 — rescore… + + + + + + + + + Resumes in 2h 12m + Thu Jul 17, 4:08 PM · Attempt 1 + + + Check now + + + + + + + Reply… + + + + + + + Agent mode + + + + + Send + + + + + + + + + 1 + + + diff --git a/design/pap-14557-monitor-visibility/wireframes/02-composer.svg b/design/pap-14557-monitor-visibility/wireframes/02-composer.svg new file mode 100644 index 0000000000..cc417d0f53 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/02-composer.svg @@ -0,0 +1,100 @@ + + + + + + + Issues / + PAP-14557 + + + + + PROPERTIES + Status + In Progress + Monitor + + + + In 2h 12m + Jul 17, 4:08 PM · Attempt 1 + + + ⌃ earlier comments + + + + Senior Engineer Fable + 15m ago + + + + Next action (armed): + + + + + + + + + Previous · PAP-14544 + Phase 3 — rescore + gap re-runs… + + + + + + + + + Resumes in 2h 12m + Thu Jul 17, 4:08 PM · Attempt 1 · Watching: vercel-deploy + + + Check now + + + + + + + Reply… + + + + + + + Agent mode + + + + + Senior Engineer Fable + + + + Send + + + + + Sending a reply wakes the agent now — before the scheduled check. + + + + + + + 1 + + + + + 2 + + + diff --git a/design/pap-14557-monitor-visibility/wireframes/03-properties-monitor.svg b/design/pap-14557-monitor-visibility/wireframes/03-properties-monitor.svg new file mode 100644 index 0000000000..bb39e5f302 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/03-properties-monitor.svg @@ -0,0 +1,121 @@ + + + + + Properties pane — Monitor row redesign + Side pane fragment, 352px wide. The same row is reused in the mobile properties sheet. + + + A · Current — cramped, truncated + + + Approvers + None + + Monitor + + + + Ne… + Jul 17, 2026 + Attempt + 1 + + Watchdog + None + + Label truncates to “Ne…”, date is ambiguous (which year matters, + not the time?), attempt badge wraps into two lines. + + + B · Proposed — two-line row + + + Approvers + None + + Monitor + + + + In 2h 12m + Jul 17, 4:08 PM · Attempt 1 + + Watchdog + None + + Relative time leads and is bold; absolute time + attempt get their + own muted line. Nothing truncates at 352px. Row grows to 2 lines. + + + C · Hover / tap — full details + + + + Monitor + + + + In 2h 12m + Jul 17, 4:08 PM · Attempt 1 + + + + + + Monitor + Attempt 1 + + + Next check + Thu, Jul 17, 2026 · 4:08 PM PDT + in 2h 12m + + Watching + vercel-deploy + + Notes + Verify completion, commit ledger / + scores, extend comparison site. + + Last triggered + — not yet triggered + + + + + Check now + + + + Edit + + + + Clear + + + + Hover on desktop, tap on mobile. Exact timestamp with timezone, + service, notes and quick actions — no digging into the edit popover. + + + + + + + 1 + + + + + 2 + + + + + 3 + + + diff --git a/design/pap-14557-monitor-visibility/wireframes/04-states.svg b/design/pap-14557-monitor-visibility/wireframes/04-states.svg new file mode 100644 index 0000000000..105dbc4b84 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/04-states.svg @@ -0,0 +1,103 @@ + + + + + Monitor row — state matrix + One copy system across banner, composer strip and properties row. Relative time always leads. + + + + + SCHEDULED · UNDER 1 HOUR + Monitor + + + + In 24m + Today, 2:20 PM + + + + + + SCHEDULED · UNDER 24 HOURS + Monitor + + + + In 2h 12m + Today, 4:08 PM + + + + + + SCHEDULED · BEYOND TODAY + Monitor + + + + In 3d + Mon Jul 20, 9:00 AM + + + + + + RETRYING · ATTEMPT > 1 + Monitor + + + + In 2h 12m + Today, 4:08 PM · + Attempt 3 + + + + + + DUE NOW + Monitor + + + + Due now + checking momentarily… + + + + + + OVERDUE + Monitor + + + + Overdue by 18m + Jul 17, 1:38 PM · fires on next tick + + + + + + CLEARED + Monitor + Cleared + 2h ago · after attempt 2 + + + + + + NONE + Monitor + None + + + + Copy rules + < 1h → “In 24m” · < 24h → “In 2h 12m” (two units, not “in 2h”) · beyond → “In 3d” + weekday date · past due → “Overdue by …”. + Absolute time says “Today” when it is today; adds the year only when it is not the current year. Banner + strip render only in scheduled / due / overdue states. + diff --git a/design/pap-14557-monitor-visibility/wireframes/flow.svg b/design/pap-14557-monitor-visibility/wireframes/flow.svg new file mode 100644 index 0000000000..3e6d9403c2 --- /dev/null +++ b/design/pap-14557-monitor-visibility/wireframes/flow.svg @@ -0,0 +1,65 @@ + + + + One monitor state, three surfaces — banner (top), strip (composer), row (properties) — all fed by the same copy rules (04). + + + + + + + + + + + + + 01 · Task page top — banner + + + + + + + + + + + + + + + 02 · Above the composer — strip + + + + + + + + + + + + + + + + + + 03 · Properties row + tooltip + + + + + + + + + + + + + 04 · State matrix + copy rules + diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index a23f16d90d..54cdb577dc 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -527,6 +527,8 @@ interface IssueChatThreadProps { verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], ) => Promise | void; composerRef?: Ref; + /** Optional node rendered inline directly above the sticky composer dock (e.g. the monitor strip). */ + composerAccessory?: ReactNode; issueWorkMode?: IssueWorkMode; /** * Hook for the parent to refetch comments when the user explicitly asks @@ -4249,6 +4251,7 @@ export function IssueChatThread({ onCancelInteraction, onSubmitInteractionVerdicts, composerRef, + composerAccessory, issueWorkMode, onWorkModeChange, onRefreshLatestComments, @@ -5003,6 +5006,12 @@ export function IssueChatThread({ + {showComposer && composerAccessory ? ( +
+ {composerAccessory} +
+ ) : null} + {showComposer ? (
= {}): Issue { - return { - id: "issue-1", - companyId: "company-1", - projectId: null, - projectWorkspaceId: null, - goalId: null, - parentId: null, - title: "Watch deploy", - description: null, - status: "in_progress", - priority: "medium", - assigneeAgentId: "agent-1", - assigneeUserId: null, - responsibleUserId: null, - checkoutRunId: null, - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - createdByAgentId: null, - createdByUserId: "local-board", - issueNumber: 1, - identifier: "PAP-1", - requestDepth: 0, - billingCode: null, - assigneeAdapterOverrides: null, - executionPolicy: { - mode: "normal", - commentRequired: true, - stages: [], - monitor: { - nextCheckAt: "2026-04-11T12:30:00.000Z", - notes: "Check deployment health", - scheduledBy: "board", - }, - }, - executionState: { - status: "idle", - currentStageId: null, - currentStageIndex: null, - currentStageType: null, - currentParticipant: null, - returnAssignee: null, - reviewRequest: null, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - monitor: { - status: "scheduled", - nextCheckAt: "2026-04-11T12:30:00.000Z", - lastTriggeredAt: null, - attemptCount: 0, - notes: "Check deployment health", - scheduledBy: "board", - clearedAt: null, - clearReason: null, - }, - }, - monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"), - monitorLastTriggeredAt: null, - monitorAttemptCount: 0, - monitorNotes: "Check deployment health", - monitorScheduledBy: "board", - executionWorkspaceId: null, - executionWorkspacePreference: null, - executionWorkspaceSettings: null, - startedAt: null, - completedAt: null, - cancelledAt: null, - hiddenAt: null, - createdAt: new Date("2026-04-11T10:00:00.000Z"), - updatedAt: new Date("2026-04-11T10:00:00.000Z"), - ...overrides, - workMode: overrides.workMode ?? "standard", - }; -} - -describe("IssueMonitorActivityCard", () => { - let container: HTMLDivElement; - - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-11T12:00:00.000Z")); - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - vi.useRealTimers(); - container.remove(); - }); - - it("renders the scheduled monitor details and check-now action", () => { - const onCheckNow = vi.fn(); - const root = createRoot(container); - - act(() => { - root.render(); - }); - - expect(container.textContent).toContain("Monitor scheduled"); - expect(container.textContent).toContain("Next check"); - expect(container.textContent).toContain("in 30m"); - expect(container.textContent).toContain("Check deployment health"); - - const button = Array.from(container.querySelectorAll("button")).find((candidate) => - candidate.textContent?.includes("Check now"), - ); - expect(button).toBeTruthy(); - - act(() => { - button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - - expect(onCheckNow).toHaveBeenCalledTimes(1); - - act(() => root.unmount()); - }); - - it("does not render external references from monitor metadata", () => { - const root = createRoot(container); - - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toContain("Deploy provider"); - expect(container.textContent).not.toContain("provider.example"); - expect(container.textContent).not.toContain("token=secret"); - - act(() => root.unmount()); - }); - - it("renders without throwing when monitorNextCheckAt arrives as an ISO string", () => { - const root = createRoot(container); - - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toContain("Monitor scheduled"); - expect(container.textContent).toContain("Next check"); - expect(container.textContent).toContain("in 30m"); - - act(() => root.unmount()); - }); - - it("renders nothing when the issue has no scheduled monitor", () => { - const root = createRoot(container); - - act(() => { - root.render( - , - ); - }); - - expect(container.textContent).toBe(""); - - act(() => root.unmount()); - }); -}); diff --git a/ui/src/components/IssueMonitorActivityCard.tsx b/ui/src/components/IssueMonitorActivityCard.tsx deleted file mode 100644 index 58764a93ee..0000000000 --- a/ui/src/components/IssueMonitorActivityCard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { Issue } from "@paperclipai/shared"; -import { Button } from "@/components/ui/button"; -import { formatMonitorOffset } from "@/lib/issue-monitor"; -import { formatDateTime } from "@/lib/utils"; - -function resolveScheduledMonitor(issue: Issue) { - const nextCheckAt = - issue.monitorNextCheckAt ?? - issue.executionPolicy?.monitor?.nextCheckAt ?? - issue.executionState?.monitor?.nextCheckAt ?? - null; - if (!nextCheckAt) return null; - - return { - nextCheckAt, - notes: issue.executionPolicy?.monitor?.notes ?? issue.monitorNotes ?? issue.executionState?.monitor?.notes ?? null, - attemptCount: issue.monitorAttemptCount ?? issue.executionState?.monitor?.attemptCount ?? 0, - serviceName: issue.executionPolicy?.monitor?.serviceName ?? issue.executionState?.monitor?.serviceName ?? null, - }; -} - -interface IssueMonitorActivityCardProps { - issue: Issue; - onCheckNow?: (() => void) | null; - checkingNow?: boolean; -} - -export function IssueMonitorActivityCard({ - issue, - onCheckNow = null, - checkingNow = false, -}: IssueMonitorActivityCardProps) { - const monitor = resolveScheduledMonitor(issue); - if (!monitor) return null; - - return ( -
-
-
-
Monitor scheduled
-
- Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)}) -
- {monitor.notes ? ( -
{monitor.notes}
- ) : null} - {monitor.serviceName ? ( -
- {monitor.serviceName} -
- ) : null} - {monitor.attemptCount > 0 ? ( -
Attempt {monitor.attemptCount}
- ) : null} -
- {onCheckNow ? ( - - ) : null} -
-
- ); -} diff --git a/ui/src/components/IssueMonitorBanner.test.tsx b/ui/src/components/IssueMonitorBanner.test.tsx new file mode 100644 index 0000000000..53c9f0fc7b --- /dev/null +++ b/ui/src/components/IssueMonitorBanner.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { Issue } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + IssueMonitorBanner, + IssueMonitorComposerStrip, + buildMonitorSurfaceCopy, + hasVisibleMonitorSurface, +} from "./IssueMonitorBanner"; +import type { DerivedMonitorState } from "@/lib/issue-monitor"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const NOW = new Date("2026-07-17T20:00:00.000Z"); + +function derived(overrides: Partial & { state: DerivedMonitorState["state"] }): DerivedMonitorState { + return { + source: "monitor", + nextCheckAt: null, + attemptCount: 0, + serviceName: null, + ...overrides, + }; +} + +describe("buildMonitorSurfaceCopy", () => { + it("leads with two-unit relative time while scheduled", () => { + const copy = buildMonitorSurfaceCopy( + derived({ + state: "scheduled", + nextCheckAt: new Date(NOW.getTime() + (2 * 60 + 12) * 60_000).toISOString(), + attemptCount: 1, + serviceName: "vercel-deploy", + }), + NOW, + ); + + expect(copy).not.toBeNull(); + expect(copy!.bannerTitle).toBe("Waiting on monitor — resumes in 2h 12m"); + expect(copy!.stripTitle).toBe("Resumes in 2h 12m"); + expect(copy!.tone).toBe("info"); + expect(copy!.bannerMeta).toContain("Attempt 1"); + expect(copy!.bannerMeta).toContain("Watching: vercel-deploy"); + // Absolute time carries the "(your time)" hint on the banner only. + expect(copy!.bannerMeta.some((piece) => piece.includes("(your time)"))).toBe(true); + expect(copy!.stripMeta.some((piece) => piece.includes("(your time)"))).toBe(false); + }); + + it("keeps the retrying attempt count visible", () => { + const copy = buildMonitorSurfaceCopy( + derived({ + state: "retrying", + nextCheckAt: new Date(NOW.getTime() + 90 * 60_000).toISOString(), + attemptCount: 3, + }), + NOW, + ); + expect(copy!.stripTitle).toBe("Resumes in 1h 30m"); + expect(copy!.stripMeta).toContain("Attempt 3"); + }); + + it("uses agent copy for scheduled retries without a monitor", () => { + const copy = buildMonitorSurfaceCopy( + derived({ + state: "retrying", + source: "scheduled-retry", + nextCheckAt: new Date(NOW.getTime() + 90 * 60_000).toISOString(), + attemptCount: 2, + }), + NOW, + ); + + expect(copy!.bannerTitle).toBe("Agent resumes in 1h 30m"); + expect(copy!.stripTitle).toBe("Resumes in 1h 30m"); + }); + + it("switches copy for due-now and overdue states", () => { + const dueNow = buildMonitorSurfaceCopy( + derived({ state: "due-now", nextCheckAt: NOW.toISOString(), attemptCount: 1 }), + NOW, + ); + expect(dueNow!.bannerTitle).toBe("Waiting on monitor — due now"); + expect(dueNow!.stripTitle).toBe("Due now"); + expect(dueNow!.bannerMeta).toContain("Checking momentarily…"); + expect(dueNow!.tone).toBe("info"); + + const overdue = buildMonitorSurfaceCopy( + derived({ + state: "overdue", + nextCheckAt: new Date(NOW.getTime() - 18 * 60_000).toISOString(), + attemptCount: 2, + }), + NOW, + ); + expect(overdue!.bannerTitle).toBe("Waiting on monitor — overdue by 18m"); + expect(overdue!.stripTitle).toBe("Overdue by 18m"); + expect(overdue!.bannerMeta).toContain("Fires on next tick"); + expect(overdue!.tone).toBe("warning"); + }); + + it("hides both surfaces when cleared, none, or without a next check", () => { + expect(buildMonitorSurfaceCopy(derived({ state: "cleared", attemptCount: 2 }), NOW)).toBeNull(); + expect(buildMonitorSurfaceCopy(derived({ state: "none" }), NOW)).toBeNull(); + // A "scheduled" state with no timestamp cannot render an ETA — hide. + expect(buildMonitorSurfaceCopy(derived({ state: "scheduled", nextCheckAt: null }), NOW)).toBeNull(); + }); +}); + +describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + vi.useRealTimers(); + container.remove(); + }); + + function issueWithMonitor(nextCheckAt: string | null): Issue { + return { + executionState: nextCheckAt + ? { monitor: { status: "scheduled", nextCheckAt, attemptCount: 1, serviceName: "vercel-deploy" } } + : null, + scheduledRetry: null, + } as unknown as Issue; + } + + it("renders the banner with a working Check now button while waiting", () => { + const onCheckNow = vi.fn(); + expect(hasVisibleMonitorSurface(issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString()))).toBe(true); + const root = createRoot(container); + flushSync(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Waiting on monitor — resumes in 2h"); + expect(container.textContent).toContain("Watching: vercel-deploy"); + + const button = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Check now"), + ); + expect(button).toBeTruthy(); + flushSync(() => button?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onCheckNow).toHaveBeenCalledTimes(1); + + flushSync(() => root.unmount()); + }); + + it("hides the banner and strip when there is no monitor", () => { + expect(hasVisibleMonitorSurface(issueWithMonitor(null))).toBe(false); + const root = createRoot(container); + flushSync(() => { + root.render( + <> + + + , + ); + }); + expect(container.textContent).toBe(""); + flushSync(() => root.unmount()); + }); + + it("renders the composer strip with the reply-wakes-agent hint", () => { + const root = createRoot(container); + flushSync(() => { + root.render( + , + ); + }); + + expect(container.querySelector("[data-testid='issue-monitor-composer-strip']")).toBeTruthy(); + expect(container.textContent).toContain("Resumes in 2h"); + expect(container.textContent).toContain("Sending a reply wakes the agent now"); + + flushSync(() => root.unmount()); + }); +}); diff --git a/ui/src/components/IssueMonitorBanner.tsx b/ui/src/components/IssueMonitorBanner.tsx new file mode 100644 index 0000000000..5208a3431e --- /dev/null +++ b/ui/src/components/IssueMonitorBanner.tsx @@ -0,0 +1,211 @@ +import { useMemo } from "react"; +import { Clock } from "lucide-react"; +import type { Issue } from "@paperclipai/shared"; + +import { Button } from "@/components/ui/button"; +import { InlineBanner } from "@/components/InlineBanner"; +import { cn } from "@/lib/utils"; +import { + 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"; +} + +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, +): MonitorSurfaceCopy | null { + if (!isWaitingMonitorState(derived.state) || !derived.nextCheckAt) return null; + + 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" + 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): 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(() => deriveMonitorState(issue).nextCheckAt, [issue]); + const now = useMonitorCountdown(nextCheckAt); + return useMemo(() => buildMonitorSurfaceCopy(deriveMonitorState(issue, now), now), [issue, now]); +} + +function CheckNowButton({ + onCheckNow, + checkingNow, +}: { + onCheckNow: () => void; + checkingNow: boolean; +}) { + return ( + + ); +} + +export interface IssueMonitorSurfaceProps { + issue: Issue; + onCheckNow?: (() => void) | null; + checkingNow?: boolean; +} + +/** + * 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, +}: IssueMonitorSurfaceProps) { + const copy = useMonitorSurfaceCopy(issue); + if (!copy) return null; + + return ( + : null} + > + {copy.bannerMeta.join(" · ")} + + ); +} + +/** + * 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, + className, +}: IssueMonitorSurfaceProps & { className?: string }) { + const copy = useMonitorSurfaceCopy(issue); + if (!copy) return null; + + return ( +
+
+
+
+ {onCheckNow ? : null} +
+

+ Sending a reply wakes the agent now — before the scheduled check. +

+
+ ); +} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index f441a6ecec..4e1f2c46ef 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -1948,6 +1948,7 @@ describe("IssueProperties", () => { }); it("renders monitor controls and clears an existing monitor", async () => { + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-04-11T10:00:00.000Z").getTime()); const onUpdate = vi.fn(); const root = renderProperties(container, { issue: createIssue({ @@ -1987,12 +1988,11 @@ describe("IssueProperties", () => { await flush(); expect(container.textContent).toContain("Monitor"); - expect(container.textContent).toContain("Next check"); + expect(container.textContent).toContain("In 2h 30m"); expect(container.querySelector('input[type="datetime-local"]')).toBeNull(); expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull(); - const monitorTrigger = Array.from(container.querySelectorAll("button")) - .find((button) => button.textContent?.includes("Next check")); + const monitorTrigger = container.querySelector('[data-testid="monitor-row-trigger"]')?.closest("button"); expect(monitorTrigger).not.toBeUndefined(); await act(async () => { @@ -2025,6 +2025,100 @@ describe("IssueProperties", () => { }); act(() => root.unmount()); + dateNowSpy.mockRestore(); + }); + + it("renders scheduled, retrying, due, overdue, cleared, and empty monitor row states", async () => { + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(new Date("2026-07-17T13:56:00.000Z").getTime()); + const baseMonitorState = { + status: "scheduled" as const, + nextCheckAt: "2026-07-17T16:08:00.000Z", + lastTriggeredAt: null, + attemptCount: 1, + notes: "Verify deployment", + scheduledBy: "board" as const, + clearedAt: null, + clearReason: null, + }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const root = createRoot(container); + const monitorRowText = () => container.querySelector('[data-testid="monitor-row-trigger"]')?.textContent; + const renderMonitor = (issue: Issue) => { + act(() => { + root.render( + + + , + ); + }); + }; + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }), + executionState: createExecutionState({ monitor: baseMonitorState }), + monitorAttemptCount: 1, + })); + await flush(); + expect(monitorRowText()).toContain("In 2h 12m"); + expect(monitorRowText()).toContain("Today, 4:08 PM · Attempt 1"); + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T18:08:00.000Z" } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T16:08:00.000Z" } }), + monitorNextCheckAt: new Date("2026-07-17T17:08:00.000Z"), + })); + await flush(); + expect(monitorRowText()).toContain("In 2h 12m"); + expect(monitorRowText()).toContain("Today, 4:08 PM"); + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, serviceName: "vercel-deploy" } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, attemptCount: 3 } }), + monitorAttemptCount: 3, + })); + await flush(); + expect(monitorRowText()).toContain("Attempt 3"); + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:56:00.000Z" } }), + })); + await flush(); + expect(monitorRowText()).toContain("Due now"); + expect(monitorRowText()).toContain("checking momentarily…"); + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }), + executionState: createExecutionState({ monitor: { ...baseMonitorState, nextCheckAt: "2026-07-17T13:38:00.000Z" } }), + })); + await flush(); + expect(monitorRowText()).toContain("Overdue by 18m"); + expect(monitorRowText()).toContain("Today, 1:38 PM · fires on next tick"); + + renderMonitor(createIssue({ + executionPolicy: createExecutionPolicy(), + executionState: createExecutionState({ monitor: { + ...baseMonitorState, + status: "cleared", + nextCheckAt: null, + lastTriggeredAt: "2026-07-17T11:56:00.000Z", + attemptCount: 2, + clearedAt: "2026-07-17T12:00:00.000Z", + clearReason: "manual", + } }), + monitorAttemptCount: 2, + monitorLastTriggeredAt: new Date("2026-07-17T11:56:00.000Z"), + })); + await flush(); + expect(monitorRowText()).toContain("Cleared"); + expect(monitorRowText()).toContain("last checked 2h ago · after attempt 2"); + + renderMonitor(createIssue()); + await flush(); + expect(monitorRowText()).toContain("None"); + + act(() => root.unmount()); + dateNowSpy.mockRestore(); }); const watchdogAgent = { diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 1a524e0648..1da295b16e 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -26,7 +26,14 @@ import { getRecentProjectIds, trackRecentProject } from "../../lib/recent-projec import { orderItemsBySelectedAndRecent } from "../../lib/recent-selections"; import { formatAssigneeUserLabel, formatUserLabel } from "../../lib/assignees"; import { buildExecutionPolicy, stageParticipantValues } from "../../lib/issue-execution-policy"; -import { formatMonitorOffset } from "../../lib/issue-monitor"; +import { + formatMonitorAbsolute, + formatMonitorAbsoluteFull, + formatMonitorEta, + formatMonitorEtaLabel, + formatMonitorOffset, + useMonitorCountdown, +} from "../../lib/issue-monitor"; import { extractProviderIdWithFallback } from "../../lib/model-utils"; import { formatRetryReason } from "../../lib/runRetryState"; import { useRetryNowMutation } from "../../hooks/useRetryNowMutation"; @@ -44,6 +51,7 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore } from "lucide-react"; import { AgentIcon } from "../AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector"; @@ -126,6 +134,8 @@ interface IssuePropertiesProps { externalObjectsLoading?: boolean; externalObjectsError?: boolean; onRetryExternalObjects?: () => void; + onCheckMonitorNow?: () => void; + checkingMonitorNow?: boolean; } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; @@ -142,6 +152,8 @@ export function IssueProperties({ externalObjectsLoading, externalObjectsError, onRetryExternalObjects, + onCheckMonitorNow, + checkingMonitorNow = false, }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -176,6 +188,7 @@ export function IssueProperties({ const [approversOpen, setApproversOpen] = useState(false); const [approverSearch, setApproverSearch] = useState(""); const [monitorOpen, setMonitorOpen] = useState(false); + const [monitorDetailsOpen, setMonitorDetailsOpen] = useState(false); const [scheduledRetryOpen, setScheduledRetryOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); @@ -990,45 +1003,94 @@ export function IssueProperties({ updateMonitor(null); setMonitorOpen(false); }; - const currentMonitorLabel = (() => { - if (issue.executionPolicy?.monitor?.nextCheckAt) { - return `Next check ${formatDate(new Date(issue.executionPolicy.monitor.nextCheckAt))}`; - } - if (issue.executionState?.monitor?.status === "cleared") { - return "Cleared"; - } - if (issue.monitorLastTriggeredAt) { - return `Last triggered ${timeAgo(issue.monitorLastTriggeredAt)}`; - } - return "None"; - })(); - const monitorNextCheckAt = issue.executionPolicy?.monitor?.nextCheckAt ?? null; + const monitorState = issue.executionState?.monitor ?? null; + const monitorNextCheckAt = monitorState?.nextCheckAt ?? issue.monitorNextCheckAt ?? issue.executionPolicy?.monitor?.nextCheckAt ?? null; + const monitorAttemptCount = issue.monitorAttemptCount ?? monitorState?.attemptCount ?? 0; + const monitorLastTriggeredAt = issue.monitorLastTriggeredAt ?? monitorState?.lastTriggeredAt ?? null; + const monitorServiceName = issue.executionPolicy?.monitor?.serviceName ?? monitorState?.serviceName ?? null; + const monitorNotes = issue.executionPolicy?.monitor?.notes ?? monitorState?.notes ?? null; + const monitorNow = useMonitorCountdown(monitorNextCheckAt); + const monitorRelative = monitorNextCheckAt ? formatMonitorEta(monitorNextCheckAt, monitorNow) : null; + const monitorIsDueNow = monitorRelative === "due now"; + const monitorIsOverdue = Boolean(monitorRelative?.startsWith("overdue by ")); + const monitorPrimary = monitorNextCheckAt + ? formatMonitorEtaLabel(monitorNextCheckAt, monitorNow) + : monitorState?.status === "cleared" + ? "Cleared" + : "None"; + const monitorSecondary = monitorNextCheckAt + ? monitorIsDueNow + ? "checking momentarily…" + : `${formatMonitorAbsolute(monitorNextCheckAt, {}, monitorNow)}${monitorIsOverdue ? " · fires on next tick" : monitorAttemptCount > 0 ? ` · Attempt ${monitorAttemptCount}` : ""}` + : monitorState?.status === "cleared" + ? [ + monitorLastTriggeredAt ? `last checked ${timeAgo(monitorLastTriggeredAt)}` : null, + monitorAttemptCount > 0 ? `after attempt ${monitorAttemptCount}` : null, + ].filter(Boolean).join(" · ") + : null; const monitorTrigger = ( - + + + + setMonitorDetailsOpen(false)} + > {monitorNextCheckAt ? ( - + {monitorNextCheckAt ? ( + event.stopPropagation()} + > +
+ Monitor + {monitorAttemptCount > 0 ? Attempt {monitorAttemptCount} : null} +
+
+
+
Next check
+
{formatMonitorAbsoluteFull(monitorNextCheckAt)}
+
{monitorRelative}
+
+
+
Watching
+
{monitorServiceName ?? "—"}
+
+
+
Notes
+
{monitorNotes ?? "—"}
+
+
+
Last triggered
+
{monitorLastTriggeredAt ? formatMonitorAbsoluteFull(monitorLastTriggeredAt) : "— not yet triggered"}
+
+
+
+ {onCheckMonitorNow ? ( + + ) : null} + + +
+
) : null} -
+ + ); - const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? ( - - Attempt {issue.monitorAttemptCount} - - ) : null; const scheduledRetry = issue.scheduledRetry ?? null; const retryNow = useRetryNowMutation(issue.id); @@ -2191,7 +2253,6 @@ export function IssueProperties({ triggerContent={monitorTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-(--sz-32rem)")} - extra={monitorAttemptBadge} > {monitorContent} diff --git a/ui/src/lib/issue-monitor.test.tsx b/ui/src/lib/issue-monitor.test.tsx new file mode 100644 index 0000000000..84b7ced040 --- /dev/null +++ b/ui/src/lib/issue-monitor.test.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + deriveMonitorState, + formatMonitorAbsolute, + formatMonitorAbsoluteFull, + formatMonitorEta, + formatMonitorEtaLabel, + formatMonitorOffset, + useMonitorCountdown, +} from "./issue-monitor"; + +describe("monitor time formatting", () => { + const now = new Date("2026-07-17T19:56:00.000Z"); + + it.each([ + [45_000, "in 45s"], + [5 * 60_000, "in 5m"], + [2 * 60 * 60_000, "in 2h"], + [(2 * 60 + 12) * 60_000, "in 2h 12m"], + [(2 * 60 + 59) * 60_000, "in 2h 59m"], + [(3 * 24 + 4) * 60 * 60_000, "in 3d 4h"], + [3 * 24 * 60 * 60_000, "in 3d"], + ])("formats future offset %i with up to two non-zero units", (offsetMs, expected) => { + expect(formatMonitorEta(new Date(now.getTime() + offsetMs), now)).toBe(expected); + }); + + it("uses due-now grace before switching to overdue copy", () => { + expect(formatMonitorEta(now, now)).toBe("due now"); + expect(formatMonitorEta(new Date(now.getTime() - 59_999), now)).toBe("due now"); + expect(formatMonitorEta(new Date(now.getTime() - 60_000), now)).toBe("overdue by 1m"); + expect(formatMonitorEta(new Date(now.getTime() - 12 * 60_000), now)).toBe("overdue by 12m"); + }); + + it("formats sentence-case ETA labels without slicing prefixes", () => { + expect(formatMonitorEtaLabel(new Date(now.getTime() + (2 * 60 + 12) * 60_000), now)).toBe("In 2h 12m"); + expect(formatMonitorEtaLabel(now, now)).toBe("Due now"); + expect(formatMonitorEtaLabel(new Date(now.getTime() - 18 * 60_000), now)).toBe("Overdue by 18m"); + }); + + it("uses the injectable Date.now clock for scheduled retry offsets", () => { + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now.getTime()); + expect(formatMonitorOffset(new Date(now.getTime() + 15 * 60_000))).toBe("in 15m"); + expect(formatMonitorOffset(new Date(now.getTime() + 10_000))).toBe("now"); + expect(formatMonitorOffset(now)).toBe("now"); + dateNowSpy.mockRestore(); + }); + + it("formats the full local timestamp with weekday, year and zone", () => { + const timestamp = "2026-07-17T21:08:00.000Z"; + const options = { locale: "en-US", timeZone: "America/Chicago" } as const; + + expect(formatMonitorAbsoluteFull(timestamp, options)).toBe("Friday, July 17, 2026, 4:08:00 PM CDT"); + }); + + describe("formatMonitorAbsolute compact copy (wireframe 04)", () => { + const options = { locale: "en-US", timeZone: "America/Chicago" } as const; + // 2026-07-17T21:08:00Z is 4:08 PM in America/Chicago. + const timestamp = "2026-07-17T21:08:00.000Z"; + + it("says Today when the check lands on the reference day", () => { + const now = "2026-07-17T14:00:00.000Z"; // 9:00 AM Chicago, same day + expect(formatMonitorAbsolute(timestamp, options, now)).toBe("Today, 4:08 PM"); + }); + + it("compares the day in the display time zone, not UTC", () => { + // 2026-07-18T04:08:00Z is still 11:08 PM on Jul 17 in Chicago. + const lateNight = "2026-07-18T04:08:00.000Z"; + const now = "2026-07-17T14:00:00.000Z"; // 9:00 AM Chicago, Jul 17 + expect(formatMonitorAbsolute(lateNight, options, now)).toBe("Today, 11:08 PM"); + }); + + it("prefixes the weekday beyond today within the current year", () => { + const later = "2026-07-20T14:00:00.000Z"; // 9:00 AM Chicago, Mon Jul 20 + const now = "2026-07-17T14:00:00.000Z"; + expect(formatMonitorAbsolute(later, options, now)).toBe("Mon Jul 20, 9:00 AM"); + }); + + it("adds the year only when it differs from the reference year", () => { + const nextYear = "2027-01-04T15:00:00.000Z"; // 9:00 AM Chicago, Mon Jan 4 2027 + const now = "2026-07-17T14:00:00.000Z"; + expect(formatMonitorAbsolute(nextYear, options, now)).toBe("Mon Jan 4, 2027, 9:00 AM"); + }); + }); +}); + +describe("deriveMonitorState", () => { + const now = new Date("2026-07-17T20:00:00.000Z"); + + it("derives scheduled and retrying states with monitor details", () => { + expect( + deriveMonitorState( + { + executionPolicy: { monitor: { nextCheckAt: "2026-07-17T22:12:00.000Z", serviceName: "API" } }, + executionState: { + monitor: { + status: "scheduled", + nextCheckAt: "2026-07-17T22:12:00.000Z", + attemptCount: 1, + serviceName: "API", + }, + }, + }, + now, + ), + ).toEqual({ + state: "scheduled", + source: "monitor", + nextCheckAt: "2026-07-17T22:12:00.000Z", + attemptCount: 1, + serviceName: "API", + }); + + expect( + deriveMonitorState( + { + executionState: { + monitor: { + status: "scheduled", + nextCheckAt: "2026-07-17T22:12:00.000Z", + attemptCount: 3, + serviceName: "deploy health", + }, + }, + }, + now, + ).state, + ).toBe("retrying"); + }); + + it("derives due-now and overdue at the grace boundary", () => { + const issue = (nextCheckAt: string) => ({ + executionState: { monitor: { status: "scheduled" as const, nextCheckAt, attemptCount: 1 } }, + }); + + expect(deriveMonitorState(issue("2026-07-17T19:59:00.001Z"), now).state).toBe("due-now"); + expect(deriveMonitorState(issue("2026-07-17T19:59:00.000Z"), now).state).toBe("overdue"); + }); + + it("derives cleared, none, and scheduled retry states", () => { + expect( + deriveMonitorState({ executionState: { monitor: { status: "cleared", attemptCount: 2 } } }, now), + ).toMatchObject({ state: "cleared", attemptCount: 2 }); + expect(deriveMonitorState({}, now)).toEqual({ + state: "none", + source: "none", + nextCheckAt: null, + attemptCount: 0, + serviceName: null, + }); + expect( + deriveMonitorState( + { + monitorAttemptCount: 0, + scheduledRetry: { + status: "scheduled_retry", + scheduledRetryAt: "2026-07-17T20:05:00.000Z", + scheduledRetryAttempt: 2, + }, + }, + now, + ), + ).toMatchObject({ state: "retrying", source: "scheduled-retry", attemptCount: 2 }); + }); +}); + +describe("useMonitorCountdown", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T20:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("ticks every 30 seconds normally, every second near due, and cleans up", () => { + const container = document.createElement("div"); + const root = createRoot(container); + const observed: number[] = []; + + function Probe({ nextCheckAt }: { nextCheckAt: string | null }) { + observed.push(useMonitorCountdown(nextCheckAt).getTime()); + return null; + } + + flushSync(() => root.render()); + expect(vi.getTimerCount()).toBe(1); + + flushSync(() => vi.advanceTimersByTime(30_000)); + expect(observed.at(-1)).toBe(new Date("2026-07-17T20:00:30.000Z").getTime()); + + flushSync(() => root.render()); + flushSync(() => vi.advanceTimersByTime(1_000)); + expect(observed.at(-1)).toBe(new Date("2026-07-17T20:00:31.000Z").getTime()); + + flushSync(() => root.render()); + expect(vi.getTimerCount()).toBe(0); + + flushSync(() => root.unmount()); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/ui/src/lib/issue-monitor.ts b/ui/src/lib/issue-monitor.ts index 3b0f8bde40..adc6f2a476 100644 --- a/ui/src/lib/issue-monitor.ts +++ b/ui/src/lib/issue-monitor.ts @@ -1,12 +1,266 @@ -export function formatMonitorOffset(nextCheckAt: Date | string): string { - const deltaMs = new Date(nextCheckAt).getTime() - Date.now(); - const absMinutes = Math.round(Math.abs(deltaMs) / 60_000); - if (absMinutes <= 0) return "now"; - if (absMinutes < 60) return deltaMs >= 0 ? `in ${absMinutes}m` : `${absMinutes}m ago`; +import { useEffect, useState } from "react"; - const absHours = Math.round(absMinutes / 60); - if (absHours < 24) return deltaMs >= 0 ? `in ${absHours}h` : `${absHours}h ago`; +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const DUE_NOW_GRACE_MS = MINUTE_MS; - const absDays = Math.round(absHours / 24); - return deltaMs >= 0 ? `in ${absDays}d` : `${absDays}d ago`; +type MonitorDate = Date | string; + +type MonitorDetails = { + nextCheckAt?: MonitorDate | null; + attemptCount?: number | null; + serviceName?: string | null; + status?: "scheduled" | "triggered" | "cleared" | null; +}; + +type MonitorPolicy = { + nextCheckAt?: MonitorDate | null; + serviceName?: string | null; +}; + +type ScheduledRetry = { + status?: "scheduled_retry" | "queued" | "running" | "cancelled" | null; + scheduledRetryAt?: MonitorDate | null; + scheduledRetryAttempt?: number | null; +}; + +export interface MonitorIssueLike { + executionState?: { monitor?: MonitorDetails | null } | null; + executionPolicy?: { monitor?: MonitorPolicy | null } | null; + monitorNextCheckAt?: MonitorDate | null; + monitorAttemptCount?: number | null; + scheduledRetry?: ScheduledRetry | null; +} + +export type MonitorDisplayState = + | "scheduled" + | "retrying" + | "due-now" + | "overdue" + | "cleared" + | "none"; + +export interface DerivedMonitorState { + state: MonitorDisplayState; + source: "monitor" | "scheduled-retry" | "none"; + nextCheckAt: MonitorDate | null; + attemptCount: number; + serviceName: string | null; +} + +export interface MonitorDateTimeFormatOptions { + locale?: Intl.LocalesArgument; + timeZone?: string; +} + +function toTimestamp(value: MonitorDate): number { + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) throw new RangeError("Invalid monitor date"); + return timestamp; +} + +function formatDuration(durationMs: number): string { + if (durationMs < MINUTE_MS) { + return `${Math.max(1, Math.ceil(durationMs / SECOND_MS))}s`; + } + if (durationMs < HOUR_MS) { + return `${Math.floor(durationMs / MINUTE_MS)}m`; + } + if (durationMs < DAY_MS) { + const hours = Math.floor(durationMs / HOUR_MS); + const minutes = Math.floor((durationMs % HOUR_MS) / MINUTE_MS); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + const days = Math.floor(durationMs / DAY_MS); + const hours = Math.floor((durationMs % DAY_MS) / HOUR_MS); + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; +} + +export function formatMonitorEta(nextCheckAt: MonitorDate, now: MonitorDate = new Date()): string { + const deltaMs = toTimestamp(nextCheckAt) - toTimestamp(now); + if (deltaMs > 0) return `in ${formatDuration(deltaMs)}`; + if (deltaMs > -DUE_NOW_GRACE_MS) return "due now"; + return `overdue by ${formatDuration(Math.abs(deltaMs))}`; +} + +export function formatMonitorEtaLabel(nextCheckAt: MonitorDate, now: MonitorDate = new Date()): string { + const eta = formatMonitorEta(nextCheckAt, now); + return `${eta.charAt(0).toUpperCase()}${eta.slice(1)}`; +} + +function zonedYmd( + date: Date, + locale: Intl.LocalesArgument, + timeZone: string | undefined, +): { year: string; month: string; day: string } { + const parts = new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "numeric", + day: "numeric", + timeZone, + }).formatToParts(date); + const pick = (type: Intl.DateTimeFormatPartTypes) => + parts.find((part) => part.type === type)?.value ?? ""; + return { year: pick("year"), month: pick("month"), day: pick("day") }; +} + +/** + * Compact absolute time for the monitor surfaces (wireframe 04). Renders + * `Today, 8:16 PM` when the check lands on the reference day, otherwise prefixes + * the weekday (`Mon Jul 20, 9:00 AM`) and only adds the year when it differs from + * the reference year (`Mon Jul 20, 2027, 9:00 AM`). Day/year comparisons are made + * in the display time zone so "Today" matches what the user sees. + */ +export function formatMonitorAbsolute( + nextCheckAt: MonitorDate, + options: MonitorDateTimeFormatOptions = {}, + now: MonitorDate = new Date(), +): string { + const target = new Date(toTimestamp(nextCheckAt)); + const reference = new Date(toTimestamp(now)); + const targetYmd = zonedYmd(target, options.locale, options.timeZone); + const referenceYmd = zonedYmd(reference, options.locale, options.timeZone); + + const time = new Intl.DateTimeFormat(options.locale, { + hour: "numeric", + minute: "2-digit", + timeZone: options.timeZone, + }).format(target); + + const isToday = + targetYmd.year === referenceYmd.year && + targetYmd.month === referenceYmd.month && + targetYmd.day === referenceYmd.day; + if (isToday) return `Today, ${time}`; + + const weekday = new Intl.DateTimeFormat(options.locale, { + weekday: "short", + timeZone: options.timeZone, + }).format(target); + const date = new Intl.DateTimeFormat(options.locale, { + month: "short", + day: "numeric", + year: targetYmd.year === referenceYmd.year ? undefined : "numeric", + timeZone: options.timeZone, + }).format(target); + + return `${weekday} ${date}, ${time}`; +} + +export function formatMonitorAbsoluteFull( + nextCheckAt: MonitorDate, + options: MonitorDateTimeFormatOptions = {}, +): string { + const date = new Date(toTimestamp(nextCheckAt)); + const datePart = new Intl.DateTimeFormat(options.locale, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + timeZone: options.timeZone, + }).format(date); + const timePart = new Intl.DateTimeFormat(options.locale, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + timeZoneName: "short", + timeZone: options.timeZone, + }).format(date); + return `${datePart}, ${timePart}`; +} + +export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = new Date()): DerivedMonitorState { + const runtimeMonitor = issue.executionState?.monitor ?? null; + const policyMonitor = issue.executionPolicy?.monitor ?? null; + const scheduledRetry = issue.scheduledRetry ?? null; + const retryIsActive = + scheduledRetry?.status === "scheduled_retry" || + scheduledRetry?.status === "queued" || + scheduledRetry?.status === "running"; + const nextCheckAt = + runtimeMonitor?.nextCheckAt ?? + issue.monitorNextCheckAt ?? + policyMonitor?.nextCheckAt ?? + (retryIsActive ? scheduledRetry?.scheduledRetryAt : null) ?? + null; + const hasMonitor = runtimeMonitor !== null || policyMonitor !== null || issue.monitorNextCheckAt != null; + const source = hasMonitor ? "monitor" : retryIsActive ? "scheduled-retry" : "none"; + const attemptCount = + runtimeMonitor?.attemptCount ?? + (hasMonitor ? issue.monitorAttemptCount : null) ?? + (retryIsActive ? scheduledRetry?.scheduledRetryAttempt : null) ?? + 0; + const serviceName = runtimeMonitor?.serviceName ?? policyMonitor?.serviceName ?? null; + + if (runtimeMonitor?.status === "cleared") { + return { state: "cleared", source, nextCheckAt, attemptCount, serviceName }; + } + + if (!hasMonitor && !retryIsActive) { + return { state: "none", source, nextCheckAt: null, attemptCount: 0, serviceName: null }; + } + if (!nextCheckAt) { + return { state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount, serviceName }; + } + + const deltaMs = toTimestamp(nextCheckAt) - toTimestamp(now); + if (deltaMs <= -DUE_NOW_GRACE_MS) { + return { state: "overdue", source, nextCheckAt, attemptCount, serviceName }; + } + if (deltaMs <= 0) { + return { state: "due-now", source, nextCheckAt, attemptCount, serviceName }; + } + return { + state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled", + source, + nextCheckAt, + attemptCount, + serviceName, + }; +} + +function countdownCadence(nextCheckAt: MonitorDate): number { + const deltaMs = toTimestamp(nextCheckAt) - Date.now(); + return deltaMs > -DUE_NOW_GRACE_MS && deltaMs < DUE_NOW_GRACE_MS ? SECOND_MS : 30 * SECOND_MS; +} + +export function useMonitorCountdown(nextCheckAt: MonitorDate | null | undefined): Date { + const [now, setNow] = useState(() => new Date(Date.now())); + const nextCheckTimestamp = nextCheckAt == null ? null : toTimestamp(nextCheckAt); + + useEffect(() => { + setNow(new Date(Date.now())); + if (nextCheckTimestamp === null) return; + + let timeoutId: ReturnType | null = null; + let cancelled = false; + const scheduleNextTick = () => { + timeoutId = setTimeout(() => { + if (cancelled) return; + setNow(new Date(Date.now())); + scheduleNextTick(); + }, countdownCadence(new Date(nextCheckTimestamp))); + }; + + scheduleNextTick(); + return () => { + cancelled = true; + if (timeoutId !== null) clearTimeout(timeoutId); + }; + }, [nextCheckTimestamp]); + + return now; +} + +export function formatMonitorOffset(nextCheckAt: MonitorDate): string { + const now = new Date(Date.now()); + const deltaMs = toTimestamp(nextCheckAt) - now.getTime(); + if (Math.round(Math.abs(deltaMs) / MINUTE_MS) === 0) return "now"; + const eta = formatMonitorEta(nextCheckAt, now); + if (eta === "due now") return "now"; + if (eta.startsWith("overdue by ")) return `${eta.slice("overdue by ".length)} ago`; + return eta; } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 897e6a0c59..38d8a1982f 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -102,7 +102,11 @@ import { IssuesList } from "../components/IssuesList"; import { AgentIcon } from "../components/AgentIconPicker"; import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary"; import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel"; -import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard"; +import { + IssueMonitorBanner, + IssueMonitorComposerStrip, + hasVisibleMonitorSurface, +} from "../components/IssueMonitorBanner"; import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard"; import { IssueProperties } from "../components/IssueProperties"; import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews"; @@ -883,6 +887,8 @@ type IssueDetailChatTabProps = { onRefreshLatestComments: () => Promise | void; onWorkModeChange?: (workMode: IssueWorkMode) => Promise | void; composerRef: Ref; + /** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */ + composerAccessory?: ReactNode; footer?: ReactNode; feedbackVotes?: FeedbackVote[]; feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt"; @@ -970,6 +976,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ onRefreshLatestComments, onWorkModeChange, composerRef, + composerAccessory, footer, feedbackVotes, feedbackDataSharingPreference, @@ -1162,6 +1169,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ ) : null} ; pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null; onApprovalAction: (approvalId: string, action: "approve" | "reject") => void; - onCheckMonitorNow: () => void; - checkingMonitorNow: boolean; handoffFocusSignal?: number; externalReferences?: MarkdownExternalReferenceMap; }; @@ -1275,8 +1281,6 @@ function IssueDetailActivityTab({ userProfileMap, pendingApprovalAction, onApprovalAction, - onCheckMonitorNow, - checkingMonitorNow, handoffFocusSignal = 0, externalReferences, }: IssueDetailActivityTabProps) { @@ -1507,11 +1511,7 @@ function IssueDetailActivityTab({
)} - + {/* Waiting-monitor state now lives in the pinned top banner (IssueMonitorBanner) — PAP-14557 decision 1. */} ); } @@ -3174,6 +3174,8 @@ export function IssueDetail() { externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} + onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} + checkingMonitorNow={checkIssueMonitorNow.isPending} /> ); return () => closePanel(); @@ -3186,6 +3188,8 @@ export function IssueDetail() { panelChildIssues, panelIssue, resolvedHasActiveRun, + checkIssueMonitorNow.isPending, + checkIssueMonitorNow.mutate, externalObjectsState.isEnabled, externalObjectsState.groups, externalObjectsState.isLoading, @@ -4478,6 +4482,12 @@ export function IssueDetail() { className="text-xl font-bold" /> + checkIssueMonitorNow.mutate()} + checkingNow={checkIssueMonitorNow.isPending} + /> + updateIssue.mutateAsync({ description })} @@ -4753,6 +4763,15 @@ export function IssueDetail() { onLoadOlderComments={loadOlderComments} onRefreshLatestComments={refetchLatestComments} composerRef={commentComposerRef} + composerAccessory={ + hasVisibleMonitorSurface(issue) ? ( + checkIssueMonitorNow.mutate()} + checkingNow={checkIssueMonitorNow.isPending} + /> + ) : null + } footer={ siblingNavigation ? ( { approvalDecision.mutate({ approvalId, action }); }} - onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} - checkingMonitorNow={checkIssueMonitorNow.isPending} externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} /> ) : null} @@ -5035,6 +5052,8 @@ export function IssueDetail() { externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} + onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} + checkingMonitorNow={checkIssueMonitorNow.isPending} /> diff --git a/ui/storybook/stories/monitor-surfaces.stories.tsx b/ui/storybook/stories/monitor-surfaces.stories.tsx index a8af38f5e8..7fbfc66c98 100644 --- a/ui/storybook/stories/monitor-surfaces.stories.tsx +++ b/ui/storybook/stories/monitor-surfaces.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Issue } from "@paperclipai/shared"; -import { IssueMonitorActivityCard } from "@/components/IssueMonitorActivityCard"; +import { IssueMonitorBanner, IssueMonitorComposerStrip } from "@/components/IssueMonitorBanner"; import { IssueProperties } from "@/components/IssueProperties"; import { storybookExecutionWorkspaces, @@ -38,24 +38,6 @@ const monitoredIssue: Issue = { }, }; -const longerWaitIssue: Issue = { - ...baseIssue, - monitorNextCheckAt: inTwoHours(), - monitorNotes: null, - monitorAttemptCount: 0, - executionPolicy: { - ...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }), - monitor: { - nextCheckAt: inTwoHours().toISOString(), - notes: null, - kind: null, - scheduledBy: "assignee", - serviceName: null, - externalRef: null, - }, - }, -}; - const triggeredIssue: Issue = { ...baseIssue, monitorNextCheckAt: null, @@ -123,39 +105,150 @@ const clearedIssue: Issue = { }, }; +const executionStateStub = baseIssue.executionState ?? { + status: "pending" as const, + currentStageId: null, + currentStageIndex: null, + currentStageType: null, + currentParticipant: null, + returnAssignee: null, + reviewRequest: null, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, +}; + +type StoryMonitor = NonNullable["monitor"]>; + +function withMonitor(monitor: StoryMonitor | null): Issue { + return { + ...baseIssue, + monitorNextCheckAt: monitor?.nextCheckAt ? new Date(monitor.nextCheckAt) : null, + monitorAttemptCount: monitor?.attemptCount ?? 0, + executionState: { ...executionStateStub, monitor }, + scheduledRetry: null, + }; +} + +const scheduledMonitorIssue = withMonitor({ + status: "scheduled", + nextCheckAt: inTwoHours().toISOString(), + lastTriggeredAt: null, + attemptCount: 1, + notes: null, + scheduledBy: "assignee", + kind: "external_service", + serviceName: "vercel-deploy", + externalRef: null, + timeoutAt: null, + maxAttempts: null, + recoveryPolicy: null, + clearedAt: null, + clearReason: null, +}); + +const retryingMonitorIssue = withMonitor({ + status: "scheduled", + nextCheckAt: new Date(Date.now() + 90 * 60_000).toISOString(), + lastTriggeredAt: new Date(Date.now() - 5 * 60_000).toISOString(), + attemptCount: 3, + notes: null, + scheduledBy: "assignee", + kind: "external_service", + serviceName: "deploy health", + externalRef: null, + timeoutAt: null, + maxAttempts: null, + recoveryPolicy: null, + clearedAt: null, + clearReason: null, +}); + +const dueNowMonitorIssue = withMonitor({ + status: "scheduled", + nextCheckAt: new Date(Date.now() - 10_000).toISOString(), + lastTriggeredAt: null, + attemptCount: 1, + notes: null, + scheduledBy: "assignee", + kind: null, + serviceName: "vercel-deploy", + externalRef: null, + timeoutAt: null, + maxAttempts: null, + recoveryPolicy: null, + clearedAt: null, + clearReason: null, +}); + +const overdueMonitorIssue = withMonitor({ + status: "scheduled", + nextCheckAt: new Date(Date.now() - 18 * 60_000).toISOString(), + lastTriggeredAt: null, + attemptCount: 2, + notes: null, + scheduledBy: "assignee", + kind: null, + serviceName: "vercel-deploy", + externalRef: null, + timeoutAt: null, + maxAttempts: null, + recoveryPolicy: null, + clearedAt: null, + clearReason: null, +}); + function MonitorSurfaceStories() { return (
-
+
- IssueMonitorActivityCard - external service (Greptile) + Pinned top banner — scheduled (2h wait, external service)
- undefined} checkingNow={false} /> -
-
- IssueMonitorActivityCard - generic 2h wait, no service metadata + Pinned top banner — retrying (attempt 3)
- undefined} - checkingNow={false} - /> -
+ undefined} /> -
- IssueMonitorActivityCard - returns null when no monitor is set + Pinned top banner — due now +
+ undefined} /> + +
+ Pinned top banner — overdue (warning tone) +
+ undefined} /> + +
+ Pinned top banner — cleared / none (renders nothing)
- (intentionally renders nothing for issues without a scheduled monitor) + (banner + strip hide entirely for cleared / no-monitor issues)
- undefined} /> + undefined} /> + undefined} /> +
+ +
+
+ Composer strip — scheduled (inline, above the reply composer) +
+ undefined} + /> + +
+ Composer strip — overdue +
+ undefined} />
@@ -222,7 +315,7 @@ const meta = { docs: { description: { component: - "Surfaces the IssueMonitorActivityCard and IssueProperties Monitor row in scheduled / not-scheduled / external-service variants for UX review.", + "Surfaces the pinned monitor banner, composer strip, and IssueProperties Monitor row across the wireframe-04 states (scheduled / retrying / due / overdue / cleared) for UX review.", }, }, },