diff --git a/design/PAP-9432-doc-annotations/index.html b/design/PAP-9432-doc-annotations/index.html new file mode 100644 index 00000000000..e29fad38a39 --- /dev/null +++ b/design/PAP-9432-doc-annotations/index.html @@ -0,0 +1,461 @@ + + + + + +PAP-9432 — Document Annotations UX + + + +
+
+ + + PAP-9432 · Doc Annotations
+ Jump to a screen +
+ +
+ +
+ +
+
+
PAP-9432 · Phase 1 UX spec
+

Document annotations — UX spec

+

Document-native highlights and comment threads on Paperclip issue documents (primary target: the plan document). Paired desktop/mobile wireframes for rendered mode, edit mode, side panel, state matrix, and deep-link behaviour. Pair this with the spec document on PAP-9432.

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

Annotation lifecycle across revisions

+

Single thread followed through select → compose → resolve → revision → re-anchor outcomes. Dashed arrows are side effects (e.g. assignee wake).

+
+
flow.svg1280×720
+ Annotation lifecycle flow +
+
+ +
+
Step 1 · Capture
+

1.Rendered mode — selection

+

User selects body text; floating toolbar offers Comment + Copy link. ⌘⇧M is the keyboard equivalent.

+
+
+
rendered-selection.svg1280×800 · desktop
+ Rendered-mode selection — desktop +
+
+
mobile375×812
+ Rendered-mode selection — mobile +
+
+
+

Annotations

+
    +
  • 1 — floating selection toolbar anchored above the range (flips below if <48px from card top).
  • +
  • 2 — active highlight: solid mid-grey underline.
  • +
  • 3 — resolved highlight: dashed underline, only visible when "Show resolved" is on.
  • +
  • 4 — unresolved-count chip lives between rev menu and right-actions.
  • +
  • 5 — ⌘⇧M opens the comment composer for the current selection.
  • +
  • Mobile — selection sheet → + Comment opens the bottom sheet directly; native platform sheet is preferred when extensible.
  • +
+
Why this changes: document-native capture matches Google Docs / Notion mental models; agents and reviewers already think in "select text → comment" rather than "open a chat".
+
+
+
+
+ +
+
Step 2 · Read & reply
+

2.Side panel · bottom sheet

+

Annotation panel is bound to one document. Desktop = right slide-in (380px), mobile = bottom sheet with peek/half/full snap points.

+
+
+
side-panel.svg1280×800 · desktop
+ Side panel — desktop +
+
+
mobile375×812
+ Bottom sheet — mobile +
+
+
+

Annotations

+
    +
  • 1 — filter chips (Open / Resolved / Stale / Orphan); active chip is filled.
  • +
  • 2 — focused thread auto-expands with composer; others collapse to 2-line summary.
  • +
  • 3 — focused anchor in body gets a 2px border + soft fill; bi-directional click linkage.
  • +
  • Mobile — single thread at a time; prev/next steps through current filter. Composer collapses to a pill at rest.
  • +
+
Why this changes: right-rail Properties is rarely read while reviewing comments — collapsing it for the panel buys 380px without a new layout. On mobile, single-thread focus + prev/next avoids horizontal cramming that kills usability in narrow viewports.
+
+
+
+
+ +
+
Step 3 · Edit mode
+

3.Edit-mode capture + dirty-draft guardrails

+

MDXEditor/Lexical hosts selection capture via a plugin; existing thread anchors render as read-only decorations. Dirty/conflicted drafts disable new-anchor creation with explicit reason text.

+
+
+
edit-mode.svg1280×800 · desktop
+ Edit mode — desktop +
+
+
mobile375×812
+ Edit mode — mobile +
+
+
+

Annotations

+
    +
  • 1 — toolbar primary action uses current Lexical RangeSelection; same ⌘⇧M shortcut.
  • +
  • 2 — dirty-draft banner names the reason; resolves the second autosave commits.
  • +
  • 3 — side-panel footer CTA mirrors the disabled state; existing threads remain readable.
  • +
  • Mobile — keyboard accessory bar shows the disabled state inline so it isn't a surprise.
  • +
+
Why this changes: anchors are tied to a saved revision baseline. Allowing creation against a dirty draft either fabricates revisions or risks anchors into never-saved text. The explicit disabled-with-reason pattern is preferable to silent allow-then-fail.
+
+
+
+
+ +
+
Step 4 · State matrix
+

4.Open · Resolved · Stale · Orphaned

+

