fix(desktop): stabilize members dialog scrolling (#6670)

## Summary

- Keep virtualized member rows measurable by removing
`content-visibility: auto` from the measured row subtree.
- Use the member card's 60px baseline as the virtualizer estimate while
retaining deferred rendering for eager search and archived-member lists.
- Cover large rosters with a regression test that checks stable scroll
extent across the list and verifies the final member remains reachable.

### Related issue

None found.

### Testing

- `just ci`
- `pnpm -C desktop build:e2e`
- `pnpm -C desktop exec playwright test tests/e2e/channels.spec.ts
--grep 'members sidebar virtualizes large channel rosters'
--repeat-each=5`

#### Before


https://github.com/user-attachments/assets/a5fcc040-6872-4200-bcc3-7b4197a4dd23

#### After


https://github.com/user-attachments/assets/f2c97f2f-3719-4c2a-b17a-2450c6c70a55

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-25 01:18:29 +10:00
committed by GitHub
parent 17af15effa
commit 72ba987c36
3 changed files with 102 additions and 53 deletions

View File

@@ -70,6 +70,7 @@ import { useMembersSidebarActions } from "./useMembersSidebarActions";
import { useMembersSidebarModeration } from "./useMembersSidebarModeration";
const MEMBER_ADD_RESULT_LIMIT = 50;
const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2;
const MEMBER_ROW_ESTIMATE_PX = 60;
type AddMemberSearchCandidate = UserSearchResult & {
isManagedAgent?: boolean;
isMember?: boolean;
@@ -645,57 +646,62 @@ export function MembersSidebar({
? managedAgentPairAction(managedAgentRuntime)
: undefined;
return (
<div className="content-visibility-auto" key={member.pubkey}>
<MembersSidebarMemberCard
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canModerate={canModerate && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={
isActionPending ||
changeRoleMutation.isPending ||
isModerationPending
}
isArchived={isArchived}
managedAgent={managedAgent}
managedAgentRuntime={managedAgentRuntime}
member={member}
memberIsBot={memberIsBot}
memberAvatarLabel={
member.displayName ?? truncatePubkey(member.pubkey)
}
memberLabel={formatMemberName(member, currentPubkey)}
moderationState={moderationStateByPubkey.get(
normalizePubkey(member.pubkey),
)}
onBan={onBan}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent, managedAgentRuntime);
}}
onOpenProfile={handleOpenProfile}
onRemoveMember={handleRemoveMember}
onTimeout={onTimeout}
onUnban={onUnban}
onUntimeout={onUntimeout}
onViewActivity={
onViewActivity
? (pubkey: string) => {
onOpenChange(false);
onViewActivity(pubkey);
}
: undefined
}
pairAction={pairAction}
presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
profileAvatarUrl={memberProfile?.avatarUrl ?? null}
showOtherSetupMarker={showOtherSetupMarker}
viewerIsOwner={viewerIsOwner}
/>
<MembersSidebarMemberCard
canChangeRole={canManageMembers && member.pubkey !== currentPubkey}
canModerate={canModerate && member.pubkey !== currentPubkey}
canRemoveMember={canRemoveMember(member)}
isActionPending={
isActionPending || changeRoleMutation.isPending || isModerationPending
}
isArchived={isArchived}
managedAgent={managedAgent}
managedAgentRuntime={managedAgentRuntime}
member={member}
memberIsBot={memberIsBot}
memberAvatarLabel={member.displayName ?? truncatePubkey(member.pubkey)}
memberLabel={formatMemberName(member, currentPubkey)}
moderationState={moderationStateByPubkey.get(
normalizePubkey(member.pubkey),
)}
onBan={onBan}
onChangeRole={(m, role) => {
void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role });
}}
onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined}
onManagedAgentAction={(agent) => {
void handleAgentLifecycleAction(agent, managedAgentRuntime);
}}
onOpenProfile={handleOpenProfile}
onRemoveMember={handleRemoveMember}
onTimeout={onTimeout}
onUnban={onUnban}
onUntimeout={onUntimeout}
onViewActivity={
onViewActivity
? (pubkey: string) => {
onOpenChange(false);
onViewActivity(pubkey);
}
: undefined
}
pairAction={pairAction}
presenceStatus={
memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null
}
profileAvatarUrl={memberProfile?.avatarUrl ?? null}
showOtherSetupMarker={showOtherSetupMarker}
viewerIsOwner={viewerIsOwner}
/>
);
}
function renderDeferredMemberCard(
member: ChannelMember,
memberIsBot: boolean,
) {
return (
<div className="content-visibility-auto-member-row" key={member.pubkey}>
{renderMemberCard(member, memberIsBot)}
</div>
);
}
@@ -780,7 +786,7 @@ export function MembersSidebar({
{normalizedSearchQuery ? (
<div>
{filteredActiveMembers.map((member) =>
renderMemberCard(member, isBot(member)),
renderDeferredMemberCard(member, isBot(member)),
)}
{canAddMembers ? (
<>
@@ -824,6 +830,7 @@ export function MembersSidebar({
) : filteredActiveMembers.length > 0 ? (
<VirtualizedList
className="h-[calc(100%_-_2.25rem)]"
estimateSize={MEMBER_ROW_ESTIMATE_PX}
getItemKey={(member) => member.pubkey}
items={filteredActiveMembers}
renderItem={(member) =>
@@ -867,7 +874,7 @@ export function MembersSidebar({
data-testid="members-sidebar-archived-list"
>
{filteredArchivedMembers.map((member) =>
renderMemberCard(member, isBot(member)),
renderDeferredMemberCard(member, isBot(member)),
)}
{filteredArchivedMembers.length === 0 ? (
<p className="text-sm text-muted-foreground">

View File

@@ -37,6 +37,15 @@
contain-intrinsic-size: auto 2rem;
}
/*
* Member cards rendered eagerly can skip offscreen layout and paint.
* Virtualized member rows must not use this utility.
*/
.content-visibility-auto-member-row {
content-visibility: auto;
contain-intrinsic-size: auto 3.75rem;
}
.buzz-huddle-tooltip {
background: hsl(
var(--huddle-tooltip-surface, var(--huddle-control-surface, 0 0% 20%))

View File

@@ -4007,7 +4007,40 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => {
await expect(memberRows.first()).toBeVisible();
expect(await memberRows.count()).toBeLessThan(50);
const firstGeneratedRow = memberList.getByTestId(
`sidebar-member-${pubkeys[0]}`,
);
await expect
.poll(() =>
firstGeneratedRow.evaluate(
(row) =>
getComputedStyle(row.parentElement as HTMLElement).contentVisibility,
),
)
.toBe("visible");
const virtualizedList = memberList.locator(".overflow-y-auto");
await page.evaluate(() => document.fonts.ready);
const heights: number[] = [];
for (const ratio of [0, 0.1, 0.25, 0.5, 0.75, 1]) {
heights.push(
await virtualizedList.evaluate(async (element, ratio) => {
element.scrollTop =
(element.scrollHeight - element.clientHeight) * ratio;
element.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
return element.scrollHeight;
}, ratio),
);
}
expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(120);
await virtualizedList.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll"));