feat: tuck other users' spaces into a collapsible home tree row (#11073)

* feat: group other users' spaces under a collapsible row in the home tree

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eAE5cpKPdtH9DNJm5TjcH

* fix: page the other users group on its own and use a design-system toggle

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013eAE5cpKPdtH9DNJm5TjcH

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-10 18:57:50 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 63cb46d7bb
commit d87f089288
3 changed files with 174 additions and 40 deletions
@@ -1991,6 +1991,7 @@
allUsers={treeInjectUsers}
ownerCounts={!searching && labelFilter == undefined ? ownerCounts : undefined}
selfUsername={$userStore?.username}
groupOtherUsers={treeLazyMode}
ownerLoad={treeLazyMode ? ownerLoad : undefined}
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
@@ -32,8 +32,9 @@
// `all` pages the prefix to the end in one call instead of fetching a single page.
onExpandOwner?: (prefix: string, more?: boolean, opts?: { all?: boolean }) => void
onCollapseOwner?: (prefix: string) => void
// Position of this node among the rendered root nodes; "expand all" only
// auto-loads the first EXPAND_ALL_LOAD_LIMIT of them (see the effect below).
// This root owner's place in line for "expand all", which only auto-loads the first
// EXPAND_ALL_LOAD_LIMIT (see the effect below). Not always its rendered position:
// owners nested under a grouping row are ranked after the rest.
rootIndex?: number
showEditButton?: boolean
// Path prefix of the parent node, so this one can name its own (`ownerLoad` and
@@ -43,6 +44,10 @@
// is grouped under this node is only part of it: counts render as "N+" and the
// node offers to load the rest of itself.
ancestorHasMore?: boolean
// Visual nesting on top of `depth`. `depth` stays semantic (0 is a top-level owner
// that loads lazily), so an owner shown inside a grouping row is indented through
// this rather than by raising its depth.
indent?: number
}
let {
@@ -59,9 +64,12 @@
rootIndex = 0,
showEditButton = true,
parentPrefix,
ancestorHasMore = false
ancestorHasMore = false,
indent = 0
}: Props = $props()
let visualDepth = $derived(depth + indent)
// Bounds the request burst from "expand all": however many root owners the tree
// renders (its slice grows as you scroll), it fetches at most this many. Lazy owners
// past the cap stay collapsed and load on a single click (see the effect).
@@ -256,7 +264,7 @@
>
<div
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
style={depth > 0 ? `padding-left: ${depth * 16}px;` : ''}
style={visualDepth > 0 ? `padding-left: ${visualDepth * 16}px;` : ''}
>
<div class="flex justify-center items-center">
{#if isUser(item)}
@@ -310,7 +318,7 @@
<a
href="{base}/pipeline/{encodeURIComponent(item.folderName)}"
class="flex items-center gap-4 px-4 py-3 border-b text-sm hover:bg-surface-hover transition-colors"
style="padding-left: {(depth + 1) * 16}px;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<NetworkIcon size={16} class="text-emerald-600 dark:text-emerald-400" />
<span class="text-xs font-medium text-emphasis">Pipeline</span>
@@ -335,12 +343,13 @@
{showCode}
{showEditButton}
depth={depth + 1}
{indent}
/>
{/each}
{#if effectiveMax < item.items.length}
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<!-- Rows, not items: this slices the node's own entries, where a subfolder
is one row standing for everything under it. -->
@@ -377,7 +386,7 @@
as rows still missing. -->
<div
class="px-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
style="padding-left: {(depth + 1) * 16}px;"
style="padding-left: {(visualDepth + 1) * 16}px;"
>
<span class="text-xs text-secondary">
Showing {loadedHere}{ownerTotal != undefined ? ` of ${ownerTotal}` : ''} items in {nodePrefix}
@@ -427,6 +436,6 @@
on:appChanged
on:rawAppChanged
on:reload
{depth}
depth={visualDepth}
/>
{/if}
@@ -1,8 +1,10 @@
<script lang="ts">
import { untrack } from 'svelte'
import TreeView from './TreeView.svelte'
import { groupItems, type ItemType } from './treeViewUtils'
import { countLeaves, groupItems, type ItemType, type UserItem } from './treeViewUtils'
import { Button } from '$lib/components/common'
import { ChevronDown, ChevronUp, Users } from 'lucide-svelte'
import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
interface Props {
collapseAll: boolean
@@ -38,6 +40,10 @@
onExpandOwner?: (owner: string, more?: boolean, opts?: { all?: boolean }) => void
onCollapseOwner?: (owner: string) => void
showEditButton?: boolean
// Tuck every user space but `selfUsername`'s into one collapsible "Other users" row
// under it. Only for browsing: a search or filter must not hide its matches behind
// a closed row.
groupOtherUsers?: boolean
}
let {
@@ -57,7 +63,8 @@
ownerLoad,
onExpandOwner,
onCollapseOwner,
showEditButton = true
showEditButton = true,
groupOtherUsers = false
}: Props = $props()
// How many root nodes render at once. A root node is a collapsed owner row that
@@ -153,6 +160,61 @@
})
})
type RootNode = ReturnType<typeof groupItems>[number]
// `loadRank` is what "expand all" caps its requests by (see TreeView's rootIndex).
type RootRow = { kind: 'node'; node: RootNode; loadRank: number } | { kind: 'otherUsers' }
const OTHER_USERS_OPEN_SETTING_NAME = 'homeTreeOtherUsersOpen'
let otherUsersOpen = $state(getLocalSetting(OTHER_USERS_OPEN_SETTING_NAME) == 'true')
function toggleOtherUsers() {
otherUsersOpen = !otherUsersOpen
storeLocalSetting(OTHER_USERS_OPEN_SETTING_NAME, otherUsersOpen ? 'true' : undefined)
}
// The group pages its rows separately from the root slice. Drawing from `nbDisplayed`
// instead, opening it would push folders past the slice and unmount them, dropping
// whatever they had expanded.
let nbOtherUsersDisplayed = $state(ROOT_PAGE)
function isOtherUser(node: RootNode): node is UserItem {
return 'username' in node && node.username !== selfUsername
}
let otherUsers: UserItem[] = $derived(
groupOtherUsers && selfUsername != undefined && Array.isArray(groupedItems)
? groupedItems.filter(isOtherUser)
: []
)
let otherUsersItemCount = $derived(
ownerCounts != undefined
? otherUsers.reduce(
(sum, u) => sum + Math.max(ownerCounts[`u/${u.username}`] ?? 0, countLeaves(u)),
0
)
: undefined
)
let rows: RootRow[] = $derived.by(() => {
if (!Array.isArray(groupedItems)) return []
if (otherUsers.length === 0) {
return groupedItems.map((node, i): RootRow => ({ kind: 'node', node, loadRank: i }))
}
// `groupItems` orders users before folders, so with the others taken out the viewer's
// own space is what leads.
const main = groupedItems.filter((g) => !isOtherUser(g))
const lead = main.filter((g) => 'username' in g)
const rest = main.filter((g) => !('username' in g))
return [
...lead.map((node, i): RootRow => ({ kind: 'node', node, loadRank: i })),
{ kind: 'otherUsers' },
...rest.map((node, i): RootRow => ({ kind: 'node', node, loadRank: lead.length + i }))
]
})
// Owner rows only, for the footer: the "Other users" row is neither a folder nor a user.
let ownerRowCount = $derived(rows.filter((r) => r.kind === 'node').length)
let shownOwnerRowCount = $derived(
rows.slice(0, nbDisplayed).filter((r) => r.kind === 'node').length
)
let footerEl: HTMLDivElement | undefined = $state()
// Reveal the next slice of root nodes as the footer comes into view. Only the
// client-side slice auto-grows — those nodes are already grouped and render
@@ -162,10 +224,9 @@
if (!el) return
const observer = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return
const grouped = groupedItems
if (!Array.isArray(grouped) || nbDisplayed >= grouped.length) return
if (nbDisplayed >= rows.length) return
if (nbDisplayed >= AUTO_REVEAL_LIMIT) return
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, grouped.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
// Revealing more doesn't change whether the footer intersects, so no further
// callback would fire and scrolling would stall with rows left unrevealed.
// Re-observing re-delivers the current intersection after the rows render.
@@ -177,6 +238,29 @@
})
</script>
{#snippet ownerNode(node: RootNode, loadRank: number, indent: number)}
<TreeView
rootIndex={loadRank}
{indent}
{isSearching}
{collapseAll}
item={node}
{pipelineFolders}
{ownerCounts}
ancestorHasMore={hasMoreServer}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
{showCode}
{showEditButton}
/>
{/snippet}
{#if groupedItems === 'loading'}
<div class="flex flex-row items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 dark:border-gray-100"
@@ -188,30 +272,70 @@
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary">
{#each groupedItems.slice(0, nbDisplayed) as item, rootIndex ('folderName' in item ? `f__${item.folderName}` : 'username' in item ? `u__${item.username}` : `i__${item.type}__${item.path}`)}
{#if item}
<TreeView
{rootIndex}
{isSearching}
{collapseAll}
{item}
{pipelineFolders}
{ownerCounts}
ancestorHasMore={hasMoreServer}
{ownerLoad}
{onExpandOwner}
{onCollapseOwner}
on:scriptChanged
on:flowChanged
on:appChanged
on:rawAppChanged
on:reload
{showCode}
{showEditButton}
/>
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)}
{#if row.kind === 'otherUsers'}
<!-- Same shape as an owner row, so it reads as part of the tree. It only reveals
the user rows under it: each still loads its own items when opened. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onclick={toggleOtherUsers}
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
>
<div class="flex flex-row items-center gap-4">
<Users size={16} class="text-secondary" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">Other users</span>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
({pluralize(otherUsers.length, 'user')}{otherUsersItemCount != undefined
? ` · ${pluralize(otherUsersItemCount, 'item')}`
: ''})
</div>
</div>
</div>
<Button
iconOnly
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: otherUsersOpen ? ChevronUp : ChevronDown }}
title={otherUsersOpen ? 'Hide other users' : 'Show other users'}
aria-label="Other users"
aria-expanded={otherUsersOpen}
onClick={toggleOtherUsers}
/>
</div>
{#if otherUsersOpen}
<!-- Ranked after every root owner, so opening this row can't push folders out
of what "expand all" auto-loads. -->
{#each otherUsers.slice(0, nbOtherUsersDisplayed) as user, i (user.username)}
{@render ownerNode(user, ownerRowCount + i, 1)}
{/each}
{#if nbOtherUsersDisplayed < otherUsers.length}
<div
class="pl-8 pr-4 py-2 border-b flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users
</span>
<Button
unifiedSize="sm"
variant="subtle"
onClick={() =>
(nbOtherUsersDisplayed = Math.min(
nbOtherUsersDisplayed + ROOT_PAGE,
otherUsers.length
))}
>
Show more
</Button>
</div>
{/if}
{/if}
{:else}
{@render ownerNode(row.node, row.loadRank, 0)}
{/if}
{/each}
{#if nbDisplayed < groupedItems.length || hasMoreServer}
{#if nbDisplayed < rows.length || hasMoreServer}
<!-- Last row of the tree's own frame, not a caption under it: what is missing
has to read as part of the list to be noticed at all. -->
<div
@@ -219,8 +343,8 @@
class="px-4 py-3 flex flex-row items-center justify-between gap-4 bg-surface-secondary"
>
<span class="text-xs text-secondary">
{#if nbDisplayed < groupedItems.length}
Showing {nbDisplayed} of {groupedItems.length} folders and users
{#if nbDisplayed < rows.length}
Showing {shownOwnerRowCount} of {ownerRowCount} folders and users
{:else}
<!-- Scoped to one owner: the tree groups the paged browse stream, so what
is missing is items, not root nodes. -->
@@ -231,12 +355,12 @@
unifiedSize="sm"
variant="subtle"
on:click={() => {
if (nbDisplayed < groupedItems.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, groupedItems.length)
if (nbDisplayed < rows.length)
nbDisplayed = Math.min(nbDisplayed + ROOT_PAGE, rows.length)
else onLoadMore?.()
}}
>
{nbDisplayed < groupedItems.length ? 'Show more' : 'Load more'}
{nbDisplayed < rows.length ? 'Show more' : 'Load more'}
</Button>
</div>
{/if}