feat: make issue monitors visible across task surfaces (#9783)

## Thinking Path

> - Paperclip is the open source control plane people use to supervise
AI-agent companies.
> - Operators need task execution state to be visible where they read,
reply to, and manage an issue.
> - Scheduled monitor state was easy to miss because it lived in a
description-area card and used inconsistent, mostly static time copy.
> - The task page, composer, retry card, and properties panel therefore
needed one shared monitor-state and countdown language.
> - This pull request adds shared live-ticking time utilities, moves
monitor status into a top-of-page banner and composer strip, and
redesigns the properties row with complete read-only details.
> - The benefit is that operators can immediately understand when an
agent resumes, why it is waiting, and how to act without hunting across
the page.

## Linked Issues or Issue Description

Issue monitors can schedule a future agent check, retry, or wake, but
the UI did not present that state consistently or prominently. The
existing description-area activity card competed with issue content, the
composer did not explain that replying wakes the agent early, compact
properties copy truncated important details, and relative times did not
share a live two-unit formatter.

This change makes scheduled monitor state visible and consistent across
the issue header, reply composer, properties panel, and scheduled-retry
card. Related prior server-side recovery visibility work: #9629
(distinct scope).

## What Changed

- Added shared two-unit monitor ETA/offset formatters and a live-ticking
countdown hook, including compact absolute-time rules for Today,
weekday, and cross-year dates.
- Replaced the description-area monitor activity card with a top-of-page
status banner and added an inline composer strip that explains replies
wake the agent before the scheduled check.
- Redesigned the properties Monitor row as readable two-line copy with
attempt state, due/overdue/cleared wording, click-to-edit behavior, and
a hover/tap details tooltip.
- Adopted the shared two-unit formatting in the scheduled-retry card and
added focused coverage for monitor formatting, state transitions,
visibility, and controls.
- Added the approved wireframe package and published reference:
https://pages.paperclip.ing/pap-14557-monitor-visibility/

## Verification

- `pnpm vitest run ui/src/lib/issue-monitor.test.tsx
ui/src/components/IssueMonitorBanner.test.tsx
ui/src/components/IssueProperties.test.tsx` — 3 files, 74 tests passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed with existing Vite optimization/chunk-size
warnings.
- UX review approved the rendered real components across desktop/mobile,
light/dark, and the scheduled/retrying/due/overdue/cleared/none state
matrix.
- QA passed 6/6 criteria in Chromium, including a live countdown
transition without refresh. Review evidence included the P2 banner
state-matrix screenshot and the P3 properties-row and details-tooltip
screenshots, plus a dark-mode capture.
- `pnpm check:token-gates` currently reports five pre-existing `#9627`
comment literals on `origin/master`; this branch introduces none of
those literals or any new token violation.

## Risks

- Low-to-moderate UI behavior risk: monitor copy and placement change
across several issue surfaces, but all derive from one shared state
builder and focused tests cover the state matrix.
- Countdown rendering wakes once per minute while a visible monitor is
scheduled; the hook is limited to active monitor surfaces and stops when
hidden.
- No database, API contract, migration, telemetry, or authorization
behavior changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, `gpt-5.6-sol`; context-window metadata was unavailable
in this runtime; reasoning, repository tool use, command execution, and
test execution enabled. Earlier implementation commits were assisted by
Claude Fable 5 and Claude Opus 4.8 (1M context), as credited in their
commit trailers.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-18 14:39:37 -05:00 committed by GitHub
parent 67616dd7ea
commit f12bb27bcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2337 additions and 403 deletions

View File

@ -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
}

View File