Identical data layout, distinct chrome. Resolved hidden by default; stale highlights remain but flagged; orphaned have no in-body anchor.

+
+
+
states.svg1280×800 · desktop
+ Annotation states — desktop +
+
+
mobile375×812
+ Annotation states — mobile +
+
+
+

Annotations

+
    +
  • Open — solid grey underline, primary "Open" pill, Reply / Resolve / Copy link.
  • +
  • Resolved — dashed outline + dimmed card, hidden by default; Reopen + Copy link.
  • +
  • Stale — dashed outline + ⚠; Re-anchor (inline re-select), Show diff, Resolve.
  • +
  • Orphaned — no in-body anchor; panel-only with "View rev N" link to the last valid revision.
  • +
+
Why this changes: distinguishing fuzzy vs. lost anchors avoids the binary "vanished" failure mode. Reviewers keep their thoughts even when prose changes, and stale state surfaces a path back to a clean anchor.
+
+
+
+
+ +
+
Step 5 · Deep linking
+

5.Folded → unfolded with focused thread + comment

+

URL adds &thread=…&comment=… to the existing #document-key anchor. Resolving forces unfold, scroll, pulse, panel auto-open, thread expand, comment focus.

+
+
+
deep-link.svg1280×800 · desktop
+ Deep link — desktop +
+
+
mobile375×812
+ Deep link — mobile +
+
+
+

Annotations

+
    +
  • 1 — folded card auto-unfolds; count chip remains the only chrome on the folded header.
  • +
  • 2 — URL grammar — three precision levels, each optional.
  • +
  • 3 — body pulse ring (1.4s) on anchor; then steady focused border.
  • +
  • 4 — panel auto-opens, expands target thread, comment-level focus ring.
  • +
+
Why this changes: agents quote-link inside comments today; without unambiguous deep-link resolution, a clicked link drops the reader into a generic doc view and forces them to scroll-hunt. Three precision levels (doc / thread / comment) means we don't have to invent a new scheme for each entry point.
+
+
+
+
+ +
+
Accessibility
+

Keyboard, focus, motion, ARIA

+
+
    +
  • Shortcut — ⌘⇧M / Ctrl⇧M opens Comment for the current selection in both rendered and edit modes.
  • +
  • Tab order — header filters → thread cards (tabindex=0) → expanded thread composer.
  • +
  • Focus on open — composer textarea (compose) or first comment row (deep link).
  • +
  • Focus on close — returns to the originating highlight or the count chip.
  • +
  • Escape semantics — composer dirty → confirm-discard; otherwise collapse thread → close panel. Bottom sheet snaps down one step per Esc / scrim tap.
  • +
  • Visible focus — focus-visible:ring-2 ring-ring ring-offset-2; 2px outline on highlight runs (never colour alone).
  • +
  • ARIA — panel role="complementary", thread role="article" + aria-labelledby on quote, hidden state text on highlights and thread headers.
  • +
  • Reduced motion — pulse, slide, snap fall back to instant under prefers-reduced-motion: reduce; focus rings stay.
  • +
  • Contrast — quote chip bg-muted/40 on bg-card passes AA; Stale never relies on the ⚠ glyph alone.
  • +
+
+
+ +
+
For reviewer
+

Open questions

+
+
    +
  • Q1 — Selection on touch: inject into native selection sheet where supported (iOS Safari, Chrome Android), fall back to floating FAB anchored to selection rect. Recommend platform-sheet-first with FAB fallback.
  • +
  • Q2 — Resolved visibility default: hidden with toggle (recommended) or visible with softer chrome. Hidden by default — matches Google Docs and keeps long plan docs readable.
  • +
  • Q3 — Stale Re-anchor: inline re-select (recommended) vs. modal with two body previews. Inline; "Show diff" covers the modal-style comparison need.
  • +
