diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md
index a9c79fc..5917e4f 100644
--- a/plugins/top-tabs/README.md
+++ b/plugins/top-tabs/README.md
@@ -63,8 +63,9 @@ showed, so what you used is where you look for it.
### The Threads tab
While another tab is in view, the Threads tab shows the thread it will return
-to beside its name. Clicking it returns to that thread, or to the compose
-screen if you were there.
+to beside its name. The name slides open as you leave and shut as you come
+back, so the tabs after it move rather than jump. Clicking it returns to that
+thread, or to the compose screen if you were there.
It always shows three counts:
@@ -236,10 +237,22 @@ The sidebar belongs to Threads:
undoes that. See [The Settings tab](#the-settings-tab).
- **A split** pauses all of this until it closes.
-When the strip moves the sidebar as part of a switch, it does so instantly.
-The sidebar changes in the same step as the page, so the page lays out once,
-at its final width. A slide would make a long thread lay itself out again on
-every frame. Opening or closing the sidebar yourself still slides.
+When the strip moves the sidebar as part of a switch, the space it takes
+changes instantly, in the same step as the page, so the page lays out once,
+at its final width. Sliding that space open would make a long thread lay
+itself out again on every frame. What moves is drawn on top: going back to
+Threads, the sidebar slides in over the space it already has and the thread
+fades in, both animated without laying anything out again. They wait until
+bb has finished drawing the thread, so the motion plays from start to end
+instead of freezing partway or appearing half done, and the Threads tab's
+title moves with them. Coming back from a page with a sidebar of its own
+(Plugins, Skills, Settings), that sidebar and page stay out of sight until
+bb has the thread list ready, and the thread list is what slides in.
+Leaving Threads, the sidebar stays over the new page until bb has drawn it,
+then slides away as the page fades in; opening Settings, its sections slide
+in the same way. Only these changes of layout move: going between other
+tabs keeps the layout, and switches instantly, as a browser's tabs do.
+Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates.
Above the thread list, the sidebar keeps bb's own navigation, unchanged.
Its rows, drag-to-reorder, options menu, More, customize editor and
diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx
index f670a91..0a7dacd 100644
--- a/plugins/top-tabs/components/TopTabs.tsx
+++ b/plugins/top-tabs/components/TopTabs.tsx
@@ -33,7 +33,13 @@ import {
interceptPageClose,
isSidebarOpen,
observeSidebar,
+ pageGone,
+ playEntrance,
+ playSidebarExit,
+ sidebarOf,
+ stopEntrances,
toggleSidebar,
+ type Ready,
} from "../lib/shell.ts";
import { useBridgedSplits } from "../lib/split-bridge.ts";
import { getState, initStore, update, useTabsState } from "../lib/store.ts";
@@ -247,11 +253,16 @@ export function TopTabs() {
* instead of the page rendering at one width and then reflowing to
* another; navigation from anywhere else is caught by the effect below.
* Either way it runs once per move, because it records `next` as where
- * the strip now is.
+ * the strip now is. `pageLeft`, from a tab click, holds the sidebar's exit
+ * until the page being left has gone.
*/
- const syncSidebar = useCallback((next: TabId | null) => {
+ const syncSidebar = useCallback((next: TabId | null, pageLeft?: Ready) => {
const before = previous.current;
previous.current = next;
+ // An entrance left running would carry on over the page being left for.
+ // Only on a real move: this runs again once bb has rendered the page,
+ // and the exit started on the way out has to survive that.
+ if (next !== before && next !== THREADS) stopEntrances();
const { compact, collapseSidebar, inSplit } = live.current;
if (compact || !collapseSidebar || inSplit) return;
const sidebarOpen = isSidebarOpen();
@@ -267,7 +278,16 @@ export function TopTabs() {
? s
: { ...s, threadsSidebarOpen: step.threadsSidebarOpen },
);
- if (step.action !== null) toggleSidebar({ instant: true });
+ if (step.action === null) return;
+ // The sidebar slides in coming back to Threads or opening Settings, and
+ // out leaving Threads, on the compositor; see playEntrance and
+ // playSidebarExit. Not on load, where there is nothing to move from.
+ if (before !== undefined) {
+ if (step.action === "expand" && next === THREADS) playEntrance("sidebar");
+ if (step.action === "expand" && next === SETTINGS) playEntrance("sidebar", sidebarOf("page"));
+ if (step.action === "collapse" && before === THREADS) playSidebarExit(pageLeft);
+ }
+ toggleSidebar({ instant: true });
}, []);
useEffect(() => {
@@ -328,14 +348,28 @@ export function TopTabs() {
}
// No early return for Threads: navigating to the saved location is a
// no-op when already there, and anywhere else it is the way back.
- if (active !== THREADS) syncSidebar(THREADS);
+ if (active !== THREADS) {
+ syncSidebar(THREADS);
+ if (!live.current.inSplit) playEntrance("page");
+ }
if (saved === undefined || !navigateToPath(saved)) bbNavigate.toCompose();
return;
}
const item = byId.get(id);
if (id === active || item === undefined) return;
markMove();
- syncSidebar(id);
+ // The page being left, before navigating away from it: the sidebar's
+ // exit and the new page's entrance both wait for it to go.
+ const pageLeft = pageGone();
+ syncSidebar(id, pageLeft);
+ // A change of layout fades the new page in, as the return to Threads
+ // does: leaving Threads, and going to or from Settings, whose sidebar is
+ // its own. Tab to tab keeps the layout and switches instantly, as a
+ // browser's tabs do. It waits for the page being left to go, so it never
+ // fades that one back in.
+ if (!live.current.inSplit && (active === THREADS || active === SETTINGS || id === SETTINGS)) {
+ playEntrance("page", pageLeft);
+ }
update((s) => adopt(s, id));
if (saved !== undefined && navigateToPath(saved)) return;
// Settings is the strip's own entry; bb's actions do not know it.
@@ -379,7 +413,10 @@ export function TopTabs() {
const { active, threadActions } = live.current;
setSwitcher(null);
stripMove.current = { to: THREADS, at: performance.now() };
- if (active !== THREADS) syncSidebar(THREADS);
+ if (active !== THREADS) {
+ syncSidebar(THREADS);
+ if (!live.current.inSplit) playEntrance("page");
+ }
// bb's own open: it focuses the thread's pane if a split shows it.
threadActions.open(thread.id);
},
@@ -798,7 +835,7 @@ export function TopTabs() {
>
{threadsLabelled && (
-
+
)}
{threadsLabelled && }
diff --git a/plugins/top-tabs/components/threads-tab.tsx b/plugins/top-tabs/components/threads-tab.tsx
index a0fb655..c13e545 100644
--- a/plugins/top-tabs/components/threads-tab.tsx
+++ b/plugins/top-tabs/components/threads-tab.tsx
@@ -1,22 +1,46 @@
// The Threads tab's contents: its label, and what the hidden thread list
// would tell you if you could see it.
-import { useMemo } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { experimental_useSidebarThreads } from "@get-bb/plugin-sdk/app";
+import { whenSettled } from "../lib/shell.ts";
import { groupThreads, threadIdFromPath } from "../lib/tabs-model.ts";
/**
* "Threads", plus the thread it will return to while another tab is in view —
* the strip's answer to a browser tab's page title.
+ *
+ * The title stays mounted and slides open and shut rather than appearing,
+ * so the tabs after it glide instead of jumping (see top-tabs.css). It keys
+ * on `active`, the tab the route is on, not the one drawn selected, and
+ * waits for bb to finish drawing: the strip selects a tab before bb renders
+ * it, and a thread's render would freeze the slide halfway. It keeps its
+ * last title while it closes.
*/
export function ThreadsLabel({ active, savedPath }: { active: boolean; savedPath: string | undefined }) {
const { threads } = experimental_useSidebarThreads();
- const threadId = active ? null : threadIdFromPath(savedPath);
+ const threadId = threadIdFromPath(savedPath);
const title =
threadId === null ? null : (threads.find((t) => t.id === threadId)?.displayTitle ?? null);
+ const lastTitle = useRef(title);
+ if (title !== null) lastTitle.current = title;
+ // Moves with the sidebar and the page, once bb has drawn the thread: the
+ // width is animated on the main thread, which bb's render would stall.
+ const wanted = !active && title !== null;
+ const [shown, setShown] = useState(wanted);
+ useEffect(() => {
+ if (wanted === shown) return;
+ return whenSettled(() => setShown(wanted));
+ }, [wanted, shown]);
return (
Threads
- {title !== null && {title}}
+ {lastTitle.current !== null && (
+
+
+ {lastTitle.current}
+
+
+ )}
);
}
diff --git a/plugins/top-tabs/lib/shell.ts b/plugins/top-tabs/lib/shell.ts
index a2ec240..d780b32 100644
--- a/plugins/top-tabs/lib/shell.ts
+++ b/plugins/top-tabs/lib/shell.ts
@@ -120,3 +120,272 @@ export function toggleSidebar(options: { instant?: boolean } = {}): boolean {
trigger.click();
return true;
}
+
+/**
+ * The motion an instant switch keeps. Each animates only `translate` or
+ * `opacity`, which the compositor runs without laying the page out again —
+ * the page still lays out once, at its final width, as `instant` promises —
+ * and which keeps going while bb finishes rendering a long thread. Nothing
+ * here uses `transform` on the page, which would become the containing block
+ * of everything fixed inside it.
+ */
+const ENTRANCES = {
+ /** The sidebar slides back in over the space it already holds. */
+ sidebar: {
+ selector: '[data-side="left"] > [data-sidebar="panel"]',
+ keyframes: [{ translate: "-100% 0" }, { translate: "0 0" }],
+ timing: { duration: 200, easing: "cubic-bezier(0.2, 0, 0, 1)" },
+ },
+ /** The page fades in, so a thread rendered afresh does not just appear. */
+ page: {
+ selector: '[data-testid="app-layout-content-shell"]',
+ keyframes: [{ opacity: 0 }, { opacity: 1 }],
+ timing: { duration: 180, easing: "ease-out" },
+ },
+} satisfies Record;
+
+/**
+ * How long after it starts an entrance adopts elements bb mounts.
+ * Leaving bb's own pages (Plugins, Skills) mounts a fresh sidebar and page
+ * shell for the thread, a moment after the old ones are on screen.
+ */
+const ADOPT_MS = 1000;
+
+/**
+ * Longest an entrance holds its elements out of sight, waiting for bb to
+ * mount and draw the thread. Going back to Threads always ends on the
+ * thread list, so this only bounds a render slower than bb ever is.
+ */
+const HOLD_MS = 3000;
+
+/**
+ * A page's own sidebar rather than the thread list. bb marks each sidebar's
+ * top row by its page: `app-sidebar-top-reserve-row` for the thread list,
+ * `skills-sidebar-top-reserve-row` on Skills. Matching every other page's
+ * mark, not the thread list's, means a rename leaves everything counted as
+ * the thread list, which is how this behaved before it knew the difference.
+ */
+const OTHER_PAGES_SIDEBAR =
+ '[data-side="left"] [data-testid$="-sidebar-top-reserve-row"]:not([data-testid="app-sidebar-top-reserve-row"])';
+
+/** A frame this quick means the browser has caught up with bb's render. */
+const STEADY_FRAME_MS = 25;
+
+/** Longest to wait for steady frames before moving anyway. */
+const SETTLE_MAX_MS = 300;
+
+/**
+ * Run `run` once the browser has caught up: two quick frames in a row, or
+ * at most `SETTLE_MAX_MS` from now. bb renders a thread in long frames, and
+ * motion started during them is spent unseen or stalls, so the strip moves
+ * once they're over. Returns a cancel.
+ */
+export function whenSettled(run: (now: number) => void): () => void {
+ const deadline = performance.now() + SETTLE_MAX_MS;
+ let last: number | null = null;
+ let steady = 0;
+ let frame = 0;
+ const tick = (now: number) => {
+ steady = last !== null && now - last < STEADY_FRAME_MS ? steady + 1 : 0;
+ last = now;
+ if (steady >= 2 || now >= deadline) run(now);
+ else frame = requestAnimationFrame(tick);
+ };
+ frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
+}
+
+/**
+ * Run `run` once `ready` says yes and the browser has caught up with bb's
+ * render. Returns a cancel.
+ */
+function whenReady(ready: Ready, run: (now: number) => void): () => void {
+ let cancelSettled = () => {};
+ if (ready()) {
+ cancelSettled = whenSettled(run);
+ return () => cancelSettled();
+ }
+ const observer = new MutationObserver(() => {
+ if (!ready()) return;
+ observer.disconnect();
+ cancelSettled = whenSettled(run);
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ return () => {
+ observer.disconnect();
+ cancelSettled();
+ };
+}
+
+const playing = new Map void>();
+
+/**
+ * What an entrance waits for before it may start: until it says yes, its
+ * elements stay held out of sight.
+ */
+export type Ready = () => boolean;
+
+/** Ready once the sidebar on screen is the thread list's, or a page's own. */
+export function sidebarOf(owner: "threads" | "page"): Ready {
+ return () => (document.querySelector(OTHER_PAGES_SIDEBAR) !== null) === (owner === "page");
+}
+
+/**
+ * Ready once the page on screen now has gone. Call it before navigating. bb
+ * keeps its page shell and `` across pages and swaps what is inside.
+ */
+export function pageGone(): Ready {
+ const page = document.querySelector(`${ENTRANCES.page.selector} > main > *`);
+ return () => page === null || !page.isConnected;
+}
+
+/**
+ * Play one entrance, once bb has drawn what it is for.
+ *
+ * Until then the elements are held at the entrance's first keyframe, out of
+ * sight: until `ready`, and then through bb's render, which would otherwise
+ * spend or stall the motion (see whenSettled). By default it waits for the
+ * thread list: going back to Threads from a page with its own sidebar, what
+ * is up at first is that page's sidebar and content, still there while bb
+ * renders the thread. The entrance then starts on what is there, new or
+ * kept.
+ *
+ * Once it is playing, anything bb mounts in place of its elements takes the
+ * running animation's start time and carries on rather than starting over.
+ */
+export function playEntrance(kind: keyof typeof ENTRANCES, ready: Ready = sidebarOf("threads")): void {
+ playing.get(kind)?.();
+ if (kind === "sidebar") playing.get("sidebarExit")?.();
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const { selector, keyframes, timing } = ENTRANCES[kind];
+ const holds = new Map();
+ const animations: Animation[] = [];
+ const seen = new WeakSet();
+ let started = false;
+ let waiting = false;
+ // When the entrance was first drawn. A replacement mounts after that, so
+ // it takes the first animation's start time, or this while that is still
+ // on its way to the compositor.
+ let begun: number | null = null;
+ const hold = (element: Element) => {
+ if (holds.has(element)) return;
+ holds.set(element, element.animate([keyframes[0]!, keyframes[0]!], { duration: HOLD_MS * 2 }));
+ };
+ const release = () => {
+ for (const animation of holds.values()) animation.cancel();
+ holds.clear();
+ };
+ const adopt = (element: Element) => {
+ if (seen.has(element)) return;
+ seen.add(element);
+ const animation = element.animate(keyframes, timing);
+ const lead = animations[0];
+ if (lead !== undefined && (lead.startTime ?? begun) !== null) {
+ animation.startTime = lead.startTime ?? begun;
+ }
+ animations.push(animation);
+ };
+ let timer: number | undefined;
+ /** Stop watching for bb's elements in a while, and show anything held. */
+ const settle = (ms: number) => {
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => {
+ observer.disconnect();
+ release();
+ }, ms);
+ };
+ const start = (now: number) => {
+ started = true;
+ const targets = [...holds.keys()].filter((element) => element.isConnected);
+ release();
+ targets.forEach(adopt);
+ document.querySelectorAll(selector).forEach(adopt);
+ begun = now;
+ settle(ADOPT_MS);
+ };
+ let cancelSettled = () => {};
+ const observer = new MutationObserver((records) => {
+ const added: Element[] = [];
+ for (const record of records) {
+ for (const node of Array.from(record.addedNodes)) {
+ if (!(node instanceof Element)) continue;
+ if (node.matches(selector)) added.push(node);
+ else added.push(...Array.from(node.querySelectorAll(selector)));
+ }
+ }
+ if (started) {
+ added.forEach(adopt);
+ return;
+ }
+ added.forEach(hold);
+ if (waiting && ready()) {
+ waiting = false;
+ cancelSettled = whenSettled(start);
+ }
+ });
+ const frame = requestAnimationFrame(() => {
+ waiting = !ready();
+ document.querySelectorAll(selector).forEach(hold);
+ observer.observe(document.body, { childList: true, subtree: true });
+ // Counted from the first frame, since the render that blocks before it
+ // can last longer than the animation. Anything still held by then shows.
+ settle(HOLD_MS);
+ if (!waiting) cancelSettled = whenSettled(start);
+ });
+ playing.set(kind, () => {
+ cancelAnimationFrame(frame);
+ cancelSettled();
+ window.clearTimeout(timer);
+ observer.disconnect();
+ release();
+ for (const animation of animations) animation.cancel();
+ playing.delete(kind);
+ });
+}
+
+/**
+ * Slide the sidebar out as the strip collapses it on leaving Threads. Call
+ * it just before the collapse, while the sidebar is still there to find.
+ *
+ * The collapse is instant, so the page beneath lays out once at full width;
+ * the sidebar is drawn over it, where it was, until `ready` and bb has drawn
+ * that page, then slides away. Pass the `pageGone()` the page's own entrance
+ * waits on, so the two move together. bb hides a collapsed sidebar and moves it a width to the
+ * left, so it is kept visible and translated back by that width. Only
+ * `translate` moves, on the compositor; visibility is held separately so it
+ * cannot stop that. If bb replaces the sidebar on the way — another page's
+ * sidebar, as on Plugins — it simply goes with the element.
+ */
+export function playSidebarExit(ready: Ready = () => true): void {
+ playing.get("sidebarExit")?.();
+ playing.get("sidebar")?.();
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
+ const panel = document.querySelector(ENTRANCES.sidebar.selector);
+ if (panel === null) return;
+ const visible = panel.animate([{ visibility: "visible" }, { visibility: "visible" }], { duration: HOLD_MS * 2 });
+ let motion = panel.animate([{ translate: "100% 0" }, { translate: "100% 0" }], { duration: HOLD_MS * 2 });
+ const stop = () => {
+ cancelSettled();
+ window.clearTimeout(timer);
+ visible.cancel();
+ motion.cancel();
+ if (playing.get("sidebarExit") === stop) playing.delete("sidebarExit");
+ };
+ const cancelSettled = whenReady(ready, () => {
+ motion.cancel();
+ if (!panel.isConnected) return stop();
+ motion = panel.animate([{ translate: "100% 0" }, { translate: "0 0" }], {
+ duration: 160,
+ easing: "cubic-bezier(0.3, 0, 0.8, 0.15)",
+ });
+ motion.finished.then(stop, () => {});
+ });
+ // However long bb takes, the sidebar doesn't stay over the page.
+ const timer = window.setTimeout(stop, HOLD_MS);
+ playing.set("sidebarExit", stop);
+}
+
+/** Stop every entrance now, for a switch away before one has finished. */
+export function stopEntrances(): void {
+ for (const stop of [...playing.values()]) stop();
+}
diff --git a/plugins/top-tabs/top-tabs.css b/plugins/top-tabs/top-tabs.css
index ff93ac0..5a49102 100644
--- a/plugins/top-tabs/top-tabs.css
+++ b/plugins/top-tabs/top-tabs.css
@@ -288,7 +288,39 @@ svg.bb-top-tab-icon,
white-space: nowrap;
}
+/* The Threads tab's thread title opens and shuts by its width, so the tabs
+ after it glide rather than jump. A one-column grid going from 0fr to 1fr
+ animates to the title's own width without measuring it. Only the strip
+ lays out, and it is small and fixed. The title inside keeps its full width
+ and is clipped, so it doesn't re-ellipsize on every frame. */
+.bb-top-tab-sublabel-reveal {
+ display: grid;
+ grid-template-columns: 0fr;
+ opacity: 0;
+ transition:
+ grid-template-columns 200ms cubic-bezier(0.2, 0, 0, 1),
+ opacity 150ms ease-out;
+}
+
+.bb-top-tab-sublabel-reveal[data-shown] {
+ grid-template-columns: 1fr;
+ opacity: 1;
+}
+
+.bb-top-tab-sublabel-clip {
+ min-width: 0;
+ overflow: hidden;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bb-top-tab-sublabel-reveal {
+ transition: none;
+ }
+}
+
.bb-top-tab-sublabel {
+ display: block;
+ width: max-content;
min-width: 0;
max-width: 200px;
overflow: hidden;