diff --git a/packages/web/src/api/keys.ts b/packages/web/src/api/keys.ts index 89d5606..38e27ae 100644 --- a/packages/web/src/api/keys.ts +++ b/packages/web/src/api/keys.ts @@ -13,7 +13,8 @@ export const QK = { peerSessions: (wsId: string, pId: string, page: number, size: number) => ["peer-sessions", wsId, pId, page, size] as const, - sessions: (wsId: string, page: number, size: number) => ["sessions", wsId, page, size] as const, + sessions: (wsId: string, page: number, size: number, reverse?: boolean) => + ["sessions", wsId, page, size, reverse] as const, session: (wsId: string, sId: string) => ["session", wsId, sId] as const, sessionMessages: (wsId: string, sId: string, page: number, size: number) => ["session-messages", wsId, sId, page, size] as const, diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index b4dd926..fc60436 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -314,16 +314,20 @@ export function useChat( // ─── Sessions ───────────────────────────────────────────────────────────────── -export function useSessions(workspaceId: string, page = 1, pageSize = 20) { +export function useSessions(workspaceId: string, page = 1, pageSize = 20, reverse = true) { return useQuery({ - queryKey: QK.sessions(workspaceId, page, pageSize), + queryKey: QK.sessions(workspaceId, page, pageSize, reverse), queryFn: async () => { const { data, error } = await client.current.POST( "/v3/workspaces/{workspace_id}/sessions/list", { params: { path: { workspace_id: workspaceId }, - query: { page, page_size: pageSize }, + // Server-side global ordering: the honcho fork orders by + // created_at only (`reverse`), so Newest/Oldest must be + // requested, not re-sorted client-side — a client sort + // only shuffles the current page slice. + query: { page, size: pageSize, reverse }, }, body: {}, }, diff --git a/packages/web/src/api/schema.d.ts b/packages/web/src/api/schema.d.ts index e53c5b5..cb64fa6 100644 --- a/packages/web/src/api/schema.d.ts +++ b/packages/web/src/api/schema.d.ts @@ -2311,6 +2311,8 @@ export interface operations { page?: number; /** @description Page size */ size?: number; + /** @description Whether to reverse the order of results (newest created_at first) */ + reverse?: boolean | null; }; header?: never; path: { diff --git a/packages/web/src/components/sessions/SessionList.tsx b/packages/web/src/components/sessions/SessionList.tsx index b4d2049..17fc657 100644 --- a/packages/web/src/components/sessions/SessionList.tsx +++ b/packages/web/src/components/sessions/SessionList.tsx @@ -1,7 +1,7 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { motion, type Variants } from "framer-motion"; import { ChevronRight, CircleDot, Clock, MessageSquare } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useSessions } from "@/api/queries"; import type { components } from "@/api/schema.d.ts"; import { Breadcrumb } from "@/components/layout/Breadcrumb"; @@ -16,11 +16,17 @@ import { COLOR } from "@/lib/constants"; type Session = components["schemas"]["Session"]; -const SORT_OPTIONS = [ - { value: "created_at", label: "Newest" }, - { value: "active", label: "Active" }, - { value: "id", label: "ID" }, -]; +const SORT_OPTIONS = [{ value: "created_at", label: "Newest" }]; + +// Server semantics (honcho fork): sessions/list orders by created_at only and +// exposes the `reverse` query flag — there is no sort-field parameter and no +// last-activity ordering. The sort direction is therefore a GLOBAL server-side +// sort across all pages. Fields without a server equivalent (former `active` +// and `id` options) were removed rather than kept as page-local sorts: a +// page-local sort over a paginated workspace silently shows the extreme of the +// loaded slice, not of the workspace (the bug PR #109 fixed for created_at). +// A global active/id sort or last-activity ordering needs a honcho-side +// sort-field parameter — out of scope here, documented as open point. const container: Variants = { hidden: { opacity: 0 }, @@ -35,33 +41,23 @@ export function SessionList() { const { mask } = useDemo(); const { workspaceId } = useParams({ strict: false }) as { workspaceId: string }; const [page, setPage] = useState(1); - const [sortField, setSortField] = useState("created_at"); const [sortDir, setSortDir] = useState("desc"); const navigate = useNavigate(); - const { data, isLoading, error } = useSessions(workspaceId, page); + // created_at is sorted SERVER-SIDE over the whole result set (honcho + // `reverse` query param): desc = Newest, asc = Oldest. There is no + // page-local re-sort — direction changes re-request from the server. + const reverse = sortDir === "desc"; + const { data, isLoading, error } = useSessions(workspaceId, page, 20, reverse); const sessions: Session[] = (data as { items?: Session[] } | undefined)?.items ?? []; const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1; const total = (data as { total?: number } | undefined)?.total ?? 0; - const sorted = useMemo(() => { - return [...sessions].sort((a, b) => { - let cmp = 0; - if (sortField === "created_at") { - cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - } else if (sortField === "active") { - // active sessions first (true > false) - cmp = Number(a.is_active) - Number(b.is_active); - } else if (sortField === "id") { - cmp = a.id.localeCompare(b.id); - } - return sortDir === "asc" ? cmp : -cmp; - }); - }, [sessions, sortField, sortDir]); + const sorted = sessions; - function handleSort(field: string, dir: SortDir) { - setSortField(field); + function handleSort(_field: string, dir: SortDir) { setSortDir(dir); + setPage(1); } return ( @@ -83,10 +79,10 @@ export function SessionList() { {total} )} -
+
diff --git a/packages/web/src/test/sessions-api-mock.ts b/packages/web/src/test/sessions-api-mock.ts new file mode 100644 index 0000000..f222a14 --- /dev/null +++ b/packages/web/src/test/sessions-api-mock.ts @@ -0,0 +1,41 @@ +/** + * Shared mock for the honcho API client used by sessions-sort tests. + * Simulates POST /v3/workspaces/{id}/sessions/list: global created_at + * ordering (reverse flag), paginated in 2-per-page slices of a 3-session + * workspace — i.e. MORE sessions than one page holds. + */ +import { vi } from "vitest"; + +export type ListCall = { page: number; size?: number; reverse?: boolean | null }; + +export const listCalls: ListCall[] = []; + +export function resetListCalls() { + listCalls.length = 0; +} + +const SESSIONS = [ + { id: "s-old", created_at: "2026-05-01T00:00:00Z", is_active: true }, + { id: "s-mid", created_at: "2026-06-01T00:00:00Z", is_active: true }, + { id: "s-new", created_at: "2026-09-21T09:00:00Z", is_active: true }, +]; + +export const client = { + get current() { + return { + POST: vi.fn(async (_path: string, opts: { params: { query: ListCall } }) => { + listCalls.push({ ...opts.params.query }); + const ordered = opts.params.query.reverse ? [...SESSIONS].reverse() : SESSIONS; + const page = opts.params.query.page ?? 1; + // The component requests pageSize 20; the mock serves 2-per-page + // slices so a >1-page workspace is exercised (regression guard). + const size = 2; + const items = ordered.slice((page - 1) * size, page * size); + return { + data: { items, pages: Math.ceil(SESSIONS.length / size), total: SESSIONS.length }, + error: undefined, + }; + }), + }; + }, +}; diff --git a/packages/web/src/test/sessions-global-sort.test.tsx b/packages/web/src/test/sessions-global-sort.test.tsx new file mode 100644 index 0000000..9a47f6a --- /dev/null +++ b/packages/web/src/test/sessions-global-sort.test.tsx @@ -0,0 +1,121 @@ +/** + * Regression: sessions-list sort must be GLOBAL over the whole result set. + * + * The honcho fork's POST /v3/workspaces/{id}/sessions/list orders by + * created_at only; `reverse` is the one server-side sort lever. The client + * must request the direction (Newest => reverse=true, Oldest => false) and + * must NOT re-sort the loaded page slice — the old behavior sorted only the + * 20 loaded cards, so "Oldest" showed the oldest entry OF THE CURRENT PAGE, + * not of the workspace. + * + * The mock serves a 3-session workspace in 2-per-page slices (more sessions + * than one page holds): reverse=true => [s-new, s-mid] | [s-old] + * reverse=false => [s-old, s-mid] | [s-new] + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { listCalls, resetListCalls } from "./sessions-api-mock"; + +vi.mock("@/api/client", () => import("./sessions-api-mock")); + +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ workspaceId: "ws-1" }), + useRouter: () => ({ state: { location: { pathname: "/workspaces/ws-1/sessions" } } }), + useMatch: () => false, + Link: ({ children }: { children: React.ReactNode }) => {children}, +})); + +import { SessionList } from "@/components/sessions/SessionList"; +import { DemoProvider } from "@/context/DemoContext"; + +function makeQc() { + return new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); +} + +function renderList() { + return render( + + + + + , + ); +} + +beforeEach(() => { + resetListCalls(); +}); + +describe("SessionList — global server-side ordering", () => { + it("requests reverse=true by default (Newest first, page 1 = globally newest)", async () => { + const { container } = renderList(); + await waitFor(() => expect(listCalls.length).toBeGreaterThan(0)); + expect(listCalls[0].reverse).toBe(true); + // The rendered page-1 slice comes from the mocked server order + // (reverse=true => newest first): s-new is on page 1, s-old is not. + const ids = await waitForRenderedIds(container); + expect(ids[0]).toBe("s-new"); + expect(ids).toContain("s-mid"); + // the globally OLDEST session is NOT on page 1 when Newest is selected + expect(ids).not.toContain("s-old"); + }); + + it("toggling to Oldest re-requests page 1 with reverse=false and renders the GLOBALLY oldest session", async () => { + const { container } = renderList(); + await waitFor(() => expect(listCalls.length).toBeGreaterThan(0)); + + // Act like a user on page 2 of Newest: flip the sort direction. + const toggle = screen.getByRole("button", { name: /newest/i }); + await userEvent.click(toggle); + + const last = await waitFor(() => { + const call = listCalls[listCalls.length - 1]; + expect(call.reverse).toBe(false); + return call; + }); + // Direction change resets to page 1, requested from the server. + expect(last.page).toBe(1); + expect(last.reverse).toBe(false); + // Page 1 of the ASC server order is [s-old, s-mid]: the globally + // OLDEST session is now the first rendered card. + const ids = await waitForRenderedIds(container); + expect(ids[0]).toBe("s-old"); + expect(ids).not.toContain("s-new"); + }); + + it("paginates within the server order: page 2 of Oldest holds the globally newest session", async () => { + const { container } = renderList(); + await waitFor(() => expect(listCalls.length).toBeGreaterThan(0)); + + // Switch to Oldest (reverse=false), then walk to page 2. + await userEvent.click(screen.getByRole("button", { name: /newest/i })); + await waitFor(() => expect(listCalls[listCalls.length - 1].reverse).toBe(false)); + await userEvent.click(screen.getByRole("button", { name: /next/i })); + + const last = await waitFor(() => { + const call = listCalls[listCalls.length - 1]; + expect(call.page).toBe(2); + return call; + }); + // Same direction rides every page change — the client never re-sorts. + expect(last.reverse).toBe(false); + // reverse=false page 2 is [s-new]: the GLOBALLY newest session shows up + // on the LAST page under Oldest — proof ordering spans all pages. + const ids = await waitForRenderedIds(container); + expect(ids[0]).toBe("s-new"); + expect(ids).not.toContain("s-old"); + }); +}); + +async function waitForRenderedIds(container: HTMLElement): Promise { + await waitFor(() => + expect(container.querySelectorAll("button span.font-mono").length).toBeGreaterThan(0), + ); + // Only the session cards' mono id spans, not the metadata/date captions. + return [...container.querySelectorAll("button span.font-mono")] + .map((el) => el.textContent ?? "") + .filter((t) => t.startsWith("s-")); +}