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. */}
diff --git a/plugins/file-pins/pin-layout.test.ts b/plugins/file-pins/pin-layout.test.ts
index f6860a83a..b9138b2c3 100644
--- a/plugins/file-pins/pin-layout.test.ts
+++ b/plugins/file-pins/pin-layout.test.ts
@@ -4,7 +4,7 @@ import { layoutPins, pinFile, unpinFile, type PinMetrics } from "./pin-layout.js
const order = ["a", "b", "c", "d", "e"];
const pins = order.map((id) => ({ id }));
const ids = (items: Array<{ id: string }>) => items.map((pin) => pin.id);
-// A strip `width` px wide with a 4px gap, a 28px ⋯ button (always shown) and a 96px minimum pin.
+// A strip `width` px wide with a 4px gap, a 28px ⋯ button when needed and a 96px minimum pin.
const metrics = (width: number, widths: Record): PinMetrics => ({ width, gap: 4, more: 28, min: 96, pins: widths });
const even = (width: number) => metrics(width, Object.fromEntries(order.map((id) => [id, 100])));
@@ -14,21 +14,21 @@ it("shows one short pin, or one long pin with room, at its full width", () => {
});
it("truncates the longest pins first and leaves shorter ones whole", () => {
- // 600 - 2 gaps - ⋯ and its gap = 560; the 80px pin keeps its width and the two long ones share 480.
+ // 600 - 2 gaps = 592; the 80px pin keeps its width and the two long ones share 512.
const layout = layoutPins(pins.slice(0, 3), [], metrics(600, { a: 80, b: 400, c: 300 }));
- expect([ids(layout.strip), layout.caps]).toEqual([["a", "b", "c"], { b: 240, c: 240 }]);
+ expect([ids(layout.strip), layout.caps]).toEqual([["a", "b", "c"], { b: 256, c: 256 }]);
// Only the longest pin truncates when that alone makes room.
- expect(layoutPins(pins.slice(0, 3), [], metrics(600, { a: 80, b: 400, c: 150 })).caps).toEqual({ b: 330 });
+ expect(layoutPins(pins.slice(0, 3), [], metrics(600, { a: 80, b: 400, c: 150 })).caps).toEqual({ b: 362 });
// A label just over the share (105.5 vs 105) stays whole; the two long pins give up the difference.
- expect(layoutPins(pins.slice(0, 3), [], metrics(355, { a: 300, b: 300, c: 105.5 })).caps).toEqual({ a: 104.75, b: 104.75 });
+ expect(layoutPins(pins.slice(0, 3), [], metrics(323, { a: 300, b: 300, c: 105.5 })).caps).toEqual({ a: 104.75, b: 104.75 });
});
it("moves pins into ⋯ only once every shown pin is at the minimum", () => {
- // Five 100px pins need 548px beside ⋯; at 540 each truncates slightly rather than overflowing.
- const tight = layoutPins(pins, [], even(540));
- expect([ids(tight.strip), tight.more, tight.caps]).toEqual([order, [], Object.fromEntries(order.map((id) => [id, 98.4]))]);
- // At 510 five minimum pins (480 in 462px) no longer fit, so the last goes to ⋯.
- const crowded = layoutPins(pins, [], even(510));
+ // Five 100px pins need 516px; at 510 each truncates slightly rather than overflowing.
+ const tight = layoutPins(pins, [], even(510));
+ expect([ids(tight.strip), tight.more, tight.caps]).toEqual([order, [], Object.fromEntries(order.map((id) => [id, 98.8]))]);
+ // At 490 five minimum pins (496 with gaps) no longer fit, so the last goes to ⋯.
+ const crowded = layoutPins(pins, [], even(490));
expect([ids(crowded.strip), ids(crowded.more)]).toEqual([["a", "b", "c", "d"], ["e"]]);
});
@@ -43,9 +43,9 @@ it("keeps labels whole on touch, moving a pin that doesn't fit whole into ⋯",
// Two long links in a 350px phone strip: the first keeps its whole label and the second waits in ⋯.
const phone = layoutPins(pins.slice(0, 2), [], metrics(350, { a: 280, b: 260 }), { whole: true });
expect([ids(phone.strip), ids(phone.more), phone.caps]).toEqual([["a"], ["b"], {}]);
- // Both fit whole beside ⋯ when there is room.
+ // Both fit whole when there is room.
expect(layoutPins(pins.slice(0, 2), [], metrics(350, { a: 150, b: 150 }), { whole: true })).toEqual({ strip: [{ id: "a" }, { id: "b" }], more: [], caps: {} });
- // A lone pin wider than the row still shows, truncated beside ⋯.
+ // A first pin wider than the row still shows, truncated beside ⋯ for the second pin.
const lone = layoutPins(pins.slice(0, 2), [], metrics(350, { a: 500, b: 100 }), { whole: true });
expect([ids(lone.strip), ids(lone.more), lone.caps]).toEqual([["a"], ["b"], { a: 318 }]);
});
@@ -71,7 +71,6 @@ it("pins a file last on the strip only when every pinned file still fits", () =>
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 };
diff --git a/plugins/file-pins/pin-layout.ts b/plugins/file-pins/pin-layout.ts
index 747ccb523..b02fcfb3a 100644
--- a/plugins/file-pins/pin-layout.ts
+++ b/plugins/file-pins/pin-layout.ts
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState, type RefObject } from "react";
// One stored order plus the files that are not pinned. The strip shows pinned
-// files in order at their natural width, as many as fit beside ⋯, which is always there; its list holds the rest.
+// files in order at their natural width, as many as fit; the ⋯ list holds the rest.
// When they don't all fit, the longest labels truncate first, down to the minimum.
export const PIN_MIN_WIDTH_CLASS = "w-24";
// A pin this close to the longest pins' share keeps its full label; the longer pins absorb the difference.
@@ -23,8 +23,10 @@ export function layoutPins(pins: readonly T[], unpinne
const rest = pins.filter((pin) => unpinned.includes(pin.id));
if (!metrics) return { strip: pinned, more: rest, caps: {} };
const natural = (pin: T) => metrics.pins[pin.id] ?? metrics.min;
- // ⋯ always shows, so its width is always reserved.
- const room = (count: number) => metrics.width - Math.max(0, count - 1) * metrics.gap - metrics.more - metrics.gap;
+ // Reserve ⋯ only when there are pins in its list. The independent measurement row
+ // keeps this decision stable when the visible button mounts or unmounts.
+ const room = (count: number) => metrics.width - Math.max(0, count - 1) * metrics.gap
+ - (count < pinned.length || rest.length > 0 ? metrics.more + metrics.gap : 0);
const least = (pin: T) => whole ? natural(pin) : Math.min(natural(pin), metrics.min);
let count = pinned.length;
while (count > (whole ? 1 : 0) && pinned.slice(0, count).reduce((sum, pin) => sum + least(pin), 0) > room(count)) count--;
From bef708297bb209fcca85139f4f6df91e79580b72 Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:17:48 +0000
Subject: [PATCH 03/20] Publish File Pins version tags with the existing root
package layout
---
tooling/README.md | 5 ++
.../plugin-sdk-upgrade.external.test.ts | 49 +++++++++++++++++++
tooling/publish-install-refs.mjs | 19 +++++--
3 files changed, 68 insertions(+), 5 deletions(-)
diff --git a/tooling/README.md b/tooling/README.md
index ce9448290..d6c688200 100644
--- a/tooling/README.md
+++ b/tooling/README.md
@@ -40,6 +40,11 @@ package tree, lockfile digest, SDK archive, and builder provenance in
no install-time package dependencies. Source manifests on `main` retain the
dependencies needed for development.
+Pinned Files & Links also publishes `file-pins/v` tags. Its existing
+marketplace entry has no subdirectory, so these tags contain the same root package
+tree as `plugin/file-pins`. The tag commit records its source as its parent; the
+root package does not include the monorepo provenance file.
+
Published version tags are immutable. A rerun accepts an existing tag only when
its plugin contents match; otherwise bump the version. Other plugins keep their
existing release process. The remote compatibility test installs the generated
diff --git a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
index d13ce41df..2bc75d6e5 100644
--- a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
+++ b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
@@ -362,3 +362,52 @@ it("preserves Prompt Improver's marketplace prefix and package subdirectory", as
expect(manifest.bb.app).toBe("./dist/install-app.mjs");
expect(manifest.dependencies).toBeUndefined();
});
+
+
+it("installs the latest File Pins tag at the marketplace root", async () => {
+ if (!pluginRepository) throw new Error("BB_PLUGIN_COMPAT_REPOSITORY is required");
+ const repository = pluginRepository;
+ const moduleUrl = pathToFileURL(resolve(repository, "tooling/publish-install-refs.mjs")).href;
+ const { prepareVersionedRelease, assertVersionTagUnchanged } = await import(moduleUrl);
+ await run(process.execPath, [resolve(repository, "tooling/build-plugin.mjs"), resolve(repository, "plugins/file-pins")]);
+ const release = await prepareVersionedRelease("file-pins");
+ expect(release.tag).toBe(`file-pins/v${release.plugin.manifest.version}`);
+ expect(release.subdirectory).toBe("");
+ expect(await git(repository, ["rev-parse", `${release.commit}^{tree}`])).toBe(release.packageTree);
+ expect((await prepareVersionedRelease("file-pins")).commit).toBe(release.commit);
+ expect(() => assertVersionTagUnchanged(release, release.commit)).not.toThrow();
+ expect(() => assertVersionTagUnchanged(release, release.sourceRevision))
+ .toThrow(/already exists with different contents/);
+
+ const fixture = await mkdtemp(join(tmpdir(), "file-pins-version-install-"));
+ let server: Awaited> | undefined;
+ try {
+ const remote = join(fixture, "release.git");
+ await git(repository, ["init", "--bare", remote]);
+ await git(remote, ["fetch", "--depth=1", "--no-tags", repository, release.commit]);
+ await git(remote, ["tag", release.tag, release.commit]);
+ const previousRevision = "80b3e89e31da407b84e9de8bee777b16eacead44";
+ await git(remote, ["fetch", "--depth=1", "--no-tags", "https://github.com/brsbl/bb-plugins.git", previousRevision]);
+ await git(remote, ["tag", "file-pins/v0.1.8", previousRevision]);
+
+ server = await startTestServer({ appVersion: "0.43.4" });
+ server.pluginService.bindSdk({ baseUrl: server.baseUrl });
+ await server.pluginService.install(`git:${remote}@semver:file-pins/:^0.1.2`);
+ const installed = server.pluginService.list().find((entry) => entry.id === "file-pins")!;
+ expect(installed).toMatchObject({ version: release.plugin.manifest.version, status: "running" });
+ expect(getInstalledPluginRegistration(server.db, "file-pins"))
+ .toMatchObject({ gitResolvedCommit: release.commit });
+ const manifest = JSON.parse(await readFile(join(installed.rootDir, "package.json"), "utf8"));
+ expect(manifest.bb.host).toBe("./dist/install-host.mjs");
+ expect(manifest.dependencies).toBeUndefined();
+ expect(manifest.devDependencies).toBeUndefined();
+ expect(await readFile(join(installed.rootDir, "dist/app.css"), "utf8"))
+ .toContain("bb-plugin-release-style");
+ } finally {
+ if (server) {
+ await server.pluginService.stop();
+ await server.close();
+ }
+ await rm(fixture, { recursive: true, force: true });
+ }
+}, 180_000);
diff --git a/tooling/publish-install-refs.mjs b/tooling/publish-install-refs.mjs
index b2df8c321..b8b9e8781 100644
--- a/tooling/publish-install-refs.mjs
+++ b/tooling/publish-install-refs.mjs
@@ -26,9 +26,11 @@ const productionDependencyFields = [
"peerDependencies",
];
// Opt in per plugin so existing release histories are never rewritten.
-const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "improve-prompt", "desktop"]);
+const versionTaggedPlugins = new Set(["thread-organizer", "context-katamari", "ambient", "server-switcher", "improve-prompt", "desktop", "file-pins"]);
// Preserve the published prefix when a workspace directory differs from its stable ID.
const versionTagPrefixes = { "improve-prompt": "prompt-shaper" };
+// File Pins was published at the repository root, with no marketplace subdirectory.
+const versionTagSubdirectories = { "file-pins": "" };
const explicitlyRetiredInstallRefs = Object.freeze([
"plugin/omegacode",
"plugin/saved-places",
@@ -267,6 +269,7 @@ export async function prepareVersionedRelease(slug) {
throw new Error(`${slug}: stable release version required`);
}
const tag = `${versionTagPrefixes[slug] ?? slug}/v${version}`;
+ const subdirectory = versionTagSubdirectories[slug] ?? plugin.source;
const temporary = await mkdtemp(resolve(tmpdir(), "bb-plugin-tag-"));
const indexPath = resolve(temporary, "index");
try {
@@ -292,7 +295,11 @@ export async function prepareVersionedRelease(slug) {
builder: await readJson("tooling/vendor/plugin-build-provenance.json"),
};
addBlob(indexPath, "plugin-release.json", `${JSON.stringify(provenance, null, 2)}\n`);
- const tree = git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } });
+ // Root-shaped tags must contain exactly the install package tree. Their
+ // parent records the source revision; nested tags retain the provenance file.
+ const tree = subdirectory
+ ? git(["write-tree"], { env: { GIT_INDEX_FILE: indexPath } })
+ : packageTree;
const sourceDate = git(["show", "-s", "--format=%cI", sourceRevision]);
const commit = git([
"commit-tree", tree, "-p", sourceRevision,
@@ -305,7 +312,7 @@ export async function prepareVersionedRelease(slug) {
GIT_AUTHOR_DATE: sourceDate,
GIT_COMMITTER_DATE: sourceDate,
} });
- return { plugin, tag, commit, packageTree, sourceRevision };
+ return { plugin, tag, commit, packageTree, sourceRevision, subdirectory };
} finally {
await rm(temporary, { recursive: true, force: true });
}
@@ -313,7 +320,9 @@ export async function prepareVersionedRelease(slug) {
export function assertVersionTagUnchanged(release, existingCommit) {
const existingPackageTree = git([
- "rev-parse", `${existingCommit}:${release.plugin.source}`,
+ "rev-parse", release.subdirectory
+ ? `${existingCommit}:${release.subdirectory}`
+ : `${existingCommit}^{tree}`,
], { allowFailure: true });
if (existingPackageTree !== release.packageTree) {
throw new Error(`${release.tag} already exists with different contents; bump the plugin version`);
@@ -335,7 +344,7 @@ async function publishVersionTag(release, push) {
console.log(`${release.tag} ${existingCommit} already published and unchanged`);
return;
}
- await verifyReleaseCommit(release.plugin, release.commit, release.plugin.source);
+ await verifyReleaseCommit(release.plugin, release.commit, release.subdirectory);
if (push) {
// A normal tag push cannot overwrite an existing release, including a
// concurrent publisher that won the race after the lookup above.
From 901685feaf96d5ffc622b04696d87c140f77aaed Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Wed, 21 Oct 2026 18:18:35 +0000
Subject: [PATCH 04/20] Select the root package in the marketplace install
regression
---
tooling/compatibility/plugin-sdk-upgrade.external.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
index 2bc75d6e5..5db9b5e6c 100644
--- a/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
+++ b/tooling/compatibility/plugin-sdk-upgrade.external.test.ts
@@ -392,7 +392,7 @@ it("installs the latest File Pins tag at the marketplace root", async () => {
server = await startTestServer({ appVersion: "0.43.4" });
server.pluginService.bindSdk({ baseUrl: server.baseUrl });
- await server.pluginService.install(`git:${remote}@semver:file-pins/:^0.1.2`);
+ await server.pluginService.install(`git:${remote}@semver:file-pins/:^0.1.2`, { kind: "root" });
const installed = server.pluginService.list().find((entry) => entry.id === "file-pins")!;
expect(installed).toMatchObject({ version: release.plugin.manifest.version, status: "running" });
expect(getInstalledPluginRegistration(server.db, "file-pins"))
From 1a95085a2a6594c65e21c2def157a9ad4da50c8e Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Thu, 22 Oct 2026 02:22:06 +0000
Subject: [PATCH 05/20] Use whole-label pin overflow at every width
---
plugins/file-pins/PLUGIN_OVERVIEW.md | 4 +-
plugins/file-pins/README.md | 26 +++----
plugins/file-pins/app.tsx | 71 ++----------------
plugins/file-pins/pin-layout.test.ts | 107 +++++++--------------------
plugins/file-pins/pin-layout.ts | 79 ++++----------------
5 files changed, 64 insertions(+), 223 deletions(-)
diff --git a/plugins/file-pins/PLUGIN_OVERVIEW.md b/plugins/file-pins/PLUGIN_OVERVIEW.md
index 759aa3042..d75e7c572 100644
--- a/plugins/file-pins/PLUGIN_OVERVIEW.md
+++ b/plugins/file-pins/PLUGIN_OVERVIEW.md
@@ -1,13 +1,13 @@
## What you get
-- **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 **⋯**.
+- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips. At every width, labels stay whole while they fit; the rest wait in **⋯**, which appears only when it has pins. A lone first pin wider than the row shortens to fit. Previously hand-hidden pins return to this layout automatically.
- **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.
## How it works
-Click a pin to open it the way bb opens files and links. Right-click a file for **Open preview**, **Open externally**, **Copy file path** and **Copy file name**, then **Unpin** to remove it with **Undo**. Hover or focus a pin for a preview card with the URL or path in its header; link cards open complete, without loading placeholders, when the page answers quickly. Web cards show a thumbnail or site icon, site name, title, and description. Pin a file back from the ⋯ list while the strip has room. Missing files stay visible with a light red tint.
+Click a pin to open it the way bb opens files and links. Right-click a file for **Open preview**, **Open externally**, **Copy file path** and **Copy file name**, then **Unpin** to remove it with **Undo**. Hover or focus a pin for a preview card with the URL or path in its header; link cards open complete, without loading placeholders, when the page answers quickly. Web cards show a thumbnail or site icon, site name, title, and description. Missing files stay visible with a light red tint.
With Moss Viewer installed, a pinned Moss note opens in its panel, even when the note lives on another machine, and **Open in Moss** there opens it for editing.
diff --git a/plugins/file-pins/README.md b/plugins/file-pins/README.md
index 19bf67569..e796d5499 100644
--- a/plugins/file-pins/README.md
+++ b/plugins/file-pins/README.md
@@ -5,7 +5,7 @@ composer even when the file viewer is closed, and are shared across clients.

-
+

@@ -29,19 +29,17 @@ 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 **⋯** 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
-have loaded (prefetched as the pointer arrives), or after 800 ms with what has arrived.
-Each chip is as wide as its label. In narrow windows, labels truncate only when the strip runs out of
-room beside **⋯**, and pinned files that still don't fit wait there. On touch screens, which have no hover to
-show a full label, pins keep their whole labels while they fit, and a pin that doesn't fit whole goes
-to **⋯** with the rest. Files that aren't pinned sit in the **⋯** list. File icons mark code, documents and images in the theme's file color. Hover or focus a pin for a preview card with its URL or path in a subtle header. Web links use Moss’s rich-card layout: a thumbnail or site-icon fallback, site name, title, and description. Files show their title, availability, and machine when needed.
+Pinned files read as quiet raised chips above the composer. At every window width, pins stay
+in order with whole labels while they fit; the first that does not fit and all later pins go
+into **⋯**. The button appears only when it has pins. A lone first pin wider than the row
+shortens to fit. The strip does not scroll sideways or shrink several labels to make room.
+Long names and addresses still use the label rules in [`pin-label.ts`](pin-label.ts).
+Previously hand-hidden pins participate in the same layout again; saved hiding preferences
+are ignored. Hover cards open once the preview and its image have loaded (prefetched as the
+pointer arrives), or after 800 ms with what has arrived.
+File icons mark code, documents and images in the theme's file color. Hover or focus a pin for a preview card with its URL or path in a subtle header. Web links use Moss’s rich-card layout: a thumbnail or site-icon fallback, site name, title, and description. Files show their title, availability, and machine when needed.
Right-click a strip file for **Open preview**, **Open externally**, **Copy file path** and
-**Copy file name**, then **Unpin**. Compact menus use the same actions in the **⋯** list;
-Previously hidden items offer **Pin** instead of **Unpin**, while the strip has room. **Unpin** removes the
+**Copy file name**, then **Unpin**. The **⋯** list uses the same actions. **Unpin** removes the
item from the thread and offers **Undo**. Missing files keep their light red tint and
assistive labels, and their **×** also unpins them. Availability refreshes on focus and every
30 seconds.
@@ -75,7 +73,7 @@ PR keeps the site icon unless it is the thread's own PR, whose state comes from
GitHub pauses every lookup until it resets, and the last known state stays meanwhile.
A normal click on a pinned file follows bb's FileLink click behavior and opener
-choices. Pin menus start with bb's open and copy items, then Pin/Unpin.
+choices. Pin menus start with bb's open and copy items, then Unpin.
With [Moss Viewer](../moss-viewer) installed, a pinned Moss note opens in its panel, even when the
note lives on another machine, and **Open in Moss** there opens it for editing.
diff --git a/plugins/file-pins/app.tsx b/plugins/file-pins/app.tsx
index 2cf06be83..4e6f86318 100644
--- a/plugins/file-pins/app.tsx
+++ b/plugins/file-pins/app.tsx
@@ -11,7 +11,7 @@ import { usePointerCoarse } from "./components/ui/hooks/use-pointer-coarse.js";
import { Icon } from "./components/ui/icon.js";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip.js";
import { PinPopover as Popover, PinPopoverContent as PopoverContent, PinPopoverTrigger as PopoverTrigger } from "./pin-popover.js";
-import { layoutPins, pinFile, PIN_MIN_WIDTH_CLASS, useMeasurePins, type Arrangement } from "./pin-layout.js";
+import { layoutPins, PIN_MIN_WIDTH_CLASS, useMeasurePins } from "./pin-layout.js";
import { previewTarget } from "./open-target.js";
import { pinTooltip } from "./pin-tooltip.js";
import { LinkPreviewCard, PREVIEW_HOLD_MS, preparePreview } from "./link-preview-card.js";
@@ -115,7 +115,6 @@ function PinStrip({ threadId }: { threadId: string }) {
const navigate = useBbNavigate();
const connection = useRealtimeConnectionState();
const [pins, setPins] = useState([]);
- const [more, setMore] = useState([]);
const [threadHostId, setThreadHostId] = useState(null);
const [busy, setBusy] = useState(false);
// Refresh preview is busy per pin, so other pins' actions stay available.
@@ -125,10 +124,7 @@ function PinStrip({ threadId }: { threadId: string }) {
// Phones have no room beside the list, so row menus open below the row there.
const compact = useIsCompactViewport();
const measureRow = useRef(null);
- const arranging = useRef({ pending: 0, queue: Promise.resolve() });
const metrics = useMeasurePins(measureRow, pins.map((pin) => pin.id).join("\n"));
- // Touch screens have no hover tooltip to reveal a cut-off label, so pins keep whole labels and the rest wait in ⋯.
- const whole = usePointerCoarse();
// Re-render labels when a pinned PR's state arrives, so they name it for screen readers.
usePrStateAnswers();
const openLabel = (pin: Reference) => {
@@ -144,7 +140,7 @@ function PinStrip({ threadId }: { threadId: string }) {
const request = ++generation.current;
try {
const result = await rpc.call("inspect", { threadId });
- if (alive.current && request === generation.current && arranging.current.pending === 0) { setPins(result.pins); setMore(result.more); setThreadHostId(result.threadHostId); }
+ if (alive.current && request === generation.current) { setPins(result.pins); setThreadHostId(result.threadHostId); }
} catch (cause) { report(cause); }
}, [rpc, threadId, report]);
useEffect(() => {
@@ -207,55 +203,15 @@ function PinStrip({ threadId }: { threadId: string }) {
function linkTarget(pin: FileReference) {
return previewTarget(pin, threadHostId) ?? { kind: "host" as const, hostId: pin.hostId, path: pin.path };
}
- // Use bb's compact breakpoint. Wide strips keep every pinned item at its natural width.
- const layoutMetrics = compact ? metrics : null;
- const layout = layoutPins(pins, more, layoutMetrics, { whole });
- const scrollRow = useRef(null);
- const [edges, setEdges] = useState({ left: false, right: false });
- useLayoutEffect(() => {
- const row = scrollRow.current;
- if (compact || !row) return;
- const check = () => {
- const left = row.scrollLeft > 1;
- const right = row.scrollLeft < row.scrollWidth - row.clientWidth - 1;
- setEdges((previous) => previous.left === left && previous.right === right ? previous : { left, right });
- };
- // Some platforms leave Shift-wheel on the vertical axis. Trackpad deltaX stays native.
- const wheel = (event: WheelEvent) => {
- if (!event.shiftKey || event.ctrlKey || event.deltaX !== 0 || event.deltaY === 0) return;
- const unit = event.deltaMode === 1 ? row.clientHeight : event.deltaMode === 2 ? row.clientWidth : 1;
- const before = row.scrollLeft;
- row.scrollLeft += event.deltaY * unit;
- if (row.scrollLeft !== before) event.preventDefault();
- };
- check();
- row.addEventListener("scroll", check, { passive: true });
- row.addEventListener("wheel", wheel, { passive: false });
- const observer = new ResizeObserver(check);
- observer.observe(row);
- 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]);
- // ⋯ 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.
+ // Every viewport uses whole labels and measured overflow. Legacy hand-hidden
+ // preferences are ignored; all saved pins participate in their stored order.
+ const layout = layoutPins(pins, metrics);
const listed = layout.more;
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
- // refreshes wait until the last one lands so the strip never steps back.
- generation.current++;
- setPins(next.order.map((id) => pins.find((pin) => pin.id === id)!));
- setMore(next.more);
- const saves = arranging.current;
- saves.pending++;
- saves.queue = saves.queue.then(() => rpc.call("arrange", { threadId, ...next })).then(() => undefined, report)
- .finally(() => { if (--saves.pending === 0) void refresh(); });
- }
// A small, hoverable preview card for both pointer and keyboard users.
function withTooltip(pin: Reference, trigger: ReactElement) {
const tip = pinTooltip(pin, threadHostId);
@@ -270,15 +226,8 @@ function PinStrip({ threadId }: { threadId: string }) {
} />;
}
- // 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 && }
{pins.length > 0 &&
-
- {/* ⋯ sits outside the scrolling row, flush with the composer's right edge, so pins scroll under its fade and it never moves. */}
{hasMore &&
diff --git a/plugins/file-pins/pin-layout.test.ts b/plugins/file-pins/pin-layout.test.ts
index b9138b2c3..acb452dc9 100644
--- a/plugins/file-pins/pin-layout.test.ts
+++ b/plugins/file-pins/pin-layout.test.ts
@@ -1,95 +1,42 @@
import { expect, it } from "vitest";
-import { layoutPins, pinFile, unpinFile, type PinMetrics } from "./pin-layout.js";
+import { layoutPins, type PinMetrics } from "./pin-layout.js";
-const order = ["a", "b", "c", "d", "e"];
-const pins = order.map((id) => ({ id }));
-const ids = (items: Array<{ id: string }>) => items.map((pin) => pin.id);
-// A strip `width` px wide with a 4px gap, a 28px ⋯ button when needed and a 96px minimum pin.
+const pins = ["a", "b", "c", "d", "e"].map((id) => ({ id }));
const metrics = (width: number, widths: Record): PinMetrics => ({ width, gap: 4, more: 28, min: 96, pins: widths });
-const even = (width: number) => metrics(width, Object.fromEntries(order.map((id) => [id, 100])));
-it("shows one short pin, or one long pin with room, at its full width", () => {
- expect(layoutPins([{ id: "a" }], [], metrics(600, { a: 60 }))).toEqual({ strip: [{ id: "a" }], more: [], caps: {} });
- expect(layoutPins([{ id: "a" }], [], metrics(600, { a: 320 })).caps).toEqual({});
-});
-
-it("truncates the longest pins first and leaves shorter ones whole", () => {
- // 600 - 2 gaps = 592; the 80px pin keeps its width and the two long ones share 512.
- const layout = layoutPins(pins.slice(0, 3), [], metrics(600, { a: 80, b: 400, c: 300 }));
- expect([ids(layout.strip), layout.caps]).toEqual([["a", "b", "c"], { b: 256, c: 256 }]);
- // Only the longest pin truncates when that alone makes room.
- expect(layoutPins(pins.slice(0, 3), [], metrics(600, { a: 80, b: 400, c: 150 })).caps).toEqual({ b: 362 });
- // A label just over the share (105.5 vs 105) stays whole; the two long pins give up the difference.
- expect(layoutPins(pins.slice(0, 3), [], metrics(323, { a: 300, b: 300, c: 105.5 })).caps).toEqual({ a: 104.75, b: 104.75 });
-});
-
-it("moves pins into ⋯ only once every shown pin is at the minimum", () => {
- // Five 100px pins need 516px; at 510 each truncates slightly rather than overflowing.
- const tight = layoutPins(pins, [], even(510));
- expect([ids(tight.strip), tight.more, tight.caps]).toEqual([order, [], Object.fromEntries(order.map((id) => [id, 98.8]))]);
- // At 490 five minimum pins (496 with gaps) no longer fit, so the last goes to ⋯.
- const crowded = layoutPins(pins, [], even(490));
- expect([ids(crowded.strip), ids(crowded.more)]).toEqual([["a", "b", "c", "d"], ["e"]]);
-});
-
-it("keeps labels whole on touch, moving a pin that doesn't fit whole into ⋯", () => {
- // 155 + 4 + 170 overflows the 314px left beside ⋯, so the second pin waits in ⋯ rather than shortening.
- const next = layoutPins(pins.slice(0, 3), [], metrics(350, { a: 155, b: 170, c: 150 }), { whole: true });
- expect([ids(next.strip), ids(next.more), next.caps]).toEqual([["a"], ["b", "c"], {}]);
- // A short "Example" pin, then a long URL whose whole label (260px) doesn't fit in the 314px beside ⋯ with it.
- const url = layoutPins(pins.slice(0, 2), [], metrics(350, { a: 90, b: 260 }), { whole: true });
- expect([ids(url.strip), ids(url.more), url.caps]).toEqual([["a"], ["b"], {}]);
-
- // Two long links in a 350px phone strip: the first keeps its whole label and the second waits in ⋯.
- const phone = layoutPins(pins.slice(0, 2), [], metrics(350, { a: 280, b: 260 }), { whole: true });
- expect([ids(phone.strip), ids(phone.more), phone.caps]).toEqual([["a"], ["b"], {}]);
- // Both fit whole when there is room.
- expect(layoutPins(pins.slice(0, 2), [], metrics(350, { a: 150, b: 150 }), { whole: true })).toEqual({ strip: [{ id: "a" }, { id: "b" }], more: [], caps: {} });
- // A first pin wider than the row still shows, truncated beside ⋯ for the second pin.
- const lone = layoutPins(pins.slice(0, 2), [], metrics(350, { a: 500, b: 100 }), { whole: true });
- expect([ids(lone.strip), ids(lone.more), lone.caps]).toEqual([["a"], ["b"], { a: 318 }]);
+it("keeps whole labels in order at desktop, medium, and narrow widths", () => {
+ const widths = { a: 250, b: 240, c: 230, d: 220, e: 210 };
+ for (const [width, count] of [[1440, 5], [800, 3], [600, 2], [390, 1]] as const) {
+ expect(layoutPins(pins, metrics(width, widths))).toEqual({ strip: pins.slice(0, count), more: pins.slice(count), caps: {} });
+ }
});
-it("lists pinned files that don't fit before unpinned ones", () => {
- const layout = layoutPins(pins, ["b"], even(212));
- expect([ids(layout.strip), ids(layout.more)]).toEqual([["a"], ["c", "d", "e", "b"]]);
+it("moves the first label that cannot fit and all later pins into overflow without shrinking labels", () => {
+ const widths = { a: 80, b: 400, c: 150 };
+ expect(layoutPins(pins.slice(0, 3), metrics(600, widths))).toEqual({ strip: pins.slice(0, 2), more: pins.slice(2, 3), caps: {} });
+ // A short later pin cannot jump ahead of the first pin that doesn't fit.
+ expect(layoutPins(pins.slice(0, 3), metrics(450, widths))).toEqual({ strip: pins.slice(0, 1), more: pins.slice(1, 3), caps: {} });
});
-it("unpins only the chosen file, so the next pinned file takes its place", () => {
- const next = unpinFile({ order, more: [] }, "b");
- expect(next).toEqual({ order, more: ["b"] });
- expect(ids(layoutPins(pins, next.more, even(240)).strip)).toEqual(["a", "c"]);
+it("uses the entire row when all labels fit, without reserving an empty menu", () => {
+ const pair = pins.slice(0, 2);
+ for (const width of [304, 305, 335, 304]) {
+ expect(layoutPins(pair, metrics(width, { a: 150, b: 150 }))).toEqual({ strip: pair, more: [], caps: {} });
+ }
+ expect(layoutPins(pair, metrics(303, { a: 150, b: 150 }))).toEqual({ strip: pair.slice(0, 1), more: pair.slice(1), caps: {} });
});
-it("pins a file last on the strip only when every pinned file still fits", () => {
- const roomy = { order, more: ["b", "c", "d", "e"] };
- expect(pinFile(pins, roomy, "d", even(240))).toEqual({ order: ["a", "d", "b", "c", "e"], more: ["b", "c", "e"] });
- // Two pins fit at 260, so a third would push one into ⋯.
- const full = { order, more: ["c", "d", "e"] };
- expect(pinFile(pins, full, "e", even(260))).toBeNull();
- // 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("reserves overflow space even when doing so moves another pin into the menu", () => {
+ expect(layoutPins(pins.slice(0, 3), metrics(210, { a: 100, b: 100, c: 20 }))).toEqual({ strip: pins.slice(0, 1), more: pins.slice(1, 3), caps: {} });
});
-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("shortens only a lone first pin that is wider than the available row", () => {
+ expect(layoutPins(pins.slice(0, 1), metrics(200, { a: 300 }))).toEqual({ strip: pins.slice(0, 1), more: [], caps: { a: 200 } });
+ expect(layoutPins(pins.slice(0, 2), metrics(350, { a: 500, b: 100 }))).toEqual({ strip: pins.slice(0, 1), more: pins.slice(1, 2), caps: { a: 318 } });
+ expect(layoutPins(pins.slice(0, 2), metrics(20, { a: 500, b: 100 })).caps).toEqual({ a: 0 });
});
-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: {} });
+it("preserves every saved pin and its order before measurement", () => {
+ expect(layoutPins(pins, null)).toEqual({ strip: pins, more: [], caps: {} });
+ expect(layoutPins([], metrics(100, {}))).toEqual({ strip: [], more: [], caps: {} });
});
diff --git a/plugins/file-pins/pin-layout.ts b/plugins/file-pins/pin-layout.ts
index b02fcfb3a..6f29f18f8 100644
--- a/plugins/file-pins/pin-layout.ts
+++ b/plugins/file-pins/pin-layout.ts
@@ -1,75 +1,28 @@
import { useLayoutEffect, useState, type RefObject } from "react";
-// One stored order plus the files that are not pinned. The strip shows pinned
-// files in order at their natural width, as many as fit; the ⋯ list holds the rest.
-// When they don't all fit, the longest labels truncate first, down to the minimum.
+// Used only as a fallback while a pin's natural width is unavailable.
export const PIN_MIN_WIDTH_CLASS = "w-24";
-// A pin this close to the longest pins' share keeps its full label; the longer pins absorb the difference.
-const SLACK = 16;
-/** Measured widths in px: the strip's room, its gap, the ⋯ button, the minimum pin, and each pin's natural width. */
+/** Measured widths in px: the strip's room, its gap, the ⋯ button, the fallback pin, and each pin's natural width. */
export type PinMetrics = { width: number; gap: number; more: number; min: number; pins: Record };
-/** `caps` holds a max width for each strip pin that truncates; the rest keep their natural width. */
+/** Only a lone first pin wider than the available row receives a width cap. */
export type PinLayout = { strip: T[]; more: T[]; caps: Record };
-export type Arrangement = { order: string[]; more: string[] };
-/**
- * Pinned files that no longer fit lead the ⋯ list until there is room again. With `whole` (touch screens,
- * where no hover reveals a cut-off label), pins keep their full labels while they fit and the first that
- * doesn't goes to ⋯ with the rest; only a lone first pin wider than the row shows shortened.
- */
-export function layoutPins(pins: readonly T[], unpinned: readonly string[], metrics: PinMetrics | null, { whole = false } = {}): PinLayout {
- const pinned = pins.filter((pin) => !unpinned.includes(pin.id));
- const rest = pins.filter((pin) => unpinned.includes(pin.id));
- if (!metrics) return { strip: pinned, more: rest, caps: {} };
+/** Keep labels whole in stored order at every width; the first that doesn't fit goes to ⋯ with the rest. */
+export function layoutPins(pins: readonly T[], metrics: PinMetrics | null): PinLayout {
+ if (!metrics) return { strip: [...pins], more: [], caps: {} };
const natural = (pin: T) => metrics.pins[pin.id] ?? metrics.min;
- // Reserve ⋯ only when there are pins in its list. The independent measurement row
- // keeps this decision stable when the visible button mounts or unmounts.
- const room = (count: number) => metrics.width - Math.max(0, count - 1) * metrics.gap
- - (count < pinned.length || rest.length > 0 ? metrics.more + metrics.gap : 0);
- const least = (pin: T) => whole ? natural(pin) : Math.min(natural(pin), metrics.min);
- let count = pinned.length;
- while (count > (whole ? 1 : 0) && pinned.slice(0, count).reduce((sum, pin) => sum + least(pin), 0) > room(count)) count--;
- const strip = pinned.slice(0, count);
+ // Reserve ⋯ only when it has pins. The independent measurement row makes
+ // this decision stable when the visible button mounts or unmounts.
+ const room = (count: number) => Math.max(0, metrics.width - Math.max(0, count - 1) * metrics.gap
+ - (count < pins.length ? metrics.more + metrics.gap : 0));
+ let count = pins.length;
+ let width = pins.reduce((sum, pin) => sum + natural(pin), 0);
+ while (count > 1 && width > room(count)) width -= natural(pins[--count]!);
+ const strip = pins.slice(0, count);
const caps: Record = {};
- const space = room(count);
- const cap = share(strip.map(natural), space);
- if (cap !== null) {
- // Spare labels just over the share when the longest pins can give up the difference and stay above the minimum.
- const cut = strip.filter((pin) => natural(pin) > cap + SLACK);
- const kept = strip.reduce((sum, pin) => cut.includes(pin) ? sum : sum + natural(pin), 0);
- const tighter = cut.length > 0 ? (space - kept) / cut.length : 0;
- const spare = cut.length > 0 && tighter >= metrics.min;
- for (const pin of spare ? cut : strip) if (natural(pin) > cap) caps[pin.id] = spare ? tighter : cap;
- }
- return { strip, more: [...pinned.slice(count), ...rest], caps };
-}
-
-/** The widest a pin may be so the strip fits `room`: shorter pins keep their width, the longest share what is left. */
-function share(widths: number[], room: number): number | null {
- const sorted = [...widths].sort((a, b) => a - b);
- let left = room;
- for (const [index, width] of sorted.entries()) {
- const each = left / (sorted.length - index);
- if (width > each) return each;
- left -= width;
- }
- return null;
-}
-
-export function unpinFile({ order, more }: Arrangement, id: string): Arrangement {
- return { order, more: [...new Set([...more, id])] };
-}
-
-/** Puts a file last on the strip; returns null when it would not fit beside every pinned file. */
-export function pinFile(pins: readonly T[], { order, more }: Arrangement, id: string, metrics: PinMetrics | null, options: { whole?: boolean } = {}): Arrangement | null {
- const rest = order.filter((pinId) => pinId !== id);
- const last = rest.filter((pinId) => !more.includes(pinId)).at(-1);
- const index = last === undefined ? 0 : rest.indexOf(last) + 1;
- const next = { order: [...rest.slice(0, index), id, ...rest.slice(index)], more: more.filter((pinId) => pinId !== id) };
- const byId = new Map(pins.map((pin) => [pin.id, pin]));
- const layout = layoutPins(next.order.flatMap((pinId) => byId.get(pinId) ?? []), next.more, metrics, options);
- return layout.more.every((pin) => next.more.includes(pin.id)) ? next : null;
+ if (strip.length === 1 && natural(strip[0]!) > room(count)) caps[strip[0]!.id] = room(count);
+ return { strip, more: pins.slice(count), caps };
}
/**
From 23fb51f38eba7e3349f11029f61767aa3a54dfa1 Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Thu, 22 Oct 2026 02:34:21 +0000
Subject: [PATCH 06/20] Align pinning instructions with automatic overflow
---
plugins/file-pins/skills/file-pins/SKILL.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/plugins/file-pins/skills/file-pins/SKILL.md b/plugins/file-pins/skills/file-pins/SKILL.md
index e674f08e6..cee8be10b 100644
--- a/plugins/file-pins/skills/file-pins/SKILL.md
+++ b/plugins/file-pins/skills/file-pins/SKILL.md
@@ -62,7 +62,7 @@ page contents or automatically add them to agent context.
Pins show above the thread composer; links show their site's icon and open the
way bb opens links. In the UI, Unpin removes an item from the thread with an Undo toast.
-Pin moves a previously hidden item back to the strip while there is room.
+Pins that do not fit in the strip stay in the overflow menu.
The CLI `remove` command matches Unpin, without the Undo toast.
Missing files stay visible with a small × to remove them. The composer's pin
button adds a "Pin" pill for the user to follow with the files or links to
From efd67bb137af2490c9940ee7a1652dcdfa4e693e Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Thu, 22 Oct 2026 03:04:09 +0000
Subject: [PATCH 07/20] Replace pin overflow menus with a count chip and direct
unpin
---
plugins/file-pins/PLUGIN_OVERVIEW.md | 2 +-
plugins/file-pins/README.md | 10 +++----
plugins/file-pins/app.tsx | 40 +++++++++-------------------
plugins/file-pins/pin-label.ts | 2 +-
plugins/file-pins/pin-layout.test.ts | 15 ++++++++++-
plugins/file-pins/pin-layout.ts | 16 +++++------
plugins/file-pins/url-pin-icon.tsx | 2 +-
7 files changed, 43 insertions(+), 44 deletions(-)
diff --git a/plugins/file-pins/PLUGIN_OVERVIEW.md b/plugins/file-pins/PLUGIN_OVERVIEW.md
index d75e7c572..f39dfa909 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. At every width, labels stay whole while they fit; the rest wait in **⋯**, which appears only when it has pins. A lone first pin wider than the row shortens to fit. Previously hand-hidden pins return to this layout automatically.
+- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips. At every width, labels stay whole while they fit; the rest wait in a **+N** chip beside the last visible pin, which appears only when it has pins. A lone first pin wider than the row shortens to fit. Previously hand-hidden pins return to this layout automatically.
- **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 e796d5499..ac951924c 100644
--- a/plugins/file-pins/README.md
+++ b/plugins/file-pins/README.md
@@ -5,7 +5,7 @@ composer even when the file viewer is closed, and are shared across clients.

-
+

@@ -31,7 +31,7 @@ removed when its server is retired. A short always-on instruction points every t
Pinned files read as quiet raised chips above the composer. At every window width, pins stay
in order with whole labels while they fit; the first that does not fit and all later pins go
-into **⋯**. The button appears only when it has pins. A lone first pin wider than the row
+into a **+N** count chip immediately after the last visible pin. The chip appears only when it has pins. A lone first pin wider than the row
shortens to fit. The strip does not scroll sideways or shrink several labels to make room.
Long names and addresses still use the label rules in [`pin-label.ts`](pin-label.ts).
Previously hand-hidden pins participate in the same layout again; saved hiding preferences
@@ -39,12 +39,12 @@ are ignored. Hover cards open once the preview and its image have loaded (prefet
pointer arrives), or after 800 ms with what has arrived.
File icons mark code, documents and images in the theme's file color. Hover or focus a pin for a preview card with its URL or path in a subtle header. Web links use Moss’s rich-card layout: a thumbnail or site-icon fallback, site name, title, and description. Files show their title, availability, and machine when needed.
Right-click a strip file for **Open preview**, **Open externally**, **Copy file path** and
-**Copy file name**, then **Unpin**. The **⋯** list uses the same actions. **Unpin** removes the
+**Copy file name**, then **Unpin**. In the overflow list, click a row to open its pin. Its **×** unpins the item; it appears on row hover or keyboard focus and stays visible on touch screens. **Unpin** removes the
item from the thread and offers **Undo**. Missing files keep their light red tint and
assistive labels, and their **×** also unpins them. Availability refreshes on focus and every
30 seconds.
-Pinned links share the same menus and Undo behavior. Clicking a link follows bb's browser
+Pinned links share the same strip context menus and Undo behavior. Clicking a link follows bb's browser
preference; its menu offers **Open**, **Copy link**, and **Unpin**.
A thread without pins shows no strip; the pin button is always in the action row.
@@ -73,7 +73,7 @@ PR keeps the site icon unless it is the thread's own PR, whose state comes from
GitHub pauses every lookup until it resets, and the last known state stays meanwhile.
A normal click on a pinned file follows bb's FileLink click behavior and opener
-choices. Pin menus start with bb's open and copy items, then Unpin.
+choices. Strip context menus start with bb's open and copy items, then Unpin.
With [Moss Viewer](../moss-viewer) installed, a pinned Moss note opens in its panel, even when the
note lives on another machine, and **Open in Moss** there opens it for editing.
diff --git a/plugins/file-pins/app.tsx b/plugins/file-pins/app.tsx
index 4e6f86318..a6f9f1121 100644
--- a/plugins/file-pins/app.tsx
+++ b/plugins/file-pins/app.tsx
@@ -1,11 +1,9 @@
import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactElement, type RefObject } from "react";
-import * as Menu from "@radix-ui/react-dropdown-menu";
import { definePluginApp, experimental_FileLink as FileLink, UrlLink, useBbNavigate, useComposer, useRealtime, useRealtimeConnectionState, useRpc } from "@get-bb/plugin-sdk/app";
import { toast } from "sonner";
import { PIN_MENTION, isUrlPin, type FileReference, type Reference, type UrlPin, type rpcContract } from "./contract.js";
import { Button } from "./components/ui/button.js";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "./components/ui/context-menu.js";
-import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from "./components/ui/dropdown-menu.js";
import { useIsCompactViewport } from "./components/ui/hooks/use-compact-viewport.js";
import { usePointerCoarse } from "./components/ui/hooks/use-pointer-coarse.js";
import { Icon } from "./components/ui/icon.js";
@@ -24,11 +22,11 @@ import { cn } from "./lib/utils.js";
const linkClass = `group inline-flex h-7 min-w-0 items-center gap-1.5 rounded px-1.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`;
// bb's composer-stack card chrome at chip scale, kept quiet: a hairline at rest, a shadow on hover.
-const moreClass = `${linkClass} shrink-0`;
const pinClass = cn(linkClass, "rounded-md border border-border-seam bg-surface-raised-solid transition-shadow hover:shadow-xs");
-// ⋯ list rows use bb's menu item density; their ⋯ shows on hover, keyboard focus and touch.
+const moreClass = cn(pinClass, "shrink-0 tabular-nums");
+// Overflow rows expose Unpin on hover, keyboard focus and touch.
const rowLinkClass = "flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-[0.3125rem] text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring";
-const rowActionClass = "flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 data-[state=open]:opacity-100 pointer-coarse:opacity-100 [@media(hover:none)]:opacity-100";
+const rowActionClass = "flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 pointer-coarse:opacity-100 [@media(hover:none)]:opacity-100";
const noMotion = { animation: "none", transition: "none" };
const menuClass = "min-w-40 p-1";
type PinAction = { label: string; hint?: string; disabled?: boolean; run(): void };
@@ -120,8 +118,6 @@ function PinStrip({ threadId }: { threadId: string }) {
// Refresh preview is busy per pin, so other pins' actions stay available.
const [refreshing, setRefreshing] = useState>(() => new Set());
const [moreOpen, setMoreOpen] = useState(false);
- const [rowMenu, setRowMenu] = useState(null);
- // Phones have no room beside the list, so row menus open below the row there.
const compact = useIsCompactViewport();
const measureRow = useRef(null);
const metrics = useMeasurePins(measureRow, pins.map((pin) => pin.id).join("\n"));
@@ -210,7 +206,7 @@ function PinStrip({ threadId }: { threadId: string }) {
const hasMore = listed.length > 0;
const moreLabel = `${listed.length} more ${listed.length === 1 ? "pin" : "pins"}`;
useEffect(() => {
- if (!hasMore) { setMoreOpen(false); setRowMenu(null); }
+ if (!hasMore) setMoreOpen(false);
}, [hasMore]);
// A small, hoverable preview card for both pointer and keyboard users.
function withTooltip(pin: Reference, trigger: ReactElement) {
@@ -288,48 +284,38 @@ function PinStrip({ threadId }: { threadId: string }) {
}
function listRow(pin: Reference) {
const name = <>{pinLabel(pin)}>;
- // Right-click opens the row's ⋯ menu, anchored beside the row, in place of FileLink's own menu.
- const onContextMenu = (event: MouseEvent) => { event.preventDefault(); setRowMenu(pin.id); };
const link = isUrlPin(pin)
- ? {name}
+ ? {name}
: pin.status === "missing"
- ? {name} (missing)
- : open(pin, event)} onContextMenu={onContextMenu}
+ ? {name} (missing)
+ : open(pin, event)}
aria-label={openLabel(pin)} className={cn(rowLinkClass, pin.status === "available" ? "cursor-pointer" : "opacity-60")}>{name};
- return
{/* bb's composer fade, repeated above the flat strip so text scrolling under it doesn't end in a hard edge. */}
{fade && }
{pins.length > 0 &&
-
+
{layout.strip.map((pin) => stripPin(pin))}
{hasMore &&
-
+
{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. */}
+ {/* Mirrors the strip row with every pin at its natural width, plus count chips and the minimum pin, to measure what fits. */}
;
From 5fdf2b4df1b866866d8968d6cc3d9af584eb2a5c Mon Sep 17 00:00:00 2001
From: brsbl <57682038+brsbl@users.noreply.github.com>
Date: Fri, 23 Oct 2026 03:24:04 +0000
Subject: [PATCH 13/20] Scroll pins sideways in one row and unpin from each pin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every pin keeps its whole label in a single row that scrolls sideways at
every width. A vertical mouse wheel over the strip scrolls it, trackpad and
touch swipes stay native, keyboard focus scrolls the focused pin into view,
and edge fades appear only on sides with more pins. Each pin shows an
Unpin × on hover or focus; touch screens long-press for the pin menu.
The +N count, its menu, the measured overflow layout and unused overlay
components are removed.
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 +-
package-lock.json | 2 -
plugins/file-pins/PLUGIN_OVERVIEW.md | 4 +-
plugins/file-pins/README.md | 16 +-
plugins/file-pins/app.tsx | 103 +-
.../file-pins/components/ui/dropdown-menu.tsx | 656 -------------
.../ui/hooks/use-compact-viewport.tsx | 37 -
.../components/ui/responsive-overlay.tsx | 895 ------------------
plugins/file-pins/docs/more-list.png | Bin 64760 -> 0 bytes
plugins/file-pins/docs/phone-more-list.png | Bin 52808 -> 0 bytes
plugins/file-pins/package.json | 2 -
plugins/file-pins/pin-layout.test.ts | 55 --
plugins/file-pins/pin-layout.ts | 60 --
plugins/file-pins/pin-popover.tsx | 19 -
plugins/file-pins/pin-strip.test.ts | 30 +
plugins/file-pins/pin-strip.ts | 57 ++
plugins/file-pins/skills/file-pins/SKILL.md | 2 +-
plugins/file-pins/ui-provenance.json | 5 -
18 files changed, 133 insertions(+), 1814 deletions(-)
delete mode 100644 plugins/file-pins/components/ui/dropdown-menu.tsx
delete mode 100644 plugins/file-pins/components/ui/hooks/use-compact-viewport.tsx
delete mode 100644 plugins/file-pins/components/ui/responsive-overlay.tsx
delete mode 100644 plugins/file-pins/docs/more-list.png
delete mode 100644 plugins/file-pins/docs/phone-more-list.png
delete mode 100644 plugins/file-pins/pin-layout.test.ts
delete mode 100644 plugins/file-pins/pin-layout.ts
delete mode 100644 plugins/file-pins/pin-popover.tsx
create mode 100644 plugins/file-pins/pin-strip.test.ts
create mode 100644 plugins/file-pins/pin-strip.ts
diff --git a/README.md b/README.md
index 07fcfd433..38fcc5d82 100644
--- a/README.md
+++ b/README.md
@@ -355,13 +355,11 @@ Keep a thread's key files and links one click away in a strip above the composer

-
-


-
+
[Source](plugins/file-pins) · [README](plugins/file-pins/README.md)
diff --git a/package-lock.json b/package-lock.json
index 18ffa6dbc..1dabc8879 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9016,8 +9016,6 @@
"devDependencies": {
"@get-bb/plugin-sdk": "file:../../tooling/vendor/get-bb-plugin-sdk-0.5.9.tgz",
"@radix-ui/react-context-menu": "^2.3.3",
- "@radix-ui/react-dropdown-menu": "^2.1.19",
- "@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-tooltip": "^1.2.12",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.0.0",
diff --git a/plugins/file-pins/PLUGIN_OVERVIEW.md b/plugins/file-pins/PLUGIN_OVERVIEW.md
index 1b9d83356..b97fed019 100644
--- a/plugins/file-pins/PLUGIN_OVERVIEW.md
+++ b/plugins/file-pins/PLUGIN_OVERVIEW.md
@@ -1,13 +1,13 @@
## What you get
-- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips. At every width, labels stay whole while they fit; the rest wait in a **+N** chip beside the last visible pin, which appears only when it has pins. A lone first pin wider than the row shortens to fit. Previously hand-hidden pins return to this layout automatically.
+- **A strip above the composer.** Pinned files and links sit in order as quiet raised chips with whole labels, in one row that scrolls sideways when they don't all fit. Swipe, or use the mouse wheel over the strip; fades mark the sides with more pins. Previously hand-hidden pins return to the strip automatically.
- **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.
## How it works
-Click a pin to open it the way bb opens files and links. Right-click a file for **Open preview**, **Open externally**, **Copy file path** and **Copy file name**, then **Unpin** to remove it with **Undo**. Hover or focus a pin for a preview card with the URL or path in its header; link cards open complete, without loading placeholders, when the page answers quickly. Web cards show a thumbnail or site icon, site name, title, and description. Missing files stay visible with a light red tint.
+Click a pin to open it the way bb opens files and links. Hover or focus a pin for its **×** to unpin it. Right-click a file, or long-press a pin on a touch screen, for **Open preview**, **Open externally**, **Copy file path** and **Copy file name**, then **Unpin** to remove it with **Undo**. Hover or focus a pin for a preview card with the URL or path in its header; link cards open complete, without loading placeholders, when the page answers quickly. Web cards show a thumbnail or site icon, site name, title, and description. Missing files stay visible with a light red tint.
Pins persist per thread across clients and restarts. Agents and scripts can also run `bb file-pins pin`, `list` and `remove`.
diff --git a/plugins/file-pins/README.md b/plugins/file-pins/README.md
index 7df437e32..4e7f38798 100644
--- a/plugins/file-pins/README.md
+++ b/plugins/file-pins/README.md
@@ -5,7 +5,7 @@ composer even when the file viewer is closed, and are shared across clients.

-
+

@@ -29,17 +29,17 @@ 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. At every window width, pins stay
-in order with whole labels while they fit; the first that does not fit and all later pins go
-into a **+N** count chip immediately after the last visible pin. The chip appears only when it has pins. A lone first pin wider than the row
-shortens to fit. The strip does not scroll sideways or shrink several labels to make room.
+Pinned files read as quiet raised chips above the composer. Every pin keeps its whole label,
+in order, in one row. When they don't all fit, the row scrolls sideways at any window width:
+swipe on a trackpad or touch screen, or turn the mouse wheel over it. A soft fade marks each
+side that has more pins, and focusing a pin from the keyboard scrolls it into view.
Long names and addresses still use the label rules in [`pin-label.ts`](pin-label.ts).
-Previously hand-hidden pins participate in the same layout again; saved hiding preferences
-are ignored. Hover cards open once the preview and its image have loaded (prefetched as the
+Previously hand-hidden pins show in the strip again; saved hiding preferences are ignored. Hover cards open once the preview and its image have loaded (prefetched as the
pointer arrives), or after 800 ms with what has arrived.
File icons mark code, documents and images in the theme's file color. Hover or focus a pin for a preview card with its URL or path in a subtle header. Web links use Moss’s rich-card layout: a thumbnail or site-icon fallback, site name, title, and description. Files show their title, availability, and machine when needed.
Right-click a strip file for **Open preview**, **Open externally**, **Copy file path** and
-**Copy file name**, then **Unpin**. In the overflow list, click a row to open its pin. Its **×** unpins the item; it appears on row hover or keyboard focus and stays visible on touch screens. **Unpin** removes the
+**Copy file name**, then **Unpin**; on a touch screen, long-press a pin for the same menu.
+Hovering or focusing a pin shows its **×**, which unpins it. **Unpin** removes the
item from the thread and offers **Undo**. Missing files keep their light red tint and
assistive labels, and their **×** also unpins them. Availability refreshes on focus and every
30 seconds.
diff --git a/plugins/file-pins/app.tsx b/plugins/file-pins/app.tsx
index 8423de9c3..d7b51ac96 100644
--- a/plugins/file-pins/app.tsx
+++ b/plugins/file-pins/app.tsx
@@ -4,31 +4,26 @@ import { toast } from "sonner";
import { PIN_MENTION, isUrlPin, type FileReference, type Reference, type UrlPin, type rpcContract } from "./contract.js";
import { Button } from "./components/ui/button.js";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "./components/ui/context-menu.js";
-import { useIsCompactViewport } from "./components/ui/hooks/use-compact-viewport.js";
import { usePointerCoarse } from "./components/ui/hooks/use-pointer-coarse.js";
import { Icon } from "./components/ui/icon.js";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip.js";
-import { PinPopover as Popover, PinPopoverContent as PopoverContent, PinPopoverTrigger as PopoverTrigger } from "./pin-popover.js";
-import { layoutPins, PIN_MIN_WIDTH_CLASS, useMeasurePins } from "./pin-layout.js";
import { previewTarget } from "./open-target.js";
+import { edgeMask, useSwipeStrip } from "./pin-strip.js";
import { pinTooltip } from "./pin-tooltip.js";
import { LinkPreviewCard, PREVIEW_HOLD_MS, preparePreview } from "./link-preview-card.js";
import { pinLabel } from "./pin-label.js";
import { PreviewAddress } from "./preview-address.js";
import { previewClient, previewKey, refreshNotice } from "./preview-client.js";
import { ReferenceIcon } from "./reference-icon.js";
-import { prStateLabel, showsPrState, UrlPinIcon, usePrStateAnswers } from "./url-pin-icon.js";
+import { prStateLabel, UrlPinIcon, usePrStateAnswers } from "./url-pin-icon.js";
import { cn } from "./lib/utils.js";
const linkClass = `group inline-flex h-7 min-w-0 items-center gap-1.5 rounded px-1.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`;
// bb's composer-stack card chrome at chip scale, kept quiet: a hairline at rest, a shadow on hover.
const pinClass = cn(linkClass, "rounded-md border border-border-seam bg-surface-raised-solid transition-shadow hover:shadow-xs");
-// Solid accent distinguishes the control from the strip; inverse text keeps the count readable.
-const moreClass = cn(linkClass, "min-w-7 shrink-0 justify-center rounded-full bg-timeline-accent text-background tabular-nums hover:text-background hover:bg-[color-mix(in_oklab,var(--timeline-accent)_90%,var(--foreground))] active:bg-[color-mix(in_oklab,var(--timeline-accent)_80%,var(--foreground))] data-[pressed]:bg-[color-mix(in_oklab,var(--timeline-accent)_80%,var(--foreground))] data-[state=open]:bg-[color-mix(in_oklab,var(--timeline-accent)_80%,var(--foreground))]");
-function countClass(count: number) { return cn(moreClass, count < 10 && "w-7 px-0"); }
-// Overflow rows expose Unpin on hover, keyboard focus and touch.
-const rowLinkClass = "flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-[0.3125rem] text-xs text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring";
-const rowActionClass = "flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 pointer-coarse:opacity-100 [@media(hover:none)]:opacity-100";
+// Desktop Unpin: a × over the pin's end on hover or keyboard focus, fading the label beneath it.
+// Touch screens long-press the pin for its menu instead.
+const unpinClass = "absolute inset-y-px right-px flex w-6 items-center justify-center rounded-r-[5px] bg-surface-raised-solid text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/pin:opacity-100 group-has-[:focus-visible]/pin:opacity-100 before:pointer-events-none before:absolute before:inset-y-0 before:right-full before:w-3 before:bg-gradient-to-r before:from-transparent before:to-surface-raised-solid pointer-coarse:hidden";
const noMotion = { animation: "none", transition: "none" };
const menuClass = "min-w-40 p-1";
type PinAction = { label: string; hint?: string; disabled?: boolean; run(): void };
@@ -119,10 +114,9 @@ function PinStrip({ threadId }: { threadId: string }) {
const [busy, setBusy] = useState(false);
// Refresh preview is busy per pin, so other pins' actions stay available.
const [refreshing, setRefreshing] = useState>(() => new Set());
- const [moreOpen, setMoreOpen] = useState(false);
- const compact = useIsCompactViewport();
- const measureRow = useRef(null);
- const metrics = useMeasurePins(measureRow, pins.map((pin) => pin.id).join("\n"));
+ // A touch long-press opens a pin's menu; the click that ends that same press must not also open the pin.
+ const pressedAt = useRef(0);
+ const menuOpenedAt = useRef(0);
// Re-render labels when a pinned PR's state arrives, so they name it for screen readers.
usePrStateAnswers();
const openLabel = (pin: Reference) => {
@@ -160,7 +154,6 @@ function PinStrip({ threadId }: { threadId: string }) {
setBusy(true);
try {
const { undoToken } = await rpc.call("remove", { threadId, pinId: pin.id });
- setMoreOpen(false);
await refresh();
if (undoToken) toast(`Unpinned ${pin.name}`, {
duration: 10_000,
@@ -201,15 +194,10 @@ function PinStrip({ threadId }: { threadId: string }) {
function linkTarget(pin: FileReference) {
return previewTarget(pin, threadHostId) ?? { kind: "host" as const, hostId: pin.hostId, path: pin.path };
}
- // Every viewport uses whole labels and measured overflow. Legacy hand-hidden
- // preferences are ignored; all saved pins participate in their stored order.
- const layout = layoutPins(pins, metrics);
- const listed = layout.more;
- const hasMore = listed.length > 0;
- const moreLabel = `${listed.length} more ${listed.length === 1 ? "pin" : "pins"}`;
- useEffect(() => {
- if (!hasMore) setMoreOpen(false);
- }, [hasMore]);
+ // Every pin keeps its whole label in one row that scrolls sideways at every width. Legacy
+ // hand-hidden preferences are ignored; all saved pins show in their stored order.
+ const scrollRow = useRef(null);
+ const edges = useSwipeStrip(scrollRow, pins.map((pin) => pin.id).join("\n"));
// A small, hoverable preview card for both pointer and keyboard users.
function withTooltip(pin: Reference, trigger: ReactElement) {
const tip = pinTooltip(pin, threadHostId);
@@ -231,7 +219,6 @@ function PinStrip({ threadId }: { threadId: string }) {
// "Open with" and "Open in" need core-only openers and local app targets, so they are left out.
// A URL opens the way bb opens links: its browser preference, else a new tab.
function openUrl(pin: UrlPin) {
- setMoreOpen(false);
if (!navigate.openUrl(pin.url)) window.open(pin.url, "_blank", "noopener,noreferrer");
}
function menuGroups(pin: Reference): PinAction[][] {
@@ -249,10 +236,9 @@ function PinStrip({ threadId }: { threadId: string }) {
return [
[
{ label: "Open preview", run: () => {
- setMoreOpen(false);
if (preview) navigate.experimental_openFilePreview({ target: preview, location: null }); else elsewhere(pin);
} },
- { label: "Open externally", disabled: pin.status !== "available", run: () => { setMoreOpen(false); navigate.experimental_openFileExternally(file); } },
+ { label: "Open externally", disabled: pin.status !== "available", run: () => navigate.experimental_openFileExternally(file) },
],
[
{ label: "Copy file path", run: () => void copyText(pin.path, "File path copied", "Failed to copy file path") },
@@ -270,61 +256,40 @@ function PinStrip({ threadId }: { threadId: string }) {
;
}
function stripPin(pin: Reference) {
- const cap = layout.caps[pin.id];
- const style = cap === undefined ? undefined : { maxWidth: cap };
- const link = isUrlPin(pin) ?
+ const link = isUrlPin(pin) ? {pinLabel(pin)}
- : pin.status === "missing" ?
+ : pin.status === "missing" ? {pinLabel(pin)} (missing) void remove(pin)}
className="absolute right-0.5 top-0.5 flex size-3.5 items-center justify-center rounded-sm text-xs leading-none text-muted-foreground/70 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring">×
- : open(pin, event)} style={style}
+ : open(pin, event)}
aria-label={openLabel(pin)} className={cn(pinClass, pin.status === "available" ? "cursor-pointer" : "[&>*]:opacity-60")}>{pinLabel(pin)};
- return {withTooltip(pin, {link})}{contextMenu(pin)};
- }
- function listRow(pin: Reference) {
- const name = <>{pinLabel(pin)}>;
- const link = isUrlPin(pin)
- ? {name}
- : pin.status === "missing"
- ? {name} (missing)
- : open(pin, event)}
- aria-label={openLabel(pin)} className={cn(rowLinkClass, pin.status === "available" ? "cursor-pointer" : "opacity-60")}>{name};
- return
{/* bb's composer fade, repeated above the flat strip so text scrolling under it doesn't end in a hard edge. */}
{fade && }
- {pins.length > 0 &&
-
- {layout.strip.map((pin) => stripPin(pin))}
+ {pins.length > 0 &&
+ {/* Edge fades show only on sides with more pins; focus scrolls a pin clear of them. */}
+