= {}): 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 ? (
-
- {checkingNow ? "Checking..." : "Check now"}
-
- ) : 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 (
+
+ {checkingNow ? "Checking…" : "Check now"}
+
+ );
+}
+
+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 (
+
+
+
+
+
+
{copy.stripTitle}
+
{copy.stripMeta.join(" · ")}
+
+
+ {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 ? (
-
+
) : null}
-
- {monitorNextCheckAt ? `Next check ${formatMonitorOffset(monitorNextCheckAt)}` : currentMonitorLabel}
-
- {monitorNextCheckAt ? (
-
- {formatDate(new Date(monitorNextCheckAt))}
+
+ {monitorPrimary}
+ {monitorSecondary ? (
+ {monitorSecondary}
+ ) : null}
+
+
+ {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 ? (
+ { setMonitorDetailsOpen(false); onCheckMonitorNow(); }}>
+ {checkingMonitorNow ? "Checking…" : "Check now"}
+
+ ) : null}
+ { setMonitorDetailsOpen(false); setMonitorOpen(true); }}>Edit
+ { setMonitorDetailsOpen(false); clearMonitor(); }}>Clear
+
+
) : 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({
)}