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); }} > - ✓ +