@ -0,0 +1,420 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PAP-14557 — Monitor visibility wireframes</title>
<style>
:root {
--bg: #fafaf8;
--panel: #ffffff;
--ink: #111111;
--muted: #666666;
--line: #e5e5e0;
--accent: #d33;
--maxw: 1200px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font: 15px/1.55 -apple-system, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
a { color: inherit; }
.shell { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }
.toc {
position: sticky; top: 0; align-self: start;
height: 100vh; overflow: auto;
padding: 24px 16px 24px 24px;
border-right: 1px solid var(--line);
background: var(--panel);
font-size: 13px;
z-index: 20;
}
.toc h1 { font-size: 14px; margin: 0 0 4px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
.toc h2 { font-size: 12px; margin: 24px 0 6px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); font-weight: 700; }
.toc a { display: block; text-decoration: none; padding: 6px 8px; margin-left: -8px; border-radius: 4px; color: var(--ink); }
.toc a:hover { background: var(--bg); }
.toc .num { display: inline-block; width: 22px; color: var(--muted); }
.toc .toc-summary { display: none; }
.toc .toc-body { display: block; }
main { padding: 48px 56px 96px; max-width: 1300px; min-width: 0; }
header.hero { max-width: var(--maxw); margin-bottom: 64px; }
header.hero .crumb { color: var(--muted); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
header.hero h1 { font-size: 36px; line-height: 1.15; margin: 8px 0 16px; }
header.hero p { color: var(--muted); max-width: 720px; font-size: 16px; }
header.hero .pills { margin-top: 20px; display: flex; gap: 8px; flex-wrap: wrap; }
header.hero .pill { font-size: 12px; padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); color: var(--muted); }
section { scroll-margin-top: 24px; max-width: var(--maxw); margin-bottom: 96px; }
section .lede { color: var(--muted); margin: 0 0 12px; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; font-weight: 700; }
section h2 { font-size: 28px; line-height: 1.2; margin: 0 0 8px; }
section h2 .step-num { display: inline-block; width: 40px; color: var(--muted); }
section h3 { font-size: 16px; margin: 24px 0 8px; }
section .desc { color: var(--muted); margin: 0 0 24px; max-width: 760px; }
.grid {
display: grid; gap: 24px;
grid-template-columns: minmax(0, 1.4fr) 220px minmax(0, 0.9fr);
align-items: start;
}
.grid.no-mobile { grid-template-columns: minmax(0, 1.6fr) minmax(0, 0.9fr); }
@media (max-width: 1180px) {
.grid { grid-template-columns: minmax(0, 1fr) 200px; }
.grid.no-mobile { grid-template-columns: minmax(0, 1fr); }
.grid > .notes-col { grid-column: 1 / -1; }
}
@media (max-width: 980px) { .grid { grid-template-columns: 1fr; } .grid > .mobile-col { max-width: 280px; } }
@media (max-width: 900px) {
.shell { grid-template-columns: 1fr; }
.toc {
position: sticky; top: 0;
height: auto; max-height: 60vh;
padding: 12px 16px;
border-right: none;
border-bottom: 1px solid var(--line);
}
.toc .toc-summary {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
list-style: none;
padding: 4px 0;
}
.toc .toc-summary::-webkit-details-marker { display: none; }
.toc .toc-summary .title { font-size: 13px; font-weight: 600; }
.toc .toc-summary .crumb { font-size: 11px; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; }
.toc .toc-summary .chevron {
width: 10px; height: 10px;
border-right: 1.5px solid var(--ink);
border-bottom: 1.5px solid var(--ink);
transform: rotate(45deg);
transition: transform 120ms ease;
margin-right: 4px;
}
.toc[open] .toc-summary .chevron { transform: rotate(225deg) translate(-2px, -2px); }
.toc .toc-body { display: none; padding-top: 8px; border-top: 1px solid var(--line); margin-top: 8px; }
.toc[open] .toc-body { display: block; }
main { padding: 24px 20px 64px; }
header.hero { margin-bottom: 40px; }
header.hero h1 { font-size: 28px; }
header.hero p { font-size: 15px; }
section { margin-bottom: 64px; scroll-margin-top: 80px; }
section h2 { font-size: 24px; }
section h2 .step-num { width: 32px; }
.flow-section { padding: 20px; margin-bottom: 40px; scroll-margin-top: 80px; }
}
@media (max-width: 560px) {
main { padding: 16px 14px 56px; }
header.hero { margin-bottom: 32px; }
header.hero h1 { font-size: 24px; line-height: 1.2; }
header.hero p { font-size: 14px; }
section { margin-bottom: 48px; }
section h2 { font-size: 20px; line-height: 1.25; }
section h2 .step-num { width: 28px; }
section h3 { font-size: 15px; margin: 20px 0 6px; }
section .desc { font-size: 14px; }
.grid { gap: 16px; }
.flow-section { padding: 16px; border-radius: 8px; }
.wire .label { padding: 8px 10px; font-size: 11px; gap: 8px; }
body { font-size: 14px; }
}
.wire {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
cursor: zoom-in;
transition: transform 80ms ease, box-shadow 80ms ease;
}
.wire:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.08); }
.wire .label {
padding: 10px 14px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--line);
display: flex; justify-content: space-between; align-items: center;
}
.wire img { display: block; width: 100%; height: auto; background: #fff; }
.wire.mobile-wire img { max-width: 220px; margin: 0 auto; }
.ref {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
margin-bottom: 16px;
}
.ref .label { font-size: 12px; color: var(--muted); margin-bottom: 8px; display: flex; justify-content: space-between; align-items: baseline; }
.ref img { display: block; width: 100%; height: auto; border: 1px solid var(--line); border-radius: 4px; cursor: zoom-in; }
.notes ul { padding-left: 20px; margin: 8px 0; }
.notes li { margin-bottom: 6px; }
.notes .why {
background: var(--panel);
border: 1px solid var(--line);
border-left: 3px solid var(--ink);
padding: 14px 18px;
border-radius: 4px;
margin-top: 12px;
font-size: 14px;
}
.flow-section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 32px; margin-bottom: 64px; max-width: var(--maxw); }
.flow-section h2 { margin-top: 0; }
.flow-section .wire { margin-top: 16px; }
.lightbox {
position: fixed; inset: 0;
background: rgba(0,0,0,0.86);
display: none;
align-items: center; justify-content: center;
z-index: 100;
cursor: zoom-out;
padding: 32px;
}
.lightbox.open { display: flex; }
.lightbox img { max-width: 100%; max-height: 100%; background: #fff; box-shadow: 0 30px 80px rgba(0,0,0,0.4); border-radius: 4px; }
.lightbox .close { position: absolute; top: 16px; right: 24px; font-size: 28px; color: #fff; cursor: pointer; }
.lightbox .caption { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 13px; opacity: 0.9; }
@media (max-width: 560px) {
.lightbox { padding: 12px; overflow: auto; touch-action: pinch-zoom; }
.lightbox.open { display: block; }
.lightbox img { display: block; width: 100%; max-width: none; max-height: none; height: auto; margin: 40px auto 56px; box-shadow: none; }
.lightbox .close { position: fixed; top: 8px; right: 12px; font-size: 32px; }
.lightbox .caption { position: fixed; left: 50%; transform: translateX(-50%); font-size: 12px; bottom: 12px; text-align: center; background: rgba(0,0,0,0.7); border-radius: 4px; padding: 6px 12px; }
}
.footer { color: var(--muted); font-size: 12px; max-width: var(--maxw); border-top: 1px solid var(--line); padding-top: 24px; }
</style>
</head>
<body>
<div class="shell">
<details class="toc">
<summary class="toc-summary">
<span>
<span class="crumb">PAP-14557 · Monitor visibility</span><br>
<span class="title">Jump to a screen</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</summary>
<nav class="toc-body" aria-label="Section navigation">
<h1>PAP-14557</h1>
<div style="font-size: 13px; color: var(--muted); margin-bottom: 16px;">Monitor visibility wireframes</div>
<h2>Flow</h2>
<a href="#flow"><span class="num"></span>The three surfaces</a>
<h2>Screens</h2>
<a href="#s1"><span class="num">1</span>Task page top — banner</a>
<a href="#s2"><span class="num">2</span>Above the composer — strip</a>
<a href="#s3"><span class="num">3</span>Properties row + tooltip</a>
<a href="#s4"><span class="num">4</span>State matrix + copy rules</a>
<h2>Open questions</h2>
<a href="#open"><span class="num">?</span>For reviewer</a>
</nav>
</details>
<main>
<header class="hero">
<div class="crumb">PAP-14557 · Wireframes</div>
<h1>Make a waiting monitor obvious</h1>
<p>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.</p>
<div class="pills">
<span class="pill">4 screens + 2 mobile</span>
<span class="pill">Desktop + mobile</span>
<span class="pill">Lo-fi · monochrome</span>
<span class="pill">Click any wireframe to zoom</span>
</div>
</header>
<section id="flow" class="flow-section">
<div class="lede">Flow</div>
<h2>One monitor state, three surfaces</h2>
<p class="desc">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.</p>
<div class="wire" data-zoom data-caption="Flow — the three surfaces">
<div class="label"><span>flow.svg</span><span>1280×304</span></div>
<img src="wireframes/flow.svg" alt="Flow diagram of the three monitor surfaces" />
</div>
</section>
<section id="s1">
<div class="lede">Step 1 · Task page top</div>
<h2><span class="step-num">1.</span>Monitor banner under the title</h2>
<p class="desc">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.</p>
<div class="grid">
<div class="wire" data-zoom data-caption="01 · Task page top with monitor banner (desktop)">
<div class="label"><span>01-issue-top.svg</span><span>1280×800 · desktop</span></div>
<img src="wireframes/01-issue-top.svg" alt="Task page top with monitor banner (desktop) wireframe" />
</div>
<div class="wire mobile-wire mobile-col" data-zoom data-caption="01 · Task page (mobile)">
<div class="label"><span>mobile</span><span>375×812</span></div>
<img src="wireframes/01-issue-top-mobile.svg" alt="Task page with monitor banner (mobile) wireframe" />
</div>
<div class="notes-col">
<div class="notes">
<h3>Annotations</h3>
<ul>
<li><b>1</b> — Monitor banner: bold relative time first, exact time + attempt + watched service second, actions on the right. On mobile the actions drop below the text.</li>
<li><b>2</b> — Properties Monitor row switches to the two-line layout (detail on screen 3). On mobile (callouts 23 on the phone frame) the collapsed properties bar carries a compact "in 2h 12m" chip and the strip repeats above the composer.</li>
</ul>
<div class="why"><b>Why this changes:</b> 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?"</div>
</div>
</div>
</div>
</section>
<section id="s2">
<div class="lede">Step 2 · Thread bottom</div>
<h2><span class="step-num">2.</span>Strip above the reply composer</h2>
<p class="desc">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.</p>
<div class="grid">
<div class="wire" data-zoom data-caption="02 · Monitor strip above the composer (desktop)">
<div class="label"><span>02-composer.svg</span><span>1280×800 · desktop</span></div>
<img src="wireframes/02-composer.svg" alt="Monitor strip above composer (desktop) wireframe" />
</div>
<div class="wire mobile-wire mobile-col" data-zoom data-caption="02 · Monitor strip above the composer (mobile)">
<div class="label"><span>mobile</span><span>375×812</span></div>
<img src="wireframes/02-composer-mobile.svg" alt="Monitor strip above composer (mobile) wireframe" />
</div>
<div class="notes-col">
<div class="ref">
<div class="label"><span>Reference</span><span>current thread bottom</span></div>
<img src="screenshots/current-thread-bottom.png" alt="Current thread bottom with no monitor indication" data-zoom />
</div>
<div class="notes">
<h3>Annotations</h3>
<ul>
<li><b>1</b> — Monitor strip: anchored to the composer (scrolls with it), not part of the comment list. Same copy system as the banner.</li>
<li><b>2</b> — Wake hint under the composer: sets the expectation that sending a message preempts the scheduled check.</li>
</ul>
<div class="why"><b>Why this changes:</b> 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.</div>
</div>
</div>
</div>
</section>
<section id="s3">
<div class="lede">Step 3 · Properties pane</div>
<h2><span class="step-num">3.</span>Readable Monitor row + details tooltip</h2>
<p class="desc">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.</p>
<div class="grid no-mobile">
<div class="wire" data-zoom data-caption="03 · Properties Monitor row — before / after / hover">
<div class="label"><span>03-properties-monitor.svg</span><span>1280×800 · component sheet</span></div>
<img src="wireframes/03-properties-monitor.svg" alt="Properties monitor row redesign wireframe" />
</div>
<div class="notes-col">
<div class="ref">
<div class="label"><span>Reference</span><span>current properties row</span></div>
<img src="screenshots/current-properties-monitor.png" alt="Current cramped monitor properties row" data-zoom />
</div>
<div class="notes">
<h3>Annotations</h3>
<ul>
<li><b>1</b> — Current failure: "Next check" truncates to "Ne…", the date shows the year but no time, and "Attempt 1" wraps into a two-line crumb.</li>
<li><b>2</b> — Proposed collapsed row: relative time is the headline; absolute time and attempt live on a second muted line sized for the 352px pane.</li>
<li><b>3</b> — Hover/tap tooltip: exact timestamp with timezone, watched service, notes, last-trigger state, and Check now / Edit / Clear without opening the editor.</li>
</ul>
<div class="why"><b>Why this changes:</b> 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.</div>
</div>
</div>
</div>
</section>
<section id="s4">
<div class="lede">Step 4 · System</div>
<h2><span class="step-num">4.</span>State matrix and copy rules</h2>
<p class="desc">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.</p>
<div class="grid no-mobile">
<div class="wire" data-zoom data-caption="04 · Monitor state matrix">
<div class="label"><span>04-states.svg</span><span>1280×800 · component sheet</span></div>
<img src="wireframes/04-states.svg" alt="Monitor state matrix wireframe" />
</div>
<div class="notes-col">
<div class="notes">
<h3>States covered</h3>
<ul>
<li>Scheduled — under 1h / under 24h / beyond today.</li>
<li>Retrying — attempt count &gt; 1 gets visual weight.</li>
<li>Due now / overdue — explicit copy, never a stale-looking past date.</li>
<li>Cleared / none — muted, no clock icon, banner and strip hidden.</li>
</ul>
<div class="why"><b>Why this changes:</b> 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.</div>
</div>
</div>
</div>
</section>
<section id="open">
<div class="lede">For reviewer</div>
<h2>Open questions</h2>
<div class="notes">
<ul>
<li><b>Banner vs. existing card</b> — 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?</li>
<li><b>Strip stickiness</b> — 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.</li>
<li><b>Relative-time precision</b> — adopt the two-unit format ("in 2h 12m") everywhere, including the existing scheduled-retry row which uses the same formatter?</li>
<li><b>Hover vs. click</b> — 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?</li>
<li><b>Live countdown</b> — should the relative time tick live (re-render every minute) or is on-load accuracy enough?</li>
</ul>
</div>
</section>
<div class="footer">
Wireframes follow the in-tree <code>wireframe</code> skill house style — black on white, 1.5pt strokes, 8px grid, type sizes 12/14/20/28. Red dashed annotations are the call-out layer only and not part of the proposed UI.
</div>
</main>
</div>
<div class="lightbox" id="lb" aria-hidden="true">
<span class="close" id="lbClose">×</span>
<img id="lbImg" alt="" />
<div class="caption" id="lbCap"></div>
</div>
<script>
const lb = document.getElementById('lb');
const lbImg = document.getElementById('lbImg');
const lbCap = document.getElementById('lbCap');
const lbClose = document.getElementById('lbClose');
document.querySelectorAll('[data-zoom]').forEach((el) => {
el.addEventListener('click', () => {
const target = el.tagName === 'IMG' ? el : el.querySelector('img');
if (!target) return;
lbImg.src = target.src;
lbImg.alt = target.alt;
lbCap.textContent = el.dataset.caption || target.alt || '';
lb.classList.add('open');
});
});
function close() { lb.classList.remove('open'); }
lb.addEventListener('click', close);
lbClose.addEventListener('click', close);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
const toc = document.querySelector('details.toc');
if (toc) {
const mq = window.matchMedia('(max-width: 900px)');
const apply = () => { toc.open = !mq.matches; };
apply();
mq.addEventListener('change', apply);
toc.querySelectorAll('.toc-body a').forEach((a) => {
a.addEventListener('click', () => { if (mq.matches) toc.open = false; });
});
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -0,0 +1,105 @@
<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 01 · Issue page (mobile) — banner, properties chip, composer strip. 375×812 -->
<rect x="0" y="0" width="375" height="812" />
<!-- top bar -->
<line x1="0" y1="56" x2="375" y2="56" />
<polyline points="28,20 20,28 28,36" fill="none" />
<text x="44" y="33" font-size="14" font-weight="600" stroke="none" fill="#000">PAP-14557</text>
<circle cx="343" cy="28" r="12" fill="#e6e6e6" />
<!-- title + pill -->
<text x="16" y="92" font-size="20" font-weight="700" stroke="none" fill="#000">Nightly model comparison sweep</text>
<g transform="translate(16,108)">
<rect width="96" height="24" rx="12" />
<text x="48" y="16" font-size="12" text-anchor="middle" stroke="none" fill="#000">In Progress</text>
</g>
<!-- NEW: monitor banner -->
<g transform="translate(16,148)">
<rect width="344" height="112" rx="8" />
<circle cx="32" cy="32" r="12" />
<line x1="32" y1="32" x2="32" y2="24" />
<line x1="32" y1="32" x2="38" y2="32" />
<text x="56" y="28" font-size="14" font-weight="600" stroke="none" fill="#000">Waiting on monitor — in 2h 12m</text>
<text x="56" y="48" font-size="12" stroke="none" fill="#666">Thu Jul 17, 4:08 PM · Attempt 1</text>
<g transform="translate(56,64)">
<rect width="104" height="32" rx="4" />
<text x="52" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#000">Check now</text>
</g>
</g>
<!-- description placeholder -->
<g transform="translate(16,292)">
<line x1="0" y1="8" x2="344" y2="8" stroke="#666" />
<line x1="0" y1="24" x2="344" y2="24" stroke="#666" />
<line x1="0" y1="40" x2="240" y2="40" stroke="#666" />
</g>
<!-- comment card -->
<g transform="translate(16,356)">
<rect width="344" height="120" rx="8" />
<circle cx="28" cy="28" r="12" fill="#e6e6e6" />
<text x="48" y="24" font-size="14" font-weight="600" stroke="none" fill="#000">Senior Engineer Fable</text>
<text x="48" y="40" font-size="12" stroke="none" fill="#666">15m ago</text>
<line x1="16" y1="60" x2="328" y2="60" stroke="#666" />
<line x1="16" y1="76" x2="328" y2="76" stroke="#666" />
<line x1="16" y1="92" x2="224" y2="92" stroke="#666" />
</g>
<!-- collapsed properties row with monitor chip -->
<g transform="translate(16,500)">
<rect width="344" height="48" rx="8" />
<text x="16" y="29" font-size="14" font-weight="600" stroke="none" fill="#000">Properties</text>
<circle cx="240" cy="24" r="7" />
<line x1="240" y1="24" x2="240" y2="19" />
<line x1="240" y1="24" x2="244" y2="24" />
<text x="252" y="28" font-size="12" stroke="none" fill="#666">in 2h 12m</text>
<polyline points="324,20 332,28 324,36" fill="none" />
</g>
<!-- NEW: monitor strip above composer -->
<g transform="translate(16,624)">
<rect width="344" height="56" rx="8" />
<circle cx="28" cy="28" r="10" />
<line x1="28" y1="28" x2="28" y2="21" />
<line x1="28" y1="28" x2="33" y2="28" />
<text x="48" y="24" font-size="14" font-weight="600" stroke="none" fill="#000">Resumes in 2h 12m</text>
<text x="48" y="42" font-size="12" stroke="none" fill="#666">Jul 17, 4:08 PM · Attempt 1</text>
<text x="288" y="32" font-size="12" stroke="none" fill="#000">Check now</text>
</g>
<!-- composer -->
<g transform="translate(16,696)">
<rect width="344" height="96" rx="8" />
<text x="16" y="28" font-size="14" stroke="none" fill="#666">Reply…</text>
<g transform="translate(16,48)">
<rect width="32" height="32" rx="4" />
<line x1="10" y1="22" x2="22" y2="10" />
</g>
<g transform="translate(240,48)">
<rect width="88" height="32" rx="4" fill="#000" />
<text x="44" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#fff">Send</text>
</g>
</g>
<!-- annotations -->
<g data-region="annotations">
<rect x="8" y="140" width="360" height="128" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(360,140)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">1</text>
</g>
<rect x="224" y="508" width="112" height="32" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(216,504)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">2</text>
</g>
<rect x="8" y="616" width="360" height="72" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(360,616)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">3</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 01 · Issue page top — monitor banner. Desktop 1280×800 -->
<rect x="0" y="0" width="1280" height="800" />
<!-- top bar -->
<line x1="0" y1="56" x2="1280" y2="56" />
<text x="24" y="36" font-size="14" stroke="none" fill="#666">Issues /</text>
<text x="88" y="36" font-size="14" font-weight="600" stroke="none" fill="#000">PAP-14557</text>
<circle cx="1240" cy="28" r="12" fill="#e6e6e6" />
<!-- properties rail divider -->
<line x1="880" y1="56" x2="880" y2="800" />
<!-- title + status pill -->
<text x="48" y="112" font-size="28" font-weight="700" stroke="none" fill="#000">Nightly model comparison sweep</text>
<g transform="translate(48,128)">
<rect width="104" height="24" rx="12" />
<text x="52" y="16" font-size="12" text-anchor="middle" stroke="none" fill="#000">In Progress</text>
</g>
<!-- NEW: monitor banner -->
<g transform="translate(48,168)">
<rect width="784" height="88" rx="8" />
<!-- clock glyph -->
<circle cx="40" cy="44" r="14" />
<line x1="40" y1="44" x2="40" y2="35" />
<line x1="40" y1="44" x2="47" y2="44" />
<text x="72" y="40" font-size="14" font-weight="600" stroke="none" fill="#000">Waiting on monitor — resumes in 2h 12m</text>
<text x="72" y="62" font-size="12" stroke="none" fill="#666">Thu Jul 17, 4:08 PM (your time) · Attempt 1 · Watching: vercel-deploy</text>
<g transform="translate(568,28)">
<rect width="104" height="32" rx="4" />
<text x="52" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#000">Check now</text>
</g>
<g transform="translate(688,28)">
<rect width="72" height="32" rx="4" />
<text x="36" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#000">Edit</text>
</g>
</g>
<!-- description placeholder -->
<g transform="translate(48,288)">
<line x1="0" y1="8" x2="784" y2="8" stroke="#666" />
<line x1="0" y1="24" x2="784" y2="24" stroke="#666" />
<line x1="0" y1="40" x2="520" y2="40" stroke="#666" />
</g>
<line x1="48" y1="360" x2="832" y2="360" />
<text x="48" y="392" font-size="14" font-weight="600" stroke="none" fill="#000">Activity</text>
<!-- first comment card -->
<g transform="translate(48,408)">
<rect width="784" height="136" rx="8" />
<circle cx="32" cy="32" r="16" fill="#e6e6e6" />
<text x="56" y="28" font-size="14" font-weight="600" stroke="none" fill="#000">Senior Engineer Fable</text>
<text x="720" y="28" font-size="12" stroke="none" fill="#666">15m ago</text>
<line x1="56" y1="56" x2="744" y2="56" stroke="#666" />
<line x1="56" y1="72" x2="744" y2="72" stroke="#666" />
<line x1="56" y1="88" x2="744" y2="88" stroke="#666" />
<line x1="56" y1="104" x2="480" y2="104" stroke="#666" />
</g>
<text x="440" y="584" font-size="12" text-anchor="middle" stroke="none" fill="#666">⌄ thread continues — see screen 02 for the composer</text>
<!-- properties rail -->
<text x="904" y="96" font-size="12" stroke="none" fill="#666">PROPERTIES</text>
<text x="904" y="136" font-size="12" stroke="none" fill="#666">Status</text>
<text x="1032" y="136" font-size="14" stroke="none" fill="#000">In Progress</text>
<text x="904" y="176" font-size="12" stroke="none" fill="#666">Priority</text>
<text x="1032" y="176" font-size="14" stroke="none" fill="#000">Medium</text>
<text x="904" y="216" font-size="12" stroke="none" fill="#666">Assignee</text>
<circle cx="1040" cy="212" r="8" fill="#e6e6e6" />
<text x="1056" y="216" font-size="14" stroke="none" fill="#000">Fable</text>
<text x="904" y="256" font-size="12" stroke="none" fill="#666">Approvers</text>
<text x="1032" y="256" font-size="14" stroke="none" fill="#666">None</text>
<!-- NEW: two-line monitor row -->
<text x="904" y="304" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="1040" cy="300" r="7" />
<line x1="1040" y1="300" x2="1040" y2="295" />
<line x1="1040" y1="300" x2="1044" y2="300" />
<text x="1056" y="304" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="1056" y="324" font-size="12" stroke="none" fill="#666">Jul 17, 4:08 PM · Attempt 1</text>
<text x="904" y="376" font-size="12" stroke="none" fill="#666">Watchdog</text>
<text x="1032" y="376" font-size="14" stroke="none" fill="#666">None</text>
<!-- annotations -->
<g data-region="annotations">
<rect x="40" y="160" width="800" height="104" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(32,152)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">1</text>
</g>
<rect x="896" y="284" width="376" height="48" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(888,276)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">2</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="375" height="812" viewBox="0 0 375 812"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 02 · Thread bottom (mobile) — strip + composer. 375×812 -->
<rect x="0" y="0" width="375" height="812" />
<!-- top bar -->
<line x1="0" y1="56" x2="375" y2="56" />
<polyline points="28,20 20,28 28,36" fill="none" />
<text x="44" y="33" font-size="14" font-weight="600" stroke="none" fill="#000">PAP-14557</text>
<!-- last comment (cut off) -->
<text x="188" y="80" font-size="12" text-anchor="middle" stroke="none" fill="#666">⌃ earlier comments</text>
<g transform="translate(16,96)">
<rect width="344" height="176" rx="8" />
<circle cx="28" cy="28" r="12" fill="#e6e6e6" />
<text x="48" y="24" font-size="14" font-weight="600" stroke="none" fill="#000">Senior Engineer Fable</text>
<text x="48" y="40" font-size="12" stroke="none" fill="#666">15m ago</text>
<line x1="16" y1="64" x2="328" y2="64" stroke="#666" />
<line x1="16" y1="80" x2="328" y2="80" stroke="#666" />
<line x1="16" y1="96" x2="328" y2="96" stroke="#666" />
<text x="16" y="128" font-size="14" font-weight="600" stroke="none" fill="#000">Next action (armed):</text>
<line x1="16" y1="148" x2="264" y2="148" stroke="#666" />
</g>
<!-- previous-issue nav card -->
<g transform="translate(16,296)">
<rect width="280" height="56" rx="8" />
<polyline points="20,20 14,28 20,36" fill="none" />
<text x="32" y="24" font-size="12" stroke="none" fill="#666">Previous · PAP-14544</text>
<text x="32" y="44" font-size="14" stroke="none" fill="#000">Phase 3 — rescore…</text>
</g>
<!-- NEW: monitor strip -->
<g transform="translate(16,536)">
<rect width="344" height="88" rx="8" />
<circle cx="28" cy="28" r="10" />
<line x1="28" y1="28" x2="28" y2="21" />
<line x1="28" y1="28" x2="33" y2="28" />
<text x="48" y="26" font-size="14" font-weight="600" stroke="none" fill="#000">Resumes in 2h 12m</text>
<text x="48" y="44" font-size="12" stroke="none" fill="#666">Thu Jul 17, 4:08 PM · Attempt 1</text>
<g transform="translate(48,52)">
<rect width="104" height="28" rx="4" />
<text x="52" y="19" font-size="12" text-anchor="middle" stroke="none" fill="#000">Check now</text>
</g>
</g>
<!-- composer -->
<g transform="translate(16,640)">
<rect width="344" height="136" rx="8" />
<text x="16" y="32" font-size="14" stroke="none" fill="#666">Reply…</text>
<g transform="translate(16,88)">
<rect width="32" height="32" rx="4" />
<line x1="10" y1="22" x2="22" y2="10" />
</g>
<g transform="translate(56,88)">
<rect width="120" height="32" rx="16" />
<text x="16" y="21" font-size="12" stroke="none" fill="#000">Agent mode</text>
<polyline points="96,14 102,20 108,14" fill="none" />
</g>
<g transform="translate(240,88)">
<rect width="88" height="32" rx="4" fill="#000" />
<text x="44" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#fff">Send</text>
</g>
</g>
<!-- annotations -->
<g data-region="annotations">
<rect x="8" y="528" width="360" height="104" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(360,528)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">1</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,100 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 02 · Thread bottom — monitor strip above the composer. Desktop 1280×800 -->
<rect x="0" y="0" width="1280" height="800" />
<!-- top bar -->
<line x1="0" y1="56" x2="1280" y2="56" />
<text x="24" y="36" font-size="14" stroke="none" fill="#666">Issues /</text>
<text x="88" y="36" font-size="14" font-weight="600" stroke="none" fill="#000">PAP-14557</text>
<circle cx="1240" cy="28" r="12" fill="#e6e6e6" />
<!-- properties rail divider + abbreviated rail -->
<line x1="880" y1="56" x2="880" y2="800" />
<text x="904" y="96" font-size="12" stroke="none" fill="#666">PROPERTIES</text>
<text x="904" y="136" font-size="12" stroke="none" fill="#666">Status</text>
<text x="1032" y="136" font-size="14" stroke="none" fill="#000">In Progress</text>
<text x="904" y="184" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="1040" cy="180" r="7" />
<line x1="1040" y1="180" x2="1040" y2="175" />
<line x1="1040" y1="180" x2="1044" y2="180" />
<text x="1056" y="184" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="1056" y="204" font-size="12" stroke="none" fill="#666">Jul 17, 4:08 PM · Attempt 1</text>
<!-- last agent comment (cut off at top) -->
<text x="440" y="76" font-size="12" text-anchor="middle" stroke="none" fill="#666">⌃ earlier comments</text>
<g transform="translate(48,96)">
<rect width="784" height="176" rx="8" />
<circle cx="32" cy="32" r="16" fill="#e6e6e6" />
<text x="56" y="28" font-size="14" font-weight="600" stroke="none" fill="#000">Senior Engineer Fable</text>
<text x="720" y="28" font-size="12" stroke="none" fill="#666">15m ago</text>
<line x1="56" y1="56" x2="744" y2="56" stroke="#666" />
<line x1="56" y1="72" x2="744" y2="72" stroke="#666" />
<line x1="56" y1="88" x2="744" y2="88" stroke="#666" />
<text x="56" y="120" font-size="14" font-weight="600" stroke="none" fill="#000">Next action (armed):</text>
<line x1="216" y1="116" x2="744" y2="116" stroke="#666" />
<line x1="56" y1="140" x2="560" y2="140" stroke="#666" />
</g>
<!-- previous-issue nav card -->
<g transform="translate(48,304)">
<rect width="384" height="64" rx="8" />
<polyline points="24,24 16,32 24,40" fill="none" />
<text x="40" y="28" font-size="12" stroke="none" fill="#666">Previous · PAP-14544</text>
<text x="40" y="48" font-size="14" stroke="none" fill="#000">Phase 3 — rescore + gap re-runs…</text>
</g>
<!-- NEW: monitor strip -->
<g transform="translate(48,408)">
<rect width="784" height="64" rx="8" />
<circle cx="32" cy="32" r="12" />
<line x1="32" y1="32" x2="32" y2="24" />
<line x1="32" y1="32" x2="38" y2="32" />
<text x="56" y="28" font-size="14" font-weight="600" stroke="none" fill="#000">Resumes in 2h 12m</text>
<text x="56" y="48" font-size="12" stroke="none" fill="#666">Thu Jul 17, 4:08 PM · Attempt 1 · Watching: vercel-deploy</text>
<g transform="translate(656,16)">
<rect width="112" height="32" rx="4" />
<text x="56" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#000">Check now</text>
</g>
</g>
<!-- composer -->
<g transform="translate(48,496)">
<rect width="784" height="152" rx="8" />
<text x="16" y="36" font-size="14" stroke="none" fill="#666">Reply…</text>
<g transform="translate(16,104)">
<rect width="32" height="32" rx="4" />
<line x1="10" y1="22" x2="22" y2="10" />
</g>
<g transform="translate(56,104)">
<rect width="128" height="32" rx="16" />
<text x="16" y="21" font-size="12" stroke="none" fill="#000">Agent mode</text>
<polyline points="100,14 106,20 112,14" fill="none" />
</g>
<g transform="translate(432,104)">
<rect width="192" height="32" rx="16" />
<text x="96" y="21" font-size="12" text-anchor="middle" stroke="none" fill="#000">Senior Engineer Fable</text>
</g>
<g transform="translate(640,104)">
<rect width="128" height="32" rx="4" fill="#000" />
<text x="64" y="21" font-size="14" text-anchor="middle" stroke="none" fill="#fff">Send</text>
</g>
</g>
<!-- wake hint -->
<text x="48" y="676" font-size="12" stroke="none" fill="#666">Sending a reply wakes the agent now — before the scheduled check.</text>
<!-- annotations -->
<g data-region="annotations">
<rect x="40" y="400" width="800" height="80" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(32,392)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">1</text>
</g>
<rect x="40" y="660" width="472" height="24" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(32,656)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">2</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1,121 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 03 · Properties pane — Monitor row redesign (component sheet). Desktop 1280×800 -->
<rect x="0" y="0" width="1280" height="800" />
<text x="48" y="56" font-size="20" font-weight="600" stroke="none" fill="#000">Properties pane — Monitor row redesign</text>
<text x="48" y="80" font-size="12" stroke="none" fill="#666">Side pane fragment, 352px wide. The same row is reused in the mobile properties sheet.</text>
<!-- ===== Panel A · current ===== -->
<text x="48" y="128" font-size="14" font-weight="600" stroke="none" fill="#000">A · Current — cramped, truncated</text>
<g transform="translate(48,144)">
<rect width="352" height="176" rx="8" />
<text x="16" y="44" font-size="12" stroke="none" fill="#666">Approvers</text>
<text x="144" y="44" font-size="14" stroke="none" fill="#666">None</text>
<text x="16" y="84" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="80" r="7" />
<line x1="152" y1="80" x2="152" y2="75" />
<line x1="152" y1="80" x2="156" y2="80" />
<text x="168" y="84" font-size="14" stroke="none" fill="#000">Ne…</text>
<text x="208" y="84" font-size="12" stroke="none" fill="#666">Jul 17, 2026</text>
<text x="288" y="76" font-size="12" stroke="none" fill="#666">Attempt</text>
<text x="288" y="92" font-size="12" stroke="none" fill="#666">1</text>
<text x="16" y="124" font-size="12" stroke="none" fill="#666">Watchdog</text>
<text x="144" y="124" font-size="14" stroke="none" fill="#666">None</text>
</g>
<text x="48" y="352" font-size="12" stroke="none" fill="#666">Label truncates to “Ne…”, date is ambiguous (which year matters,</text>
<text x="48" y="368" font-size="12" stroke="none" fill="#666">not the time?), attempt badge wraps into two lines.</text>
<!-- ===== Panel B · proposed collapsed row ===== -->
<text x="464" y="128" font-size="14" font-weight="600" stroke="none" fill="#000">B · Proposed — two-line row</text>
<g transform="translate(464,144)">
<rect width="352" height="176" rx="8" />
<text x="16" y="44" font-size="12" stroke="none" fill="#666">Approvers</text>
<text x="144" y="44" font-size="14" stroke="none" fill="#666">None</text>
<text x="16" y="84" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="80" r="7" />
<line x1="152" y1="80" x2="152" y2="75" />
<line x1="152" y1="80" x2="156" y2="80" />
<text x="168" y="84" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="168" y="104" font-size="12" stroke="none" fill="#666">Jul 17, 4:08 PM · Attempt 1</text>
<text x="16" y="144" font-size="12" stroke="none" fill="#666">Watchdog</text>
<text x="144" y="144" font-size="14" stroke="none" fill="#666">None</text>
</g>
<text x="464" y="352" font-size="12" stroke="none" fill="#666">Relative time leads and is bold; absolute time + attempt get their</text>
<text x="464" y="368" font-size="12" stroke="none" fill="#666">own muted line. Nothing truncates at 352px. Row grows to 2 lines.</text>
<!-- ===== Panel C · hover tooltip ===== -->
<text x="880" y="128" font-size="14" font-weight="600" stroke="none" fill="#000">C · Hover / tap — full details</text>
<g transform="translate(880,144)">
<!-- pane fragment with the row -->
<rect width="352" height="80" rx="8" />
<text x="16" y="44" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="40" r="7" />
<line x1="152" y1="40" x2="152" y2="35" />
<line x1="152" y1="40" x2="156" y2="40" />
<text x="168" y="44" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="168" y="64" font-size="12" stroke="none" fill="#666">Jul 17, 4:08 PM · Attempt 1</text>
<!-- tooltip card -->
<polygon points="180,96 188,88 196,96" fill="#fff" />
<g transform="translate(16,96)">
<rect width="320" height="368" rx="8" />
<text x="16" y="32" font-size="14" font-weight="600" stroke="none" fill="#000">Monitor</text>
<text x="304" y="32" font-size="12" text-anchor="end" stroke="none" fill="#666">Attempt 1</text>
<line x1="0" y1="48" x2="320" y2="48" />
<text x="16" y="76" font-size="12" stroke="none" fill="#666">Next check</text>
<text x="16" y="96" font-size="14" stroke="none" fill="#000">Thu, Jul 17, 2026 · 4:08 PM PDT</text>
<text x="16" y="114" font-size="12" stroke="none" fill="#666">in 2h 12m</text>
<text x="16" y="144" font-size="12" stroke="none" fill="#666">Watching</text>
<text x="16" y="164" font-size="14" stroke="none" fill="#000">vercel-deploy</text>
<text x="16" y="192" font-size="12" stroke="none" fill="#666">Notes</text>
<text x="16" y="212" font-size="14" stroke="none" fill="#000">Verify completion, commit ledger /</text>
<text x="16" y="230" font-size="14" stroke="none" fill="#000">scores, extend comparison site.</text>
<text x="16" y="260" font-size="12" stroke="none" fill="#666">Last triggered</text>
<text x="16" y="280" font-size="14" stroke="none" fill="#000">— not yet triggered</text>
<line x1="0" y1="300" x2="320" y2="300" />
<g transform="translate(16,316)">
<rect width="96" height="32" rx="4" />
<text x="48" y="21" font-size="12" text-anchor="middle" stroke="none" fill="#000">Check now</text>
</g>
<g transform="translate(120,316)">
<rect width="56" height="32" rx="4" />
<text x="28" y="21" font-size="12" text-anchor="middle" stroke="none" fill="#000">Edit</text>
</g>
<g transform="translate(184,316)">
<rect width="64" height="32" rx="4" />
<text x="32" y="21" font-size="12" text-anchor="middle" stroke="none" fill="#000">Clear</text>
</g>
</g>
</g>
<text x="880" y="656" font-size="12" stroke="none" fill="#666">Hover on desktop, tap on mobile. Exact timestamp with timezone,</text>
<text x="880" y="672" font-size="12" stroke="none" fill="#666">service, notes and quick actions — no digging into the edit popover.</text>
<!-- annotations -->
<g data-region="annotations">
<rect x="200" y="204" width="152" height="36" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(192,200)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">1</text>
</g>
<rect x="608" y="208" width="216" height="52" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(600,204)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">2</text>
</g>
<rect x="888" y="232" width="344" height="384" fill="none" stroke="#d33" stroke-dasharray="6 3" />
<g transform="translate(880,228)">
<circle cx="0" cy="0" r="12" fill="#fff" stroke="#d33" stroke-dasharray="4 2" />
<text x="0" y="4" font-size="12" font-weight="700" text-anchor="middle" stroke="none" fill="#d33">3</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -0,0 +1,103 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- 04 · Monitor row — state matrix. Desktop 1280×800 -->
<rect x="0" y="0" width="1280" height="800" />
<text x="48" y="56" font-size="20" font-weight="600" stroke="none" fill="#000">Monitor row — state matrix</text>
<text x="48" y="80" font-size="12" stroke="none" fill="#666">One copy system across banner, composer strip and properties row. Relative time always leads.</text>
<!-- 1 · scheduled, soon -->
<g transform="translate(48,112)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">SCHEDULED · UNDER 1 HOUR</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">In 24m</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">Today, 2:20 PM</text>
</g>
<!-- 2 · scheduled, today -->
<g transform="translate(656,112)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">SCHEDULED · UNDER 24 HOURS</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">Today, 4:08 PM</text>
</g>
<!-- 3 · scheduled, future day -->
<g transform="translate(48,248)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">SCHEDULED · BEYOND TODAY</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">In 3d</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">Mon Jul 20, 9:00 AM</text>
</g>
<!-- 4 · retrying -->
<g transform="translate(656,248)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">RETRYING · ATTEMPT &gt; 1</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">In 2h 12m</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">Today, 4:08 PM · </text>
<text x="272" y="92" font-size="12" font-weight="700" stroke="none" fill="#000">Attempt 3</text>
</g>
<!-- 5 · due now -->
<g transform="translate(48,384)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">DUE NOW</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" fill="#e6e6e6" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">Due now</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">checking momentarily…</text>
</g>
<!-- 6 · overdue -->
<g transform="translate(656,384)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">OVERDUE</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<circle cx="152" cy="68" r="7" fill="#e6e6e6" />
<line x1="152" y1="68" x2="152" y2="63" />
<line x1="152" y1="68" x2="156" y2="68" />
<text x="168" y="72" font-size="14" font-weight="600" stroke="none" fill="#000">Overdue by 18m</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">Jul 17, 1:38 PM · fires on next tick</text>
</g>
<!-- 7 · cleared -->
<g transform="translate(48,520)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">CLEARED</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<text x="168" y="72" font-size="14" stroke="none" fill="#666">Cleared</text>
<text x="168" y="92" font-size="12" stroke="none" fill="#666">2h ago · after attempt 2</text>
</g>
<!-- 8 · none -->
<g transform="translate(656,520)">
<rect width="576" height="112" rx="8" />
<text x="16" y="28" font-size="12" stroke="none" fill="#666">NONE</text>
<text x="16" y="72" font-size="12" stroke="none" fill="#666">Monitor</text>
<text x="144" y="72" font-size="14" stroke="none" fill="#666">None</text>
</g>
<!-- format rules -->
<text x="48" y="688" font-size="14" font-weight="600" stroke="none" fill="#000">Copy rules</text>
<text x="48" y="712" font-size="12" stroke="none" fill="#666">&lt; 1h → “In 24m” · &lt; 24h → “In 2h 12m” (two units, not “in 2h”) · beyond → “In 3d” + weekday date · past due → “Overdue by …”.</text>
<text x="48" y="732" font-size="12" stroke="none" fill="#666">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.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@ -0,0 +1,65 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="304" viewBox="0 0 1280 304"
font-family="-apple-system, system-ui, sans-serif" fill="#fff" stroke="#000" stroke-width="1.5">
<!-- Flow — the three surfaces that show the same monitor state -->
<rect x="0" y="0" width="1280" height="304" />
<text x="48" y="40" font-size="14" font-weight="600" stroke="none" fill="#000">One monitor state, three surfaces — banner (top), strip (composer), row (properties) — all fed by the same copy rules (04).</text>
<!-- 01 thumbnail: page top with banner -->
<g transform="translate(48,72)">
<rect width="240" height="160" rx="4" />
<line x1="0" y1="24" x2="240" y2="24" />
<rect x="16" y="40" width="144" height="8" fill="#e6e6e6" stroke="none" />
<rect x="16" y="60" width="208" height="32" rx="4" fill="#e6e6e6" stroke="none" />
<circle cx="32" cy="76" r="8" fill="#fff" />
<line x1="16" y1="112" x2="224" y2="112" stroke="#666" />
<line x1="16" y1="128" x2="224" y2="128" stroke="#666" />
<line x1="16" y1="144" x2="160" y2="144" stroke="#666" />
</g>
<text x="168" y="264" font-size="12" text-anchor="middle" stroke="none" fill="#000">01 · Task page top — banner</text>
<line x1="296" y1="152" x2="336" y2="152" />
<polygon points="336,152 326,146 326,158" fill="#000" stroke="none" />
<!-- 02 thumbnail: strip + composer -->
<g transform="translate(344,72)">
<rect width="240" height="160" rx="4" />
<line x1="16" y1="24" x2="224" y2="24" stroke="#666" />
<line x1="16" y1="40" x2="224" y2="40" stroke="#666" />
<line x1="16" y1="56" x2="160" y2="56" stroke="#666" />
<rect x="16" y="80" width="208" height="24" rx="4" fill="#e6e6e6" stroke="none" />
<circle cx="30" cy="92" r="6" fill="#fff" />
<rect x="16" y="112" width="208" height="32" rx="4" />
</g>
<text x="464" y="264" font-size="12" text-anchor="middle" stroke="none" fill="#000">02 · Above the composer — strip</text>
<line x1="592" y1="152" x2="632" y2="152" />
<polygon points="632,152 622,146 622,158" fill="#000" stroke="none" />
<!-- 03 thumbnail: properties row + tooltip -->
<g transform="translate(640,72)">
<rect width="240" height="160" rx="4" />
<rect x="16" y="16" width="64" height="8" fill="#e6e6e6" stroke="none" />
<rect x="120" y="16" width="104" height="8" fill="#e6e6e6" stroke="none" />
<rect x="16" y="40" width="64" height="8" fill="#e6e6e6" stroke="none" />
<rect x="120" y="36" width="80" height="8" fill="#000" stroke="none" />
<rect x="120" y="50" width="104" height="6" fill="#e6e6e6" stroke="none" />
<rect x="120" y="72" width="104" height="72" rx="4" />
<rect x="132" y="84" width="80" height="6" fill="#e6e6e6" stroke="none" />
<rect x="132" y="98" width="80" height="6" fill="#e6e6e6" stroke="none" />
<rect x="132" y="112" width="56" height="6" fill="#e6e6e6" stroke="none" />
</g>
<text x="760" y="264" font-size="12" text-anchor="middle" stroke="none" fill="#000">03 · Properties row + tooltip</text>
<line x1="888" y1="152" x2="928" y2="152" />
<polygon points="928,152 918,146 918,158" fill="#000" stroke="none" />
<!-- 04 thumbnail: states grid -->
<g transform="translate(936,72)">
<rect width="240" height="160" rx="4" />
<rect x="16" y="16" width="96" height="56" rx="4" />
<rect x="128" y="16" width="96" height="56" rx="4" />
<rect x="16" y="88" width="96" height="56" rx="4" />
<rect x="128" y="88" width="96" height="56" rx="4" />
</g>
<text x="1056" y="264" font-size="12" text-anchor="middle" stroke="none" fill="#000">04 · State matrix + copy rules</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -527,6 +527,8 @@ interface IssueChatThreadProps {
verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[],
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
/** 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({
</div>
</IssueChatErrorBoundary>
{showComposer && composerAccessory ? (
<div data-testid="issue-chat-composer-accessory" className="mb-2">
{composerAccessory}
</div>
) : null}
{showComposer ? (
<div
ref={composerViewportAnchorRef}

View File

@ -1,236 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueMonitorActivityCard } from "./IssueMonitorActivityCard";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createIssue(overrides: Partial<Issue> = {}): 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(<IssueMonitorActivityCard issue={createIssue()} onCheckNow={onCheckNow} />);
});
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(
<IssueMonitorActivityCard
issue={createIssue({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
monitor: {
nextCheckAt: "2026-04-11T12:30:00.000Z",
notes: "Check deployment health",
scheduledBy: "board",
serviceName: "Deploy provider",
externalRef: "https://provider.example/deploy/123?token=secret",
},
},
})}
/>,
);
});
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(
<IssueMonitorActivityCard
issue={createIssue({
monitorNextCheckAt: "2026-04-11T12:30:00.000Z" as unknown as Date,
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
},
})}
/>,
);
});
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(
<IssueMonitorActivityCard
issue={createIssue({
executionPolicy: {
mode: "normal",
commentRequired: true,
stages: [],
},
executionState: {
status: "idle",
currentStageId: null,
currentStageIndex: null,
currentStageType: null,
currentParticipant: null,
returnAssignee: null,
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
monitor: null,
},
monitorNextCheckAt: null,
monitorNotes: null,
})}
/>,
);
});
expect(container.textContent).toBe("");
act(() => root.unmount());
});
});

View File

@ -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 (
<div className="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">Monitor scheduled</div>
<div className="text-xs text-muted-foreground">
Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)})
</div>
{monitor.notes ? (
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
) : null}
{monitor.serviceName ? (
<div className="mt-1 text-xs text-muted-foreground">
{monitor.serviceName}
</div>
) : null}
{monitor.attemptCount > 0 ? (
<div className="mt-1 text-xs text-muted-foreground">Attempt {monitor.attemptCount}</div>
) : null}
</div>
{onCheckNow ? (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 shadow-none"
onClick={onCheckNow}
disabled={checkingNow}
>
{checkingNow ? "Checking..." : "Check now"}
</Button>
) : null}
</div>
</div>
);
}

View File

@ -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<DerivedMonitorState> & { 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(
<IssueMonitorBanner
issue={issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString())}
onCheckNow={onCheckNow}
/>,
);
});
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(
<>
<IssueMonitorBanner issue={issueWithMonitor(null)} onCheckNow={vi.fn()} />
<IssueMonitorComposerStrip issue={issueWithMonitor(null)} onCheckNow={vi.fn()} />
</>,
);
});
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(
<IssueMonitorComposerStrip
issue={issueWithMonitor(new Date(NOW.getTime() + 2 * 60 * 60_000).toISOString())}
onCheckNow={vi.fn()}
/>,
);
});
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());
});
});

View File

@ -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 (
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 shadow-none"
onClick={onCheckNow}
disabled={checkingNow}
>
{checkingNow ? "Checking…" : "Check now"}
</Button>
);
}
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 (
<InlineBanner
tone={copy.tone}
icon={Clock}
title={copy.bannerTitle}
className="my-3"
actions={onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
>
<span>{copy.bannerMeta.join(" · ")}</span>
</InlineBanner>
);
}
/**
* Slim, inline (not sticky) strip anchored directly above the reply composer.
* Mirrors the banner's monitor state and reminds the reader that replying wakes
* the agent early (PAP-14557 decisions 2 + wireframe 02).
*/
export function IssueMonitorComposerStrip({
issue,
onCheckNow = null,
checkingNow = false,
className,
}: IssueMonitorSurfaceProps & { className?: string }) {
const copy = useMonitorSurfaceCopy(issue);
if (!copy) return null;
return (
<div
role="note"
data-testid="issue-monitor-composer-strip"
className={cn("rounded-lg border border-border bg-muted/30 px-3 py-2", className)}
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-2">
<Clock className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">{copy.stripTitle}</div>
<div className="text-xs text-muted-foreground">{copy.stripMeta.join(" · ")}</div>
</div>
</div>
{onCheckNow ? <CheckNowButton onCheckNow={onCheckNow} checkingNow={checkingNow} /> : null}
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Sending a reply wakes the agent now before the scheduled check.
</p>
</div>
);
}

View File

@ -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(
<QueryClientProvider client={queryClient}>
<IssueProperties issue={issue} childIssues={[]} onUpdate={vi.fn()} inline />
</QueryClientProvider>,
);
});
};
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 = {

View File

@ -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 = (
<span className="inline-flex min-w-0 items-center gap-1.5">
<TooltipProvider>
<Tooltip open={monitorDetailsOpen} onOpenChange={setMonitorDetailsOpen}>
<TooltipTrigger asChild>
<span
className="inline-flex min-w-0 items-start gap-1.5"
data-testid="monitor-row-trigger"
onClick={() => setMonitorDetailsOpen(false)}
>
{monitorNextCheckAt ? (
<Clock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
) : null}
<span
className={cn(
"min-w-0 truncate text-sm",
monitorNextCheckAt ? "text-foreground" : "text-muted-foreground",
)}
title={monitorNextCheckAt ? currentMonitorLabel : undefined}
>
{monitorNextCheckAt ? `Next check ${formatMonitorOffset(monitorNextCheckAt)}` : currentMonitorLabel}
</span>
{monitorNextCheckAt ? (
<span className="shrink-0 text-xs text-muted-foreground" title={currentMonitorLabel}>
{formatDate(new Date(monitorNextCheckAt))}
<span className="flex min-w-0 flex-col items-start">
<span className={cn("text-sm", monitorNextCheckAt ? "font-semibold text-foreground" : "text-muted-foreground")}>{monitorPrimary}</span>
{monitorSecondary ? (
<span className="text-xs text-muted-foreground">{monitorSecondary}</span>
) : null}
</span>
</span>
</TooltipTrigger>
{monitorNextCheckAt ? (
<TooltipContent
side="left"
className="w-80 border border-border bg-popover p-0 text-popover-foreground shadow-md"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-semibold">Monitor</span>
{monitorAttemptCount > 0 ? <span className="text-xs text-muted-foreground">Attempt {monitorAttemptCount}</span> : null}
</div>
<div className="space-y-3 px-4 py-3 text-left">
<div>
<div className="text-xs text-muted-foreground">Next check</div>
<div className="text-sm">{formatMonitorAbsoluteFull(monitorNextCheckAt)}</div>
<div className="text-xs text-muted-foreground">{monitorRelative}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Watching</div>
<div className="text-sm">{monitorServiceName ?? "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Notes</div>
<div className="whitespace-normal text-sm">{monitorNotes ?? "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Last triggered</div>
<div className="text-sm">{monitorLastTriggeredAt ? formatMonitorAbsoluteFull(monitorLastTriggeredAt) : "— not yet triggered"}</div>
</div>
</div>
<div className="flex gap-2 border-t border-border px-4 py-3">
{onCheckMonitorNow ? (
<Button type="button" size="sm" variant="outline" disabled={checkingMonitorNow} onClick={() => { setMonitorDetailsOpen(false); onCheckMonitorNow(); }}>
{checkingMonitorNow ? "Checking…" : "Check now"}
</Button>
) : null}
<Button type="button" size="sm" variant="outline" onClick={() => { setMonitorDetailsOpen(false); setMonitorOpen(true); }}>Edit</Button>
<Button type="button" size="sm" variant="outline" onClick={() => { setMonitorDetailsOpen(false); clearMonitor(); }}>Clear</Button>
</div>
</TooltipContent>
) : null}
</span>
</Tooltip>
</TooltipProvider>
);
const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? (
<span className="text-xs text-muted-foreground">
Attempt {issue.monitorAttemptCount}
</span>
) : 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}
</PropertyPicker>

View File

@ -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(<Probe nextCheckAt="2026-07-17T20:02:00.000Z" />));
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(<Probe nextCheckAt="2026-07-17T20:00:45.000Z" />));
flushSync(() => vi.advanceTimersByTime(1_000));
expect(observed.at(-1)).toBe(new Date("2026-07-17T20:00:31.000Z").getTime());
flushSync(() => root.render(<Probe nextCheckAt={null} />));
expect(vi.getTimerCount()).toBe(0);
flushSync(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
});
});

