feat(control-plane): pin the current bank to the top of the bank selector (#4298)

The selector rendered banks in server order (last write descending), so the
bank you were already in could sit anywhere in a long list — or on a page that
had not been fetched yet. Hoist it to the first row and mark it visually
(accent background, inset primary ring, primary check, bold name) so the
dropdown opens on where you are.

The rest keep their relative server order, and a bank that has not been paged
in cannot be hoisted, so the list is returned unchanged in that case.
This commit is contained in:
Nicolò Boschi
2026-09-10 12:35:45 +02:00
committed by GitHub
parent 203d95c7c6
commit 4af316e749
3 changed files with 87 additions and 7 deletions
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useBank } from "@/lib/bank-context";
import { bankRoute } from "@/lib/bank-url";
import { hoistCurrentBank } from "@/lib/bank-order";
import { withBasePath } from "@/lib/base-path";
import { client } from "@/lib/api";
import type { RetainContentBlock as ContentBlock } from "@/lib/api";
@@ -236,14 +237,20 @@ function BankSelectorInner() {
return () => window.removeEventListener("hindsight:create-bank", openCreate);
}, []);
// Banks arrive already ordered by last write descending, one page at a time, so the
// list is rendered in server order — re-sorting here would only shuffle a later page
// above an earlier one.
const maxFactCount = React.useMemo(
() => Math.max(1, ...bankInfos.map((b) => b.fact_count)),
[bankInfos]
);
// Banks arrive already ordered by last write descending, one page at a time, so the
// list stays in server order — re-sorting it here would only shuffle a later page
// above an earlier one. The single exception is hoisting the current bank; see
// hoistCurrentBank for why that one is worth the reorder.
const orderedBanks = React.useMemo(
() => hoistCurrentBank(bankInfos, currentBank),
[bankInfos, currentBank]
);
// Search runs server-side (the bank list is paginated), so the input holds a draft
// that is debounced into a fresh first page.
const [searchDraft, setSearchDraft] = React.useState("");
@@ -687,7 +694,7 @@ function BankSelectorInner() {
banksLoading && bankInfos.length > 0 && "opacity-40"
)}
>
{bankInfos.map((bank, index) => {
{orderedBanks.map((bank, index) => {
const barPct = (bank.fact_count / maxFactCount) * 100;
const isSelected = currentBank === bank.bank_id;
// Last write, not last ingestion: appends to an existing document
@@ -711,7 +718,13 @@ function BankSelectorInner() {
// already on screen, so appending page 2 flows in without
// replaying page 1. The stagger restarts per page and is capped
// so the tail of a 50-row page doesn't crawl in.
className="relative overflow-hidden py-2.5 mb-0.5 group animate-list-row-enter"
className={cn(
"relative overflow-hidden py-2.5 mb-0.5 group animate-list-row-enter",
// Not bg-accent: cmdk paints the keyboard-active row with
// data-[selected=true]:bg-accent, so reusing it here would
// make two rows look active at once while arrowing down.
isSelected && "ring-1 ring-inset ring-primary/50"
)}
style={{
animationDelay: `${Math.min(index % BANKS_PAGE_SIZE, 10) * 18}ms`,
}}
@@ -725,11 +738,14 @@ function BankSelectorInner() {
<Check
className={cn(
"h-4 w-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0"
isSelected ? "opacity-100 text-primary" : "opacity-0"
)}
/>
<span
className="truncate flex-1 font-medium"
className={cn(
"truncate flex-1",
isSelected ? "font-semibold" : "font-medium"
)}
title={bank.name || bank.bank_id}
>
{bank.name || bank.bank_id}
@@ -0,0 +1,17 @@
import type { BankInfo } from "@/lib/bank-context";
/**
* Banks arrive already ordered by last write descending, one page at a time, so the
* selector renders them in server order — with one exception: the bank you are already
* in is pinned to the top, so the list opens on where you are instead of making you
* hunt for it in a long page. The rest keep their relative order.
*
* A bank that has not been paged in yet cannot be hoisted; the list is returned
* unchanged in that case.
*/
export function hoistCurrentBank(banks: BankInfo[], currentBank: string | null): BankInfo[] {
if (!currentBank) return banks;
const idx = banks.findIndex((b) => b.bank_id === currentBank);
if (idx <= 0) return banks;
return [banks[idx], ...banks.slice(0, idx), ...banks.slice(idx + 1)];
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { hoistCurrentBank } from "@/lib/bank-order";
import type { BankInfo } from "@/lib/bank-context";
function bank(bank_id: string): BankInfo {
return {
bank_id,
name: null,
mission: null,
created_at: null,
updated_at: null,
fact_count: 0,
last_document_at: null,
last_write_at: null,
};
}
const ids = (banks: BankInfo[]) => banks.map((b) => b.bank_id);
describe("hoistCurrentBank", () => {
it("pins the current bank first and keeps the rest in server order", () => {
const banks = [bank("a"), bank("b"), bank("c")];
expect(ids(hoistCurrentBank(banks, "c"))).toEqual(["c", "a", "b"]);
});
it("leaves the list untouched when the current bank is already first", () => {
const banks = [bank("a"), bank("b")];
expect(hoistCurrentBank(banks, "a")).toBe(banks);
});
it("leaves the list untouched when no bank is selected", () => {
const banks = [bank("a"), bank("b")];
expect(hoistCurrentBank(banks, null)).toBe(banks);
});
it("leaves the list untouched when the current bank is not on the loaded page", () => {
const banks = [bank("a"), bank("b")];
expect(hoistCurrentBank(banks, "zz")).toBe(banks);
});
it("does not drop or duplicate banks", () => {
const banks = [bank("a"), bank("b"), bank("c"), bank("d")];
const out = hoistCurrentBank(banks, "b");
expect(ids(out)).toEqual(["b", "a", "c", "d"]);
expect(out).toHaveLength(banks.length);
});
});