@agentprojectcontext/apx 1.47.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.
- package/package.json +1 -1
- package/src/host/daemon/api/sessions.js +7 -1
- package/src/host/daemon/api/tasks.js +17 -7
- package/src/interfaces/web/dist/assets/{index-oOjZZktw.js → index-C9O1GTZ_.js} +45 -45
- package/src/interfaces/web/dist/assets/{index-oOjZZktw.js.map → index-C9O1GTZ_.js.map} +1 -1
- package/src/interfaces/web/dist/index.html +1 -1
- package/src/interfaces/web/src/components/Pager.tsx +102 -12
- package/src/interfaces/web/src/components/Section.tsx +15 -4
- package/src/interfaces/web/src/lib/api/sessions.ts +8 -0
- package/src/interfaces/web/src/lib/api/tasks.ts +10 -0
- package/src/interfaces/web/src/lib/http.ts +30 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +33 -29
- package/src/interfaces/web/src/screens/base/SessionsTab.tsx +25 -22
- package/src/interfaces/web/src/screens/project/TasksTab.tsx +20 -19
|
@@ -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-
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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 =
|
|
26
|
-
const end = Math.min(
|
|
65
|
+
const start = total === 0 ? 0 : offset;
|
|
66
|
+
const end = Math.min(offset + pageSize, total);
|
|
27
67
|
return {
|
|
28
|
-
|
|
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
|
|
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
|
|
15
|
-
|
|
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 {
|
|
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
|
|
15
|
-
|
|
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
|
-
{
|
|
30
|
-
{!
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<div className="
|
|
43
|
-
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
</
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
</
|
|
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 {
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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={() =>
|
|
41
|
+
<Button size="sm" variant="secondary" onClick={() => paged.mutate()}><RefreshCw size={13} /></Button>
|
|
40
42
|
</div>
|
|
41
43
|
}
|
|
42
44
|
>
|
|
43
|
-
{
|
|
44
|
-
{
|
|
45
|
-
{!
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<
|
|
50
|
-
|
|
51
|
-
<div className="
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 {
|
|
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
|
|
16
|
-
const
|
|
17
|
-
`/projects/${pid}/tasks?state=${state}`,
|
|
18
|
-
() => Tasks.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
-
{
|
|
74
|
-
{!
|
|
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
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
116
|
-
|
|
116
|
+
</ul>
|
|
117
|
+
</PagedList>
|
|
117
118
|
</Section>
|
|
118
119
|
);
|
|
119
120
|
}
|