View File

@ -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<typeof setTimeout> | 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;
}

View File

@ -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<unknown> | void;
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
composerRef: Ref<IssueChatComposerHandle>;
/** 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}
<ThreadComponent
composerRef={composerRef}
composerAccessory={composerAccessory}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
@ -1257,8 +1265,6 @@ type IssueDetailActivityTabProps = {
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
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({
</div>
)}
<IssueScheduledRetryCard issueId={issue.id} scheduledRetry={issue.scheduledRetry ?? null} />
<IssueMonitorActivityCard
issue={issue}
onCheckNow={onCheckMonitorNow}
checkingNow={checkingMonitorNow}
/>
{/* 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"
/>
<IssueMonitorBanner
issue={issue}
onCheckNow={() => checkIssueMonitorNow.mutate()}
checkingNow={checkIssueMonitorNow.isPending}
/>
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
@ -4753,6 +4763,15 @@ export function IssueDetail() {
onLoadOlderComments={loadOlderComments}
onRefreshLatestComments={refetchLatestComments}
composerRef={commentComposerRef}
composerAccessory={
hasVisibleMonitorSurface(issue) ? (
<IssueMonitorComposerStrip
issue={issue}
onCheckNow={() => checkIssueMonitorNow.mutate()}
checkingNow={checkIssueMonitorNow.isPending}
/>
) : null
}
footer={
siblingNavigation ? (
<IssueSiblingNavigation
@ -4828,8 +4847,6 @@ export function IssueDetail() {
onApprovalAction={(approvalId, action) => {
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}
/>
</div>
</ScrollArea>

View File

@ -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<NonNullable<Issue["executionState"]>["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 (
<div className="space-y-8 p-6">
<section className="space-y-2">
<section className="space-y-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - external service (Greptile)
Pinned top banner scheduled (2h wait, external service)
</div>
<IssueMonitorActivityCard
issue={monitoredIssue}
<IssueMonitorBanner
issue={scheduledMonitorIssue}
onCheckNow={() => undefined}
checkingNow={false}
/>
</section>
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - generic 2h wait, no service metadata
Pinned top banner retrying (attempt 3)
</div>
<IssueMonitorActivityCard
issue={longerWaitIssue}
onCheckNow={() => undefined}
checkingNow={false}
/>
</section>
<IssueMonitorBanner issue={retryingMonitorIssue} onCheckNow={() => undefined} />
<section className="space-y-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
IssueMonitorActivityCard - returns null when no monitor is set
Pinned top banner due now
</div>
<IssueMonitorBanner issue={dueNowMonitorIssue} onCheckNow={() => undefined} />
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Pinned top banner overdue (warning tone)
</div>
<IssueMonitorBanner issue={overdueMonitorIssue} onCheckNow={() => undefined} />
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Pinned top banner cleared / none (renders nothing)
</div>
<div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
(intentionally renders nothing for issues without a scheduled monitor)
(banner + strip hide entirely for cleared / no-monitor issues)
</div>
<IssueMonitorActivityCard issue={baseIssue} onCheckNow={() => undefined} />
<IssueMonitorBanner issue={clearedIssue} onCheckNow={() => undefined} />
<IssueMonitorBanner issue={baseIssue} onCheckNow={() => undefined} />
</section>
<section className="space-y-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Composer strip scheduled (inline, above the reply composer)
</div>
<IssueMonitorComposerStrip
issue={scheduledMonitorIssue}
onCheckNow={() => undefined}
/>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Composer strip overdue
</div>
<IssueMonitorComposerStrip issue={overdueMonitorIssue} onCheckNow={() => undefined} />
</section>
<div className="grid gap-6 lg:grid-cols-2">
@ -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.",
},
},
},