feat(app): limit Recent sidebar to 7 days and 30 rows, align its hover preview (#63313)

This commit is contained in:
James Long
2026-11-11 08:41:16 -06:00
committed by GitHub
Unverified
parent 96579e88ac
commit e376ba0e0d
4 changed files with 214 additions and 50 deletions
+61 -4
View File
@@ -707,14 +707,16 @@ test("Recent and Pinned rows show the side tabs' status dots and shortcuts for s
page,
}) => {
const d = { id: "ses_tab_d", title: "Tab D session" }
// Recent lists only the last 7 days.
const t = Date.now() - 60_000
await mockWorkspace(page, {
name: "RecentUnread",
sessions: [
{ ...a, time: { created: T0 + 3, updated: T0 + 3 } },
{ ...b, outcome: "failed", time: { created: T0 + 1, updated: T0 + 1, idle: T0 + 1 } },
{ ...c, outcome: "succeeded", time: { created: T0 + 2, updated: T0 + 2, idle: T0 + 2 } },
{ ...d, time: { created: T0, updated: T0 } },
{ ...a, time: { created: t + 3, updated: t + 3 } },
{ ...b, outcome: "failed", time: { created: t + 1, updated: t + 1, idle: t + 1 } },
{ ...c, outcome: "succeeded", time: { created: t + 2, updated: t + 2, idle: t + 2 } },
{ ...d, time: { created: t, updated: t } },
],
sessionStatus: { [d.id]: { type: "running" } },
sessionPermissions: {
@@ -755,6 +757,61 @@ test("Recent and Pinned rows show the side tabs' status dots and shortcuts for s
await expect(link(a.id)).toHaveAttribute("aria-current", "page")
await expect(row(c.id).locator('[data-mark="unread"]')).toBeHidden()
await expect(row(b.id).locator('[data-mark="failed"]')).toBeVisible()
// The hover preview sits level with the hovered row, as on side tabs, rather than clamped inside the row.
const preview = page.locator('[data-component="session-tab-popover"]')
for (const id of [b.id, c.id]) {
await row(id).hover()
await expect(preview).toBeVisible()
await expect
.poll(async () => {
const card = await preview.boundingBox()
const box = await row(id).boundingBox()
return card && box ? Math.round(card.y - box.y) : undefined
})
.toBe(0)
}
})
test("Recent shows the last 7 days, and Show more reveals 30 more rows, fetching pages on demand", async ({ page }) => {
const day = 24 * 60 * 60 * 1000
const now = Date.now()
// The mock lists sessions newest last; 10 are from today and 55 are over a week old.
const sessions = Array.from({ length: 65 }, (_, index) => {
const updated = index < 55 ? now - 8 * day - (55 - index) * 60_000 : now - (65 - index) * 60_000
return { id: `ses_cap_${index}`, title: `Cap session ${index}`, time: { created: updated, updated } }
})
await mockWorkspace(page, {
name: "RecentCap",
sessions,
seed: {
tabs: [],
settings: { appearance: { tabLayout: "vertical" }, general: { sidebarOrganize: "recent" } },
},
})
await page.goto(
`/e2e/utils/windows-menu.html?${new URLSearchParams({ server: SERVER, path: sessionHref("ses_cap_64", "sidecar") })}`,
)
const recent = page.locator('[data-slot="sidebar-recent"]').getByRole("group", { name: "Recent", exact: true })
const rows = recent.locator("[data-titlebar-tab-slot]")
const more = recent.getByRole("button", { name: "Show more", exact: true })
await expect(rows).toHaveCount(10)
await expect(rows.first()).toContainText("Cap session 64")
await expect(rows.last()).toContainText("Cap session 55")
await more.click()
await expect(rows).toHaveCount(40)
await expect(rows.last()).toContainText("Cap session 25")
// The first page held 50 sessions, so the next 30 need the server's second page.
await more.click()
await expect(rows).toHaveCount(65)
await expect(rows.last()).toContainText("Cap session 0")
await expect(more).toBeHidden()
})
test("an unfocused window preserves a restored manual unread reminder", async ({ page }) => {
@@ -7,6 +7,8 @@ import {
INITIAL_RECENT_PAGE_STATE,
RECENT_PAGE_TIMEOUT_MS,
RECENT_PAGE_LIMIT,
RECENT_VISIBLE_LIMIT,
RECENT_WINDOW_MS,
mergeRecentSessions,
partitionRecentSessions,
recentSessionEndpoints,
@@ -16,6 +18,7 @@ import {
sortRecentSessions,
transitionRecentPage,
splitRecentSessionKey,
windowRecentSessions,
type RecentSession,
} from "./recent-sessions-model"
@@ -124,6 +127,88 @@ test("pins keep their order, leave Recent, and hide archived sessions without dr
expect(result.recent).toEqual([c])
})
const now = 1_000 * RECENT_WINDOW_MS
const hour = 60 * 60 * 1000
// Newest first, one hour apart, ending `older` rows past the 7-day cutoff.
const timeline = (recent: number, older: number) =>
sortRecentSessions([
...Array.from({ length: recent }, (_, index) => item("server", session(`ses_r${index}`, now - index * hour))),
...Array.from({ length: older }, (_, index) =>
item("server", session(`ses_o${index}`, now - RECENT_WINDOW_MS - (index + 1) * hour)),
),
])
const ids = (rows: readonly RecentSession[]) => rows.map((row) => row.session.id)
test("Recent shows only the last 7 days, newest first", () => {
const items = timeline(3, 4)
const result = windowRecentSessions({ items, now, revealed: 0, serversHaveMore: false })
expect(ids(result.visible)).toEqual(["ses_r0", "ses_r1", "ses_r2"])
expect(result.hasMore).toBe(true)
// The window moves with time: the oldest recent row ages out once it is more than 7 days old.
const later = windowRecentSessions({
items,
now: now + RECENT_WINDOW_MS - 2 * hour + 1,
revealed: 0,
serversHaveMore: false,
})
expect(ids(later.visible)).toEqual(["ses_r0", "ses_r1"])
})
test("Recent caps the last 7 days at 30 rows", () => {
const result = windowRecentSessions({ items: timeline(45, 0), now, revealed: 0, serversHaveMore: false })
expect(result.visible).toHaveLength(RECENT_VISIBLE_LIMIT)
expect(result.visible.at(-1)?.session.id).toBe("ses_r29")
expect(result.hasMore).toBe(true)
})
test("Show more reveals 30 more rows regardless of age", () => {
const items = timeline(5, 40)
const result = windowRecentSessions({ items, now, revealed: RECENT_VISIBLE_LIMIT, serversHaveMore: false })
expect(result.visible).toHaveLength(5 + RECENT_VISIBLE_LIMIT)
expect(result.visible.at(-1)?.session.id).toBe("ses_o29")
expect(result.hasMore).toBe(true)
const all = windowRecentSessions({ items, now, revealed: 2 * RECENT_VISIBLE_LIMIT, serversHaveMore: false })
expect(all.visible).toHaveLength(45)
expect(all.hasMore).toBe(false)
// More rows than are loaded tells the driver to fetch further pages.
expect(all.limit).toBe(65)
})
test("Show more stays while a server still has pages, even when every loaded row shows", () => {
const items = timeline(10, 0)
expect(windowRecentSessions({ items, now, revealed: 0, serversHaveMore: false }).hasMore).toBe(false)
expect(windowRecentSessions({ items, now, revealed: 0, serversHaveMore: true }).hasMore).toBe(true)
})
test("pinned sessions do not count toward the Recent limit", () => {
const items = timeline(40, 0)
const pins = items.slice(0, 5).map((entry) => entry.key)
const partition = partitionRecentSessions({
items,
endpoints: new Map(),
open: new Set(),
pins,
resolvePin: () => undefined,
archived: () => false,
})
const result = windowRecentSessions({ items: partition.recent, now, revealed: 0, serversHaveMore: false })
expect(partition.pinned).toHaveLength(5)
expect(ids(result.visible)).toEqual(ids(items.slice(5, 5 + RECENT_VISIBLE_LIMIT)))
})
test("opening a sidecar pin excludes its HTTP alias while keeping a different server's session", () => {
const ownUrl = "http://127.0.0.1:4096"
const sidecar = { type: "sidecar", variant: "base", http: { url: ownUrl } } as const
@@ -101,6 +101,30 @@ export function sortRecentSessions(items: readonly RecentSession[]) {
return [...items].sort(compareRecentSessions)
}
export const RECENT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
/** Rows Recent shows at first, and how many more each Show more reveals. */
export const RECENT_VISIBLE_LIMIT = 30
/**
* Recent shows the sessions active in the last 7 days, at most 30. Each Show more reveals `RECENT_VISIBLE_LIMIT`
* more regardless of age. `serversHaveMore` says whether any server still has pages that were not fetched.
*/
export function windowRecentSessions(input: {
items: readonly RecentSession[]
now: number
revealed: number
serversHaveMore: boolean
}) {
const cutoff = input.now - RECENT_WINDOW_MS
const older = input.items.findIndex((item) => sessionTime(item.session) < cutoff)
const inWindow = older === -1 ? input.items.length : older
const limit = Math.min(inWindow, RECENT_VISIBLE_LIMIT) + input.revealed
const visible = input.items.slice(0, limit)
return { visible, limit, hasMore: input.items.length > visible.length || input.serversHaveMore }
}
type RecentPageBase = {
readonly sessions: readonly SessionInfo[]
readonly cursor: string | undefined
@@ -31,6 +31,7 @@ import {
dedupePins,
INITIAL_RECENT_PAGE_STATE,
RECENT_PAGE_LIMIT,
RECENT_VISIBLE_LIMIT,
mergeRecentSessions,
partitionRecentSessions,
recentPageFailed,
@@ -42,6 +43,7 @@ import {
reorderPins,
splitRecentSessionKey,
transitionRecentPage,
windowRecentSessions,
type RecentPageEffect,
type RecentPageEvent,
type RecentPageState,
@@ -60,8 +62,8 @@ const RecentSidebarState = Persistence.struct({
type Connection = { conn: ServerConnection.Any; ctx: ServerCtx; server: ServerConnection.Key }
/**
* The experimental Recent sessions sidebar: pinned sessions first, then every server's newest root sessions in one
* recency list. The desktop titlebar renders it in place of the side tabs only while the setting is on and available.
* The experimental Recent sessions sidebar: pinned sessions first, then every server's root sessions from the last
* 7 days in one recency list, at most 30 until Show more reveals older ones. The desktop titlebar renders it in place of the side tabs only while the setting is on and available.
*/
export function RecentSessions() {
const global = useGlobal()
@@ -81,6 +83,12 @@ export function RecentSessions() {
// Whether each row shows its unread dot, keyed like the rows.
const [unread, setUnread] = createStore<Record<string, boolean>>({})
// `revealed` counts the rows Show more added beyond the 7-day window. `now` ticks each minute so the window moves.
const [view, setView] = createStore({ revealed: 0, now: Date.now() })
const clock = setInterval(() => setView("now", Date.now()), 60_000)
onCleanup(() => clearInterval(clock))
const controllers = new Map<AbortController, string>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const hydrationControllers = new Map<string, AbortController>()
@@ -481,14 +489,24 @@ export function RecentSessions() {
)
const pinned = () => partition().pinned
const recent = () => partition().recent
const windowed = createMemo(() =>
windowRecentSessions({
items: partition().recent,
now: view.now,
revealed: view.revealed,
serversHaveMore: connections().some((item) => recentPageHasMore(pages[item.server])),
}),
)
const recent = () => windowed().visible
const rows = createMemo(() => [...pinned(), ...recent()])
const firstPageLoading = createMemo(() => {
if (!servers.hydrated()) return true
// Adding a server should not blank the rows already shown while its first page loads.
if (pinned().length > 0 || recent().length > 0) return false
if (pinned().length > 0 || partition().recent.length > 0) return false
return connections().some((item) => {
if (item.ctx.sdk.connection.status() !== "connected") return false
@@ -519,13 +537,15 @@ export function RecentSessions() {
})
}
const empty = createMemo(() => pinned().length === 0 && recent().length === 0 && !firstPageLoading())
const empty = createMemo(
() => pinned().length === 0 && recent().length === 0 && !windowed().hasMore && !firstPageLoading(),
)
// Every server with a cursor loads its next page together, and the merged list re-sorts. Newly loaded rows can
// therefore appear above rows already shown: one merged recency order is kept rather than holding rows back until
// every server reaches the same age. A short page or a missing next cursor ends that server's list; a failed page
// keeps its cursor so the next scroll retries it.
const loadMore = () => {
// keeps its cursor so the next Show more retries it.
const loadMore = (retry: boolean) => {
connections().forEach((item) => {
const current = pages[item.server]
@@ -534,7 +554,7 @@ export function RecentSessions() {
if (item.ctx.sdk.connection.status() !== "connected") return
if (current.status === "failed") {
dispatch(item, { type: "retry", attempt: ++attempt })
if (retry) dispatch(item, { type: "retry", attempt: ++attempt })
return
}
@@ -545,35 +565,19 @@ export function RecentSessions() {
})
}
let viewport: HTMLDivElement | undefined
let fillFrame: number | undefined
const showMore = () => {
setView("revealed", (value) => value + RECENT_VISIBLE_LIMIT)
loadMore(true)
}
// A short list cannot scroll, so a scroll event never asks for the next page. Fill the viewport once per render
// until rows overflow it or every server with a cursor is exhausted.
// Show more can reveal more rows than are loaded; fetch further pages until they are, or every server is exhausted.
// A failed page waits for the next Show more instead of retrying here.
createEffect(() => {
connections().forEach((item) => pages[item.server]?.status)
void items()
if (view.revealed === 0 || loadingMore()) return
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
fillFrame = requestAnimationFrame(() => {
fillFrame = undefined
if (windowed().limit <= partition().recent.length) return
if (disposed || !viewport || loadingMore()) return
if (viewport.scrollHeight > viewport.clientHeight) return
const more = connections().some((item) => {
const current = pages[item.server]
return current?.status === "ready" && recentPageHasMore(current)
})
if (more) loadMore()
})
})
onCleanup(() => {
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
untrack(() => loadMore(false))
})
const reorderPinned = (nextVisible: readonly string[]) => {
@@ -775,18 +779,7 @@ export function RecentSessions() {
data-shortcut-hints={hints() ? "" : undefined}
class="flex min-h-0 flex-1 flex-col [app-region:no-drag]"
>
<div
ref={(element) => (viewport = element)}
data-scrollable
class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pb-1"
onScroll={(event) => {
const element = event.currentTarget
if (element.scrollTop + element.clientHeight < element.scrollHeight - 160) return
loadMore()
}}
>
<div data-scrollable class="no-scrollbar flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pb-1">
<Show
when={!firstPageLoading()}
fallback={
@@ -826,7 +819,7 @@ export function RecentSessions() {
</DragDropProvider>
</div>
</Show>
<Show when={recent().length > 0}>
<Show when={recent().length > 0 || windowed().hasMore}>
<div role="group" aria-labelledby="sidebar-recent-list" class="flex shrink-0 flex-col gap-2 pb-2.5">
<SectionHeader id="sidebar-recent-list" label={language.t("sidebar.recent.section.recent")} />
<div data-titlebar-tab-list data-orientation="vertical" class="flex w-full min-w-0 flex-col">
@@ -834,6 +827,11 @@ export function RecentSessions() {
{(item) => row(item)}
</Key>
</div>
<Show when={windowed().hasMore && !loadingMore()}>
<Button variant="ghost-muted" size="normal" class="w-full justify-start px-2" onClick={showMore}>
{language.t("sidebar.inbox.showMore")}
</Button>
</Show>
</div>
</Show>
<Show when={empty()}>