@agentprojectcontext/apx 1.46.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,7 +18,7 @@
18
18
  <link rel="apple-touch-icon" href="/favicon/dark/apple-touch-icon.png" media="(prefers-color-scheme: dark)" />
19
19
  <link rel="manifest" href="/favicon/white/site.webmanifest" media="(prefers-color-scheme: light)" />
20
20
  <link rel="manifest" href="/favicon/dark/site.webmanifest" media="(prefers-color-scheme: dark)" />
21
- <script type="module" crossorigin src="/assets/index-oOjZZktw.js"></script>
21
+ <script type="module" crossorigin src="/assets/index-C9O1GTZ_.js"></script>
22
22
  <link rel="stylesheet" crossorigin href="/assets/index-CilEtMjV.css">
23
23
  </head>
24
24
  <body class="bg-background text-foreground antialiased">
@@ -1,34 +1,77 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import useSWR, { type SWRConfiguration } from "swr";
2
3
  import { ChevronLeft, ChevronRight } from "lucide-react";
3
4
  import { Button } from "./ui";
4
5
  import { UiSelect } from "./UiSelect";
6
+ import { cn } from "../lib/cn";
5
7
  import { t } from "../i18n";
6
8
 
7
9
  const DEFAULT_PAGE_SIZE = 20;
8
10
  export const PAGE_SIZES = [10, 20, 50, 100];
9
11
 
