@agentprojectcontext/apx 1.47.0 → 1.48.1

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-BDJfFzQk.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
  }
@@ -1,4 +1,4 @@
1
- import { http } from "../http";
1
+ import { http, unwrapPage } from "../http";
2
2
 
3
3
  export interface SessionRow {
4
4
  engine: string;
@@ -10,7 +10,15 @@ export interface SessionRow {
10
10
  }
11
11
 
12
12
  export const Sessions = {
13
- // Cross-engine sessions (apx · claude · codex), newest first.
13
+ // Cross-engine sessions (apx · claude · codex), newest first — full set.
14
14
  global: (engine?: string) =>
15
- http.get<{ sessions: SessionRow[] }>(`/sessions${engine ? `?engine=${encodeURIComponent(engine)}` : ""}`),
15
+ http
16
+ .get<unknown>(`/sessions${engine ? `?engine=${encodeURIComponent(engine)}` : ""}`)
17
+ .then((b) => ({ sessions: unwrapPage<SessionRow>(b).items })),
18
+ // Server-paginated page: returns the requested window plus the full total.
19
+ page: ({ engine, limit, offset }: { engine?: string; limit: number; offset: number }) => {
20
+ const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
21
+ if (engine) q.set("engine", engine);
22
+ return http.get<unknown>(`/sessions?${q.toString()}`).then((b) => unwrapPage<SessionRow>(b));
23
+ },
16
24
  };
@@ -1,4 +1,4 @@
1
- import { http } from "../http";
1
+ import { http, unwrapPage } from "../http";
2
2
  import type { TaskEntry } from "../../types/daemon";
3
3
 
4
4
  export interface GlobalTaskEntry extends TaskEntry {
@@ -7,10 +7,17 @@ export interface GlobalTaskEntry extends TaskEntry {
7
7
  }
8
8
 
9
9
  export const Tasks = {
10
+ // Full sets (no pagination) — unwrapped to plain arrays for non-paged callers.
10
11
  list: (pid: string, state: TaskEntry["state"] | "all" = "open") =>
11
- http.get<TaskEntry[]>(`/projects/${pid}/tasks?state=${state}`),
12
+ http.get<unknown>(`/projects/${pid}/tasks?state=${state}`).then((b) => unwrapPage<TaskEntry>(b).items),
12
13
  global: (state: TaskEntry["state"] | "all" = "open") =>
13
- http.get<GlobalTaskEntry[]>(`/tasks?state=${state}`),
14
+ http.get<unknown>(`/tasks?state=${state}`).then((b) => unwrapPage<GlobalTaskEntry>(b).items),
15
+ // Server-paginated variants: one project (listPage) or all projects
16
+ // (globalPage). Each returns the requested window plus the full total.
17
+ listPage: (pid: string, { state, limit, offset }: { state: TaskEntry["state"] | "all"; limit: number; offset: number }) =>
18
+ http.get<unknown>(`/projects/${pid}/tasks?state=${state}&limit=${limit}&offset=${offset}`).then((b) => unwrapPage<TaskEntry>(b)),
19
+ globalPage: ({ state, limit, offset }: { state: TaskEntry["state"] | "all"; limit: number; offset: number }) =>
20
+ http.get<unknown>(`/tasks?state=${state}&limit=${limit}&offset=${offset}`).then((b) => unwrapPage<GlobalTaskEntry>(b)),
14
21
  add: (pid: string, body: Partial<TaskEntry>) =>
15
22
  http.post<TaskEntry>(`/projects/${pid}/tasks`, body),
16
23
  done: (pid: string, id: string) => http.post<TaskEntry>(`/projects/${pid}/tasks/${id}/done`),
@@ -52,6 +52,34 @@ async function request<T>(
52
52
  return (await res.json()) as T;
53
53
  }
54
54
 
55
+ // Pagination metadata returned by list endpoints in the { meta, data } envelope.
56
+ export interface PageMeta {
57
+ total: number;
58
+ offset: number;
59
+ limit: number | null;
60
+ pageSize: number;
61
+ page: number;
62
+ pageCount: number;
63
+ }
64
+
65
+ // Normalize any list response into { items, total }. Accepts the { meta, data }
66
+ // envelope (current daemon), a bare array, or the legacy { sessions } object, so
67
+ // the UI keeps working across a daemon that hasn't been restarted yet (it just
68
+ // degrades to a single page when no meta.total is present).
69
+ export function unwrapPage<T>(body: unknown): { items: T[]; total: number } {
70
+ const b = body as { data?: unknown; meta?: { total?: number }; sessions?: unknown };
71
+ if (Array.isArray(body)) return { items: body as T[], total: body.length };
72
+ if (b && Array.isArray(b.data)) {
73
+ const items = b.data as T[];
74
+ return { items, total: typeof b.meta?.total === "number" ? b.meta.total : items.length };
75
+ }
76
+ if (b && Array.isArray(b.sessions)) {
77
+ const items = b.sessions as T[];
78
+ return { items, total: items.length };
79
+ }
80
+ return { items: [], total: 0 };
81
+ }
82
+
55
83
  export const http = {
56
84
  get: <T>(p: string) => request<T>("GET", p),
57
85
  post: <T>(p: string, b?: unknown) => request<T>("POST", 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
  }