Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

### Thread Organizer

Files new threads into the right existing work section while preserving native titles and every manual override.
Expand Down
105 changes: 96 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions plugins/timeline-comments/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Timeline Comments

Timeline Comments keeps review notes attached to the exact part of a bb conversation they refer to.

## Screenshots

Select message text to add a comment without leaving the timeline.

![Comment action in the floating text-selection menu](docs/selection-action.png)

Comments stay attached through an underline and a compact nearest-gutter thread.

![An anchored comment thread with a reply](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.
- 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
```
130 changes: 130 additions & 0 deletions plugins/timeline-comments/anchors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import {
chooseNearestGutter,
layoutGutterMarkers,
restoreSelector,
} from "./anchors.js";

const selector = (
overrides: Partial<Parameters<typeof restoreSelector>[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(1);
expect(placements.flatMap(({ ids }) => ids)).toHaveLength(5);
expect(placements.at(-1)!.y + 24).toBeLessThanOrEqual(80);
});

it("keeps separated markers distinct while clustering local collisions", () => {
const placements = layoutGutterMarkers(
[
{ id: "a", desiredY: 20 },
{ id: "b", desiredY: 40 },
{ id: "c", desiredY: 90 },
],
0,
140,
24,
4,
);
expect(placements.map(({ ids }) => ids)).toEqual([["a", "b"], ["c"]]);
});
});
Loading