From c5a1b98e4f850189ba7c79799330e41bfaadcfa2 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 22 Jul 2026 02:56:12 -0700 Subject: [PATCH 01/21] Add timeline comments plugin --- README.md | 12 +- package-lock.json | 78 + plugins/timeline-comments/README.md | 33 + plugins/timeline-comments/anchors.test.ts | 116 + plugins/timeline-comments/anchors.ts | 189 + plugins/timeline-comments/app.css | 381 + plugins/timeline-comments/app.test.tsx | 222 + plugins/timeline-comments/app.tsx | 568 + plugins/timeline-comments/bb-sdk-0.0.34.d.ts | 56 + plugins/timeline-comments/bridge.ts | 57 + .../browser-check/harness.html | 91 + .../browser-check/harness.ts | 157 + .../timeline-comments/browser-check/run.mjs | 85 + plugins/timeline-comments/controller.ts | 953 ++ plugins/timeline-comments/docs/screenshot.png | Bin 0 -> 48213 bytes plugins/timeline-comments/global.d.ts | 1 + plugins/timeline-comments/package.json | 51 + plugins/timeline-comments/server.test.ts | 340 + plugins/timeline-comments/server.ts | 973 ++ .../test/plugin-sdk-app-harness.d.ts | 3 + .../test/plugin-sdk-app-harness.js | 1013 ++ plugins/timeline-comments/tsconfig.json | 33 + .../types/bb-plugin-sdk-app.d.ts | 794 ++ .../types/bb-plugin-sdk.d.ts | 11762 ++++++++++++++++ plugins/timeline-comments/vitest.config.ts | 8 + 25 files changed, 17975 insertions(+), 1 deletion(-) create mode 100644 plugins/timeline-comments/README.md create mode 100644 plugins/timeline-comments/anchors.test.ts create mode 100644 plugins/timeline-comments/anchors.ts create mode 100644 plugins/timeline-comments/app.css create mode 100644 plugins/timeline-comments/app.test.tsx create mode 100644 plugins/timeline-comments/app.tsx create mode 100644 plugins/timeline-comments/bb-sdk-0.0.34.d.ts create mode 100644 plugins/timeline-comments/bridge.ts create mode 100644 plugins/timeline-comments/browser-check/harness.html create mode 100644 plugins/timeline-comments/browser-check/harness.ts create mode 100644 plugins/timeline-comments/browser-check/run.mjs create mode 100644 plugins/timeline-comments/controller.ts create mode 100644 plugins/timeline-comments/docs/screenshot.png create mode 100644 plugins/timeline-comments/global.d.ts create mode 100644 plugins/timeline-comments/package.json create mode 100644 plugins/timeline-comments/server.test.ts create mode 100644 plugins/timeline-comments/server.ts create mode 100644 plugins/timeline-comments/test/plugin-sdk-app-harness.d.ts create mode 100644 plugins/timeline-comments/test/plugin-sdk-app-harness.js create mode 100644 plugins/timeline-comments/tsconfig.json create mode 100644 plugins/timeline-comments/types/bb-plugin-sdk-app.d.ts create mode 100644 plugins/timeline-comments/types/bb-plugin-sdk.d.ts create mode 100644 plugins/timeline-comments/vitest.config.ts diff --git a/README.md b/README.md index 336a017..0f6cd8d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # bb plugins -Four bb plugins I use for product design work, kept together with the few build and repository tools they share. [![CI](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml) +Five bb plugins I use for product design work, kept together with the few build and repository tools they share. [![CI](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml/badge.svg)](https://github.com/brsbl/bb-plugins/actions/workflows/ci.yml) [bb](https://getbb.app) is an agentic IDE for running coding agents across projects, threads, and environments. Its plugins can add UI, commands, skills, and server capabilities; this repository is where I build and maintain mine. @@ -38,6 +38,16 @@ Lets you peek at a thread's status and repository context without leaving the si Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/thread-hover-cards --yes` +### Timeline Comments + +Keeps comments and comment threads attached to exact text in bb timelines. + +![Timeline Comments in bb](plugins/timeline-comments/docs/screenshot.png) + +[Source](plugins/timeline-comments) · [README](plugins/timeline-comments/README.md) + +Install: `bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/timeline-comments --yes` + ### UI Patterns Puts proven UI components and interaction guidance within reach of both designers and agents. diff --git a/package-lock.json b/package-lock.json index 34c0599..0835565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4005,6 +4005,10 @@ "resolved": "plugins/thread-hover-cards", "link": true }, + "node_modules/bb-plugin-timeline-comments": { + "resolved": "plugins/timeline-comments", + "link": true + }, "node_modules/bb-plugin-ui-patterns": { "resolved": "plugins/ui-patterns", "link": true @@ -5236,6 +5240,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.20", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", @@ -6678,6 +6729,33 @@ "bbPluginSdk": "^0.4.0" } }, + "plugins/timeline-comments": { + "name": "bb-plugin-timeline-comments", + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "@bb/plugin-sdk": "file:../../tooling/vendor/bb-plugin-sdk-0.4.0.tgz", + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "better-sqlite3": "^12.10.0", + "cron-parser": "^5.5.0", + "esbuild": "^0.28.0", + "hono": "^4.11.9", + "jsdom": "^29.0.1", + "playwright": "^1.55.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.0", + "vitest": "^4.1.8", + "zod": "^4.3.6" + }, + "engines": { + "bb": ">=0.0.34", + "bbPluginSdk": "^0.4.0" + } + }, "plugins/ui-patterns": { "name": "bb-plugin-ui-patterns", "version": "0.1.0", diff --git a/plugins/timeline-comments/README.md b/plugins/timeline-comments/README.md new file mode 100644 index 0000000..f508784 --- /dev/null +++ b/plugins/timeline-comments/README.md @@ -0,0 +1,33 @@ +# Timeline Comments + +Timeline Comments keeps review notes attached to the exact part of a bb conversation they refer to. + +![Timeline Comments in bb](docs/screenshot.png) + +## Use + +- Adds **Comment** to the floating menu when you select user or agent message text. +- Keeps open comment threads visible through a quiet underline and the nearest gutter marker. +- Provides replies, inline editing, deletion, resolve/reopen controls, and a thread-scoped Comments panel. +- Sends one comment to a new agent draft or adds every open comment to the current thread's draft without submitting it. + +Comments are stored in plugin-owned SQLite on the bb server. Missing or ambiguous source text remains manageable as **Unanchored** and is never attached to a guess. + +## Install + +```bash +bb plugin install git:https://github.com/brsbl/bb-plugins.git@plugin/timeline-comments --yes +``` + +Timeline Comments requires bb 0.0.34 or newer and the 0.4 plugin SDK. + +## Develop + +From the repository root: + +```bash +npm ci +npm run check --workspace=bb-plugin-timeline-comments +npm run test:browser --workspace=bb-plugin-timeline-comments +bb plugin install "path:$PWD/plugins/timeline-comments" --yes +``` diff --git a/plugins/timeline-comments/anchors.test.ts b/plugins/timeline-comments/anchors.test.ts new file mode 100644 index 0000000..86c55ef --- /dev/null +++ b/plugins/timeline-comments/anchors.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { describe, expect, it } from "vitest"; +import { + chooseNearestGutter, + layoutGutterMarkers, + restoreSelector, +} from "./anchors.js"; + +const selector = ( + overrides: Partial[1]> = {}, +) => ({ + version: 1 as const, + coordinateSpace: "rendered-text-utf16" as const, + start: 6, + end: 15, + exact: "nested 😀", + prefix: "alpha ", + suffix: " omega", + ...overrides, +}); + +describe("restoreSelector", () => { + it("restores UTF-16 offsets across nested rendered nodes", () => { + const root = document.createElement("div"); + root.append("alpha "); + const strong = document.createElement("strong"); + strong.append("nested "); + root.append(strong, "😀 omega"); + const restored = restoreSelector(root, selector()); + expect(restored?.strategy).toBe("offset"); + expect(restored?.range.toString()).toBe("nested 😀"); + }); + + it("uses surrounding context only when it resolves one exact copy", () => { + const root = document.createElement("div"); + root.textContent = "wrong target here; right target done"; + expect( + restoreSelector( + root, + selector({ + start: 0, + end: 6, + exact: "target", + prefix: "right ", + suffix: " done", + }), + )?.range.toString(), + ).toBe("target"); + }); + + it("returns unanchored for ambiguous context instead of guessing", () => { + const root = document.createElement("div"); + root.textContent = "same target same target"; + expect( + restoreSelector( + root, + selector({ + start: 99, + end: 105, + exact: "target", + prefix: "same ", + suffix: "", + }), + ), + ).toBeNull(); + }); + + it("accepts a unique exact phrase when old context no longer matches", () => { + const root = document.createElement("div"); + root.textContent = "updated unique phrase ending"; + expect( + restoreSelector( + root, + selector({ + start: 99, + end: 105, + exact: "unique", + prefix: "old ", + suffix: " context", + }), + )?.range.toString(), + ).toBe("unique"); + }); +}); + +describe("gutter layout", () => { + const rail = { left: 100, right: 900, width: 800 }; + + it("uses the closest fragment edge and breaks ties toward the right", () => { + expect(chooseNearestGutter([{ left: 130, right: 400 }], rail)).toBe("left"); + expect(chooseNearestGutter([{ left: 400, right: 600 }], rail)).toBe( + "right", + ); + expect(chooseNearestGutter([{ left: 110, right: 890 }], rail)).toBe( + "right", + ); + }); + + it("keeps narrow timelines on the right gutter", () => { + expect( + chooseNearestGutter([{ left: 102, right: 200 }], { ...rail, width: 480 }), + ).toBe("right"); + }); + + it("de-overlaps markers and groups visual overflow", () => { + const markers = Array.from({ length: 5 }, (_, index) => ({ + id: String(index), + desiredY: 20, + })); + const placements = layoutGutterMarkers(markers, 0, 80, 24, 4); + expect(placements).toHaveLength(3); + expect(placements.flatMap(({ ids }) => ids)).toHaveLength(5); + expect(placements[1]!.y - placements[0]!.y).toBeGreaterThanOrEqual(28); + expect(placements.at(-1)!.y + 24).toBeLessThanOrEqual(80); + }); +}); diff --git a/plugins/timeline-comments/anchors.ts b/plugins/timeline-comments/anchors.ts new file mode 100644 index 0000000..4b57c67 --- /dev/null +++ b/plugins/timeline-comments/anchors.ts @@ -0,0 +1,189 @@ +export interface StoredSelector { + version: 1; + coordinateSpace: "rendered-text-utf16"; + start: number; + end: number; + exact: string; + prefix: string; + suffix: string; +} + +export interface RestoredAnchor { + range: Range; + strategy: "offset" | "context"; +} + +interface TextSegment { + node: Text; + start: number; + end: number; +} + +function indexText(root: Element): { text: string; segments: TextSegment[] } { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const segments: TextSegment[] = []; + let text = ""; + for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) { + const value = node.nodeValue ?? ""; + segments.push({ + node: node as Text, + start: text.length, + end: text.length + value.length, + }); + text += value; + } + return { text, segments }; +} + +function boundaryAt( + segments: TextSegment[], + offset: number, + preferNext: boolean, +): { node: Text; offset: number } | null { + for (const [index, segment] of segments.entries()) { + if ( + offset < segment.end || + (offset === segment.end && (!preferNext || index === segments.length - 1)) + ) { + return { node: segment.node, offset: offset - segment.start }; + } + if (preferNext && offset === segment.end) continue; + } + return null; +} + +function rangeForOffsets( + segments: TextSegment[], + start: number, + end: number, +): Range | null { + const startBoundary = boundaryAt(segments, start, true); + const endBoundary = boundaryAt(segments, end, false); + if (startBoundary === null || endBoundary === null) return null; + const range = document.createRange(); + range.setStart(startBoundary.node, startBoundary.offset); + range.setEnd(endBoundary.node, endBoundary.offset); + return range; +} + +function contextMatches( + text: string, + at: number, + selector: StoredSelector, +): boolean { + const prefix = text.slice(Math.max(0, at - selector.prefix.length), at); + const suffixStart = at + selector.exact.length; + const suffix = text.slice(suffixStart, suffixStart + selector.suffix.length); + return prefix === selector.prefix && suffix === selector.suffix; +} + +/** Restore only an exact, unambiguous rendered-text range. Never guesses. */ +export function restoreSelector( + root: Element, + selector: StoredSelector, +): RestoredAnchor | null { + const { text, segments } = indexText(root); + if ( + text.slice(selector.start, selector.end) === selector.exact && + contextMatches(text, selector.start, selector) + ) { + const range = rangeForOffsets(segments, selector.start, selector.end); + return range === null ? null : { range, strategy: "offset" }; + } + + const occurrences: number[] = []; + const candidates: number[] = []; + let at = text.indexOf(selector.exact); + while (at !== -1) { + occurrences.push(at); + if (contextMatches(text, at, selector)) candidates.push(at); + at = text.indexOf(selector.exact, at + 1); + } + const start = + candidates.length === 1 + ? candidates[0] + : occurrences.length === 1 + ? occurrences[0] + : undefined; + if (start === undefined) return null; + const range = rangeForOffsets(segments, start, start + selector.exact.length); + return range === null ? null : { range, strategy: "context" }; +} + +export type GutterSide = "left" | "right"; + +export function chooseNearestGutter( + fragments: readonly Pick[], + rail: Pick, +): GutterSide { + if (rail.width < 520 || fragments.length === 0) return "right"; + const leftDistance = Math.min( + ...fragments.map((rect) => Math.abs(rect.left - rail.left)), + ); + const rightDistance = Math.min( + ...fragments.map((rect) => Math.abs(rail.right - rect.right)), + ); + return leftDistance < rightDistance ? "left" : "right"; +} + +export interface MarkerCandidate { + id: string; + desiredY: number; +} + +export interface MarkerPlacement { + ids: string[]; + y: number; +} + +/** + * De-overlap one gutter. When the available rail cannot fit every marker, + * adjacent candidates collapse into the smallest possible overflow groups. + */ +export function layoutGutterMarkers( + candidates: readonly MarkerCandidate[], + top: number, + bottom: number, + markerSize = 24, + gap = 4, +): MarkerPlacement[] { + if (candidates.length === 0 || bottom <= top) return []; + const sorted = [...candidates].sort( + (a, b) => a.desiredY - b.desiredY || a.id.localeCompare(b.id), + ); + const capacity = Math.max( + 1, + Math.floor((bottom - top + gap) / (markerSize + gap)), + ); + const groups: MarkerCandidate[][] = []; + if (sorted.length <= capacity) { + for (const candidate of sorted) groups.push([candidate]); + } else { + for (const candidate of sorted.slice(0, capacity - 1)) + groups.push([candidate]); + groups.push(sorted.slice(capacity - 1)); + } + + const placements = groups.map((group) => ({ + ids: group.map(({ id }) => id), + y: Math.min( + bottom - markerSize, + Math.max( + top, + group.reduce((sum, item) => sum + item.desiredY, 0) / group.length - + markerSize / 2, + ), + ), + })); + for (let index = 1; index < placements.length; index += 1) { + placements[index]!.y = Math.max( + placements[index]!.y, + placements[index - 1]!.y + markerSize + gap, + ); + } + const overflow = placements.at(-1)!.y + markerSize - bottom; + if (overflow > 0) { + for (const placement of placements) placement.y -= overflow; + } + return placements; +} diff --git a/plugins/timeline-comments/app.css b/plugins/timeline-comments/app.css new file mode 100644 index 0000000..c00c952 --- /dev/null +++ b/plugins/timeline-comments/app.css @@ -0,0 +1,381 @@ +.bb-comments-overlay { + position: fixed; + inset: 0; + z-index: 52; + pointer-events: none; +} + +.bb-comments-marker { + position: fixed; + display: grid; + width: 24px; + height: 24px; + place-items: center; + padding: 0; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--popover); + box-shadow: 0 2px 9px color-mix(in oklab, var(--foreground) 12%, transparent); + color: var(--muted-foreground); + font: + 600 10px/1 ui-sans-serif, + system-ui, + sans-serif; + pointer-events: auto; + cursor: pointer; +} + +.bb-comments-marker::before { + width: 8px; + height: 6px; + border: 1.5px solid currentColor; + border-radius: 2px; + content: ""; +} + +.bb-comments-marker:not(:empty)::before { + display: none; +} +.bb-comments-marker:hover, +.bb-comments-marker:focus-visible { + background: var(--surface-selected); + color: var(--foreground); + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.bb-comments-composer, +.bb-comments-popover { + position: fixed; + z-index: 54; + border: 1px solid var(--border); + border-radius: 11px; + background: var(--popover); + box-shadow: 0 14px 36px + color-mix(in oklab, var(--foreground) 18%, transparent); + color: var(--popover-foreground); + font: + 13px/1.45 ui-sans-serif, + system-ui, + sans-serif; +} + +.bb-comments-composer { + width: 320px; + padding: 10px; +} +.bb-comments-textarea, +.bb-comments-reply-input, +.bb-comments-edit-input, +.bb-comments-panel textarea { + width: 100%; + box-sizing: border-box; + resize: vertical; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--background); + color: var(--foreground); + font: inherit; + outline: none; +} +.bb-comments-textarea { + min-height: 70px; + padding: 8px; + border: 0; + background: transparent; + resize: none; +} +.bb-comments-textarea:focus, +.bb-comments-reply-input:focus, +.bb-comments-edit-input:focus, +.bb-comments-panel textarea:focus { + box-shadow: 0 0 0 1px var(--ring); +} +.bb-comments-composer-footer, +.bb-comments-thread-header, +.bb-comments-comment-actions, +.bb-comments-header-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.bb-comments-hint { + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-primary, +.bb-comments-quiet, +.bb-comments-icon-button, +.bb-comments-cluster-row, +.bb-comments-panel button { + font: inherit; + cursor: pointer; +} +.bb-comments-primary { + min-height: 28px; + padding: 0 9px; + border: 0; + border-radius: 6px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 11px; + font-weight: 600; +} +.bb-comments-primary:disabled { + cursor: default; + opacity: 0.45; +} +.bb-comments-error, +.bb-comments-panel-error { + padding: 6px 0; + color: var(--destructive-text, var(--foreground)); + font-size: 11px; +} + +.bb-comments-thread { + max-height: min(520px, calc(100vh - 16px)); + overflow: auto; +} +.bb-comments-thread-header { + position: sticky; + top: 0; + z-index: 1; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--popover); +} +.bb-comments-thread-source { + min-width: 0; + overflow: hidden; + color: var(--muted-foreground); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} +.bb-comments-quiet, +.bb-comments-icon-button { + border: 0; + background: transparent; + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-quiet:hover, +.bb-comments-icon-button:hover { + color: var(--foreground); +} +.bb-comments-icon-button { + width: 24px; + height: 24px; + font-size: 18px; +} +.bb-comments-comment { + padding: 10px; + border-bottom: 1px solid var(--border-seam, var(--border)); +} +.bb-comments-comment-meta { + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-comment-body { + margin: 4px 0 8px; + white-space: pre-wrap; +} +.bb-comments-comment-actions { + justify-content: flex-end; +} +.bb-comments-reply { + position: sticky; + bottom: 0; + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + padding: 8px; + background: var(--popover); +} +.bb-comments-reply-input { + min-height: 38px; + padding: 6px; + resize: none; +} +.bb-comments-edit-input { + min-height: 64px; + margin: 6px 0; + padding: 7px; +} +.bb-comments-loading { + padding: 14px; + color: var(--muted-foreground); + text-align: center; +} +.bb-comments-cluster { + padding: 4px; +} +.bb-comments-cluster-row { + display: block; + width: 100%; + padding: 7px 8px; + overflow: hidden; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--foreground); + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} +.bb-comments-cluster-row:hover { + background: var(--surface-recessed); +} + +.bb-comments-panel { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + background: var(--background); + color: var(--foreground); + font: + 13px/1.45 ui-sans-serif, + system-ui, + sans-serif; +} +.bb-comments-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px; + border-bottom: 1px solid var(--border); +} +.bb-comments-panel-header h2 { + margin: 0; + font-size: 14px; +} +.bb-comments-panel-header p { + margin: 2px 0 0; + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-filters { + display: flex; + gap: 3px; + padding: 7px 10px; + border-bottom: 1px solid var(--border); +} +.bb-comments-filters button { + min-height: 26px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted-foreground); + font-size: 11px; +} +.bb-comments-filters button[aria-pressed="true"] { + background: var(--surface-selected); + color: var(--foreground); +} +.bb-comments-panel-error { + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.bb-comments-panel-list { + min-height: 0; + flex: 1; + overflow-y: auto; +} +.bb-comments-panel-row { + border-bottom: 1px solid var(--border-seam, var(--border)); +} +.bb-comments-row-summary { + display: grid; + width: 100%; + gap: 3px; + padding: 10px 12px; + border: 0; + background: transparent; + color: var(--foreground); + text-align: left; +} +.bb-comments-row-summary:hover { + background: var(--surface-recessed); +} +.bb-comments-row-source { + overflow: hidden; + color: var(--muted-foreground); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.bb-comments-row-body { + font-size: 12px; +} +.bb-comments-row-meta { + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-panel-thread { + padding: 0 10px 10px; + background: var(--surface-recessed); +} +.bb-comments-panel-comment { + padding: 8px 2px; + border-bottom: 1px solid var(--border); +} +.bb-comments-panel-comment p { + margin: 0 0 6px; + white-space: pre-wrap; +} +.bb-comments-panel-edit textarea { + min-height: 54px; + margin-bottom: 6px; + padding: 6px; + resize: vertical; +} +.bb-comments-panel-comment div { + display: flex; + justify-content: flex-end; + gap: 7px; +} +.bb-comments-panel-comment button, +.bb-comments-resolve { + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + font-size: 10px; +} +.bb-comments-panel-comment button:hover, +.bb-comments-resolve:hover { + color: var(--foreground); +} +.bb-comments-panel-reply { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px; + margin-top: 8px; +} +.bb-comments-panel-reply textarea { + min-height: 42px; + padding: 6px; + resize: none; +} +.bb-comments-resolve { + margin-top: 9px; +} +.bb-comments-load-more { + display: block; + margin: 10px auto; + padding: 5px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + color: var(--muted-foreground); + font-size: 11px; +} +.bb-comments-empty { + padding: 28px 16px; + color: var(--muted-foreground); + text-align: center; +} diff --git a/plugins/timeline-comments/app.test.tsx b/plugins/timeline-comments/app.test.tsx new file mode 100644 index 0000000..7328768 --- /dev/null +++ b/plugins/timeline-comments/app.test.tsx @@ -0,0 +1,222 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginThreadPanelProps } from "@bb/plugin-sdk/app"; +import { + loadPluginApp, + mountPluginContentScripts, + renderSlot, +} from "./test/plugin-sdk-app-harness.js"; +import type { timelineCommentsRpcContract } from "./server.js"; + +afterEach(cleanup); + +const thread = { + id: "comment_thread_1", + bbThreadId: "thr_1", + messageId: "msg_1", + messageRole: "assistant" as const, + selector: { + version: 1 as const, + coordinateSpace: "rendered-text-utf16" as const, + start: 0, + end: 6, + exact: "source", + prefix: "", + suffix: "", + }, + version: 1, + createdAt: 1, + updatedAt: 1, + resolvedAt: null, + rootComment: { + id: "comment_1", + threadId: "comment_thread_1", + parentId: null, + body: "Make the API explicit.", + version: 1, + createdAt: 1, + updatedAt: 1, + }, + replyCount: 0, +}; + +describe("timeline comments app", () => { + it("registers a selection-only action, the Comments panel, and one content script", async () => { + const app = await loadPluginApp(() => import("./app.js")); + expect(app.messageActions).toMatchObject([ + { + id: "comment-selection", + title: "Comment", + placements: ["selection-menu"], + }, + ]); + expect(app.threadPanelActions).toMatchObject([ + { id: "comments", title: "Comments", layout: "flush" }, + ]); + expect(app.contentScripts.map(({ id }) => id)).toEqual([ + "timeline-comment-anchors", + ]); + }); + + it("removes every content-script node and tolerates repeated disposal", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const scripts = await mountPluginContentScripts(app, { + pluginId: "timeline-comments", + }); + expect( + document.querySelectorAll("[data-bb-timeline-comments-owned]"), + ).toHaveLength(2); + await scripts.lifecycle.dispose(); + await scripts.lifecycle.dispose(); + expect( + document.querySelectorAll("[data-bb-timeline-comments-owned]"), + ).toHaveLength(0); + }); + + it("adds a fresh bulk mention without replacing the existing draft", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const panel = renderSlot< + PluginThreadPanelProps, + typeof timelineCommentsRpcContract + >( + app.threadPanelActions[0]!, + { + threadId: "thr_1", + params: null, + revealMessage: vi.fn(async () => "revealed" as const), + }, + { + context: { threadId: "thr_1" }, + composer: { + text: "Keep this draft", + scope: { kind: "thread", threadId: "thr_1" }, + }, + rpc: { + listCommentThreads: () => ({ threads: [thread], nextCursor: null }), + getThreadHandoffSummary: () => ({ + threadCount: 1, + commentCount: 1, + codePointSize: 100, + }), + getCommentThread: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + createThread: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + reply: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + updateComment: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + deleteComment: () => ({ deletedThreadId: null, thread: null }), + setThreadResolved: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + listOpenAnchors: () => ({ anchors: [], nextCursor: null }), + }, + }, + ); + const button = await panel.findByRole("button", { + name: "Add all to chat", + }); + fireEvent.click(button); + await vi.waitFor(() => + expect(panel.inspection.composer.mentions).toHaveLength(1), + ); + expect(panel.inspection.composer.mentions[0]).toEqual({ + provider: "thread-comments", + id: "thr_1", + label: "1 comments from 1 open thread", + }); + expect(panel.inspection.composer.text).toContain("Keep this draft"); + }); + + it("reveals panel rows and sends exactly one comment to a new composer", async () => { + const app = await loadPluginApp(() => import("./app.js")); + const revealMessage = vi.fn(async () => "revealed" as const); + const updateComment = vi.fn(() => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + })); + const panel = renderSlot< + PluginThreadPanelProps, + typeof timelineCommentsRpcContract + >( + app.threadPanelActions[0]!, + { threadId: "thr_1", params: null, revealMessage }, + { + context: { threadId: "thr_1" }, + rpc: { + listCommentThreads: () => ({ threads: [thread], nextCursor: null }), + getThreadHandoffSummary: () => ({ + threadCount: 1, + commentCount: 1, + codePointSize: 100, + }), + getCommentThread: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + createThread: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + reply: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + updateComment, + deleteComment: () => ({ deletedThreadId: null, thread: null }), + setThreadResolved: () => ({ + thread, + comments: [thread.rootComment], + nextCursor: null, + }), + listOpenAnchors: () => ({ anchors: [], nextCursor: null }), + }, + }, + ); + fireEvent.click(await panel.findByRole("button", { name: /source/i })); + await panel.findByText("Send to agent"); + expect(revealMessage).toHaveBeenCalledWith("msg_1"); + fireEvent.click(panel.getByRole("button", { name: "Send to agent" })); + expect(panel.inspection.navigateCalls).toEqual([ + { + method: "toCompose", + options: { initialPrompt: "Make the API explicit.", focusPrompt: true }, + }, + ]); + + fireEvent.click(panel.getByRole("button", { name: "Edit" })); + const editor = panel.getByRole("textbox", { name: "Edit comment" }); + fireEvent.change(editor, { + target: { value: "Make the contract explicit." }, + }); + fireEvent.click(panel.getByRole("button", { name: "Save" })); + await vi.waitFor(() => + expect(updateComment).toHaveBeenCalledWith({ + bbThreadId: "thr_1", + commentId: "comment_1", + expectedVersion: 1, + body: "Make the contract explicit.", + }), + ); + }); +}); diff --git a/plugins/timeline-comments/app.tsx b/plugins/timeline-comments/app.tsx new file mode 100644 index 0000000..ba04005 --- /dev/null +++ b/plugins/timeline-comments/app.tsx @@ -0,0 +1,568 @@ +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + definePluginApp, + useBbNavigate, + useComposer, + useRealtime, + useRealtimeConnectionState, + useRpc, + type PluginThreadPanelProps, +} from "@bb/plugin-sdk/app"; +import type { + TimelineComment, + TimelineCommentThreadDetail, + TimelineCommentThreadSummary, + timelineCommentsRpcContract, +} from "./server.js"; +import { + beginTimelineComment, + focusTimelineComment, + getTimelineCommentAnchorHealth, + subscribeTimelineCommentAnchorHealth, +} from "./bridge.js"; +import { mountTimelineCommentsController } from "./controller.js"; +import "./app.css"; + +type Filter = "open" | "resolved" | "all"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Something went wrong"; +} + +function excerpt(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length - 1)}…` : value; +} + +function CommentPanel({ threadId, revealMessage }: PluginThreadPanelProps) { + const rpc = useRpc(); + const composer = useComposer(); + const navigate = useBbNavigate(); + const connection = useRealtimeConnectionState(); + const anchorHealth = useSyncExternalStore( + subscribeTimelineCommentAnchorHealth, + getTimelineCommentAnchorHealth, + getTimelineCommentAnchorHealth, + ); + const previousConnection = useRef(connection); + const revealRequest = useRef(0); + const [filter, setFilter] = useState("open"); + const [threads, setThreads] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [detail, setDetail] = useState( + null, + ); + const [editingComment, setEditingComment] = useState<{ + id: string; + body: string; + } | null>(null); + const [unanchored, setUnanchored] = useState>(new Set()); + const [summary, setSummary] = useState({ threadCount: 0, commentCount: 0 }); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const loadSummary = useCallback(async () => { + try { + const result = await rpc.call("getThreadHandoffSummary", { + bbThreadId: threadId, + }); + setSummary({ + threadCount: result.threadCount, + commentCount: result.commentCount, + }); + } catch (caught) { + setError(errorMessage(caught)); + } + }, [rpc, threadId]); + + const loadThreads = useCallback( + async (append = false) => { + setLoading(true); + setError(null); + try { + const cursor = append ? nextCursor : null; + const page = await rpc.call("listCommentThreads", { + bbThreadId: threadId, + filter, + ...(cursor !== null ? { cursor } : {}), + }); + setThreads((current) => + append ? [...current, ...page.threads] : page.threads, + ); + setNextCursor(page.nextCursor); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setLoading(false); + } + }, + [filter, nextCursor, rpc, threadId], + ); + + const loadDetail = useCallback( + async (commentThreadId: string, cursor?: string) => { + const page = await rpc.call("getCommentThread", { + bbThreadId: threadId, + commentThreadId, + ...(cursor !== undefined ? { cursor } : {}), + }); + setDetail((current) => + cursor !== undefined && current?.thread.id === commentThreadId + ? { ...page, comments: [...current.comments, ...page.comments] } + : page, + ); + return page; + }, + [rpc, threadId], + ); + + const reconcile = useCallback(async () => { + await Promise.all([loadThreads(false), loadSummary()]); + if (expandedId !== null) { + try { + await loadDetail(expandedId); + } catch { + setExpandedId(null); + setDetail(null); + } + } + }, [expandedId, loadDetail, loadSummary, loadThreads]); + + useEffect(() => { + setExpandedId(null); + setDetail(null); + setEditingComment(null); + void Promise.all([loadThreads(false), loadSummary()]); + }, [filter, threadId]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect( + () => () => { + revealRequest.current += 1; + }, + [threadId], + ); + + useRealtime("comments-changed", (payload) => { + if ( + typeof payload === "object" && + payload !== null && + (payload as { bbThreadId?: unknown }).bbThreadId === threadId + ) { + void reconcile(); + } + }); + + useEffect(() => { + const previous = previousConnection.current; + previousConnection.current = connection; + if (connection === "connected" && previous === "reconnecting") + void reconcile(); + }, [connection, reconcile]); + + const activate = async (item: TimelineCommentThreadSummary) => { + const request = ++revealRequest.current; + setExpandedId(item.id); + setError(null); + try { + await loadDetail(item.id); + if (request !== revealRequest.current) return; + const revealed = await revealMessage(item.messageId); + if (request !== revealRequest.current) return; + const anchored = + revealed === "revealed" && (await focusTimelineComment(item.id)); + if (request !== revealRequest.current) return; + setUnanchored((current) => { + const next = new Set(current); + if (anchored) next.delete(item.id); + else next.add(item.id); + return next; + }); + } catch (caught) { + if (request === revealRequest.current) setError(errorMessage(caught)); + } + }; + + const mutate = async (operation: () => Promise) => { + setBusy(true); + setError(null); + try { + await operation(); + await reconcile(); + } catch (caught) { + setError(errorMessage(caught)); + if (/changed/iu.test(errorMessage(caught))) await reconcile(); + } finally { + setBusy(false); + } + }; + + const addAll = async () => { + setBusy(true); + setError(null); + try { + const latest = await rpc.call("getThreadHandoffSummary", { + bbThreadId: threadId, + }); + if (latest.threadCount === 0) + throw new Error("There are no open comments to add"); + if ( + composer.scope.kind !== "thread" || + composer.scope.threadId !== threadId + ) { + throw new Error("The current thread composer is not available"); + } + composer.insertMention({ + provider: "thread-comments", + id: threadId, + label: `${latest.commentCount} comments from ${latest.threadCount} open ${latest.threadCount === 1 ? "thread" : "threads"}`, + }); + composer.focus(); + setSummary({ + threadCount: latest.threadCount, + commentCount: latest.commentCount, + }); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setBusy(false); + } + }; + + const saveEditedComment = async (comment: TimelineComment) => { + if (editingComment?.id !== comment.id) return; + if (editingComment.body === comment.body) { + setEditingComment(null); + return; + } + setBusy(true); + setError(null); + try { + const updated = await rpc.call("updateComment", { + bbThreadId: threadId, + commentId: comment.id, + expectedVersion: comment.version, + body: editingComment.body, + }); + setDetail(updated); + setEditingComment(null); + await reconcile(); + } catch (caught) { + const message = errorMessage(caught); + setError(message); + if (/changed/iu.test(message)) { + await reconcile(); + setEditingComment(null); + } + } finally { + setBusy(false); + } + }; + + const deleteComment = (comment: TimelineComment) => { + if ( + !window.confirm( + comment.parentId === null + ? "Delete this comment thread?" + : "Delete this reply?", + ) + ) + return; + void mutate(async () => { + const result = await rpc.call("deleteComment", { + bbThreadId: threadId, + commentId: comment.id, + expectedVersion: comment.version, + }); + if (result.deletedThreadId !== null) { + setExpandedId(null); + setDetail(null); + } else { + setDetail(result.thread); + } + }); + }; + + return ( +
+
+
+

Comments

+

+ {summary.commentCount} open{" "} + {summary.commentCount === 1 ? "comment" : "comments"} +

+
+ {summary.threadCount > 0 ? ( + + ) : null} +
+ +
+ {(["open", "resolved", "all"] as const).map((value) => ( + + ))} +
+ + {error !== null ? ( +
+ {error} +
+ ) : null} +
+ {!loading && threads.length === 0 ? ( +
+ No {filter === "all" ? "" : `${filter} `}comments. +
+ ) : null} + {threads.map((item) => { + const isExpanded = expandedId === item.id; + const itemDetail = + isExpanded && detail?.thread.id === item.id ? detail : null; + return ( +
+ + {itemDetail !== null ? ( +
+ {itemDetail.comments.map((comment) => ( +
+ {editingComment?.id === comment.id ? ( +
{ + event.preventDefault(); + void saveEditedComment(comment); + }} + > +