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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions plugins/top-tabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions plugins/top-tabs/components/TopTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -798,7 +835,7 @@ export function TopTabs() {
>
<ThreadsGlyph className="bb-top-tab-icon" />
{threadsLabelled && (
<ThreadsLabel active={selected === THREADS} savedPath={tabs.paths[THREADS]} />
<ThreadsLabel active={active === THREADS} savedPath={tabs.paths[THREADS]} />
)}
<ThreadsStatus />
{threadsLabelled && <PaneMap screen={screen} tab={THREADS} />}
Expand Down
30 changes: 27 additions & 3 deletions plugins/top-tabs/components/threads-tab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="bb-top-tab-label bb-top-tab-threads-label">
<span className="bb-top-tab-name">Threads</span>
{title !== null && <span className="bb-top-tab-sublabel">{title}</span>}
{lastTitle.current !== null && (
<span className="bb-top-tab-sublabel-reveal" data-shown={shown ? "" : undefined} aria-hidden={!shown}>
<span className="bb-top-tab-sublabel-clip">
<span className="bb-top-tab-sublabel">{lastTitle.current}</span>
</span>
</span>
)}
</span>
);
}
Expand Down
Loading
Loading