10
- // Client-side pagination over an already-fetched array. The list endpoints
11
- // return the full set (sessions/tasks are bounded), so we page in the browser
12
- // rather than round-trip the daemon. Pass `resetKey` (e.g. the active filter)
13
- // to jump back to page 1 whenever the source set changes; the window is also
14
- // clamped so a shrinking list never strands the user on an empty page.
15
- export function usePaged<T>(items: T[], resetKey?: unknown, initialPageSize = DEFAULT_PAGE_SIZE) {
12
+ // The pager-facing slice of usePagedQuery's return value. PagedList/Pager only
13
+ // need these fields, so call sites can pass the whole query result structurally.
14
+ export interface PagerState {
15
+ page: number;
16
+ pageCount: number;
17
+ total: number;
18
+ start: number;
19
+ end: number;
20
+ pageSize: number;
21
+ setPage: (p: number) => void;
22
+ setPageSize: (n: number) => void;
23
+ }
24
+
25
+ // Server-side pagination. The daemon paginates by ?limit&?offset and returns
26
+ // the full count, so we fetch only the current page (real API pagination, not a
27
+ // client-side slice of everything). `fetchPage` is called with (limit, offset)
28
+ // and must return { items, total }. Pass `resetKey` (e.g. the active filter) to
29
+ // jump back to page 1 when the query changes; the page is also clamped so
30
+ // removing the last row on a page never strands the user on an empty page.
31
+ //
32
+ // Returns the page `items` plus SWR state (isLoading/error/mutate) and the
33
+ // PagerState fields consumed by <PagedList> / <Pager>.
34
+ export function usePagedQuery<T>({
35
+ key,
36
+ fetchPage,
37
+ resetKey,
38
+ initialPageSize = DEFAULT_PAGE_SIZE,
39
+ swr,
40
+ }: {
41
+ key: string | null;
42
+ fetchPage: (limit: number, offset: number) => Promise<{ items: T[]; total: number }>;
43
+ resetKey?: unknown;
44
+ initialPageSize?: number;
45
+ swr?: SWRConfiguration;
46
+ }) {
16
47
  const [page, setPage] = useState(1);
17
48
  const [pageSize, setPageSize] = useState(initialPageSize);
18
49
 
19
50
  useEffect(() => { setPage(1); }, [resetKey]);
20
51
 
21
- const pageCount = Math.max(1, Math.ceil(items.length / pageSize));
52
+ const offset = (page - 1) * pageSize;
53
+ const res = useSWR(
54
+ key == null ? null : [key, pageSize, offset],
55
+ () => fetchPage(pageSize, offset),
56
+ { keepPreviousData: true, ...swr },
57
+ );
58
+
59
+ const total = res.data?.total ?? 0;
60
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
61
+ // Clamp once totals are known (e.g. after the last row on a page is removed).
22
62
  const safePage = Math.min(page, pageCount);
23
63
  useEffect(() => { if (page !== safePage) setPage(safePage); }, [page, safePage]);
24
64
 
25
- const start = (safePage - 1) * pageSize;
26
- const end = Math.min(start + pageSize, items.length);
65
+ const start = total === 0 ? 0 : offset;
66
+ const end = Math.min(offset + pageSize, total);
27
67
  return {
28
- slice: items.slice(start, end),
68
+ items: (res.data?.items ?? []) as T[],
69
+ isLoading: res.isLoading,
70
+ error: res.error as Error | undefined,
71
+ mutate: res.mutate,
29
72
  page: safePage,
30
73
  pageCount,
31
- total: items.length,
74
+ total,
32
75
  start,
33
76
  end,
34
77
  pageSize,
@@ -86,3 +129,50 @@ export function Pager({
86
129
  </div>
87
130
  );
88
131
  }
132
+
133
+ // Reusable list + pager wrapper. `children` is the list markup (built from
134
+ // `paged.slice`); the Pager is wired from `paged` automatically.
135
+ //
136
+ // With `fullHeight`, the list area becomes an internal scroller and the pager
137
+ // is pinned at the bottom, so the whole block fits one viewport with no outer
138
+ // page scroll. This requires the parent to give it a bounded height — render it
139
+ // inside <Section fullHeight> (which lays out as a flex column). Without the
140
+ // flag it falls back to normal document flow (list, then pager, page scrolls).
141
+ export function PagedList({
142
+ paged,
143
+ fullHeight,
144
+ className,
145
+ children,
146
+ }: {
147
+ paged: PagerState;
148
+ fullHeight?: boolean;
149
+ className?: string;
150
+ children: ReactNode;
151
+ }) {
152
+ const pager = (
153
+ <Pager
154
+ page={paged.page}
155
+ pageCount={paged.pageCount}
156
+ total={paged.total}
157
+ start={paged.start}
158
+ end={paged.end}
159
+ pageSize={paged.pageSize}
160
+ onPage={paged.setPage}
161
+ onPageSize={paged.setPageSize}
162
+ />
163
+ );
164
+ if (!fullHeight) {
165
+ return (
166
+ <div className={className}>
167
+ {children}
168
+ {pager}
169
+ </div>
170
+ );
171
+ }
172
+ return (
173
+ <div className="flex min-h-0 flex-1 flex-col">
174
+ <div className={cn("min-h-0 flex-1 overflow-y-auto", className)}>{children}</div>
175
+ <div className="shrink-0">{pager}</div>
176
+ </div>
177
+ );
178
+ }
@@ -7,19 +7,30 @@ interface SectionProps {
7
7
  action?: ReactNode;
8
8
  className?: string;
9
9
  children: ReactNode;
10
+ // Fill the available height and let the body manage its own scroll instead of
11
+ // growing the page. The card becomes a flex column (header pinned, body
12
+ // flex-1); pair with a <PagedList fullHeight> child for an internal scroller
13
+ // with a pinned pager. Needs a bounded-height parent (the tab content area is).
14
+ fullHeight?: boolean;
10
15
  }
11
16
 
12
- export function Section({ title, description, action, className, children }: SectionProps) {
17
+ export function Section({ title, description, action, className, children, fullHeight }: SectionProps) {
13
18
  return (
14
- <section className={cn("rounded-xl border border-border bg-card p-5", className)}>
15
- <header className="mb-4 flex items-start justify-between gap-4">
19
+ <section
20
+ className={cn(
21
+ "rounded-xl border border-border bg-card p-5",
22
+ fullHeight && "flex h-full min-h-0 flex-col",
23
+ className
24
+ )}
25
+ >
26
+ <header className={cn("mb-4 flex items-start justify-between gap-4", fullHeight && "shrink-0")}>
16
27
  <div>
17
28
  <h2 className="text-lg font-semibold tracking-tight">{title}</h2>
18
29
  {description && <p className="mt-0.5 text-sm text-muted-fg">{description}</p>}
19
30
  </div>
20
31
  {action}
21
32
  </header>
22
- <div>{children}</div>
33
+ <div className={cn(fullHeight && "flex min-h-0 flex-1 flex-col")}>{children}</div>
23
34
  </section>
24
35
  );
25
36
  }
@@ -13,4 +13,12 @@ export const Sessions = {
13
13
  // Cross-engine sessions (apx · claude · codex), newest first.
14
14
  global: (engine?: string) =>
15
15
  http.get<{ sessions: SessionRow[] }>(`/sessions${engine ? `?engine=${encodeURIComponent(engine)}` : ""}`),
16
+ // Server-paginated page: returns the requested window plus the full total.
17
+ page: ({ engine, limit, offset }: { engine?: string; limit: number; offset: number }) => {
18
+ const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
19
+ if (engine) q.set("engine", engine);
20
+ return http
21
+ .getWithTotal<{ sessions: SessionRow[] }>(`/sessions?${q.toString()}`)
22
+ .then((r) => ({ items: r.data.sessions, total: r.total }));
23
+ },
16
24
  };
@@ -11,6 +11,16 @@ export const Tasks = {
11
11
  http.get<TaskEntry[]>(`/projects/${pid}/tasks?state=${state}`),
12
12
  global: (state: TaskEntry["state"] | "all" = "open") =>
13
13
  http.get<GlobalTaskEntry[]>(`/tasks?state=${state}`),
14
+ // Server-paginated variants: one project (listPage) or all projects
15
+ // (globalPage). Each returns the requested window plus the full total.
16
+ listPage: (pid: string, { state, limit, offset }: { state: TaskEntry["state"] | "all"; limit: number; offset: number }) =>
17
+ http
18
+ .getWithTotal<TaskEntry[]>(`/projects/${pid}/tasks?state=${state}&limit=${limit}&offset=${offset}`)
19
+ .then((r) => ({ items: r.data, total: r.total })),
20
+ globalPage: ({ state, limit, offset }: { state: TaskEntry["state"] | "all"; limit: number; offset: number }) =>
21
+ http
22
+ .getWithTotal<GlobalTaskEntry[]>(`/tasks?state=${state}&limit=${limit}&offset=${offset}`)
23
+ .then((r) => ({ items: r.data, total: r.total })),
14
24
  add: (pid: string, body: Partial<TaskEntry>) =>
15
25
  http.post<TaskEntry>(`/projects/${pid}/tasks`, body),
16
26
  done: (pid: string, id: string) => http.post<TaskEntry>(`/projects/${pid}/tasks/${id}/done`),
@@ -52,8 +52,38 @@ async function request<T>(
52
52
  return (await res.json()) as T;
53
53
  }
54
54
 
55
+ // GET that also surfaces the total-row count for server-side pagination. The
56
+ // daemon returns the full count in the X-Total-Count header (the body keeps its
57
+ // normal shape); we fall back to the payload length when the header is absent
58
+ // (e.g. an older daemon) so pagination degrades gracefully instead of breaking.
59
+ async function getWithTotal<T>(path: string): Promise<{ data: T; total: number }> {
60
+ const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};
61
+ const res = await fetch(path, { method: "GET", headers });
62
+ if (!res.ok) {
63
+ let detail = "";
64
+ let parsed: unknown = null;
65
+ try {
66
+ parsed = await res.json();
67
+ detail = (parsed as { error?: string })?.error || JSON.stringify(parsed);
68
+ } catch {
69
+ detail = await res.text();
70
+ }
71
+ throw new HttpError(res.status, `GET ${path} → ${res.status}: ${detail}`, parsed);
72
+ }
73
+ const data = (await res.json()) as T;
74
+ const header = res.headers.get("X-Total-Count");
75
+ const total =
76
+ header != null && header !== ""
77
+ ? parseInt(header, 10)
78
+ : Array.isArray(data)
79
+ ? data.length
80
+ : 0;
81
+ return { data, total };
82
+ }
83
+
55
84
  export const http = {
56
85
  get: <T>(p: string) => request<T>("GET", p),
86
+ getWithTotal,
57
87
  post: <T>(p: string, b?: unknown) => request<T>("POST", p, b),
58
88
  put: <T>(p: string, b?: unknown) => request<T>("PUT", p, b),
59
89
  patch: <T>(p: string, b?: unknown) => request<T>("PATCH", p, b),
@@ -1,21 +1,24 @@
1
1
  import { useState } from "react";
2
2
  import { useNavigate } from "react-router-dom";
3
- import useSWR from "swr";
4
3
  import { Tasks } from "../../lib/api";
5
4
  import { Section } from "../../components/Section";
6
- import { Pager, usePaged } from "../../components/Pager";
5
+ import { PagedList, usePagedQuery } from "../../components/Pager";
7
6
  import { Badge, Button, Empty, Loading } from "../../components/ui";
8
7
  import { t } from "../../i18n";
9
8
 
10
- // All projects' tasks, aggregated (GET /tasks).
9
+ // All projects' tasks, aggregated (GET /tasks), server-paginated.
11
10
  export function GlobalTasksTab() {
12
11
  const navigate = useNavigate();
13
12
  const [state, setState] = useState<"open" | "done" | "dropped" | "all">("open");
14
- const list = useSWR(`/tasks?state=${state}`, () => Tasks.global(state));
15
- const paged = usePaged(list.data || [], state);
13
+ const paged = usePagedQuery({
14
+ key: `/tasks?state=${state}`,
15
+ fetchPage: (limit, offset) => Tasks.globalPage({ state, limit, offset }),
16
+ resetKey: state,
17
+ });
16
18
 
17
19
  return (
18
20
  <Section
21
+ fullHeight
19
22
  title={t("project.global_tasks.title")}
20
23
  description={t("project.global_tasks.subtitle")}
21
24
  action={
@@ -26,31 +29,32 @@ export function GlobalTasksTab() {
26
29
  </div>
27
30
  }
28
31
  >
29
- {list.isLoading && <Loading />}
30
- {!list.isLoading && (list.data?.length ?? 0) === 0 && <Empty>{t("project.global_tasks.empty")}</Empty>}
31
- <ul className="space-y-2 text-sm">
32
- {paged.slice.map((task) => (
33
- <li key={`${task.project_id}-${task.id}`} className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
34
- <button
35
- type="button"
36
- onClick={() => navigate(`/p/${task.project_id}/tasks`)}
37
- title={t("project.global_tasks.go_project")}
38
- >
39
- <Badge tone="info">{(task.project_name || "").split("/").pop() || task.project_id}</Badge>
40
- </button>
41
- <div className="min-w-0 flex-1">
42
- <div className="font-medium">{task.title}</div>
43
- <div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
44
- <span>{task.state}</span>
45
- {task.agent && <Badge tone="muted">@{task.agent}</Badge>}
46
- {task.tags?.map((tg) => <span key={tg}>#{tg}</span>)}
47
- {task.due && <span>{t("project.global_tasks.due")} {task.due}</span>}
32
+ {paged.isLoading && <Loading />}
33
+ {!paged.isLoading && paged.total === 0 && <Empty>{t("project.global_tasks.empty")}</Empty>}
34
+ <PagedList paged={paged} fullHeight>
35
+ <ul className="space-y-2 text-sm">
36
+ {paged.items.map((task) => (
37
+ <li key={`${task.project_id}-${task.id}`} className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
38
+ <button
39
+ type="button"
40
+ onClick={() => navigate(`/p/${task.project_id}/tasks`)}
41
+ title={t("project.global_tasks.go_project")}
42
+ >
43
+ <Badge tone="info">{(task.project_name || "").split("/").pop() || task.project_id}</Badge>
44
+ </button>
45
+ <div className="min-w-0 flex-1">
46
+ <div className="font-medium">{task.title}</div>
47
+ <div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
48
+ <span>{task.state}</span>
49
+ {task.agent && <Badge tone="muted">@{task.agent}</Badge>}
50
+ {task.tags?.map((tg) => <span key={tg}>#{tg}</span>)}
51
+ {task.due && <span>{t("project.global_tasks.due")} {task.due}</span>}
52
+ </div>
48
53
  </div>
49
- </div>
50
- </li>
51
- ))}
52
- </ul>
53
- <Pager page={paged.page} pageCount={paged.pageCount} total={paged.total} start={paged.start} end={paged.end} pageSize={paged.pageSize} onPage={paged.setPage} onPageSize={paged.setPageSize} />
54
+ </li>
55
+ ))}
56
+ </ul>
57
+ </PagedList>
54
58
  </Section>
55
59
  );
56
60
  }
@@ -1,9 +1,8 @@
1
1
  import { useState } from "react";
2
- import useSWR from "swr";
3
2
  import { RefreshCw } from "lucide-react";
4
3
  import { Sessions } from "../../lib/api";
5
4
  import { Section } from "../../components/Section";
6
- import { Pager, usePaged } from "../../components/Pager";
5
+ import { PagedList, usePagedQuery } from "../../components/Pager";
7
6
  import { Badge, Button, Empty, Loading } from "../../components/ui";
8
7
  import { UiSelect } from "../../components/UiSelect";
9
8
  import { t } from "../../i18n";
@@ -14,12 +13,15 @@ const ENGINE_TONE: Record<string, "success" | "info" | "warning" | "muted"> = {
14
13
 
15
14
  export function SessionsTab() {
16
15
  const [engine, setEngine] = useState("");
17
- const list = useSWR(`/sessions?engine=${engine}`, () => Sessions.global(engine || undefined));
18
- const rows = list.data?.sessions || [];
19
- const paged = usePaged(rows, engine);
16
+ const paged = usePagedQuery({
17
+ key: `/sessions?engine=${engine}`,
18
+ fetchPage: (limit, offset) => Sessions.page({ engine: engine || undefined, limit, offset }),
19
+ resetKey: engine,
20
+ });
20
21
 
21
22
  return (
22
23
  <Section
24
+ fullHeight
23
25
  title={t("base.sessions_title")}
24
26
  description={t("base.sessions_desc")}
25
27
  action={
@@ -36,26 +38,27 @@ export function SessionsTab() {
36
38
  ]}
37
39
  />
38
40
  </div>
39
- <Button size="sm" variant="secondary" onClick={() => list.mutate()}><RefreshCw size={13} /></Button>
41
+ <Button size="sm" variant="secondary" onClick={() => paged.mutate()}><RefreshCw size={13} /></Button>
40
42
  </div>
41
43
  }
42
44
  >
43
- {list.isLoading && <Loading />}
44
- {list.error && <Empty>{t("base.sessions_error", { msg: (list.error as Error).message })}</Empty>}
45
- {!list.isLoading && !list.error && rows.length === 0 && <Empty>{t("base.sessions_empty")}</Empty>}
46
- <ul className="space-y-1 text-sm">
47
- {paged.slice.map((s, i) => (
48
- <li key={`${s.engine}-${s.id}-${i}`} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
49
- <Badge tone={ENGINE_TONE[s.engine] || "muted"}>{s.engine}</Badge>
50
- <div className="min-w-0 flex-1">
51
- <div className="truncate">{s.title || s.id}</div>
52
- <div className="truncate font-mono text-[10px] text-muted-fg">{s.cwd}</div>
53
- </div>
54
- {s.mtime > 0 && <span className="shrink-0 text-[11px] text-muted-fg">{new Date(s.mtime).toLocaleString()}</span>}
55
- </li>
56
- ))}
57
- </ul>
58
- <Pager page={paged.page} pageCount={paged.pageCount} total={paged.total} start={paged.start} end={paged.end} pageSize={paged.pageSize} onPage={paged.setPage} onPageSize={paged.setPageSize} />
45
+ {paged.isLoading && <Loading />}
46
+ {paged.error && <Empty>{t("base.sessions_error", { msg: (paged.error as Error).message })}</Empty>}
47
+ {!paged.isLoading && !paged.error && paged.total === 0 && <Empty>{t("base.sessions_empty")}</Empty>}
48
+ <PagedList paged={paged} fullHeight>
49
+ <ul className="space-y-1 text-sm">
50
+ {paged.items.map((s, i) => (
51
+ <li key={`${s.engine}-${s.id}-${i}`} className="flex items-center gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
52
+ <Badge tone={ENGINE_TONE[s.engine] || "muted"}>{s.engine}</Badge>
53
+ <div className="min-w-0 flex-1">
54
+ <div className="truncate">{s.title || s.id}</div>
55
+ <div className="truncate font-mono text-[10px] text-muted-fg">{s.cwd}</div>
56
+ </div>
57
+ {s.mtime > 0 && <span className="shrink-0 text-[11px] text-muted-fg">{new Date(s.mtime).toLocaleString()}</span>}
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ </PagedList>
59
62
  </Section>
60
63
  );
61
64
  }
@@ -1,9 +1,8 @@
1
1
  import { useState } from "react";
2
- import useSWR from "swr";
3
2
  import { Check, Plus, RotateCcw, Trash2 } from "lucide-react";
4
3
  import { Tasks } from "../../lib/api";
5
4
  import { Section } from "../../components/Section";
6
- import { Pager, usePaged } from "../../components/Pager";
5
+ import { PagedList, usePagedQuery } from "../../components/Pager";
7
6
  import { Badge, Button, Empty, Field, Input, Loading } from "../../components/ui";
8
7
  import { useToast } from "../../components/Toast";
9
8
  import { t } from "../../i18n";
@@ -12,13 +11,13 @@ export function TasksTab({ pid }: { pid: string }) {
12
11
  const [state, setState] = useState<"open" | "done" | "dropped">("open");
13
12
  const toast = useToast();
14
13
  // dedupingInterval:0 so switching the state filter always revalidates the
15
- // target list instead of showing the stale cached page from a prior switch.
16
- const list = useSWR(
17
- `/projects/${pid}/tasks?state=${state}`,
18
- () => Tasks.list(pid, state),
19
- { dedupingInterval: 0, revalidateOnFocus: true },
20
- );
21
- const paged = usePaged(list.data || [], state);
14
+ // target page instead of showing the stale cached one from a prior switch.
15
+ const paged = usePagedQuery({
16
+ key: `/projects/${pid}/tasks?state=${state}`,
17
+ fetchPage: (limit, offset) => Tasks.listPage(pid, { state, limit, offset }),
18
+ resetKey: state,
19
+ swr: { dedupingInterval: 0, revalidateOnFocus: true },
20
+ });
22
21
  const [draft, setDraft] = useState("");
23
22
  const [busy, setBusy] = useState(false);
24
23
 
@@ -29,7 +28,7 @@ export function TasksTab({ pid }: { pid: string }) {
29
28
  await Tasks.add(pid, { title: draft.trim() });
30
29
  setDraft("");
31
30
  toast.success(t("project.tasks.created"));
32
- list.mutate();
31
+ paged.mutate();
33
32
  } catch (e: any) {
34
33
  toast.error(e?.message || t("project.tasks.create_error"));
35
34
  } finally {
@@ -37,12 +36,13 @@ export function TasksTab({ pid }: { pid: string }) {
37
36
  }
38
37
  };
39
38
  const mark = async (fn: () => Promise<unknown>, label: string) => {
40
- try { await fn(); toast.success(label); list.mutate(); }
39
+ try { await fn(); toast.success(label); paged.mutate(); }
41
40
  catch (e: any) { toast.error(e?.message || t("common.error_generic")); }
42
41
  };
43
42
 
44
43
  return (
45
44
  <Section
45
+ fullHeight
46
46
  title={t("project.tasks.title")}
47
47
  description={t("project.tasks.subtitle")}
48
48
  action={
@@ -55,7 +55,7 @@ export function TasksTab({ pid }: { pid: string }) {
55
55
  </div>
56
56
  }
57
57
  >
58
- <div className="mb-4 flex items-end gap-2">
58
+ <div className="mb-4 flex shrink-0 items-end gap-2">
59
59
  <Field label={t("project.tasks.add_label")}>
60
60
  <Input
61
61
  data-testid="task-input"
@@ -70,8 +70,8 @@ export function TasksTab({ pid }: { pid: string }) {
70
70
  </Button>
71
71
  </div>
72
72
 
73
- {list.isLoading && <Loading />}
74
- {!list.isLoading && (list.data?.length ?? 0) === 0 && (
73
+ {paged.isLoading && <Loading />}
74
+ {!paged.isLoading && paged.total === 0 && (
75
75
  <Empty>
76
76
  {state === "open"
77
77
  ? t("project.tasks.empty_open")
@@ -80,9 +80,10 @@ export function TasksTab({ pid }: { pid: string }) {
80
80
  </Empty>
81
81
  )}
82
82
 
83
- <ul className="space-y-2 text-sm" data-testid="task-list">
84
- {paged.slice.map((task) => (
85
- <li key={task.id} data-testid={`task-${task.id}`} className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
83
+ <PagedList paged={paged} fullHeight>
84
+ <ul className="space-y-2 text-sm" data-testid="task-list">
85
+ {paged.items.map((task) => (
86
+ <li key={task.id} data-testid={`task-${task.id}`} className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
86
87
  <span className="mt-0.5 font-mono text-[10px] text-muted-fg">{task.id}</span>
87
88
  <div className="flex-1">
88
89
  <div className="font-medium">{task.title}</div>
@@ -112,8 +113,8 @@ export function TasksTab({ pid }: { pid: string }) {
112
113
  </div>
113
114
  </li>
114
115
  ))}
115
- </ul>
116
- <Pager page={paged.page} pageCount={paged.pageCount} total={paged.total} start={paged.start} end={paged.end} pageSize={paged.pageSize} onPage={paged.setPage} onPageSize={paged.setPageSize} />
116
+ </ul>
117
+ </PagedList>
117
118
  </Section>
118
119
  );
119
120
  }