+
+
+ + +
+
+ + + + + + diff --git a/design/PAP-9432-doc-annotations/wireframes/deep-link-mobile.svg b/design/PAP-9432-doc-annotations/wireframes/deep-link-mobile.svg new file mode 100644 index 00000000000..f12c113adfd --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/deep-link-mobile.svg @@ -0,0 +1,81 @@ + + + + + + + + + + ← Issue + PLAN — rev 4 + ⋯ + + + + + + …#document-plan&thread=docann_xyz + + + + + + + + + + + + + + + + + + + + + + + Thread + × + + + + + “quote …” + + Dotta · 2m ago + + + + + + UXDesigner · just now + [comment=c_42] + + + + + ‹ + 1 of 2 open + + › + + Resolve + + + + + + + + + + + 1 deep link unfolds doc, scrolls anchor into upper half of viewport above the sheet + 2 bottom sheet opens automatically; comment-id focus ring on target comment + + diff --git a/design/PAP-9432-doc-annotations/wireframes/deep-link.svg b/design/PAP-9432-doc-annotations/wireframes/deep-link.svg new file mode 100644 index 00000000000..4ab80f9842f --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/deep-link.svg @@ -0,0 +1,102 @@ + + + + + + + + + /PAP/issues/PAP-9436#document-plan&thread=docann_xyz&comment=c_42 + + + + + Before deep link + + + ▸ PLAN rev 4 updated 12m ago 3 comments + ⋯ + + + + + + After deep link — folded card auto-opens, scrolls to anchor, opens panel to thread + + + + ▾ + + PLAN + rev 4 ▾ + + + 3 comments + + + + + + + + + + + + + + + + + + + + + + Comments + PLAN · rev 4 + + + + + + “selected quote …” + + + Dotta · 2m ago + + + + + + + UXDesigner · just now + [comment=c_42] + + + + + + + Reply… + + Reply + + + + + + + + + + + + + 1 folded state — fold curtain still hides body. Deep link forces unfold + scroll + focus. + 2 URL grammar — `#document-{key}&thread={id}&comment={id}`; key alone scrolls to doc only + 3 body pulse — 1.4s ring on anchor, then steady focused border + 4 panel auto-opens on the right, expands target thread, scrolls to target comment, focus ring on comment + + diff --git a/design/PAP-9432-doc-annotations/wireframes/edit-mode-mobile.svg b/design/PAP-9432-doc-annotations/wireframes/edit-mode-mobile.svg new file mode 100644 index 00000000000..911ca7fa757 --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/edit-mode-mobile.svg @@ -0,0 +1,73 @@ + + + + + + + + + + ← Cancel + Edit plan + Done + + + + + + ⟳ Saving draft… new comments disabled until saved + + + + + + + B + I + H + • + ⌧ + + + + Comment (save first) + + + + + + + + + + + + + + + + + + + + + + + + + Comment disabled — autosave in progress + Hide + + + + + + + + + + 1 dirty-state ribbon — explains why new anchors are disabled + 2 toolbar action disabled with reason hover (sheet on tap) + 3 keyboard accessory mirrors the disabled state + + diff --git a/design/PAP-9432-doc-annotations/wireframes/edit-mode.svg b/design/PAP-9432-doc-annotations/wireframes/edit-mode.svg new file mode 100644 index 00000000000..86ecf9fdfef --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/edit-mode.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + EDIT + + PLAN + rev 4 ← editing + + ⟳ Saving… + + + + 3 comments + + + Cancel + + Done + + + + + + + B + I + S + + H1 + H2 + • + 1. + + ⌧ Link + ⌗ Code + ⌹ Table + + + + + Comment on selection + ⌘⇧M + + + + + + + + + + + + + + + + + + + + + + + + + + Comment + + ⌘⇧M + + + + + + + + Save draft to anchor new comments + Existing threads remain readable. New anchors are disabled while the draft is unsaved. + + Save now + + + + + + + + Comments + PLAN · rev 4 · read-only while draft is dirty + + + + + + “existing quote …” + Dotta · 1h ago + 2 replies + + + + + New comment disabled — save draft first + + + + + + + + + + + 1 toolbar primary action — uses current Lexical selection; same shortcut ⌘⇧M + 2 dirty-draft banner — explains why new anchors are disabled until save lands + 3 disabled "new comment" CTA mirrors banner reason + + diff --git a/design/PAP-9432-doc-annotations/wireframes/flow.svg b/design/PAP-9432-doc-annotations/wireframes/flow.svg new file mode 100644 index 00000000000..6d314420926 --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/flow.svg @@ -0,0 +1,141 @@ + + + + Annotation lifecycle — single thread across revisions + Each card is one state in the lifecycle of a single thread. Arrows show user/system events that drive transitions. + + + + + + + 1. Select text + + + + + Comment + selection toolbar + + + + + 2. Compose + + “quote…” + + type comment… + + Send + + + + + 3. Open thread + + + anchor + 2 replies + wakes agent on issue + + + + + 4. Resolve + + + Resolved + hidden by default; + "Show resolved" reveals + + + + + + 5. Doc revised + rev 4 → rev 5 + + + server remaps anchor + + + + + 6a. Exact remap + + quote+prefix+suffix match + thread stays Open + + + + + 6b. Stale + + fuzzy match only + ⚠ flag + Re-anchor action + + + + + 6c. Orphaned + + no match in body + view rev 3 to see original + + + + + + 4'. Reopen + + resolved → open + re-wakes assignee + + + + + + Edit-mode draft + dirty / conflicted + + read-only panel + no new anchors + save → unlocks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Legend + + user/system event + + side-effect (e.g. wake) + + resolved / stale chrome + + diff --git a/design/PAP-9432-doc-annotations/wireframes/rendered-selection-mobile.svg b/design/PAP-9432-doc-annotations/wireframes/rendered-selection-mobile.svg new file mode 100644 index 00000000000..26aa1996827 --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/rendered-selection-mobile.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + ← Issue + ⋯ + + + + + PAP-9436 + Phase 4: Document + annotation UI + + + + + + + + ▾ + + PLAN + rev 4 ▾ + + + + 3 comments + + + + + + + + + + + + + + + + + + + + + + + + Comment + + ⎘ Copy link + + + + + + + + Long-press text to select • Selection menu appears above + + + + + + + + + 1 native selection sheet → tap "Comment" opens compose drawer (full bottom sheet) + 2 unresolved count chip + + diff --git a/design/PAP-9432-doc-annotations/wireframes/rendered-selection.svg b/design/PAP-9432-doc-annotations/wireframes/rendered-selection.svg new file mode 100644 index 00000000000..dc5e9f51a80 --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/rendered-selection.svg @@ -0,0 +1,121 @@ + + + + + + + + + + Share + ⋯ More + + + + + PAP / Issue + Phase 4: Document annotation UI + + in progress + + medium + + + + + + + + ▾ + + PLAN + rev 4 ▾ + updated 12m ago + + + + 3 comments + + + ⧉ + + ⋯ + + Comments + + + + + + + + + + + + + + + + + + + + + + + + + + + + + show more ▾ + + + + + + + Comment + + ⎘ Copy link + ⌘⇧M + + + + + + + + + Properties + + Assignee + + Status + in progress + Priority + medium + Project + Paperclip App + Blockers + 0 + + + + + + + + + + + + 1 selected range — floating toolbar anchors above selection (or below on near-top) + 2 active thread highlight — solid underline, hover/focus elevates + 3 resolved thread highlight — dashed underline, dimmed + 4 unresolved count chip in document chrome + 5 floating toolbar — "Comment" + "Copy link"; ⌘⇧M keyboard shortcut + + diff --git a/design/PAP-9432-doc-annotations/wireframes/side-panel-mobile.svg b/design/PAP-9432-doc-annotations/wireframes/side-panel-mobile.svg new file mode 100644 index 00000000000..85fa67908fa --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/side-panel-mobile.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + Tap scrim to dismiss + + + + + + + + + + Comments + PLAN · rev 4 + × + + + + + + Open 2 + + Resolved 1 + + Stale 0 + + + + + + + “selected quote …” + + + Dotta · board + 2m ago + + + + + + + ‹ + 1 of 2 open + + › + + Resolve + + + + + + + Reply… + + Send + + + + + + + + + + + 1 grab handle — swipe up to expand to full height, swipe down to dismiss + 2 single thread focus — prev/next steps through "Open" filter; tapping body scrolls to anchor + 3 composer collapses; tapping expands to multiline + mention chip + + diff --git a/design/PAP-9432-doc-annotations/wireframes/side-panel.svg b/design/PAP-9432-doc-annotations/wireframes/side-panel.svg new file mode 100644 index 00000000000..f3ef3d82e3b --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/side-panel.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + ▾ + + PLAN + rev 4 ▾ + + + + 3 comments + + + ⧉ + + ⋯ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Comments + PLAN · rev 4 + + + + Open 2 + + Resolved 1 + + × + + + + + + + + + “selected quote from the document …” + + + + Dotta · board + 2m ago + + + + + + + UXDesigner · agent + just now + + + + + + + + Reply… + + @ mention + + Resolve + + Reply + + + + + + + “another anchored quote…” + + + Senior PE + 1d ago · 2 replies + + + + + + + + “resolved quote…” + + + CodexCoder + Resolved · 3h ago + + Reopen + + + + + + + + + New comment on selection + ⌘⇧M + + + + + + + + + + + 1 panel header — filter chips (Open / Resolved) + clear-focus × + 2 expanded thread — quote chip, replies, mention picker, Resolve + Reply + 3 focused anchor — soft inset background, links pane ↔ body + + diff --git a/design/PAP-9432-doc-annotations/wireframes/states-mobile.svg b/design/PAP-9432-doc-annotations/wireframes/states-mobile.svg new file mode 100644 index 00000000000..6245124bd10 --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/states-mobile.svg @@ -0,0 +1,91 @@ + + + + + + + + + + ← PLAN + Comments + ⋯ + + + + + + Open 1 + + Resolved 1 + + Stale 1 + + Orphan 1 + + + + + + + + “quote …” + + Open + rev 4 · 2 replies + + Reply + Resolve + Copy link + + + + + + “quote …” + + Resolved + rev 3 → 4 + + Reopen + Copy link + + + + + + “fuzzy quote …” + + Stale + ⚠ matched fuzzy + + Re-anchor + Show diff + Resolve + + + + + + “original quote …” + + Orphaned + last seen rev 3 + + View rev 3 + Resolve + Copy link + + + + + + Filter chips wrap to a second row at 320px. "Show resolved/orphaned" toggles persist per-document. + + + + + + + diff --git a/design/PAP-9432-doc-annotations/wireframes/states.svg b/design/PAP-9432-doc-annotations/wireframes/states.svg new file mode 100644 index 00000000000..c6253850dfb --- /dev/null +++ b/design/PAP-9432-doc-annotations/wireframes/states.svg @@ -0,0 +1,118 @@ + + + + + + Thread state matrix — visible chrome per state + Same anchor data, different presentation. Card states on the left, in-body highlight states on the right. + + + + + + + + + “selected quote …” + Open · active anchor + Dotta · 2m ago · 2 replies + + Open + rev 4 (current) + + Reply • Resolve • Copy link + + + + + + + “resolved quote …” + Resolved + CodexCoder · 3h ago · 1 reply + + Resolved + rev 3 → 4 + + Reopen • Copy link + + + + + + + “fuzzy-matched quote …” + Stale · fuzzy anchor + Senior PE · resolved match at rev 4 + + Stale + + Re-anchor + + Show diff + + Click "Show diff" to compare rev 3 anchor vs rev 4 text + + + + + + + “original quote no longer in body” + Orphaned · no match + QA · last seen rev 3 · 4 replies + + Orphaned + + View rev 3 + + Resolve + + No in-body anchor → no scroll-to action; quote stays canonical + + + + + + + In-body highlight legend + + + + + + Active selection (composing) + + + Open thread — solid mid-grey underline + + + Resolved thread — dashed outline, dim fill + + + Stale (fuzzy match) — dashed outline, ⚠ flag + ⚠ + + + Orphaned — no in-body underline; panel-only + + + Focused thread — heavy 2px border across anchor + + + + + + + 3 comments + unresolved count only — resolved/stale/orphaned hidden until filter toggled + + + + + + + + All four states show identical author/quote/reply metadata. Only the chrome (border, fill, badge, actions) differs — never the data layout. + diff --git a/design/PAP-9486-doc-comments-right/screenshots/01-integrated-desktop-open.png b/design/PAP-9486-doc-comments-right/screenshots/01-integrated-desktop-open.png new file mode 100644 index 00000000000..55b3f60ad0c Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/01-integrated-desktop-open.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/02-integrated-desktop-zero-count.png b/design/PAP-9486-doc-comments-right/screenshots/02-integrated-desktop-zero-count.png new file mode 100644 index 00000000000..7c271658c14 Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/02-integrated-desktop-zero-count.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/03-integrated-desktop-edit-mode.png b/design/PAP-9486-doc-comments-right/screenshots/03-integrated-desktop-edit-mode.png new file mode 100644 index 00000000000..8da6279b49a Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/03-integrated-desktop-edit-mode.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/04-integrated-desktop-dirty-draft.png b/design/PAP-9486-doc-comments-right/screenshots/04-integrated-desktop-dirty-draft.png new file mode 100644 index 00000000000..37f94e2edab Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/04-integrated-desktop-dirty-draft.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/05-integrated-mobile-sheet.png b/design/PAP-9486-doc-comments-right/screenshots/05-integrated-mobile-sheet.png new file mode 100644 index 00000000000..d049f6ee57e Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/05-integrated-mobile-sheet.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/10-states-open-focused.png b/design/PAP-9486-doc-comments-right/screenshots/10-states-open-focused.png new file mode 100644 index 00000000000..259ac719d09 Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/10-states-open-focused.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/11-states-resolved-focused.png b/design/PAP-9486-doc-comments-right/screenshots/11-states-resolved-focused.png new file mode 100644 index 00000000000..bf6811d1fce Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/11-states-resolved-focused.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/12-states-stale-focused.png b/design/PAP-9486-doc-comments-right/screenshots/12-states-stale-focused.png new file mode 100644 index 00000000000..2c8552fe01b Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/12-states-stale-focused.png differ diff --git a/design/PAP-9486-doc-comments-right/screenshots/13-states-orphaned-focused.png b/design/PAP-9486-doc-comments-right/screenshots/13-states-orphaned-focused.png new file mode 100644 index 00000000000..5d298631b3a Binary files /dev/null and b/design/PAP-9486-doc-comments-right/screenshots/13-states-orphaned-focused.png differ diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0412632f8b8..03a672f0e12 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4118,7 +4118,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) continuationAttempt: decision.nextAttempt, updatedAt: new Date(), }) - .where(eq(heartbeatRuns.id, continuationRun.id)); + .where(eq(heartbeatRuns.id, run.id)); } } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 0f12e60444a..f58f378eae7 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -2919,7 +2919,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) and( eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), isNull(issues.hiddenAt), - notInArray(issues.status, ["done", "cancelled"]), ), ); const result = { @@ -3248,7 +3247,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) let escalation: Awaited>; try { escalation = await issuesSvc.create(issue.companyId, { - title: `Unblock liveness incident for ${recoveryIssue.identifier ?? recoveryIssue.title}`, + title: `Unblock liveness incident for ${issue.identifier ?? issue.id}`, description: buildLivenessEscalationDescription(input.finding), status: "todo", priority: "high", diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index b22a8d4137e..0e8aa5eacfc 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -85,6 +85,7 @@ export function DocumentAnnotationLayer({ const [toolbarPosition, setToolbarPosition] = useState(null); const overlayRef = useRef(null); const layerContainerRef = useRef(null); + const lastCaptureSelectionRequestIdRef = useRef(0); const visibleThreads = useMemo(() => { if (!hideResolved) return threads; @@ -230,6 +231,8 @@ export function DocumentAnnotationLayer({ useEffect(() => { if (captureSelectionRequestId === undefined) return; if (captureSelectionRequestId === 0) return; + if (lastCaptureSelectionRequestIdRef.current === captureSelectionRequestId) return; + lastCaptureSelectionRequestIdRef.current = captureSelectionRequestId; const anchor = captureSelection(); if (anchor) { onPendingAnchorChange(anchor); diff --git a/ui/src/components/DocumentAnnotationPanel.tsx b/ui/src/components/DocumentAnnotationPanel.tsx index 6e0a0538a2e..c4d1132a8cc 100644 --- a/ui/src/components/DocumentAnnotationPanel.tsx +++ b/ui/src/components/DocumentAnnotationPanel.tsx @@ -94,7 +94,7 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) { aria-label={`Annotations for ${props.documentKey.toUpperCase()}, revision ${props.documentRevisionNumber}`} data-testid="document-annotation-panel" className={cn( - "sticky top-4 z-30 flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md", + "flex h-full max-h-[80vh] w-[360px] shrink-0 flex-col overflow-hidden rounded-lg border border-border bg-card shadow-md", props.className, )} > @@ -366,6 +366,9 @@ function AnnotationPanelBody(props: AnnotationPanelProps) { variant="secondary" className="w-full justify-start gap-2 text-[12px]" onClick={startNewCommentFromSelection} + onMouseDown={(event) => { + if (!props.newCommentDisabled) event.preventDefault(); + }} disabled={props.newCommentDisabled} title={props.newCommentDisabled ? props.newCommentDisabledReason ?? undefined diff --git a/ui/src/components/IssueDocumentAnnotations.test.tsx b/ui/src/components/IssueDocumentAnnotations.test.tsx index 87929a3fe1f..8a1800cee1c 100644 --- a/ui/src/components/IssueDocumentAnnotations.test.tsx +++ b/ui/src/components/IssueDocumentAnnotations.test.tsx @@ -64,17 +64,30 @@ vi.mock("./DocumentAnnotationLayer", () => ({ onPendingAnchorChange: (anchor: typeof mockPendingAnchor | null) => void; onRequestComment: (anchor: typeof mockPendingAnchor) => void; }) => ( - + <> + + + ), })); @@ -259,6 +272,9 @@ describe("IssueDocumentAnnotations", () => { await flush(); const panel = container.querySelector('[data-testid="document-annotation-panel"]'); expect(panel).not.toBeNull(); + const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]'); + expect(anchor).not.toBeNull(); + expect(anchor?.className).toContain("fixed"); }); it("auto-opens the panel and focuses the thread when deep-linked", async () => { @@ -448,6 +464,49 @@ describe("IssueDocumentAnnotations", () => { expect(cta!.textContent).toMatch(/⌘⇧M/); }); + it("keeps a captured selection available for the panel CTA without opening the composer early", async () => { + mockAnnotationsApi.list.mockResolvedValue([]); + const root = createRoot(container); + const queryClient = makeQueryClient(); + const doc = makeDoc(); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const selectOnlyButton = container.querySelector( + '[data-testid="mock-annotation-selection-only"]', + ) as HTMLButtonElement | null; + expect(selectOnlyButton).not.toBeNull(); + await act(async () => { + selectOnlyButton!.click(); + }); + await flush(); + + expect(container.querySelector('[data-testid="document-annotation-composer"]')).toBeNull(); + + const cta = container.querySelector( + '[data-testid="document-annotation-new-comment-cta"]', + ) as HTMLButtonElement | null; + expect(cta).not.toBeNull(); + await act(async () => { + cta!.click(); + }); + await flush(); + + const composer = container.querySelector( + '[data-testid="document-annotation-composer"]', + ) as HTMLTextAreaElement | null; + expect(composer).not.toBeNull(); + expect(container.textContent).toContain(mockPendingAnchor.selectedText); + }); + it("creates a thread from a captured selection and refreshes the shared annotations query", async () => { mockAnnotationsApi.list.mockResolvedValue([]); mockAnnotationsApi.create.mockResolvedValue(makeThread({ id: "created-1" })); diff --git a/ui/src/components/IssueDocumentAnnotations.tsx b/ui/src/components/IssueDocumentAnnotations.tsx index 1c27f40f089..cdd576f3572 100644 --- a/ui/src/components/IssueDocumentAnnotations.tsx +++ b/ui/src/components/IssueDocumentAnnotations.tsx @@ -11,6 +11,10 @@ import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotatio import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel"; import type { CompanyUserProfile } from "@/lib/company-members"; +const DESKTOP_ANNOTATION_PANEL_WIDTH = 360; +const DESKTOP_ANNOTATION_PANEL_GAP = 12; +const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16; + export interface IssueDocumentAnnotationsProps { issueId: string; doc: IssueDocument; @@ -53,8 +57,14 @@ export function IssueDocumentAnnotations({ const containerRef = useRef(null); const [focusedThreadId, setFocusedThreadId] = useState(defaultFocusedThreadId ?? null); const [focusedCommentId, setFocusedCommentId] = useState(null); - const [pendingAnchor, setPendingAnchor] = useState(null); + const [selectionAnchor, setSelectionAnchor] = useState(null); + const [composerAnchor, setComposerAnchor] = useState(null); const [isMobile, setIsMobile] = useState(false); + const [desktopPanelFrame, setDesktopPanelFrame] = useState<{ + left: number; + top: number; + maxHeight: number; + } | null>(null); const hashHandledRef = useRef(null); // Bus token to ask the body layer to capture the current selection into a pendingAnchor. const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0); @@ -71,6 +81,42 @@ export function IssueDocumentAnnotations({ return undefined; }, []); + useEffect(() => { + if (!panelOpen || isMobile || typeof window === "undefined") { + setDesktopPanelFrame(null); + return; + } + + const updatePanelFrame = () => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) { + setDesktopPanelFrame(null); + return; + } + const top = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, rect.top); + const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP; + const maxVisibleLeft = window.innerWidth + - DESKTOP_ANNOTATION_PANEL_WIDTH + - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN; + setDesktopPanelFrame({ + left: Math.max( + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, + Math.min(desiredLeft, maxVisibleLeft), + ), + top, + maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN), + }); + }; + + updatePanelFrame(); + window.addEventListener("resize", updatePanelFrame); + window.addEventListener("scroll", updatePanelFrame, true); + return () => { + window.removeEventListener("resize", updatePanelFrame); + window.removeEventListener("scroll", updatePanelFrame, true); + }; + }, [doc.key, isMobile, panelOpen]); + const annotationsQuery = useQuery({ queryKey: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"), queryFn: () => documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }), @@ -102,9 +148,19 @@ export function IssueDocumentAnnotations({ ? "Document has no saved revision yet." : null; + const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => { + setSelectionAnchor(anchor); + }, []); + + const handleClearComposerAnchor = useCallback(() => { + setSelectionAnchor(null); + setComposerAnchor(null); + }, []); + const handleRequestComment = useCallback((anchor: PendingAnchor) => { if (newCommentDisabled) return; - setPendingAnchor(anchor); + setSelectionAnchor(null); + setComposerAnchor(anchor); onPanelOpenChange(true); }, [newCommentDisabled, onPanelOpenChange]); @@ -118,9 +174,13 @@ export function IssueDocumentAnnotations({ const handleRequestCommentFromSelection = useCallback(() => { if (newCommentDisabled) return; + if (selectionAnchor) { + handleRequestComment(selectionAnchor); + return; + } // Trigger the layer to re-read the current selection and emit a pendingAnchor. setCaptureSelectionRequestId((current) => current + 1); - }, [newCommentDisabled]); + }, [handleRequestComment, newCommentDisabled, selectionAnchor]); // ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open. useEffect(() => { @@ -153,69 +213,83 @@ export function IssueDocumentAnnotations({ [allThreads], ); + const annotationPanel = panelOpen ? ( + { + onPanelOpenChange(open); + if (!open) { + setSelectionAnchor(null); + setComposerAnchor(null); + setFocusedThreadId(null); + setFocusedCommentId(null); + } + }} + issueId={issueId} + documentKey={doc.key} + documentRevisionNumber={doc.latestRevisionNumber} + baseRevisionId={doc.latestRevisionId} + baseRevisionNumber={doc.latestRevisionNumber} + threads={allThreads as DocumentAnnotationThreadWithComments[]} + focusedThreadId={focusedThreadId} + focusedCommentId={focusedCommentId} + onFocusThread={(id) => { + setFocusedThreadId(id); + if (!id) setFocusedCommentId(null); + }} + pendingAnchor={composerAnchor} + onClearPendingAnchor={handleClearComposerAnchor} + onRequestCommentFromSelection={handleRequestCommentFromSelection} + newCommentDisabled={newCommentDisabled} + newCommentDisabledReason={newCommentDisabledReason} + isMobile={isMobile} + className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"} + agentMap={agentMap} + userProfileMap={userProfileMap} + /> + ) : null; + return ( -
-
-
{ - containerRef.current = element; +
+
{ + containerRef.current = element; + }} + className="relative min-w-0" + data-testid={`document-annotation-body-${doc.key}`} + > + {children} + {!historicalPreview && doc.latestRevisionId ? ( + + ) : null} +
+ {panelOpen && !isMobile && desktopPanelFrame ? ( +
- {children} - {!historicalPreview && doc.latestRevisionId ? ( - setPendingAnchor(anchor)} - onRequestComment={handleRequestComment} - newCommentDisabled={newCommentDisabled} - newCommentDisabledReason={newCommentDisabledReason} - hideResolved - captureSelectionRequestId={captureSelectionRequestId} - /> - ) : null} -
-
- {panelOpen ? ( - { - onPanelOpenChange(open); - if (!open) { - setPendingAnchor(null); - setFocusedThreadId(null); - setFocusedCommentId(null); - } - }} - issueId={issueId} - documentKey={doc.key} - documentRevisionNumber={doc.latestRevisionNumber} - baseRevisionId={doc.latestRevisionId} - baseRevisionNumber={doc.latestRevisionNumber} - threads={allThreads as DocumentAnnotationThreadWithComments[]} - focusedThreadId={focusedThreadId} - focusedCommentId={focusedCommentId} - onFocusThread={(id) => { - setFocusedThreadId(id); - if (!id) setFocusedCommentId(null); - }} - pendingAnchor={pendingAnchor} - onClearPendingAnchor={() => setPendingAnchor(null)} - onRequestCommentFromSelection={handleRequestCommentFromSelection} - newCommentDisabled={newCommentDisabled} - newCommentDisabledReason={newCommentDisabledReason} - isMobile={isMobile} - className={isMobile ? undefined : "lg:w-[360px] lg:max-w-[360px]"} - agentMap={agentMap} - userProfileMap={userProfileMap} - /> + {annotationPanel} +
) : null} + {panelOpen && isMobile ? annotationPanel : null} ); }