From b2ac6de7c0a32b8c92f4c0b2a15cb919e5f36739 Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:06:14 +0000 Subject: [PATCH 01/20] Test pin layout without an empty overflow menu --- plugins/file-pins/pin-layout.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/plugins/file-pins/pin-layout.test.ts b/plugins/file-pins/pin-layout.test.ts index 9e3995912..f6860a83a 100644 --- a/plugins/file-pins/pin-layout.test.ts +++ b/plugins/file-pins/pin-layout.test.ts @@ -70,3 +70,27 @@ it("pins a file last on the strip only when every pinned file still fits", () => // A short label still fits where a long one would not. expect(pinFile(pins, full, "e", metrics(260, { a: 100, b: 100, e: 20 }))).toEqual({ order: ["a", "b", "e", "c", "d"], more: ["c", "d"] }); }); + + +it("uses the whole compact row when the overflow list is empty", () => { + const pair = pins.slice(0, 2); + const widths = { a: 150, b: 150 }; + // No button is needed at the exact whole-label boundary, or above it. + for (const width of [304, 305, 335, 304]) { + expect(layoutPins(pair, [], metrics(width, widths), { whole: true })).toEqual({ strip: pair, more: [], caps: {} }); + } + // Below that boundary, reserve the button and move the second pin into it. + expect(layoutPins(pair, [], metrics(303, widths), { whole: true })).toEqual({ strip: [pair[0]], more: [pair[1]], caps: {} }); + // A single long touch label uses the entire row without an empty menu. + expect(layoutPins(pair.slice(0, 1), [], metrics(200, { a: 300 }), { whole: true }).caps).toEqual({ a: 200 }); +}); + +it("reserves the button only when at least one pin belongs in its list", () => { + expect(layoutPins(pins, [], even(496)).more).toEqual([]); + expect(ids(layoutPins(pins, [], even(495)).more)).toEqual(["e"]); + const pair = pins.slice(0, 2); + expect(layoutPins(pair, ["b"], metrics(160, { a: 150, b: 150 }), { whole: true })).toEqual({ strip: [pair[0]], more: [pair[1]], caps: { a: 128 } }); + // Desktop's scrolling strip has no measured capacity and no overflow menu entries. + expect(layoutPins(pins, [], null)).toEqual({ strip: pins, more: [], caps: {} }); + expect(layoutPins([], [], even(100))).toEqual({ strip: [], more: [], caps: {} }); +}); From 057816c7b19cbcfa8c2775e4223d377b032b90dd Mon Sep 17 00:00:00 2001 From: brsbl <57682038+brsbl@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:08:51 +0000 Subject: [PATCH 02/20] Hide the pin overflow button when its list is empty --- plugins/file-pins/PLUGIN_OVERVIEW.md | 2 +- plugins/file-pins/README.md | 4 ++-- plugins/file-pins/app.tsx | 15 +++++++++------ plugins/file-pins/pin-layout.test.ts | 25 ++++++++++++------------- plugins/file-pins/pin-layout.ts | 8 +++++--- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/plugins/file-pins/PLUGIN_OVERVIEW.md b/plugins/file-pins/PLUGIN_OVERVIEW.md index 88320f72d..759aa3042 100644 --- a/plugins/file-pins/PLUGIN_OVERVIEW.md +++ b/plugins/file-pins/PLUGIN_OVERVIEW.md @@ -1,6 +1,6 @@ ## What you get -- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips, with a **⋯** always at the right edge that lists the pins the strip doesn't show. Desktop strips scroll horizontally under it with edge fades and bring keyboard-focused pins into view. Long labels shorten by kind: file names keep their extension, titles end at a word, and addresses keep their host. In narrow windows, labels truncate when the strip runs out of room, and the rest wait in the **⋯** list. On touch screens pins keep whole labels, and a pin that doesn't fit whole moves to **⋯**. +- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips, with a **⋯** at the right edge only when its list has pins. Desktop strips scroll horizontally with edge fades and bring keyboard-focused pins into view. Long labels shorten by kind: file names keep their extension, titles end at a word, and addresses keep their host. In narrow windows, labels truncate when the strip runs out of room, and the rest wait in the **⋯** list. On touch screens pins keep whole labels, and a pin that doesn't fit whole moves to **⋯**. - **Files and links together.** Files show outline icons for code, documents and images in the theme's file color. Links show their site's icon and the page title, a terminal for a dev server on localhost or a private network, and a globe for sites that have none. A public GitHub pull request also shows its state beside GitHub's icon: open, draft, merged or closed, in the theme's PR colors. - **Agent-assisted pinning.** The pin button adds a **Pin** pill to the composer. Type paths, file names or URLs after it and send; the agent finds each file, asks about any that are ambiguous, and pins them. - **Key links pinned for you.** Agents pin the PR they open, the issue or ticket, the spec they follow, and a dev site or preview they hand you, and remove a dev site's pin when its server stops. diff --git a/plugins/file-pins/README.md b/plugins/file-pins/README.md index 5575fb7c9..19bf67569 100644 --- a/plugins/file-pins/README.md +++ b/plugins/file-pins/README.md @@ -29,8 +29,8 @@ story server, its BB Connect URL, or a deployed preview). Each gets a clear titl URLs are never pinned, superseded links are replaced rather than piling up, and a dev site's pin is removed when its server is retired. A short always-on instruction points every thread at the skill's rule. -Pinned files read as quiet raised chips above the composer, with a **⋯** always at the strip's right edge -that lists the pins the strip doesn't show, or says all pins are showing. On desktop, the strip scrolls horizontally with a trackpad or Shift-wheel under it; edge fades +Pinned files read as quiet raised chips above the composer, with a **⋯** at the strip's right edge +only when its list has pins. On desktop, the strip scrolls horizontally with a trackpad or Shift-wheel along the strip; edge fades indicate more pins, and keyboard focus brings a pin into view. Long labels shorten by one rule set in [`pin-label.ts`](pin-label.ts): file names lose their middle and keep the extension, titles lose their end at a word, and addresses drop path segments before the host. Hover cards open once the preview and its image diff --git a/plugins/file-pins/app.tsx b/plugins/file-pins/app.tsx index c3d9f2b4e..2cf06be83 100644 --- a/plugins/file-pins/app.tsx +++ b/plugins/file-pins/app.tsx @@ -236,10 +236,14 @@ function PinStrip({ threadId }: { threadId: string }) { for (const child of Array.from(row.children)) observer.observe(child); return () => { row.removeEventListener("scroll", check); row.removeEventListener("wheel", wheel); observer.disconnect(); }; }, [compact, metrics, pins, more]); - // ⋯ is always there. It lists only pins the strip doesn't show: ones that didn't fit, then ones hidden by hand. + // ⋯ lists only pins the strip doesn't show: ones that didn't fit, then ones hidden by hand. // Pins reachable by scrolling the strip stay off it. const listed = layout.more; - const moreLabel = listed.length ? `${listed.length} more ${listed.length === 1 ? "pin" : "pins"}` : "More pins, all pins are showing"; + const hasMore = listed.length > 0; + const moreLabel = `${listed.length} more ${listed.length === 1 ? "pin" : "pins"}`; + useEffect(() => { + if (!hasMore) { setMoreOpen(false); setRowMenu(null); } + }, [hasMore]); const current: Arrangement = { order: pins.map((pin) => pin.id), more }; function arrange(next: Arrangement) { // Apply locally first; saves run in order and @@ -369,13 +373,12 @@ function PinStrip({ threadId }: { threadId: string }) { {layout.strip.map((pin) => stripPin(pin))} {/* ⋯ sits outside the scrolling row, flush with the composer's right edge, so pins scroll under its fade and it never moves. */} - + {hasMore && - {listed.length ?
{listed.map((pin) => listRow(pin))}
- :

All pins are showing

} +
{listed.map((pin) => listRow(pin))}
-
+
} } {/* Mirrors the strip row with every pin at its natural width, plus ⋯ and the minimum pin, to measure what fits. */} } />; } - // Overflowed pins can move back to the strip; Unpin always removes with Undo. function actions(pin: Reference): PinAction[] { - if (!more.includes(pin.id)) return [{ label: "Unpin", disabled: busy, run: () => void remove(pin) }]; - const next = pinFile(pins, current, pin.id, layoutMetrics, { whole }); - return [{ - label: "Show in strip", disabled: busy || !next, hint: next ? undefined : "No room on the strip", run: () => { - if (next) { setMoreOpen(false); arrange(next); } - }, - }, { label: "Unpin", disabled: busy, run: () => void remove(pin) }]; + return [{ label: "Unpin", disabled: busy, run: () => void remove(pin) }]; } // bb's FileLink menu items that the public SDK can reproduce, then this plugin's actions. // "Open with" and "Open in" need core-only openers and local app targets, so they are left out. @@ -364,15 +313,9 @@ function PinStrip({ threadId }: { threadId: string }) { {/* bb's composer fade, repeated above the flat strip so text scrolling under it doesn't end in a hard edge. */} {fade &&