From bd77a2dcb10284ad64fe70ba02fafa291837cbc9 Mon Sep 17 00:00:00 2001 From: Poxel2 Date: Mon, 21 Sep 2026 11:21:05 +0200 Subject: [PATCH 1/4] fix(web): request server-side reverse ordering for sessions list The sessions list page showed the oldest sessions (May/June 2026 in a workspace with 965 sessions) as the newest, because page 1 of POST /v3/workspaces/{workspace_id}/sessions/list arrives in ascending created_at order while the UI applies its own client-side sort per page. Unlike conclusions/list and messages/list, the generated API types for sessions/list did not expose the server's reverse query parameter, so useSessions could not ask for newest-first pagination. The page-local desc sort then only shuffled the oldest-20 slice. - pass reverse: true in useSessions so page 1 starts at the newest session - add reverse to the QK.sessions cache key - drop the unused page_size query param in favor of size (the server silently ignores page_size and falls back to 50/page) --- packages/web/src/api/keys.ts | 3 ++- packages/web/src/api/queries.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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..5268348 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -316,14 +316,14 @@ export function useChat( export function useSessions(workspaceId: string, page = 1, pageSize = 20) { return useQuery({ - queryKey: QK.sessions(workspaceId, page, pageSize), + queryKey: QK.sessions(workspaceId, page, pageSize, true), 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 }, + query: { page, size: pageSize, reverse: true }, }, body: {}, }, From 6d50fa0cb1b1b51d9701b89eae5b5978a77b36ab Mon Sep 17 00:00:00 2001 From: Poxel2 Date: Mon, 21 Sep 2026 12:08:33 +0200 Subject: [PATCH 2/4] fix(web): global server-side sessions sort (Oldest = workspace-oldest, not page-oldest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #109: that PR made page 1 request reverse=true, but the SortControl still only re-sorted the 20 loaded cards of the CURRENT page. On a workspace with 49 pages, 'Oldest' showed the oldest entry of the loaded slice (today 10:55), not the oldest of the workspace. The honcho fork's sessions/list orders by created_at only — is the single server-side sort lever (no sort-field parameter, no last-activity ordering; see open point below). - useSessions gains a reverse parameter, forwarded as the server query param; SessionList maps created_at desc/asc to reverse true/false and stops client-side re-sorting that field (a page-local sort would hide the global order again) - active/id have no server equivalent (the list endpoint returns only is_active rows anyway); they remain page-local sorts, documented in PAGE_LOCAL_SORT_FIELDS with the server-semantics rationale - schema.d.ts: expose reverse on sessions/list query params (the server already accepted it; the generated types did not) - regression: mocked >1-page workspace, page 1 with Newest must show the globally newest and NOT the globally oldest session Open point (server semantics, documented not fixed): reverse sorts by created_at, NOT by last activity. For long-lived reused sessions 'Newest' still means 'newest created'. A last-activity sort needs a honcho-side change (out of scope here, per Nicht-Scope). --- packages/web/src/api/queries.ts | 10 ++- packages/web/src/api/schema.d.ts | 2 + .../src/components/sessions/SessionList.tsx | 32 +++++-- packages/web/src/test/sessions-api-mock.ts | 41 +++++++++ .../src/test/sessions-global-sort.test.tsx | 87 +++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 packages/web/src/test/sessions-api-mock.ts create mode 100644 packages/web/src/test/sessions-global-sort.test.tsx diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index 5268348..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, true), + 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, size: pageSize, reverse: true }, + // 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..792e84a 100644 --- a/packages/web/src/components/sessions/SessionList.tsx +++ b/packages/web/src/components/sessions/SessionList.tsx @@ -22,6 +22,15 @@ const SORT_OPTIONS = [ { value: "id", label: "ID" }, ]; +// Server semantics (honcho fork): sessions/list supports ONLY `reverse` over +// created_at — there is no sort-field parameter and no last-activity ordering. +// created_at (Newest/Oldest) is therefore a GLOBAL server-side sort across all +// pages; `active`/`id` have no server equivalent and remain page-local sorts +// over the loaded slice (the list endpoint also returns only is_active rows, +// so `active` is effectively a no-op). Changing this needs a honcho-side +// sort-field parameter — out of scope here, documented as open point. +const PAGE_LOCAL_SORT_FIELDS = new Set(["active", "id"]); + const container: Variants = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: 0.05 } }, @@ -38,18 +47,29 @@ export function SessionList() { 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. The server has no + // sort-field parameter — `id`/`active` stay page-local client sorts (see + // PAGE_LOCAL_SORT_FIELDS note). + const isServerSorted = !PAGE_LOCAL_SORT_FIELDS.has(sortField); + const { data, isLoading, error } = useSessions( + workspaceId, + page, + 20, + isServerSorted ? sortDir === "desc" : true, + ); 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(() => { + // created_at: server already paginates in the requested global order — + // re-sorting here would only shuffle the loaded page and hide the rest. + if (isServerSorted) return sessions; 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") { + if (sortField === "active") { // active sessions first (true > false) cmp = Number(a.is_active) - Number(b.is_active); } else if (sortField === "id") { @@ -57,7 +77,7 @@ export function SessionList() { } return sortDir === "asc" ? cmp : -cmp; }); - }, [sessions, sortField, sortDir]); + }, [sessions, sortField, sortDir, isServerSorted]); function handleSort(field: string, dir: SortDir) { setSortField(field); @@ -83,7 +103,7 @@ export function SessionList() { {total} )} -
+
{ + 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..bcf8a9a --- /dev/null +++ b/packages/web/src/test/sessions-global-sort.test.tsx @@ -0,0 +1,87 @@ +/** + * 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. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, waitFor } from "@testing-library/react"; +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 = listCalls.length ? 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("forwards the sort direction via the hook's reverse parameter", async () => { + const queries = await import("@/api/queries"); + // useSessions keeps its own hook identity: spy on the mocked client to + // prove the direction flag reaches the POST body when the component + // re-renders with a different sort dir (covered by the component test + // above for desc; here we pin the hook contract signature). + expect(typeof queries.useSessions).toBe("function"); + expect(queries.useSessions.length).toBeLessThanOrEqual(4); + }); + + it("paginates in server order: ordering decided by `reverse` alone", async () => { + renderList(); + await waitFor(() => expect(listCalls.length).toBeGreaterThan(0)); + // the server contract: every page change re-requests with the SAME + // reverse flag; the client never re-sorts across page boundaries + expect(listCalls[0].size).toBe(20); + }); +}); + +async function waitForRenderedIds(container: HTMLElement): Promise { + await waitFor(() => expect(container.querySelectorAll("button").length).toBeGreaterThan(1)); + // Only the session cards' mono id spans (first span inside each card button), + // not the metadata/date captions. + return [...container.querySelectorAll("button span.font-mono")] + .map((el) => el.textContent ?? "") + .filter((t) => t.startsWith("s-")); +} From df9265eabf1822645a080690079ce2047248e166 Mon Sep 17 00:00:00 2001 From: Poxel2 Date: Mon, 21 Sep 2026 12:55:54 +0200 Subject: [PATCH 3/4] fix(web): drop page-local Active/ID sort options from sessions list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the global sort fix: Active and ID had no server-side equivalent (sessions/list orders by created_at only), so keeping them as page-local client sorts silently showed the extreme of the loaded 20-card slice instead of the workspace — the exact half-correct behavior the global-sort fix removed for created_at. Removing them leaves a single honest control: Newest/Oldest, ordered server-side across all pages. A global active/id sort or last-activity ordering needs a honcho-side sort-field parameter (documented as open point in SessionList). --- .../src/components/sessions/SessionList.tsx | 60 ++++++------------- 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/packages/web/src/components/sessions/SessionList.tsx b/packages/web/src/components/sessions/SessionList.tsx index 792e84a..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,20 +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 supports ONLY `reverse` over -// created_at — there is no sort-field parameter and no last-activity ordering. -// created_at (Newest/Oldest) is therefore a GLOBAL server-side sort across all -// pages; `active`/`id` have no server equivalent and remain page-local sorts -// over the loaded slice (the list endpoint also returns only is_active rows, -// so `active` is effectively a no-op). Changing this needs a honcho-side +// 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 PAGE_LOCAL_SORT_FIELDS = new Set(["active", "id"]); const container: Variants = { hidden: { opacity: 0 }, @@ -44,44 +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(); // created_at is sorted SERVER-SIDE over the whole result set (honcho - // `reverse` query param): desc = Newest, asc = Oldest. The server has no - // sort-field parameter — `id`/`active` stay page-local client sorts (see - // PAGE_LOCAL_SORT_FIELDS note). - const isServerSorted = !PAGE_LOCAL_SORT_FIELDS.has(sortField); - const { data, isLoading, error } = useSessions( - workspaceId, - page, - 20, - isServerSorted ? sortDir === "desc" : true, - ); + // `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(() => { - // created_at: server already paginates in the requested global order — - // re-sorting here would only shuffle the loaded page and hide the rest. - if (isServerSorted) return sessions; - return [...sessions].sort((a, b) => { - let cmp = 0; - 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, isServerSorted]); + const sorted = sessions; - function handleSort(field: string, dir: SortDir) { - setSortField(field); + function handleSort(_field: string, dir: SortDir) { setSortDir(dir); + setPage(1); } return ( @@ -106,7 +82,7 @@ export function SessionList() {
From eea4d078e875dd468b3b923c0eb8b54003202aab Mon Sep 17 00:00:00 2001 From: Poxel2 Date: Mon, 21 Sep 2026 13:14:34 +0200 Subject: [PATCH 4/4] test(web): cover Oldest direction and cross-page order in sessions sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first regression round only asserted the Newest default: reverse flag on the initial request, page 1 rendering the globally newest session. Direction toggling and pagination had no coverage — the exact paths the original bug traveled. - toggling Newest -> Oldest must re-request PAGE 1 with reverse=false and render the globally OLDEST session first (not the oldest of a stale page slice) - walking to page 2 under Oldest must keep riding reverse=false and surface the globally NEWEST session on the last page — proof the order spans the whole result set, not the loaded slice Mutation-checked: a mock that ignores the reverse flag fails the Newest-default assertion, so the tests genuinely pin the server order. --- .../src/test/sessions-global-sort.test.tsx | 70 ++++++++++++++----- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/web/src/test/sessions-global-sort.test.tsx b/packages/web/src/test/sessions-global-sort.test.tsx index bcf8a9a..9a47f6a 100644 --- a/packages/web/src/test/sessions-global-sort.test.tsx +++ b/packages/web/src/test/sessions-global-sort.test.tsx @@ -7,9 +7,14 @@ * 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, waitFor } from "@testing-library/react"; +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"; @@ -51,36 +56,65 @@ describe("SessionList — global server-side ordering", () => { 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 = listCalls.length ? await waitForRenderedIds(container) : []; + 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("forwards the sort direction via the hook's reverse parameter", async () => { - const queries = await import("@/api/queries"); - // useSessions keeps its own hook identity: spy on the mocked client to - // prove the direction flag reaches the POST body when the component - // re-renders with a different sort dir (covered by the component test - // above for desc; here we pin the hook contract signature). - expect(typeof queries.useSessions).toBe("function"); - expect(queries.useSessions.length).toBeLessThanOrEqual(4); + 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 in server order: ordering decided by `reverse` alone", async () => { - renderList(); + 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)); - // the server contract: every page change re-requests with the SAME - // reverse flag; the client never re-sorts across page boundaries - expect(listCalls[0].size).toBe(20); + + // 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").length).toBeGreaterThan(1)); - // Only the session cards' mono id spans (first span inside each card button), - // not the metadata/date captions. + 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-"));