diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index 64bead88b35c9..f152cabf01851 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -1,3 +1,7 @@ +import appCss from "../index.css?inline"; +import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection"; +import * as shelfHooks from "../state/olderSettledShelf"; +import { createWorkspaceFocusController, workspaceAreaProps } from "../lib/workspaceFocus"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { DEFAULT_CLIENT_SETTINGS, @@ -16,7 +20,7 @@ import { makeThreadFixture } from "../test-fixtures"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { useProjects, useQuietThreadShells } from "../state/entities"; +import { useProjects, useServerConfigs, useQuietThreadShells } from "../state/entities"; import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -26,6 +30,10 @@ import { THREAD_JUMP_HINT_SHOW_DELAY_MS } from "./Sidebar.logic"; import { SortableThreadRow } from "./sidebar/SidebarThreadRow"; import { SidebarProvider } from "./ui/sidebar"; +vi.mock(import("../state/olderSettledShelf"), async (original) => { + const actual = await original(); + return { ...actual, useOlderSettledShelf: vi.fn(actual.useOlderSettledShelf) }; +}); vi.mock("../state/entities", { spy: true }); vi.mock("../hooks/useHandleNewThread", { spy: true }); vi.mock("../hooks/useThreadRouteTarget", { spy: true }); @@ -135,6 +143,7 @@ describe("sidebar render isolation", () => { environmentId: activeProject.environmentId, threadId: threads[0]!.id, }; + threads.unshift(threadShell(activeProject.id, 100)); vi.mocked(useProjects).mockReturnValue([activeProject]); vi.mocked(useQuietThreadShells).mockReturnValue(threads); vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "server", threadRef: routeThreadRef }); @@ -247,3 +256,98 @@ describe("sidebar thread rename", () => { }, ); }); + +describe("sidebar settled pagination", () => { + it("keeps the viewport in place when showing more settled threads", async () => { + using stack = new DisposableStack(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + stack.defer(() => style.remove()); + const focusController = createWorkspaceFocusController(document); + stack.defer(() => focusController.dispose()); + let loading = false; + const originalShelf = vi.mocked(shelfHooks.useOlderSettledShelf).getMockImplementation()!; + using _shelf = vi.spyOn(shelfHooks, "useOlderSettledShelf").mockImplementation((input) => ({ + ...originalShelf(input), + loading, + retry: () => { + loading = true; + }, + })); + const activeProject = project("active"); + const threads = Array.from({ length: 70 }, (_, index) => + makeThreadFixture({ + environmentId: activeProject.environmentId, + projectId: activeProject.id, + id: ThreadId.make(`settled-${index}`), + title: `Settled thread ${index}`, + settledOverride: "settled", + settledAt: "2026-01-01T00:00:00.000Z", + }), + ); + threads.unshift(threadShell(activeProject.id, 100)); + vi.mocked(useProjects).mockReturnValue([activeProject]); + vi.mocked(useQuietThreadShells).mockReturnValue(threads); + vi.mocked(useThreadRouteTarget).mockReturnValue({ + kind: "server", + threadRef: { environmentId: activeProject.environmentId, threadId: threads[0]!.id }, + }); + vi.mocked(useHandleNewThread).mockReturnValue({ + activeDraftThread: null, + activeThread: null, + defaultProjectRef: { + environmentId: activeProject.environmentId, + projectId: activeProject.id, + }, + defaultProjectFallbackReady: true, + handleNewThread: vi.fn().mockResolvedValue(null), + routeDraftId: null, + routeThreadRef: { environmentId: activeProject.environmentId, threadId: threads[0]!.id }, + }); + vi.mocked(useServerConfigs).mockReturnValue( + new Map([ + [ + activeProject.environmentId, + { + scratchWorkspaceRoot: null, + shellThreadFamilies: false, + } as unknown as EnvironmentServerConfig, + ], + ]), + ); + await using screen = await render( + + + , + ); + const shelf = screen.container.querySelector( + '[data-sidebar-row="shelf:settled"]', + )!; + if (shelf.getAttribute("aria-expanded") === "false") await act(() => shelf.click()); + const more = screen.getByRole("button", { name: "Show 25 more", exact: true }); + await expect.element(more).toBeInTheDocument(); + const moreButton = more.element() as HTMLButtonElement; + const viewport = moreButton.closest('[data-slot="scroll-area-viewport"]')!; + viewport.style.height = "300px"; + viewport.style.maxHeight = "300px"; + moreButton.scrollIntoView({ block: "nearest" }); + const before = viewport.scrollTop; + expect(before).toBeGreaterThan(0); + moreButton.focus({ preventScroll: true }); + await act(() => moreButton.click()); + await expect + .element(screen.getByRole("button", { name: "Loading", exact: true })) + .toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: "Loading", exact: true })) + .toHaveFocus(); + expect(viewport.scrollTop).toBe(before); + const loadedRows = screen.container.querySelectorAll("[data-thread-item]").length; + await act(() => moreButton.click()); + expect(screen.container.querySelectorAll("[data-thread-item]")).toHaveLength(loadedRows); + }); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5c9203742578c..b7ec300204a05 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3644,12 +3644,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve type="button" data-sidebar-row="settled:more" data-sidebar-parent="shelf:settled" - disabled={olderSettledShelf.loading} + aria-disabled={olderSettledShelf.loading} onClick={() => { + if (olderSettledShelf.loading) return; olderSettledShelf.retry(); showMoreSettled(); }} - className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground disabled:cursor-default" + className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground aria-disabled:cursor-default" > {/* Plus means "add", so revealing more uses a chevron. */}