Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/web/src/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 7 additions & 3 deletions packages/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
},
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
48 changes: 22 additions & 26 deletions packages/web/src/components/sessions/SessionList.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 },
Expand All @@ -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<SortDir>("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 (
Expand All @@ -83,10 +79,10 @@ export function SessionList() {
{total}
</span>
)}
<div className="ml-auto">
<div className="ml-auto flex items-center gap-2">
<SortControl
options={SORT_OPTIONS}
field={sortField}
field="created_at"
dir={sortDir}
onChange={handleSort}
/>
Expand Down
41 changes: 41 additions & 0 deletions packages/web/src/test/sessions-api-mock.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}),
};
},
};
121 changes: 121 additions & 0 deletions packages/web/src/test/sessions-global-sort.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => <span>{children}</span>,
}));

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(
<QueryClientProvider client={makeQc()}>
<DemoProvider>
<SessionList />
</DemoProvider>
</QueryClientProvider>,
);
}

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<string[]> {
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-"));
}