From e787653c8f8541632f80766e786655359f6a62d9 Mon Sep 17 00:00:00 2001 From: Angus Bezzina <37071175+angusbezzina@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:19:21 -0500 Subject: [PATCH] Redesign review comment cards: author border, hover linking, unresolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the attn-bb6t epic across the native and hosted surfaces. Card border (attn-bb6t.1): the 3px left accent strip becomes a full 2px border in the comment author's identity colour. The strip was a positioned pseudo-element because neither border-left nor an inset shadow survives the 6px corner radius — one mitres, the other tapers. A uniform border has neither failure mode, so the pseudo-element, its stacking context and the -1px offsets that kept it as tall as the card all go with it. Active no longer recolours the border (that would erase author identity); it is an outer ring instead. Excerpts and hover linking (attn-bb6t.2): the quoted excerpt is gone from anchored cards, replaced by hover in both directions. Stale cards keep their quote — a stale anchor paints no inline mark, so the quote is the only thing left that says what the comment was about. Card-to-document hover toggles a class straight on the mark DOM rather than rebuilding the decoration set, which is what the existing perf note in that file asks for. The is-focused class, emitted by the plugin since it was written but never styled anywhere, is now implemented too. HTML annotations (attn-bb6t.3): hovered becomes its own AnchorRenderState, distinct from active, with a third CSS Custom Highlight bucket. Restoring an anchor's base state on un-hover is owned by the bridge, which retains the rendered specs — a shell that got this wrong would leave a resolved anchor painted as unresolved. The frame-to-shell direction needs a new additive anchorHover message: text ranges are highlights, not DOM nodes, so they receive no events and are hit-tested against getClientRects(). Unresolve (attn-bb6t.4/.5): resolve was one-way by design, so reopening needs a new CommentReopened event — authorised exactly like resolve (non-agent, self-attributed), threaded through the session facades, and pinned by a new signing-corpus vector. The projection now folds resolve and reopen last-writer-wins by event comparison: the log is not delivery-ordered, so a Set of closed ids would flip a thread open or closed depending on arrival. Resolved cards gain an Unresolve action, the ticks become Lucide check icons, and the resolved badge spans the rail like the cards it sits among. Co-Authored-By: Claude Opus 5 (1M context) --- .../collab/test-vectors/event-signature.json | 65 +++++- src/ipc.rs | 6 + src/review/crypto/signing.rs | 20 ++ src/review/manager.rs | 60 +++++- src/review/model.rs | 16 ++ src/review/transport/inbound.rs | 51 +++++ web/e2e/html-annotation-runtime.spec.ts | 113 +++++++++++ web/src/App.svelte | 34 ++++ web/src/BrowserReviewApp.svelte | 39 ++++ web/src/app.css | 22 +++ web/src/doc-runtime/index.ts | 86 +++++++- web/src/doc-runtime/runtime.generated.js | 17 +- web/src/doc-runtime/styles.ts | 13 +- web/src/hosted/app/EditorShell.svelte | 50 +++++ web/src/hosted/app/mock-service.ts | 1 + web/src/hosted/app/real-service.ts | 1 + web/src/hosted/app/types.ts | 2 + web/src/lib/ReviewMargin.svelte | 98 ++++++--- web/src/lib/ReviewMarginCard.svelte | 187 +++++++++--------- web/src/lib/ipc.ts | 17 +- web/src/lib/prosemirror/review-decorations.ts | 34 ++++ .../review/browser-owner-authority.test.ts | 4 + web/src/lib/review/browser-owner-authority.ts | 6 + .../browser-owner-workspace-runtime.test.ts | 1 + .../review/browser-owner-workspace-runtime.ts | 5 + web/src/lib/review/browser-session.ts | 12 ++ .../lib/review/browser-share-production.ts | 3 + .../lib/review/browser-share-session.test.ts | 1 + web/src/lib/review/browser-share-session.ts | 8 + web/src/lib/review/browser-ws.ts | 5 + web/src/lib/review/doc-protocol.test.ts | 31 +++ web/src/lib/review/doc-protocol.ts | 27 ++- web/src/lib/review/doc-runtime.generated.ts | 2 +- web/src/lib/review/html-annotation-bridge.ts | 38 ++++ web/src/lib/review/review-counts.test.ts | 12 ++ web/src/lib/review/review-counts.ts | 6 + web/src/lib/review/selectors.test.ts | 55 ++++++ web/src/lib/review/selectors.ts | 44 ++++- web/src/lib/review/store.svelte.ts | 12 ++ web/src/lib/types.ts | 24 +++ 40 files changed, 1068 insertions(+), 160 deletions(-) diff --git a/planning/collab/test-vectors/event-signature.json b/planning/collab/test-vectors/event-signature.json index f4e7b3c8..f911745f 100644 --- a/planning/collab/test-vectors/event-signature.json +++ b/planning/collab/test-vectors/event-signature.json @@ -43,8 +43,14 @@ "snapshotId": "snap-vec-1", "baseHash": "hash-vec-1", "position": { - "byteRange": [0, 5], - "lineRange": [1, 1] + "byteRange": [ + 0, + 5 + ], + "lineRange": [ + 1, + 1 + ] } }, "body": "hello" @@ -70,7 +76,10 @@ "authorId": "p-vec-2", "deviceId": "d-vec-2", "createdAt": 1700000001500, - "parentEventIds": ["evt-zzz", "evt-aaa"], + "parentEventIds": [ + "evt-zzz", + "evt-aaa" + ], "snapshotId": "snap-vec-2" }, "body": { @@ -82,8 +91,14 @@ "snapshotId": "snap-vec-2", "baseHash": "hash-vec-2", "position": { - "byteRange": [10, 14], - "lineRange": [3, 3] + "byteRange": [ + 10, + 14 + ], + "lineRange": [ + 3, + 3 + ] } }, "operation": { @@ -114,7 +129,9 @@ "authorId": "p-vec-3", "deviceId": "d-vec-3", "createdAt": 1700000002000, - "parentEventIds": ["evt-parent-3"] + "parentEventIds": [ + "evt-parent-3" + ] }, "body": { "type": "comment_resolved", @@ -171,7 +188,11 @@ "authorId": "p-vec-5", "deviceId": "d-vec-5", "createdAt": 1700000004500, - "parentEventIds": ["evt-mid-5", "evt-aaa-5", "evt-zzz-5"], + "parentEventIds": [ + "evt-mid-5", + "evt-aaa-5", + "evt-zzz-5" + ], "snapshotId": "snap-vec-5" }, "body": { @@ -186,6 +207,36 @@ "signature": "doERvA05RpnAWdc5u1l2MZZQP2ZlpXXTds4ZHLh70w9m11mgOJSjahCXJrioirwj1jNr8vkXgFqmulTeA5zgBw", "signingKeyId": "tMHs6Jjs4k4k5gEjL5XGoYlxaJoN1mnm14IYU3whw4k" } + }, + { + "name": "CommentReopened with one parent — the resolve inverse (attn-bb6t.4); pins the reopen body shape", + "signingKey": { + "private": "ZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmZmY", + "public": "NLTZBDFWy23PC-sKKUm3VZyUDSvLbb6MU6mzAnjjp0Y" + }, + "event": { + "meta": { + "v": 2, + "eventId": "placeholder-event-id-6", + "roomId": "room-vec-6", + "authorId": "p-vec-6", + "deviceId": "d-vec-6", + "createdAt": 1700000005000, + "parentEventIds": [ + "evt-parent-6" + ] + }, + "body": { + "type": "comment_reopened", + "threadId": "thr-vec-6", + "reopenedBy": "p-reopener-6" + } + }, + "expected": { + "canonicalSignedBytes": "{\"body\":{\"reopenedBy\":\"p-reopener-6\",\"threadId\":\"thr-vec-6\",\"type\":\"comment_reopened\"},\"meta\":{\"authorId\":\"p-vec-6\",\"createdAt\":1700000005000,\"deviceId\":\"d-vec-6\",\"parentEventIds\":[\"evt-parent-6\"],\"roomId\":\"room-vec-6\",\"v\":2}}", + "signature": "79cQXHqjmkWD8oidDPTFbw2c80b-FT4oCzPYs0PbHOEdowA-g-OAREoPwBj07e_Wcx3xcgX4CqNxorUjJ3zmBw", + "signingKeyId": "97dnbJTffo_ZmY44-f_wTYWIsA0YAXZOPxh3o_6uRHc" + } } ] } diff --git a/src/ipc.rs b/src/ipc.rs index c985025f..4316b82f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -157,6 +157,9 @@ pub enum IpcMessage { #[serde(rename = "review_resolve_comment", rename_all = "camelCase")] ReviewResolveComment { room_id: RoomId, thread_id: String }, + #[serde(rename = "review_reopen_comment", rename_all = "camelCase")] + ReviewReopenComment { room_id: RoomId, thread_id: String }, + #[serde(rename = "review_stop", rename_all = "camelCase")] ReviewStop { #[serde(default)] @@ -571,6 +574,9 @@ pub fn handle_message(body: &str, state: &Arc>, proxy: &EventLoo IpcMessage::ReviewResolveComment { room_id, thread_id } => { submit_review_command(state, ReviewCommand::ResolveComment { room_id, thread_id }); } + IpcMessage::ReviewReopenComment { room_id, thread_id } => { + submit_review_command(state, ReviewCommand::ReopenComment { room_id, thread_id }); + } IpcMessage::ReviewStop { room_id } => { submit_review_command(state, ReviewCommand::Stop { room_id }); } diff --git a/src/review/crypto/signing.rs b/src/review/crypto/signing.rs index fb6b828d..85fc60a5 100644 --- a/src/review/crypto/signing.rs +++ b/src/review/crypto/signing.rs @@ -734,12 +734,32 @@ mod tests { resulting_hash: id::("hash-after-apply-5"), }; + // Vector 6: CommentReopened — the resolve inverse (attn-bb6t.4). + // Pinned alongside vector 3 so both halves of the resolve/reopen pair + // have a locked canonical shape for the TS implementation. + let seed6: [u8; 32] = [0x66u8; 32]; + let meta6 = EventMeta { + v: 2, + event_id: id::("placeholder-event-id-6"), + room_id: id::("room-vec-6"), + author_id: id::("p-vec-6"), + device_id: id::("d-vec-6"), + created_at: 1_700_000_005_000, + parent_event_ids: vec![id::("evt-parent-6")], + snapshot_id: None, + }; + let body6 = ReviewEventBody::CommentReopened { + thread_id: "thr-vec-6".to_string(), + reopened_by: id::("p-reopener-6"), + }; + for (label, seed, meta, body) in [ ("vec1", seed1, &meta1, &body1), ("vec2", seed2, &meta2, &body2), ("vec3", seed3, &meta3, &body3), ("vec4", seed4, &meta4, &body4), ("vec5", seed5, &meta5, &body5), + ("vec6", seed6, &meta6, &body6), ] { let sk = DeviceSigningKey::from_bytes(&seed).unwrap(); let vk = sk.verifying_key(); diff --git a/src/review/manager.rs b/src/review/manager.rs index 04d17a17..d956faf2 100644 --- a/src/review/manager.rs +++ b/src/review/manager.rs @@ -161,6 +161,10 @@ pub enum ReviewCommand { /// event so the resolution persists and propagates to every peer (a /// resolution is a shared fact, not a local view tweak). ResolveComment { room_id: RoomId, thread_id: String }, + /// Reopen a resolved comment thread. Mints a durable `CommentReopened` + /// event; same reasoning as `ResolveComment` — reopening is a shared + /// fact, so it travels rather than living in one client's view state. + ReopenComment { room_id: RoomId, thread_id: String }, /// Owner edited a shared file — republish a fresh snapshot so connected /// reviewers see the update. No-op when `path` isn't part of any share. PublishSnapshot { path: PathBuf }, @@ -1117,6 +1121,14 @@ impl ReviewManager { self.resolve_comment(bootstrapper, room_id, thread_id); return; } + ( + ReviewCommand::ReopenComment { room_id, thread_id }, + Some(bootstrapper), + Some(_runtime), + ) => { + self.reopen_comment(bootstrapper, room_id, thread_id); + return; + } ( ReviewCommand::SendCollab { room_id, payload }, Some(bootstrapper), @@ -1843,8 +1855,8 @@ impl ReviewManager { /// normal outbox path, so the resolution persists locally and propagates /// to peers. The frontend's `reconstructThreads` flips the thread's /// `resolved` flag off the same event, so the card collapses to its - /// resolved strip when the `EventImported` round-trips. Reopening is a - /// future `CommentReopened` event (not yet modeled). + /// resolved strip when the `EventImported` round-trips. The inverse is + /// [`Self::reopen_comment`], which mints `CommentReopened`. fn resolve_comment(&self, bootstrapper: &Arc, room_id: &RoomId, thread_id: &str) { let emit_err = |msg: String| { (self.update_tx)(ReviewUpdate::Error { @@ -1877,6 +1889,44 @@ impl ReviewManager { ); } + /// Reopen a resolved comment thread — the inverse of + /// [`Self::resolve_comment`] (attn-bb6t.4). Mints a durable + /// `CommentReopened` event carrying the reopener's participant id, so the + /// thread comes back for every peer rather than only in the clicking + /// client's view. Projections fold resolve/reopen in log order, so a + /// reopen after a resolve wins and a later resolve closes it again. + fn reopen_comment(&self, bootstrapper: &Arc, room_id: &RoomId, thread_id: &str) { + let emit_err = |msg: String| { + (self.update_tx)(ReviewUpdate::Error { + room_id: Some(room_id.clone()), + code: "ATTN_REOPEN_COMMENT".to_string(), + message: msg, + }); + }; + + let reopened_by = match bootstrapper + .config() + .identity_dir() + .and_then(|dir| crate::review::bootstrap::load_or_create_identity_in(&dir)) + { + Ok(identity) => identity.typed_participant_id(), + Err(e) => return emit_err(format!("load identity: {e}")), + }; + + let body = crate::review::model::ReviewEventBody::CommentReopened { + thread_id: thread_id.to_string(), + reopened_by, + }; + let send = bootstrapper.send_event_sync(room_id, body, unix_now_ms_for_manager()); + self.emit_event_outcome(room_id.clone(), send); + + tracing::info!( + "reopened comment thread {} (room={})", + thread_id, + room_id.as_str() + ); + } + /// Owner/reviewer manually re-anchors a stale comment or suggestion to a /// range they selected in the editor. We: /// 1. Look up the original event to recover its real `file_id` (the @@ -3722,6 +3772,7 @@ fn review_command_name(cmd: &ReviewCommand) -> &'static str { ReviewCommand::ResolveAnchor { .. } => "ResolveAnchor", ReviewCommand::ReportHtmlAnchorResolution { .. } => "ReportHtmlAnchorResolution", ReviewCommand::ResolveComment { .. } => "ResolveComment", + ReviewCommand::ReopenComment { .. } => "ReopenComment", ReviewCommand::SendCollab { .. } => "SendCollab", ReviewCommand::PublishSnapshot { .. } => "PublishSnapshot", ReviewCommand::ReannounceIdentity => "ReannounceIdentity", @@ -3902,6 +3953,10 @@ fn stub_update_for(cmd: &ReviewCommand) -> ReviewUpdate { room_id: room_id.clone(), status: "Pending resolve-comment — no bootstrap attached".to_string(), }, + ReviewCommand::ReopenComment { room_id, .. } => ReviewUpdate::RoomStatusChanged { + room_id: room_id.clone(), + status: "Pending reopen-comment — no bootstrap attached".to_string(), + }, // PublishSnapshot goes through the real bootstrap path in `submit` // when one is attached. Without a bootstrapper (smoke tests) it's a // no-op — surface a benign status so the dispatch contract stays @@ -4569,6 +4624,7 @@ fn review_event_body_name(body: &crate::review::model::ReviewEventBody) -> &'sta ReviewEventBody::SnapshotSuperseded { .. } => "snapshot_superseded", ReviewEventBody::CommentCreated { .. } => "comment_created", ReviewEventBody::CommentResolved { .. } => "comment_resolved", + ReviewEventBody::CommentReopened { .. } => "comment_reopened", ReviewEventBody::SuggestionCreated { .. } => "suggestion_created", ReviewEventBody::SuggestionAccepted { .. } => "suggestion_accepted", ReviewEventBody::SuggestionRejected { .. } => "suggestion_rejected", diff --git a/src/review/model.rs b/src/review/model.rs index 9bc115d3..8b09886e 100644 --- a/src/review/model.rs +++ b/src/review/model.rs @@ -1386,6 +1386,22 @@ pub enum ReviewEventBody { thread_id: String, resolved_by: ParticipantId, }, + /// Reopen a resolved thread (attn-bb6t.4). Deliberately its own variant + /// rather than a `resolved: bool` on `CommentResolved`: the log is + /// append-only and every existing receiver already reads + /// `CommentResolved` as "this thread is closed", so flipping a field + /// would have changed the meaning of events already on disk. Projections + /// must therefore fold resolve/reopen in log order — last writer wins, + /// not "any resolve anywhere". + /// + /// Receivers older than this variant reject the event (the enum is + /// externally tagged and unknown tags fail to deserialize), so a reopen + /// in a mixed-version room is invisible to them and the thread stays + /// resolved on their side. Same compatibility family as attn-mz25. + CommentReopened { + thread_id: String, + reopened_by: ParticipantId, + }, SuggestionCreated { suggestion_id: String, anchor: Anchor, diff --git a/src/review/transport/inbound.rs b/src/review/transport/inbound.rs index 7b8c0bad..df31bd96 100644 --- a/src/review/transport/inbound.rs +++ b/src/review/transport/inbound.rs @@ -727,6 +727,16 @@ fn authorize_event( { Ok(()) } + // Reopening carries exactly the resolve authority (attn-bb6t.4): a + // non-agent participant, acting as themselves. Anything narrower — + // "only the resolver may reopen" — would strand a thread whose + // resolver has left the room. + ReviewEventBody::CommentReopened { reopened_by, .. } + if registered.kind != ParticipantKind::Agent + && reopened_by == &event.meta.author_id => + { + Ok(()) + } ReviewEventBody::PresenceUpdated { participant_id, device_id, @@ -1188,6 +1198,47 @@ mod tests { assert_eq!(store.iter_events(&room_id).expect("events").count(), 0); } + #[tokio::test] + async fn reviewer_can_import_self_attributed_comment_reopened() { + let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); + let envelope = mint_event_envelope_with_body( + pipeline.event_key, + signer, + &room_id, + ReviewEventBody::CommentReopened { + thread_id: "thread-1".to_string(), + reopened_by: id::("p-author-01"), + }, + ); + pipeline + .import_event_envelope(&room_id, &envelope) + .await + .expect("self-attributed reopen must be accepted"); + assert_eq!(store.iter_events(&room_id).expect("events").count(), 1); + } + + #[tokio::test] + async fn comment_reopened_on_someone_elses_behalf_is_refused() { + let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); + let envelope = mint_event_envelope_with_body( + pipeline.event_key, + signer, + &room_id, + ReviewEventBody::CommentReopened { + thread_id: "thread-1".to_string(), + // Not the envelope's author: reopening in another + // participant's name is exactly what the guard exists for. + reopened_by: id::("p-someone-else"), + }, + ); + let error = pipeline + .import_event_envelope(&room_id, &envelope) + .await + .expect_err("reopen attributed to another participant must be refused"); + assert!(matches!(error, InboundError::UnauthorizedEvent)); + assert_eq!(store.iter_events(&room_id).expect("events").count(), 0); + } + #[tokio::test] async fn reviewer_cannot_import_owner_only_snapshot_event() { let (pipeline, store, signer, room_id, _tmp) = fresh_pipeline_with_signer(); diff --git a/web/e2e/html-annotation-runtime.spec.ts b/web/e2e/html-annotation-runtime.spec.ts index 5db29600..54665c68 100644 --- a/web/e2e/html-annotation-runtime.spec.ts +++ b/web/e2e/html-annotation-runtime.spec.ts @@ -145,6 +145,13 @@ async function selectText(page: import('@playwright/test').Page, needle: string) }, needle); } +interface DocRectShape { + x: number; + y: number; + width: number; + height: number; +} + test.describe('HTML annotation runtime', () => { test('completes the handshake across an opaque-origin frame', async ({ page }) => { await boot(page); @@ -703,6 +710,112 @@ test.describe('HTML annotation runtime', () => { await expect(frame.locator('.attn-chip')).toBeHidden(); }); + /** + * Card ↔ segment hover linking (attn-bb6t.3). A text-range highlight is a + * CSS Custom Highlight, not a DOM node, so it receives no events of its own + * — the runtime has to hit-test the range's rects against the pointer. This + * is the only place that geometry is exercised for real. + */ + test('reports hover over a committed text-range anchor, and its exit', async ({ page }) => { + await boot(page); + const frame = page.frameLocator('#doc'); + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'renderAnchors', + v: 1, + anchors: [ + { + anchorId: 'ranged', + html: { + v: 1, + target: 'text_range', + cssSelector: 'p.intro', + context: { tagName: 'p', scopePreview: 'The quick brown fox' }, + }, + state: 'default', + quote: 'quick brown fox', + }, + ], + }); + }); + await page.waitForFunction(() => + (window as unknown as { __attn_last: (t: string) => unknown }).__attn_last('anchorsResolved'), + ); + + // Aim at the RANGE, not the paragraph: the highlight covers only the + // quoted phrase, and the frame reports its rects in frame coordinates, + // so they need the iframe's own offset to become page coordinates. + const rect = await page.evaluate( + () => + ( + window as unknown as { + __attn_last: (t: string) => { results: { rects: DocRectShape[] }[] }; + } + ).__attn_last('anchorsResolved').results[0]!.rects[0]!, + ); + const frameBox = (await page.locator('#doc').boundingBox())!; + await page.mouse.move( + frameBox.x + rect.x + rect.width / 2, + frameBox.y + rect.y + rect.height / 2, + { steps: 8 }, + ); + const entered = await page.waitForFunction( + () => + (window as unknown as { __attn_last: (t: string) => { anchorId: string | null } | null }) + .__attn_last('anchorHover')?.anchorId === 'ranged', + ); + expect(await entered.jsonValue()).toBeTruthy(); + + // Leaving it must report null, or the shell keeps a card lit forever. + await page.mouse.move( + frameBox.x + rect.x + rect.width + 200, + frameBox.y + rect.y + rect.height / 2, + { steps: 8 }, + ); + const left = await page.waitForFunction( + () => + (window as unknown as { __attn_last: (t: string) => { anchorId: string | null } | null }) + .__attn_last('anchorHover')?.anchorId === null, + ); + expect(await left.jsonValue()).toBeTruthy(); + }); + + test('paints a hovered anchor distinctly from an active one', async ({ page }) => { + await boot(page); + const frame = page.frameLocator('#doc'); + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'renderAnchors', + v: 1, + anchors: [ + { + anchorId: 'pinned', + html: { + v: 1, + target: 'element', + cssSelector: '#title', + context: { tagName: 'h1', scopePreview: 'Quarterly report' }, + }, + state: 'default', + label: '1', + }, + ], + }); + }); + await expect(frame.locator('.attn-overlay')).toHaveAttribute('data-state', 'default'); + + await page.evaluate(() => { + (window as unknown as { __attn_send: (m: unknown) => void }).__attn_send({ + type: 'setAnchorState', + v: 1, + anchorId: 'pinned', + state: 'hovered', + }); + }); + await expect(frame.locator('.attn-overlay')).toHaveAttribute('data-state', 'hovered'); + await expect(frame.locator('.attn-pin')).toHaveAttribute('data-state', 'hovered'); + }); + test('marks a dragged selection passive and a pressed pill explicit', async ({ page }) => { await boot(page); await selectText(page, 'quick brown fox'); diff --git a/web/src/App.svelte b/web/src/App.svelte index e6c50cfa..4b29505d 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -158,6 +158,7 @@ shareTargetMatches, } from './lib/review/room-ui'; import { + applyReviewHoverHighlight, clearPendingAnchorRange, pendingAnchorHighlightPlugin, requestReviewDecorationsRebuild, @@ -567,6 +568,18 @@ bridge.renderAnchors(anchors); }); + // Card → document hover for HTML docs (attn-bb6t.3). The rail stores the + // hovered thread by ROOT EVENT id; the frame knows anchors by thread id. + $effect(() => { + const bridge = htmlBridge; + const hovered = reviewStore.hoveredEventId; + if (!bridge) return; + const thread = hovered === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.rootEvent.meta.eventId === hovered); + bridge.setHoveredAnchor(thread?.id ?? null); + }); + // Debug/E2E mirror of the shell's own annotation wiring, in the same spirit // as `__attn_collab_debug__`. It exists because the daemon automation bridge // evaluates in the SHELL's context and cannot reach into the opaque-origin @@ -675,6 +688,14 @@ const thread = reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); if (thread) reviewStore.setFocusEventId(thread.rootEvent.meta.eventId); }, + onAnchorHover: (anchorId) => { + // Document → card (attn-bb6t.3). An unknown id means "nothing", which + // is also what the frame sends on exit. + const thread = anchorId === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); + reviewStore.setHoveredEventId(thread?.rootEvent.meta.eventId ?? null); + }, }; // Markdown snapshots seed the prosemirror editor (anchors/collab). HTML @@ -1856,6 +1877,19 @@ requestReviewDecorationsRebuild(pmViewForReview); }); + // Card → segment hover linking (attn-bb6t.2). Separate from the rebuild + // effect on purpose: this one runs on every mouseenter, and all it does is + // toggle a class on the marks for one thread. It also depends on the same + // inputs as the rebuild above so the class is re-applied after ProseMirror + // redraws the marks out from under it. + $effect(() => { + const hovered = reviewStore.hoveredEventId; + void reviewStore.anchorResolutions; + void reviewStore.events; + if (!pmViewForReview) return; + applyReviewHoverHighlight(pmViewForReview, hovered); + }); + function emptyPlanStructure(): PlanStructure { return { phases: [], tasks: [], file_refs: [] }; } diff --git a/web/src/BrowserReviewApp.svelte b/web/src/BrowserReviewApp.svelte index 8814f505..1c59ac15 100644 --- a/web/src/BrowserReviewApp.svelte +++ b/web/src/BrowserReviewApp.svelte @@ -62,6 +62,7 @@ import { reviewerStatusPresentation } from './lib/review/reviewer-status-model'; import { reviewStore } from './lib/review/store.svelte'; import { + applyReviewHoverHighlight, clearPendingAnchorRange, pendingAnchorHighlightPlugin, reviewDecorationsPlugin, @@ -526,6 +527,18 @@ requestReviewDecorationsRebuild(pmViewForReview); }); + // Card → segment hover linking (attn-bb6t.2). Deliberately not folded into + // the rebuild effect above: this fires on every mouseenter and only toggles + // a class on one thread's marks. The resolution/event reads keep it correct + // across ProseMirror redraws, which discard the class. + $effect(() => { + const hovered = reviewStore.hoveredEventId; + void reviewStore.anchorResolutions; + void reviewStore.events; + if (!pmViewForReview) return; + applyReviewHoverHighlight(pmViewForReview, hovered); + }); + // --------------------------------------------------------------------------- // Derived view state. // --------------------------------------------------------------------------- @@ -1066,6 +1079,18 @@ bridge.renderAnchors(anchors); }); + // Card → document hover for HTML docs (attn-bb6t.3). The rail stores the + // hovered thread by ROOT EVENT id; the frame knows anchors by thread id. + $effect(() => { + const bridge = htmlBridge; + const hovered = reviewStore.hoveredEventId; + if (!bridge) return; + const thread = hovered === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.rootEvent.meta.eventId === hovered); + bridge.setHoveredAnchor(thread?.id ?? null); + }); + // Hover chrome is always live in an annotating frame; taking the CLICK — so // the page's own links stop firing — waits until the document is genuinely // reviewable. @@ -1125,6 +1150,14 @@ const thread = reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); if (thread) reviewStore.setFocusEventId(thread.rootEvent.meta.eventId); }, + onAnchorHover: (anchorId) => { + // Document → card (attn-bb6t.3). An unknown id means "nothing", which + // is also what the frame sends on exit. + const thread = anchorId === null + ? undefined + : reviewStore.threadsForCurrentFile.find((t) => t.id === anchorId); + reviewStore.setHoveredEventId(thread?.rootEvent.meta.eventId ?? null); + }, }; @@ -1275,6 +1308,10 @@ await session.resolveComment(threadId); } + async function reopenBrowserComment(threadId: string): Promise { + await session.reopenComment(threadId); + } + async function rememberBrowserRoom(): Promise { await session.rememberRoom(); } @@ -1698,6 +1735,7 @@ readOnly={true} reviewerAuthoring={reviewerAvailability.reviewAuthoring} onResolveComment={resolveBrowserComment} + onReopenComment={reopenBrowserComment} onReplyComment={replyBrowserComment} /> @@ -1737,6 +1775,7 @@ readOnly={true} reviewerAuthoring={reviewerAvailability.reviewAuthoring} onResolveComment={resolveBrowserComment} + onReopenComment={reopenBrowserComment} onReplyComment={replyBrowserComment} /> diff --git a/web/src/app.css b/web/src/app.css index 63ef28ef..5c72cbd9 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -938,6 +938,28 @@ text-decoration-thickness: 1px; } + /* ----- Card ↔ segment linking (attn-bb6t.2) ----- + Both halves of the hover tie are painted here rather than baked into the + decoration set: the mark keeps whatever kind/confidence fill it already + has and gains a ring, so one rule covers comments, suggestions, deletions + and every confidence tier without inventing a second color per kind. + `box-decoration-break: clone` on the base classes makes the ring wrap + correctly around each line fragment of a multi-line range. + + `is-hovered` is toggled directly on the mark DOM by + `applyReviewHoverHighlight` (hover must never rebuild decorations); + `is-focused` comes from the decoration set itself. Focus is declared last + so the stronger ring wins when a card is both hovered and focused. */ + [data-event-id].is-hovered { + border-radius: 2px; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 42%, transparent); + } + + [data-event-id].is-focused { + border-radius: 2px; + box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 72%, transparent); + } + /* Inline ghost text for a proposed insertion/replacement — the editorial "suggesting mode" surface. Green to read as "added", slightly inset so it's visually distinct from the author's own prose. */ diff --git a/web/src/doc-runtime/index.ts b/web/src/doc-runtime/index.ts index 6463f220..f56c673e 100644 --- a/web/src/doc-runtime/index.ts +++ b/web/src/doc-runtime/index.ts @@ -42,6 +42,7 @@ import { RUNTIME_STYLES } from './styles'; const HIGHLIGHT_BUCKET = 'attn-text'; const HIGHLIGHT_ACTIVE_BUCKET = 'attn-text-active'; +const HIGHLIGHT_HOVER_BUCKET = 'attn-text-hover'; /** Context captured either side of a selection, for later disambiguation. */ const CONTEXT_CHARS = 64; @@ -391,7 +392,81 @@ function scheduleHide(): void { }, HOVER_GRACE_MS) as unknown as number; } +// --------------------------------------------------------------------------- +// Anchor hover → shell (attn-bb6t.3) +// --------------------------------------------------------------------------- + +/** Last anchor reported to the shell, so we only send on transitions. */ +let lastHoverAnchorId: string | null = null; + +/** + * Which committed anchor, if any, is under the pointer. + * + * Text ranges are checked before elements because a range is always the more + * specific target: commenting on a phrase inside an already-commented block is + * exactly the nesting the annotation model supports, and reporting the block + * there would light up the wrong card. + * + * A CSS Custom Highlight is not a DOM node and receives no events, so a text + * range can only be hit-tested geometrically — hence `getClientRects()` rather + * than a listener. Both coordinate spaces are the frame's viewport. + */ +function anchorAtPoint(event: MouseEvent, target: Element | null): string | null { + // The pin hangs outside the element it belongs to, so hit-test chrome first. + const chrome = target?.closest('[data-anchor-id]'); + if (chrome?.dataset.anchorId) return chrome.dataset.anchorId; + + const x = event.clientX; + const y = event.clientY; + for (const anchor of anchors.values()) { + if (anchor.spec.html.target !== 'text_range' || !anchor.range) continue; + for (const rect of anchor.range.getClientRects()) { + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + return anchor.spec.anchorId; + } + } + } + + if (target) { + // Innermost wins when element anchors nest. + let best: { id: string; depth: number } | null = null; + for (const anchor of anchors.values()) { + if (anchor.spec.html.target !== 'element' || !anchor.element) continue; + if (!anchor.element.contains(target)) continue; + let depth = 0; + for (let node: Element | null = anchor.element; node; node = node.parentElement) depth += 1; + if (!best || depth > best.depth) best = { id: anchor.spec.anchorId, depth }; + } + if (best) return best.id; + } + return null; +} + +/** + * Report anchor hover to the shell. Runs before the inspect gate in + * `onPointerMove` on purpose: lighting up the card for the segment you are + * pointing at is reading affordance, not authoring, so it must work on a + * document whose click-to-comment mode is off. + */ +function reportAnchorHover(event: MouseEvent): void { + if (anchors.size === 0 && lastHoverAnchorId === null) return; + const target = event.target instanceof Element ? event.target : null; + const anchorId = anchorAtPoint(event, target); + if (anchorId === lastHoverAnchorId) return; + lastHoverAnchorId = anchorId; + send({ type: 'anchorHover', v: DOC_PROTOCOL_VERSION, anchorId }); +} + +/** Pointer left the document — nothing is hovered any more. */ +function clearAnchorHover(): void { + if (lastHoverAnchorId === null) return; + lastHoverAnchorId = null; + send({ type: 'anchorHover', v: DOC_PROTOCOL_VERSION, anchorId: null }); +} + function onPointerMove(event: MouseEvent): void { + reportAnchorHover(event); + // A document that cannot take a comment gets no hover chrome at all. The // chip is opaque and clickable and is painted OVER the page, so showing it on // a document that is merely being read would occlude — and swallow clicks on @@ -653,12 +728,16 @@ function repaintHighlights(): void { if (!highlights || typeof Highlight === 'undefined') return; const base: Range[] = []; const active: Range[] = []; + const hovered: Range[] = []; for (const anchor of anchors.values()) { if (anchor.spec.html.target !== 'text_range' || !anchor.range) continue; - (anchor.spec.state === 'active' ? active : base).push(anchor.range); + if (anchor.spec.state === 'active') active.push(anchor.range); + else if (anchor.spec.state === 'hovered') hovered.push(anchor.range); + else base.push(anchor.range); } highlights.set(HIGHLIGHT_BUCKET, new Highlight(...base)); highlights.set(HIGHLIGHT_ACTIVE_BUCKET, new Highlight(...active)); + highlights.set(HIGHLIGHT_HOVER_BUCKET, new Highlight(...hovered)); } /** @@ -680,6 +759,7 @@ function paintElementAnchor(anchor: LiveAnchor): void { const overlay = document.createElement('div'); overlay.className = 'attn-overlay'; overlay.dataset.state = anchor.spec.state; + overlay.dataset.anchorId = anchor.spec.anchorId; overlay.style.cssText = `top:${top}px;left:${left}px;width:${rect.width}px;height:${rect.height}px`; layer.appendChild(overlay); anchor.overlay = overlay; @@ -690,6 +770,7 @@ function paintElementAnchor(anchor: LiveAnchor): void { pin.type = 'button'; pin.className = 'attn-pin'; pin.dataset.state = anchor.spec.state; + pin.dataset.anchorId = anchor.spec.anchorId; pin.textContent = anchor.spec.label ?? '1'; pin.style.cssText = `top:${top - 10}px;left:${left - 14}px`; pin.addEventListener('click', (event) => { @@ -960,6 +1041,9 @@ function boot(): void { document.addEventListener('click', onDocumentClick, true); // Leaving the document entirely is unambiguous; no grace period needed. document.documentElement.addEventListener('mouseleave', hideHover); + // The same exit must clear anchor hover, or the shell's card stays lit after + // the cursor has left the frame (attn-bb6t.3). + document.documentElement.addEventListener('mouseleave', clearAnchorHover); window.addEventListener('scroll', scheduleReflow, { passive: true }); window.addEventListener('resize', scheduleReflow, { passive: true }); new ResizeObserver(scheduleReflow).observe(document.body); diff --git a/web/src/doc-runtime/runtime.generated.js b/web/src/doc-runtime/runtime.generated.js index 6bb04f9e..65370564 100644 --- a/web/src/doc-runtime/runtime.generated.js +++ b/web/src/doc-runtime/runtime.generated.js @@ -1,4 +1,4 @@ -"use strict";(()=>{var Z="attn:doc:hello",J="attn:shell:init";var ft=new TextEncoder;var Re=new TextEncoder;function g(e){return Re.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let l=g(i);if(s+1>t||o+l>n)break;o+=l,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function re(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function x(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),g(i.toString())}let o=0,s=re(e),r=s.nextNode();for(;r;){if(r===t)return o+g((r.nodeValue??"").slice(0,n));o+=g(r.nodeValue??""),r=s.nextNode()}return o}function ee(e,t){let n=0,o=re(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",l=g(i);if(n+l>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=g(u),d+=u.length}return{node:s,offset:i.length}}n+=l,r={node:s,offset:i.length},s=o.nextNode()}return r}function Y(e,t,n){let o=ee(e,t),s=ee(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var Oe=new Set(["TD","TH"]),_e=e=>Oe.has(e.tagName);function Le(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function H(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function se(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ie(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ce(e){let t=e.parentElement,n=e.closest("table"),o=[n?T(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${Le(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ce(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function T(e){return e.id&&se(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ce(e):_e(e)?Ne(e):`${e.tagName.toLowerCase()}${ie(e)}${H(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${H(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${H(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&se(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${H(r)}`),r=r.parentElement}let l=ie(e);return l&&n(`${e.tagName.toLowerCase()}${l}`),t.filter(c=>c!==T(e))}var ke={TR:"row",TD:"cell",TH:"columnheader",TABLE:"table",LI:"listitem",UL:"list",OL:"list",P:"paragraph",BLOCKQUOTE:"blockquote",FIGURE:"figure",IMG:"img",H1:"heading",H2:"heading",H3:"heading",H4:"heading"};function ae(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??ke[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:T(n),startOffset:x(n,t.startContainer,t.startOffset),endSelector:T(o),endOffset:x(o,t.endContainer,t.endOffset)}}function ue(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=x(e,t.startContainer,t.startOffset),s=x(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:T(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ae(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function V(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:T(t),fallbackSelectors:le(t),textPosition:{start:x(e,o.startContainer,o.startOffset),end:x(e,o.endContainer,o.endOffset)},context:ae(t,n)}}var te={range:null,element:null,status:"stale",confidence:0};function Ie(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function ne(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function oe(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,l-o.length),l),d=e.slice(l+n,l+n+s.length),u=oe([...c].reverse().join(""),[...o].reverse().join(""))+oe(d,s);return{at:l,score:u}});r.sort((l,c)=>c.score-l.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function de(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((A,C)=>A??L(e,C),null);if(!c)return te;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=Y(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=ne(i,o);if(c.length>0){let{index:d,ambiguous:u}=He(i,c,o.length,s,r),A=g(i.slice(0,d)),C=Y(e,A,A+g(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=Ie(o),{normalized:d,starts:u,ends:A}=De(i);if(c.length>0){let C=ne(d,c);if(C.length===1){let z=C[0],W=u[z],K=A[z+c.length-1];if(W!==void 0&&K!==void 0){let Ae=g(i.slice(0,W)),Te=g(i.slice(0,K)),Q=Y(e,Ae,Te);if(Q)return{range:Q,element:null,status:"remapped",confidence:.6}}}}}let l=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(l){let c=e.ownerDocument.createRange();return c.selectNodeContents(l),{range:c,element:l,status:"remapped",confidence:.35}}return te}var fe=` +"use strict";(()=>{var J="attn:doc:hello",ee="attn:shell:init";var Et=new TextEncoder;var Oe=new TextEncoder;function x(e){return Oe.encode(e).length}function S(e,t,n){let o=0,s=0,r=0;for(let i of e){let a=x(i);if(s+1>t||o+a>n)break;o+=a,s+=1,r+=i.length}return r===e.length?e:e.slice(0,r)}function se(e){return e.ownerDocument.createTreeWalker(e,NodeFilter.SHOW_TEXT)}function v(e,t,n){if(t.nodeType!==Node.TEXT_NODE){let i=e.ownerDocument.createRange();return i.selectNodeContents(e),i.setEnd(t,n),x(i.toString())}let o=0,s=se(e),r=s.nextNode();for(;r;){if(r===t)return o+x((r.nodeValue??"").slice(0,n));o+=x(r.nodeValue??""),r=s.nextNode()}return o}function te(e,t){let n=0,o=se(e),s=o.nextNode(),r=null;for(;s;){let i=s.nodeValue??"",a=x(i);if(n+a>=t){let c=0,d=0;for(let u of i){if(n+c>=t)return{node:s,offset:d};c+=x(u),d+=u.length}return{node:s,offset:i.length}}n+=a,r={node:s,offset:i.length},s=o.nextNode()}return r}function V(e,t,n){let o=te(e,t),s=te(e,n);if(!o||!s)return null;let r=e.ownerDocument.createRange();try{r.setStart(o.node,o.offset),r.setEnd(s.node,s.offset)}catch{return null}return r}function G(e){return e.textContent??""}var _e=new Set(["TD","TH"]),Le=e=>_e.has(e.tagName);function ke(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function P(e){let t=e.parentElement;if(!t)return"";let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return n.length<=1?"":`:nth-of-type(${n.indexOf(e)+1})`}function ie(e){return e.length===0||e.length>64||!/^[A-Za-z][\w-]*$/.test(e)||/^(react|radix|mui|headless|aria)[-_]/i.test(e)?!1:!/[0-9a-f]{8,}/i.test(e)}function ce(e){if(typeof e.className!="string")return"";let t=e.className.trim().split(/\s+/).filter(Boolean);for(let n of t)if(/^[\w-]+$/.test(n)&&n.length<=40&&!/[0-9a-f]{6,}/i.test(n))return`.${CSS.escape(n)}`;return""}function ae(e){let t=e.parentElement,n=e.closest("table"),o=[n?A(n):"table"];return t&&t!==n&&o.push(t.tagName.toLowerCase()),o.push(`tr:nth-of-type(${ke(e)})`),o.join(" > ")}function Ne(e){let t=e.closest("tr");if(!t)return e.tagName.toLowerCase();let n=Array.from(t.children).filter(o=>o.tagName===e.tagName);return`${ae(t)} > ${e.tagName.toLowerCase()}:nth-of-type(${n.indexOf(e)+1})`}function A(e){return e.id&&ie(e.id)?`#${CSS.escape(e.id)}`:e.tagName==="TR"?ae(e):Le(e)?Ne(e):`${e.tagName.toLowerCase()}${ce(e)}${P(e)}`}function le(e){let t=[],n=c=>{c&&!t.includes(c)&&t.length<8&&t.push(c)},o=[],s=e;for(;s&&s.tagName!=="BODY"&&o.length<12;)o.unshift(`${s.tagName.toLowerCase()}${P(s)}`),s=s.parentElement;o.length>0&&n(o.join(" > "));let r=e.parentElement,i=[`${e.tagName.toLowerCase()}${P(e)}`];for(;r&&r.tagName!=="BODY"&&i.length<6;){if(r.id&&ie(r.id)){n(`#${CSS.escape(r.id)} ${i.join(" > ")}`);break}i.unshift(`${r.tagName.toLowerCase()}${P(r)}`),r=r.parentElement}let a=ce(e);return a&&n(`${e.tagName.toLowerCase()}${a}`),t.filter(c=>c!==A(e))}var Ie={TR:"row",TD:"cell",TH:"columnheader",TABLE:"table",LI:"listitem",UL:"list",OL:"list",P:"paragraph",BLOCKQUOTE:"blockquote",FIGURE:"figure",IMG:"img",H1:"heading",H2:"heading",H3:"heading",H4:"heading"};function ue(e,t){let n=[],o=e;for(;o&&o.tagName!=="BODY"&&n.length<8;)n.unshift(o.tagName.toLowerCase()),o=o.parentElement;let s=e.getAttribute("role")??Ie[e.tagName],r={tagName:e.tagName.toLowerCase(),scopePreview:S(t,200,256),domPath:n};return s&&(r.role=s),r}function Me(e,t){let n=t.startContainer.nodeType===Node.ELEMENT_NODE?t.startContainer:t.startContainer.parentElement,o=t.endContainer.nodeType===Node.ELEMENT_NODE?t.endContainer:t.endContainer.parentElement;return!n||!o||n===o?null:{startSelector:A(n),startOffset:v(n,t.startContainer,t.startOffset),endSelector:A(o),endOffset:v(o,t.endContainer,t.endOffset)}}function de(e,t){let n=t.commonAncestorContainer.nodeType===Node.ELEMENT_NODE?t.commonAncestorContainer:t.commonAncestorContainer.parentElement??e,o=v(e,t.startContainer,t.startOffset),s=v(e,t.endContainer,t.endOffset),r={v:1,target:"text_range",cssSelector:A(n),fallbackSelectors:le(n),textPosition:{start:o,end:s},context:ue(n,S(t.toString(),120,256))},i=Me(e,t);return i&&(r.range=i),r}function U(e,t,n){let o=e.ownerDocument.createRange();return o.selectNodeContents(t),{v:1,target:"element",cssSelector:A(t),fallbackSelectors:le(t),textPosition:{start:v(e,o.startContainer,o.startOffset),end:v(e,o.endContainer,o.endOffset)},context:ue(t,n)}}var ne={range:null,element:null,status:"stale",confidence:0};function He(e){return e.replace(/[‘’]/g,"'").replace(/[“”]/g,'"').replace(/[–—]/g,"-").replace(/\s+/g," ").trim()}function De(e){let t=[],n=[],o=[],s=-1;for(let r=0;r0&&(t.push(" "),n.push(s),o.push(r)),s=-1,t.push(i),n.push(r),o.push(r+1)}return{normalized:t.join(""),starts:n,ends:o}}function L(e,t){try{return e.querySelector(t)}catch{return null}}function oe(e,t){let n=[];if(t.length===0)return n;let o=0;for(;;){let s=e.indexOf(t,o);if(s===-1||(n.push(s),o=s+1,n.length>64))return n}}function re(e,t){let n=Math.min(e.length,t.length),o=0;for(;o{let c=e.slice(Math.max(0,a-o.length),a),d=e.slice(a+n,a+n+s.length),u=re([...c].reverse().join(""),[...o].reverse().join(""))+re(d,s);return{at:a,score:u}});r.sort((a,c)=>c.score-a.score);let i=r.length>1&&r[0].score===r[1].score;return{index:r[0].at,ambiguous:i}}function fe(e,t){let{anchor:n,quote:o,prefix:s="",suffix:r=""}=t;if(n.target==="element"){let c=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((T,C)=>T??L(e,C),null);if(!c)return ne;let d=e.ownerDocument.createRange();d.selectNodeContents(c);let u=L(e,n.cssSelector)===c;return{range:d,element:c,status:u?"exact":"remapped",confidence:u?1:.7}}let i=G(e);if(o&&n.textPosition){let{start:c,end:d}=n.textPosition,u=V(e,c,d);if(u&&u.toString()===o)return{range:u,element:null,status:"exact",confidence:1}}if(o&&o.length>0){let c=oe(i,o);if(c.length>0){let{index:d,ambiguous:u}=Pe(i,c,o.length,s,r),T=x(i.slice(0,d)),C=V(e,T,T+x(o));if(C)return{range:C,element:null,status:u?"ambiguous":"remapped",confidence:u?.4:c.length===1?.9:.75}}}if(o){let c=He(o),{normalized:d,starts:u,ends:T}=De(i);if(c.length>0){let C=oe(d,c);if(C.length===1){let W=C[0],K=u[W],Q=T[W+c.length-1];if(K!==void 0&&Q!==void 0){let Ae=x(i.slice(0,K)),Re=x(i.slice(0,Q)),Z=V(e,Ae,Re);if(Z)return{range:Z,element:null,status:"remapped",confidence:.6}}}}}let a=L(e,n.cssSelector)??(n.fallbackSelectors??[]).reduce((c,d)=>c??L(e,d),null);if(a){let c=e.ownerDocument.createRange();return c.selectNodeContents(a),{range:c,element:a,status:"remapped",confidence:.35}}return ne}var pe=` .attn-layer { position: absolute; inset: 0; @@ -30,6 +30,12 @@ ::highlight(attn-text-active) { background-color: oklch(0.80 0.16 82 / 52%); } +/* Hover sits between base and active (attn-bb6t.3): strong enough to answer + "which segment is this card about?", quiet enough that it never reads as + the focused thread. */ +::highlight(attn-text-hover) { + background-color: oklch(0.81 0.15 84 / 42%); +} /* Element overlay. The fill is inert so text underneath a commented element stays selectable \u2014 you can always comment on something inside something @@ -50,6 +56,10 @@ border-style: dashed; opacity: 0.55; } +.attn-overlay[data-state="hovered"] { + border-color: color-mix(in oklch, var(--attn-element-accent) 85%, transparent); + background: color-mix(in oklch, var(--attn-element-accent) 13%, transparent); +} /* Persistent marker for a committed comment: visible without hovering, so the document reads as annotated at a glance. */ @@ -73,7 +83,8 @@ transition: transform 120ms ease; } .attn-pin:hover, -.attn-pin[data-state="active"] { +.attn-pin[data-state="active"], +.attn-pin[data-state="hovered"] { transform: scale(1.12); } .attn-pin[data-state="resolved"] { @@ -195,4 +206,4 @@ .attn-overlay, .attn-pin { transition: none; } } -`;var Pe="attn-text",Be="attn-text-active",N=64,P=null,f,b,a,y,m,E=new Map,B=new Map,p=null,I=null,j=null,R=null,Xe=0,X=!1;function v(e){P?.postMessage(e)}function $e(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map($e):[]}var Ye=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),he=e=>e.tagName==="TD"||e.tagName==="TH";function Ge(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Ve(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ye.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ge(e);o&&t.push(o)}return t}function Ue(e){return e[0]}function ge(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function Ee(e){return he(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${ge(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function xe(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${ge(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(he(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function Fe(e){let t=0;for(let n of E.values())n.element===e&&(t+=1);return t}function je(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-N*2)].slice(-N),r=[...n.toString().slice(0,N*2)].slice(0,N);if(s.length>0&&o.length>N*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=x(f,e.startContainer,e.startOffset),n=x(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=je(e);return{html:ue(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function qe(e){let t=xe(e)??Ee(e),n=V(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function ze(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function We(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,ve(),v({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ke(o),v({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ke(e){e&&(m.style.left=`${e.x+e.width}px`,m.style.top=`${e.y+e.height+8}px`,m.classList.add("is-visible"))}function ve(){m.classList.remove("is-visible")}var Qe=160,pe=4,Ze=2,O=0;function Se(e){return e instanceof Node&&b.contains(e)}function M(){O&&(clearTimeout(O),O=0)}function Ce(){O||(O=setTimeout(()=>{O=0,w()},Qe))}function Je(e){if(!X)return;if(a.contains(e.target)){M();return}if(Se(e.target))return;if(ze()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===j){p&&M();return}j=t;let n=Ve(t),o=Ue(n);if(!o){Ce();return}M(),o!==p&&(p=o,D(o),nt(tt(n)))}function w(){M(),p=null,j=null,a.classList.remove("is-visible"),D(void 0)}function et(e){if(Se(e.target)||!X)return;let t=e.target;if(!(t instanceof Element))return;let n=I,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),$(n))}function tt(e){B.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Xe+=1}`;B.set(o,n);let s=xe(n);return{scopeId:o,title:Ee(n),preview:s===null?null:S(s,200,256),selector:V(f,n,"").cssSelector,commentCount:Fe(n),rects:_(n)}});return v({type:"scopeHover",v:1,chain:t}),t}function nt(e){if(y.textContent="",I=e[0]?.scopeId??null,e.length===0){a.classList.remove("is-visible");return}let t=e.slice(0,pe).reverse(),n=e.length>pe;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let l=document.createElement("span");if(l.className="attn-chip-title",l.textContent=o.title,i.appendChild(l),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>D(B.get(o.scopeId))),i.addEventListener("mouseleave",()=>D(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),$(o.scopeId)}),y.appendChild(i)}),a.classList.add("is-visible"),p&&ye(p)}function ye(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=a.offsetHeight,r=n-s+Ze;a.style.top=`${r"u")return;let t=[],n=[];for(let o of E.values())o.spec.html.target!=="text_range"||!o.range||(o.spec.state==="active"?n:t).push(o.range);e.set(Pe,new Highlight(...t)),e.set(Be,new Highlight(...n))}function we(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),v({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function ot(e){let t=de(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return we(n),n}function rt(e){for(let t of E.values())t.overlay?.remove(),t.pin?.remove();E.clear();for(let t of e)E.set(t.anchorId,ot(t));q(),st()}function st(){let e=[];for(let t of E.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});v({type:"anchorsResolved",v:1,results:e})}function it(){let e=[];for(let t of E.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});v({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ct(e,t){let n=E.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),q())}function lt(e,t){let n=E.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var U=0;function F(){U||(U=requestAnimationFrame(()=>{U=0;for(let e of E.values())we(e);p&&!p.isConnected?w():p&&(D(p),ye(p)),q(),it()}))}function at(e){switch(e.type){case"renderAnchors":rt(e.anchors);break;case"setAnchorState":ct(e.anchorId,e.state);break;case"focusAnchor":lt(e.anchorId,e.scrollIntoView);break;case"pickScope":$(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,ve();break;case"inspect":{let t=e.enabled===!0;X&&!t&&(w(),I=null),X=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function ut(){let e=document.createElement("style");e.textContent=fe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),a=document.createElement("div"),a.className="attn-chip",a.setAttribute("role","toolbar"),a.setAttribute("aria-label","Comment on this element"),a.addEventListener("mouseenter",M),a.addEventListener("mouseleave",Ce),a.addEventListener("mousedown",t=>t.preventDefault()),a.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),I&&$(I))}),y=document.createElement("div"),y.className="attn-chip-body",a.appendChild(y),b.appendChild(a),m=document.createElement("button"),m.type="button",m.className="attn-pill",m.textContent="Comment",m.addEventListener("mousedown",t=>t.preventDefault()),m.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);v({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(m)}function dt(e){P=e,P.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&at(n)},P.start(),v({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,ut(),document.addEventListener("selectionchange",We),document.addEventListener("mousemove",Je,{passive:!0}),document.addEventListener("click",et,!0),document.documentElement.addEventListener("mouseleave",w),window.addEventListener("scroll",F,{passive:!0}),window.addEventListener("resize",F,{passive:!0}),new ResizeObserver(F).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==J)return;let[o]=t.ports;o&&dt(o)}),window.parent.postMessage({type:Z,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})(); +`;var Be="attn-text",Xe="attn-text-active",$e="attn-text-hover",k=64,B=null,f,b,l,y,g,h=new Map,X=new Map,p=null,H=null,q=null,R=null,Ye=0,$=!1;function E(e){B?.postMessage(e)}function Ve(e){return{x:e.x,y:e.y,width:e.width,height:e.height}}function _(e){return e?Array.from(e.getClientRects()).filter(n=>n.width>0&&n.height>0).slice(0,128).map(Ve):[]}var Ge=new Set(["TD","TH","TR","LI","FIGURE","PRE","CODE","TABLE","BLOCKQUOTE","H1","H2","H3","H4","H5","H6","P","IMG","FIGCAPTION","UL","OL","DL","DT","DD","SECTION","ARTICLE","ASIDE","HEADER","FOOTER","MAIN","NAV","DETAILS","SUMMARY","FORM","VIDEO","AUDIO","CANVAS","SVG","A","BUTTON","LABEL","INPUT","TEXTAREA","SELECT","HR"]),ge=e=>e.tagName==="TD"||e.tagName==="TH";function Ue(e){let t=e;for(let n=0;t&&t!==f&&n<12;n+=1){let o=t.getBoundingClientRect();if(o.width>0&&o.height>0)return t;t=t.parentElement}return null}function Fe(e){let t=[],n=e;for(;n&&n!==f&&t.length<12;)Ge.has(n.tagName.toUpperCase())&&t.push(n),n=n.parentElement;if(t.length===0){let o=Ue(e);o&&t.push(o)}return t}function je(e){return e[0]}function Ee(e){let t=e.parentElement;return t?Array.from(t.children).filter(n=>n.tagName==="TR").indexOf(e)+1:1}function xe(e){return ge(e)?"cell":e.tagName==="TR"?e.closest("thead")?"header row":`row ${Ee(e)}`:e.tagName==="LI"?"list item":e.tagName==="PRE"?"code block":e.tagName==="UL"||e.tagName==="OL"?"list":e.tagName==="A"?"link":e.tagName==="IMG"?"image":e.tagName==="BLOCKQUOTE"?"quote":/^H[1-6]$/.test(e.tagName)?"heading":e.tagName.toLowerCase()}function ve(e){if(e.tagName==="TR"){let n=Array.from(e.querySelectorAll("th,td")).map(r=>r.textContent?.trim()??""),o=e.closest("thead")?"header row":`row ${Ee(e)}`,s=[n[0],n[1]].filter(Boolean).join(" \xB7 ");return s?`${o} \xB7 ${s}`:o}if(ge(e)){let n=e.closest("tr"),o=n?Array.from(n.children).indexOf(e):-1,r=e.closest("table")?.querySelector("thead tr")?.children[o]?.textContent?.trim(),i=e.textContent?.trim()??"";return r?`${r}: ${i}`:i}let t=e.textContent?.trim()??"";return t?t.slice(0,80):null}function qe(e){let t=0;for(let n of h.values())n.element===e&&(t+=1);return t}function ze(e){let t=f.ownerDocument.createRange();t.selectNodeContents(f),t.setEnd(e.startContainer,e.startOffset);let n=f.ownerDocument.createRange();n.selectNodeContents(f),n.setStart(e.endContainer,e.endOffset);let o=t.toString(),s=[...o.slice(-k*2)].slice(-k),r=[...n.toString().slice(0,k*2)].slice(0,k);if(s.length>0&&o.length>k*2){let i=s[0].charCodeAt(0);i>=56320&&i<=57343&&s.shift()}return{prefix:s.join(""),suffix:r.join("")}}function be(e){let t=v(f,e.startContainer,e.startOffset),n=v(f,e.endContainer,e.endOffset),o=e.toString(),{prefix:s,suffix:r}=ze(e);return{html:de(f,e),quote:S(o,4e3,4096),prefix:s,suffix:r,textStart:t,textEnd:n}}function We(e){let t=ve(e)??xe(e),n=U(f,e,t),o=S((e.textContent??"").trim(),4e3,4096);return{html:n,quote:o,prefix:"",suffix:"",textStart:n.textPosition?.start??0,textEnd:n.textPosition?.end??0}}function Ke(){let e=window.getSelection();return!!e&&!e.isCollapsed&&e.rangeCount>0}function Qe(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0){R=null,Se(),E({type:"selectionCleared",v:1});return}let t=e.getRangeAt(0);if(t.toString().trim().length===0)return;R=t.cloneRange(),w();let n=_(t),o=n[n.length-1];Ze(o),E({type:"selection",v:1,proposal:be(t),rects:n,caret:o??{x:0,y:0,width:0,height:0},explicit:!1})}function Ze(e){e&&(g.style.left=`${e.x+e.width}px`,g.style.top=`${e.y+e.height+8}px`,g.classList.add("is-visible"))}function Se(){g.classList.remove("is-visible")}var Je=160,he=4,et=2,O=0;function Ce(e){return e instanceof Node&&b.contains(e)}function I(){O&&(clearTimeout(O),O=0)}function ye(){O||(O=setTimeout(()=>{O=0,w()},Je))}var M=null;function tt(e,t){let n=t?.closest("[data-anchor-id]");if(n?.dataset.anchorId)return n.dataset.anchorId;let o=e.clientX,s=e.clientY;for(let r of h.values())if(!(r.spec.html.target!=="text_range"||!r.range)){for(let i of r.range.getClientRects())if(o>=i.left&&o<=i.right&&s>=i.top&&s<=i.bottom)return r.spec.anchorId}if(t){let r=null;for(let i of h.values()){if(i.spec.html.target!=="element"||!i.element||!i.element.contains(t))continue;let a=0;for(let c=i.element;c;c=c.parentElement)a+=1;(!r||a>r.depth)&&(r={id:i.spec.anchorId,depth:a})}if(r)return r.id}return null}function nt(e){if(h.size===0&&M===null)return;let t=e.target instanceof Element?e.target:null,n=tt(e,t);n!==M&&(M=n,E({type:"anchorHover",v:1,anchorId:n}))}function ot(){M!==null&&(M=null,E({type:"anchorHover",v:1,anchorId:null}))}function rt(e){if(nt(e),!$)return;if(l.contains(e.target)){I();return}if(Ce(e.target))return;if(Ke()){w();return}let t=e.target;if(!(t instanceof Element))return;if(t===q){p&&I();return}q=t;let n=Fe(t),o=je(n);if(!o){ye();return}I(),o!==p&&(p=o,D(o),ct(it(n)))}function w(){I(),p=null,q=null,l.classList.remove("is-visible"),D(void 0)}function st(e){if(Ce(e.target)||!$)return;let t=e.target;if(!(t instanceof Element))return;let n=H,o=p;!n||!o||o!==t&&!o.contains(t)||(e.preventDefault(),e.stopPropagation(),Y(n))}function it(e){X.clear();let t=e.slice(0,8).map(n=>{let o=`scope-${Ye+=1}`;X.set(o,n);let s=ve(n);return{scopeId:o,title:xe(n),preview:s===null?null:S(s,200,256),selector:U(f,n,"").cssSelector,commentCount:qe(n),rects:_(n)}});return E({type:"scopeHover",v:1,chain:t}),t}function ct(e){if(y.textContent="",H=e[0]?.scopeId??null,e.length===0){l.classList.remove("is-visible");return}let t=e.slice(0,he).reverse(),n=e.length>he;if(n){let o=document.createElement("span");o.className="attn-chip-sep",o.textContent="\u2026",y.appendChild(o)}t.forEach((o,s)=>{if(s>0||n){let c=document.createElement("span");c.className="attn-chip-sep",c.textContent="\u203A",c.setAttribute("aria-hidden","true"),y.appendChild(c)}let r=s===t.length-1,i=document.createElement("button");i.type="button",i.className="attn-chip-seg",r&&i.classList.add("is-current"),i.dataset.scope=o.scopeId,i.setAttribute("aria-label",`Comment on ${o.preview??o.title}`);let a=document.createElement("span");if(a.className="attn-chip-title",a.textContent=o.title,i.appendChild(a),r&&o.preview&&o.preview!==o.title){let c=document.createElement("span");c.className="attn-chip-preview",c.textContent=S(o.preview,48,192),i.appendChild(c)}if(o.commentCount>0){let c=document.createElement("span");c.className="attn-chip-count",c.textContent=String(o.commentCount),i.appendChild(c)}i.addEventListener("mouseenter",()=>D(X.get(o.scopeId))),i.addEventListener("mouseleave",()=>D(p??void 0)),i.addEventListener("mousedown",c=>c.preventDefault()),i.addEventListener("click",c=>{c.preventDefault(),c.stopPropagation(),Y(o.scopeId)}),y.appendChild(i)}),l.classList.add("is-visible"),p&&we(p)}function we(e){let t=e.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=l.offsetHeight,r=n-s+et;l.style.top=`${r"u")return;let t=[],n=[],o=[];for(let s of h.values())s.spec.html.target!=="text_range"||!s.range||(s.spec.state==="active"?n.push(s.range):s.spec.state==="hovered"?o.push(s.range):t.push(s.range));e.set(Be,new Highlight(...t)),e.set(Xe,new Highlight(...n)),e.set($e,new Highlight(...o))}function Te(e){if(e.overlay?.remove(),e.pin?.remove(),e.overlay=null,e.pin=null,e.spec.html.target!=="element"||!e.element)return;let t=e.element.getBoundingClientRect(),n=t.top+window.scrollY,o=t.left+window.scrollX,s=document.createElement("div");s.className="attn-overlay",s.dataset.state=e.spec.state,s.dataset.anchorId=e.spec.anchorId,s.style.cssText=`top:${n}px;left:${o}px;width:${t.width}px;height:${t.height}px`,b.appendChild(s),e.overlay=s;let r=document.createElement("button");r.type="button",r.className="attn-pin",r.dataset.state=e.spec.state,r.dataset.anchorId=e.spec.anchorId,r.textContent=e.spec.label??"1",r.style.cssText=`top:${n-10}px;left:${o-14}px`,r.addEventListener("click",i=>{i.stopPropagation(),E({type:"anchorActivated",v:1,anchorId:e.spec.anchorId})}),b.appendChild(r),e.pin=r}function at(e){let t=fe(f,{anchor:e.html,quote:e.quote,prefix:e.prefix,suffix:e.suffix}),n={spec:e,range:t.range,element:t.element,status:t.status,confidence:t.confidence,overlay:null,pin:null};return Te(n),n}function lt(e){for(let t of h.values())t.overlay?.remove(),t.pin?.remove();h.clear();for(let t of e)h.set(t.anchorId,at(t));z(),ut()}function ut(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,status:t.status,confidence:t.confidence,rects:_(t.range??t.element)});E({type:"anchorsResolved",v:1,results:e})}function dt(){let e=[];for(let t of h.values())e.push({anchorId:t.spec.anchorId,rects:_(t.range??t.element)});E({type:"geometry",v:1,results:e,scrollTop:window.scrollY})}function ft(e,t){let n=h.get(e);n&&(n.spec={...n.spec,state:t},n.overlay&&(n.overlay.dataset.state=t),n.pin&&(n.pin.dataset.state=t),z())}function pt(e,t){let n=h.get(e);if(!n||!t)return;(n.element??n.range?.startContainer.parentElement)?.scrollIntoView({behavior:"smooth",block:"center"})}var F=0;function j(){F||(F=requestAnimationFrame(()=>{F=0;for(let e of h.values())Te(e);p&&!p.isConnected?w():p&&(D(p),we(p)),z(),dt()}))}function ht(e){switch(e.type){case"renderAnchors":lt(e.anchors);break;case"setAnchorState":ft(e.anchorId,e.state);break;case"focusAnchor":pt(e.anchorId,e.scrollIntoView);break;case"pickScope":Y(e.scopeId);break;case"dismissSelection":window.getSelection()?.removeAllRanges(),R=null,Se();break;case"inspect":{let t=e.enabled===!0;$&&!t&&(w(),H=null),$=t;break}case"theme":f.dataset.attnTheme=e.mode;break;default:break}}function mt(){let e=document.createElement("style");e.textContent=pe,document.head.appendChild(e),b=document.createElement("div"),b.className="attn-layer",document.body.appendChild(b),l=document.createElement("div"),l.className="attn-chip",l.setAttribute("role","toolbar"),l.setAttribute("aria-label","Comment on this element"),l.addEventListener("mouseenter",I),l.addEventListener("mouseleave",ye),l.addEventListener("mousedown",t=>t.preventDefault()),l.addEventListener("click",t=>{let n=t.target;n instanceof Element&&n.closest(".attn-chip-seg")||(t.preventDefault(),t.stopPropagation(),H&&Y(H))}),y=document.createElement("div"),y.className="attn-chip-body",l.appendChild(y),b.appendChild(l),g=document.createElement("button"),g.type="button",g.className="attn-pill",g.textContent="Comment",g.addEventListener("mousedown",t=>t.preventDefault()),g.addEventListener("click",t=>{if(t.preventDefault(),t.stopPropagation(),!R)return;let n=_(R);E({type:"selection",v:1,proposal:be(R),rects:n,caret:n[n.length-1]??{x:0,y:0,width:0,height:0},explicit:!0})}),b.appendChild(g)}function gt(e){B=e,B.onmessage=t=>{let n=t.data;!n||typeof n!="object"||typeof n.type!="string"||n.v===1&&ht(n)},B.start(),E({type:"ready",v:1,textLength:G(f).length,title:S(document.title,200,512)})}function me(){let e=window;e.__attnDocRuntime||(e.__attnDocRuntime=!0,f=document.body,mt(),document.addEventListener("selectionchange",Qe),document.addEventListener("mousemove",rt,{passive:!0}),document.addEventListener("click",st,!0),document.documentElement.addEventListener("mouseleave",w),document.documentElement.addEventListener("mouseleave",ot),window.addEventListener("scroll",j,{passive:!0}),window.addEventListener("resize",j,{passive:!0}),new ResizeObserver(j).observe(document.body),window.addEventListener("message",t=>{if(t.source!==window.parent)return;let n=t.data;if(!n||n.type!==ee)return;let[o]=t.ports;o&>(o)}),window.parent.postMessage({type:J,v:1},"*"))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",me,{once:!0}):me();})(); diff --git a/web/src/doc-runtime/styles.ts b/web/src/doc-runtime/styles.ts index 25f9aa20..1a1902cb 100644 --- a/web/src/doc-runtime/styles.ts +++ b/web/src/doc-runtime/styles.ts @@ -43,6 +43,12 @@ export const RUNTIME_STYLES = ` ::highlight(attn-text-active) { background-color: oklch(0.80 0.16 82 / 52%); } +/* Hover sits between base and active (attn-bb6t.3): strong enough to answer + "which segment is this card about?", quiet enough that it never reads as + the focused thread. */ +::highlight(attn-text-hover) { + background-color: oklch(0.81 0.15 84 / 42%); +} /* Element overlay. The fill is inert so text underneath a commented element stays selectable — you can always comment on something inside something @@ -63,6 +69,10 @@ export const RUNTIME_STYLES = ` border-style: dashed; opacity: 0.55; } +.attn-overlay[data-state="hovered"] { + border-color: color-mix(in oklch, var(--attn-element-accent) 85%, transparent); + background: color-mix(in oklch, var(--attn-element-accent) 13%, transparent); +} /* Persistent marker for a committed comment: visible without hovering, so the document reads as annotated at a glance. */ @@ -86,7 +96,8 @@ export const RUNTIME_STYLES = ` transition: transform 120ms ease; } .attn-pin:hover, -.attn-pin[data-state="active"] { +.attn-pin[data-state="active"], +.attn-pin[data-state="hovered"] { transform: scale(1.12); } .attn-pin[data-state="resolved"] { diff --git a/web/src/hosted/app/EditorShell.svelte b/web/src/hosted/app/EditorShell.svelte index 77058c3d..3cfcb094 100644 --- a/web/src/hosted/app/EditorShell.svelte +++ b/web/src/hosted/app/EditorShell.svelte @@ -177,6 +177,9 @@ let requestDecorationsRebuild = $state< typeof import('../../lib/prosemirror/review-decorations').requestReviewDecorationsRebuild | null >(null); + let applyHoverHighlight = $state< + typeof import('../../lib/prosemirror/review-decorations').applyReviewHoverHighlight | null + >(null); let ReviewApplyExpandComponent = $state(null); let SelectionToolbarComponent = $state(null); let CommentComposerComponent = $state(null); @@ -742,6 +745,7 @@ ]).then(([editorModule, pmState, , decorationsModule]) => { EditorComponent = editorModule.default; requestDecorationsRebuild = decorationsModule.requestReviewDecorationsRebuild; + applyHoverHighlight = decorationsModule.applyReviewHoverHighlight; changeWatcher = [ new pmState.Plugin({ view: () => ({ @@ -933,6 +937,19 @@ if (bridge) bridge.renderAnchors(htmlRenderableAnchors); }); + // Card → document hover for HTML docs (attn-bb6t.3). The rail stores the + // hovered thread by ROOT EVENT id; the frame knows anchors by thread id. + $effect(() => { + const bridge = htmlBridge; + const store = reviewStoreRef; + const hovered = store?.hoveredEventId ?? null; + if (!bridge || !store) return; + const thread = hovered === null + ? undefined + : store.threadsForCurrentFile.find((item) => item.rootEvent.meta.eventId === hovered); + bridge.setHoveredAnchor(thread?.id ?? null); + }); + // Hover chrome is always live in an annotating frame; taking the CLICK — so // the page's own links stop firing — waits until the document is genuinely // reviewable. @@ -988,6 +1005,14 @@ const thread = reviewStoreRef?.threadsForCurrentFile.find((item) => item.id === anchorId); if (thread) reviewStoreRef?.setFocusEventId(thread.rootEvent.meta.eventId); }, + onAnchorHover: (anchorId) => { + // Document → card (attn-bb6t.3). An unknown id means "nothing", which + // is also what the frame sends on exit. + const thread = anchorId === null + ? undefined + : reviewStoreRef?.threadsForCurrentFile.find((item) => item.id === anchorId); + reviewStoreRef?.setHoveredEventId(thread?.rootEvent.meta.eventId ?? null); + }, }; async function createHtmlComment(anchor: ReviewAnchor, body: string): Promise { @@ -1228,6 +1253,22 @@ rebuild(view); }); + // Card → segment hover linking (attn-bb6t.2). Kept out of the rebuild + // effect above: it fires on every mouseenter and only toggles a class on + // one thread's marks. Reading resolutions/events re-applies it after a + // ProseMirror redraw, which discards the class. + $effect(() => { + const store = reviewStoreRef; + if (!store) return; + const hovered = store.hoveredEventId; + void store.anchorResolutions; + void store.events; + const view = pmViewForReview; + const apply = applyHoverHighlight; + if (!view || !apply) return; + apply(view, hovered); + }); + function installOwnerSession(granted: EditingSession): void { session = granted; // A tab that was denied (read-only or live co-editing follower) and later @@ -2161,6 +2202,13 @@ service.announceReviewActivity(workspace.id); } + async function reopenReview(threadId: string): Promise { + const currentSession = session ?? (await ensureOwnerSession()); + if (!currentSession) throw new Error('Review authoring is unavailable.'); + await currentSession.reopenComment(threadId); + service.announceReviewActivity(workspace.id); + } + async function retryReviewDelivery(): Promise { const currentSession = session; if (!currentSession) return; @@ -3091,6 +3139,7 @@ ? { accept: acceptSuggestion, reject: rejectSuggestion } : {}} onResolveComment={resolveReview} + onReopenComment={reopenReview} onReplyComment={replyToReview} /> {:else if fixtureReviewHistory} @@ -3485,6 +3534,7 @@ ? { accept: acceptSuggestion, reject: rejectSuggestion } : {}} onResolveComment={resolveReview} + onReopenComment={reopenReview} onReplyComment={replyToReview} /> diff --git a/web/src/hosted/app/mock-service.ts b/web/src/hosted/app/mock-service.ts index 76461a0e..2979dba5 100644 --- a/web/src/hosted/app/mock-service.ts +++ b/web/src/hosted/app/mock-service.ts @@ -409,6 +409,7 @@ export class MockWorkspaceService implements WorkspaceAppService { announceProfile: async () => {}, replyToComment: async () => { throw new Error('Mock review authoring is unavailable.'); }, resolveComment: async () => { throw new Error('Mock review authoring is unavailable.'); }, + reopenComment: async () => { throw new Error('Mock review authoring is unavailable.'); }, retryReviewOutbox: async () => undefined, recoverReview: async () => undefined, inspectShare: async () => this.mockShare ? structuredClone(this.mockShare) : null, diff --git a/web/src/hosted/app/real-service.ts b/web/src/hosted/app/real-service.ts index 9d6c045b..559943f8 100644 --- a/web/src/hosted/app/real-service.ts +++ b/web/src/hosted/app/real-service.ts @@ -458,6 +458,7 @@ export class RealWorkspaceAppService implements WorkspaceAppService { announceProfile: () => runtime.announceProfile(), replyToComment: (anchor, body, threadId) => runtime.replyToComment(anchor, body, threadId), resolveComment: (threadId) => runtime.resolveComment(threadId), + reopenComment: (threadId) => runtime.reopenComment(threadId), retryReviewOutbox: () => runtime.retryOutbox(), recoverReview: () => runtime.recoverReview(), inspectShare: () => runtime.inspectShare(browserReviewBase()), diff --git a/web/src/hosted/app/types.ts b/web/src/hosted/app/types.ts index b50c6fcf..e0200043 100644 --- a/web/src/hosted/app/types.ts +++ b/web/src/hosted/app/types.ts @@ -217,6 +217,8 @@ export interface EditingSession { announceProfile(): Promise; replyToComment(anchor: Anchor, body: string, threadId: string): Promise; resolveComment(threadId: string): Promise; + /** Reopen a resolved thread (attn-bb6t.4). */ + reopenComment(threadId: string): Promise; retryReviewOutbox(): Promise; /** Recreate a definitively expired live room under the stable share. */ recoverReview(): Promise; diff --git a/web/src/lib/ReviewMargin.svelte b/web/src/lib/ReviewMargin.svelte index e3335a5d..069cfad9 100644 --- a/web/src/lib/ReviewMargin.svelte +++ b/web/src/lib/ReviewMargin.svelte @@ -67,11 +67,14 @@ shouldDismissSuggestionAfterAction, type SuggestionActionPort, } from './review/suggestion-action-port'; + import CheckIcon from '@lucide/svelte/icons/check'; + import { isThreadActive } from './review/thread-visibility'; import { reviewAcceptSuggestion, reviewCreateComment, reviewRejectSuggestion, + reviewReopenComment, reviewResolveComment, } from './ipc'; import type { @@ -118,6 +121,8 @@ */ suggestionActions?: SuggestionActionPort; onResolveComment?: (threadId: string) => Promise | void; + /** Hosted reopen authority (attn-bb6t.5). Same shape/gating as resolve. */ + onReopenComment?: (threadId: string) => Promise | void; onReplyComment?: (anchor: Anchor, body: string, threadId: string) => Promise | void; } @@ -131,6 +136,7 @@ reviewerAuthoring = false, suggestionActions, onResolveComment, + onReopenComment, onReplyComment, }: Props = $props(); @@ -541,6 +547,27 @@ } } + /** + * Reopen a resolved thread (attn-bb6t.5) — the mirror of `resolveThread`, + * down to the optimism: restore the card locally so the click lands before + * the `CommentReopened` echo, and leave the resolved card in place on + * failure so the user can retry. + */ + async function unresolveThread(threadId: string): Promise { + const roomId = reviewStore.currentRoomId; + if (!roomId) return; + try { + if (onReopenComment) await onReopenComment(threadId); + else await reviewReopenComment(roomId, threadId); + reviewStore.restoreThreadLocally(threadId); + // The thread is open again, so its expanded-resolved presentation is + // over; leaving it latched would keep the card in read-only mode. + reviewStore.collapseResolvedThread(); + } catch { + // Same as resolve: the transport surfaces its own error, the card stays. + } + } + const nativeSuggestionActions: SuggestionActionPort = { accept: async (thread) => { const root = thread.rootEvent.body; @@ -910,10 +937,6 @@ return 'open'; } - function quotePreviewFor(t: Thread): string { - return t.anchor?.quote?.exact ?? ''; - } - function activateThread(t: Thread): void { reviewStore.setFocusEventId(t.rootEvent.meta.eventId); // Move the editor cursor too (§1.5 "Click a margin card → moves the @@ -1019,7 +1042,7 @@ aria-expanded="false" onclick={() => { void expandResolved(t); }} > - ✓ +