From e376ba0e0d0d237a70eeeecc8ac150b6837169c8 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 11 Nov 2026 08:41:16 -0600 Subject: [PATCH] feat(app): limit Recent sidebar to 7 days and 30 rows, align its hover preview (#63313) --- packages/app/e2e/regression/tabs.spec.ts | 65 +++++++++++++- .../titlebar/recent-sessions-model.test.ts | 85 ++++++++++++++++++ .../shell/titlebar/recent-sessions-model.ts | 24 +++++ .../src/shell/titlebar/recent-sessions.tsx | 90 +++++++++---------- 4 files changed, 214 insertions(+), 50 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 080c6389767..0ff9ca73628 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -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 }) => { diff --git a/packages/app/src/shell/titlebar/recent-sessions-model.test.ts b/packages/app/src/shell/titlebar/recent-sessions-model.test.ts index f50c283e62d..6ce261aaaf0 100644 --- a/packages/app/src/shell/titlebar/recent-sessions-model.test.ts +++ b/packages/app/src/shell/titlebar/recent-sessions-model.test.ts @@ -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 diff --git a/packages/app/src/shell/titlebar/recent-sessions-model.ts b/packages/app/src/shell/titlebar/recent-sessions-model.ts index 552b294a091..f5b0f452237 100644 --- a/packages/app/src/shell/titlebar/recent-sessions-model.ts +++ b/packages/app/src/shell/titlebar/recent-sessions-model.ts @@ -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 diff --git a/packages/app/src/shell/titlebar/recent-sessions.tsx b/packages/app/src/shell/titlebar/recent-sessions.tsx index 87b1da24536..21dfb9e0658 100644 --- a/packages/app/src/shell/titlebar/recent-sessions.tsx +++ b/packages/app/src/shell/titlebar/recent-sessions.tsx @@ -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>({}) + // `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() const timers = new Map>() const hydrationControllers = new Map() @@ -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]" > -
(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() - }} - > +
- 0}> + 0 || windowed().hasMore}>
@@ -834,6 +827,11 @@ export function RecentSessions() { {(item) => row(item)}
+ + +