taskchef 8.3.2 → 8.4.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.
- package/.codex-plugin/plugin.json +1 -1
- package/docs/dashboard-lifecycle.md +17 -0
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +40 -40
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +121 -9
- package/src/dashboard/react/App.tsx +58 -40
- package/src/dashboard/react/components/ActivityTimeline.tsx +0 -1
- package/src/dashboard/react/components/ModelSettings.test.tsx +4 -3
- package/src/dashboard/react/components/ModelSettings.tsx +3 -6
- package/src/dashboard/react/components/NotificationCenter.tsx +2 -2
- package/src/dashboard/react/components/OpenChatButton.test.tsx +1 -0
- package/src/dashboard/react/components/OpenChatButton.tsx +3 -2
- package/src/dashboard/react/components/RelativeTime.tsx +2 -8
- package/src/dashboard/react/components/TaskBoard.test.tsx +122 -0
- package/src/dashboard/react/components/TaskBoard.tsx +147 -0
- package/src/dashboard/react/components/TaskCard.tsx +8 -4
- package/src/dashboard/react/components/TaskCardStats.test.tsx +10 -3
- package/src/dashboard/react/components/TaskCardStats.tsx +0 -1
- package/src/dashboard/react/components/TaskDetail.tsx +1 -1
- package/src/dashboard/react/styles.css +67 -14
- package/src/dashboard/state.js +5 -2
- package/src/dashboard.js +10 -10
- package/src/version.js +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
|
1
|
+
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
2
|
import type { ElementType, ReactNode } from "react";
|
|
3
3
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
4
4
|
import { DashboardApp } from "./App";
|
|
@@ -16,6 +16,12 @@ const apiMocks = vi.hoisted(() => ({
|
|
|
16
16
|
vi.mock("./api", () => apiMocks);
|
|
17
17
|
vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
|
|
18
18
|
vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
|
|
19
|
+
vi.mock("./components/TaskBoard", () => ({ TaskBoard: ({ tasks, completedLimit, onMoreCompleted }: { tasks: ReturnType<typeof fixtureTask>[]; completedLimit: number; onMoreCompleted: () => void }) => (
|
|
20
|
+
<section aria-label="Task board" data-completed-limit={completedLimit}>
|
|
21
|
+
{tasks.map((task) => <span key={task.id}>{task.title}</span>)}
|
|
22
|
+
<button onClick={onMoreCompleted}>Show more</button>
|
|
23
|
+
</section>
|
|
24
|
+
) }));
|
|
19
25
|
vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
|
|
20
26
|
vi.mock("@tabler/icons-react", async () => {
|
|
21
27
|
const { createElement } = await vi.importActual<typeof import("react")>("react");
|
|
@@ -135,10 +141,12 @@ vi.mock("@mantine/core", async () => {
|
|
|
135
141
|
afterEach(cleanup);
|
|
136
142
|
beforeEach(() => {
|
|
137
143
|
vi.clearAllMocks();
|
|
144
|
+
window.localStorage.clear();
|
|
145
|
+
window.innerWidth = 1024;
|
|
138
146
|
apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
|
|
139
147
|
});
|
|
140
148
|
|
|
141
|
-
test("places the filtered task count below the toolbar and updates it from live snapshots", () => {
|
|
149
|
+
test("places the filtered task count below the toolbar and updates it from live snapshots", async () => {
|
|
142
150
|
const recent = new Date().toISOString();
|
|
143
151
|
const tasks = [
|
|
144
152
|
fixtureTask({
|
|
@@ -167,25 +175,31 @@ test("places the filtered task count below the toolbar and updates it from live
|
|
|
167
175
|
|
|
168
176
|
render(
|
|
169
177
|
<DashboardApp
|
|
170
|
-
initialFilters={{
|
|
178
|
+
initialFilters={{ project: "Another project", status: "completed" }}
|
|
171
179
|
initialTasks={tasks}
|
|
172
180
|
/>,
|
|
173
181
|
);
|
|
182
|
+
const header = screen.getByRole("banner");
|
|
183
|
+
const title = within(header).getByRole("heading", { level: 1 });
|
|
174
184
|
const headerActions = document.querySelector<HTMLElement>(".taskchef-header-actions")!;
|
|
175
|
-
|
|
176
|
-
expect(
|
|
177
|
-
expect(
|
|
178
|
-
expect(
|
|
185
|
+
expect(title).toHaveTextContent("TaskChef Dashboard");
|
|
186
|
+
expect(await within(title).findByText("v7.25.1")).toHaveClass("taskchef-version");
|
|
187
|
+
expect(header.querySelector(".taskchef-brand-icon")).toHaveAttribute("width", "28");
|
|
188
|
+
expect(headerActions.children[0]).toContainElement(within(headerActions).getByRole("link", { name: "Settings" }));
|
|
189
|
+
expect(headerActions.children[1]).toContainElement(within(headerActions).getByRole("button", { name: "Use dark theme" }));
|
|
190
|
+
expect(headerActions.lastElementChild).toBe(within(headerActions).getByRole("status"));
|
|
179
191
|
expect(within(headerActions).getByRole("link", { name: "Settings" })).toHaveClass("taskchef-icon-button");
|
|
180
192
|
expect(within(headerActions).getByRole("button", { name: "Use dark theme" })).toHaveClass("taskchef-icon-button");
|
|
181
193
|
const projectFilter = screen.getByRole("combobox", { name: "Project" });
|
|
182
|
-
const
|
|
194
|
+
const viewSwitch = screen.getByRole("radiogroup", { name: "View" });
|
|
183
195
|
const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
|
|
184
196
|
const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
|
|
185
197
|
const taskList = screen.getByRole("region", { name: "Tasks" });
|
|
186
198
|
|
|
187
199
|
expect(projectFilter).toHaveValue("Another project");
|
|
188
|
-
expect(
|
|
200
|
+
expect(screen.getByRole("combobox", { name: "Updated" })).toHaveValue("All time");
|
|
201
|
+
expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(viewSwitch);
|
|
202
|
+
expect(viewSwitch.compareDocumentPosition(projectFilter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
189
203
|
expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
|
|
190
204
|
expect(summary).toHaveTextContent("Tasks: 1 of 3");
|
|
191
205
|
expect(toolbar).not.toContainElement(summary);
|
|
@@ -210,3 +224,101 @@ test("places the filtered task count below the toolbar and updates it from live
|
|
|
210
224
|
expect(summary).toHaveTextContent("Tasks: 2 of 2");
|
|
211
225
|
expect(summary).toHaveAttribute("aria-live", "polite");
|
|
212
226
|
}, 60_000);
|
|
227
|
+
|
|
228
|
+
test("Board ignores the List status filter and restores it on return", () => {
|
|
229
|
+
window.innerWidth = 1280;
|
|
230
|
+
const tasks = [
|
|
231
|
+
fixtureTask({ id: "one", title: "Working task" }),
|
|
232
|
+
fixtureTask({ id: "two", title: "Completed task", status: "completed" }),
|
|
233
|
+
];
|
|
234
|
+
render(<DashboardApp connect={false} initialFilters={{ status: "completed" }} initialTasks={tasks} />);
|
|
235
|
+
expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
|
|
236
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
237
|
+
const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
|
|
238
|
+
expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(screen.getByRole("radiogroup", { name: "View" }));
|
|
239
|
+
expect(screen.getByRole("radiogroup", { name: "View" }).compareDocumentPosition(screen.getByRole("combobox", { name: "Project" })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
240
|
+
expect(screen.queryByRole("radiogroup", { name: "Status" })).not.toBeInTheDocument();
|
|
241
|
+
expect(screen.queryByText("Tasks: 1 of 2")).not.toBeInTheDocument();
|
|
242
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Working task");
|
|
243
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Completed task");
|
|
244
|
+
fireEvent.click(screen.getByRole("radio", { name: "List" }));
|
|
245
|
+
expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
|
|
246
|
+
expect(screen.getByText("Tasks: 1 of 2")).toBeInTheDocument();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("Board preference and view switch remain available across screen widths", () => {
|
|
250
|
+
window.innerWidth = 1280;
|
|
251
|
+
const { unmount } = render(<DashboardApp connect={false} />);
|
|
252
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
253
|
+
expect(window.localStorage.getItem("taskchef.dashboard.view")).toBe("board");
|
|
254
|
+
expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
|
|
255
|
+
act(() => { window.innerWidth = 375; window.dispatchEvent(new Event("resize")); });
|
|
256
|
+
expect(screen.getByRole("radiogroup", { name: "View" })).toBeInTheDocument();
|
|
257
|
+
expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
|
|
258
|
+
fireEvent.click(screen.getByRole("radio", { name: "List" }));
|
|
259
|
+
expect(screen.getByRole("region", { name: "Tasks" })).toBeInTheDocument();
|
|
260
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
261
|
+
act(() => { window.innerWidth = 1200; window.dispatchEvent(new Event("resize")); });
|
|
262
|
+
expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
|
|
263
|
+
unmount();
|
|
264
|
+
window.innerWidth = 375;
|
|
265
|
+
render(<DashboardApp connect={false} />);
|
|
266
|
+
expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("storage failure keeps the selected view in memory", () => {
|
|
270
|
+
window.innerWidth = 1280;
|
|
271
|
+
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
|
|
272
|
+
render(<DashboardApp connect={false} />);
|
|
273
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
274
|
+
expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
|
|
275
|
+
setItem.mockRestore();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("completed expansion survives view changes and resets for project", () => {
|
|
279
|
+
window.innerWidth = 1280;
|
|
280
|
+
render(<DashboardApp connect={false} initialTasks={[fixtureTask({ project: { name: "Project A", path: "/tmp/a", githubRepos: [] } })]} />);
|
|
281
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
282
|
+
const board = screen.getByRole("region", { name: "Task board" });
|
|
283
|
+
fireEvent.click(screen.getByRole("button", { name: "Show more" }));
|
|
284
|
+
expect(board).toHaveAttribute("data-completed-limit", "10");
|
|
285
|
+
fireEvent.click(screen.getByRole("radio", { name: "List" }));
|
|
286
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
287
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "10");
|
|
288
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "Project A" } });
|
|
289
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "5");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("Updated filters both views and List counts, and resets completed expansion", () => {
|
|
293
|
+
const recent = new Date().toISOString();
|
|
294
|
+
const tasks = [
|
|
295
|
+
fixtureTask({ id: "recent", title: "Recent task", status: "completed", updatedAt: recent, meaningfulUpdatedAt: recent }),
|
|
296
|
+
fixtureTask({ id: "old", title: "Older task", status: "completed", updatedAt: "2020-01-01T00:00:00.000Z", meaningfulUpdatedAt: "2020-01-01T00:00:00.000Z" }),
|
|
297
|
+
];
|
|
298
|
+
render(<DashboardApp connect={false} initialTasks={tasks} />);
|
|
299
|
+
const updated = screen.getByRole("combobox", { name: "Updated" });
|
|
300
|
+
expect(updated).toHaveValue("All time");
|
|
301
|
+
expect(screen.getByText("Tasks: 2 of 2")).toBeInTheDocument();
|
|
302
|
+
fireEvent.change(updated, { target: { value: "Latest 24 hours" } });
|
|
303
|
+
expect(screen.getByText("Tasks: 1 of 2")).toBeInTheDocument();
|
|
304
|
+
expect(screen.getByRole("radio", { name: "Completed 1" })).toBeInTheDocument();
|
|
305
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
306
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Recent task");
|
|
307
|
+
expect(screen.getByRole("region", { name: "Task board" })).not.toHaveTextContent("Older task");
|
|
308
|
+
fireEvent.click(screen.getByRole("button", { name: "Show more" }));
|
|
309
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "10");
|
|
310
|
+
fireEvent.change(updated, { target: { value: "Latest 7 days" } });
|
|
311
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "5");
|
|
312
|
+
fireEvent.change(updated, { target: { value: "All time" } });
|
|
313
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Older task");
|
|
314
|
+
fireEvent.click(screen.getByRole("radio", { name: "List" }));
|
|
315
|
+
expect(screen.getByText("Tasks: 2 of 2")).toBeInTheDocument();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("old tasks remain available in List and Board", () => {
|
|
319
|
+
const oldTask = fixtureTask({ id: "old", title: "Older task", updatedAt: "2020-01-01T00:00:00.000Z", meaningfulUpdatedAt: "2020-01-01T00:00:00.000Z" });
|
|
320
|
+
render(<DashboardApp connect={false} initialTasks={[oldTask]} />);
|
|
321
|
+
expect(screen.getByText("Tasks: 1 of 1")).toBeInTheDocument();
|
|
322
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
323
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Older task");
|
|
324
|
+
});
|
|
@@ -42,6 +42,7 @@ import { usageStillCalculating } from "./presentation";
|
|
|
42
42
|
import type { DashboardSnapshot, NotificationSnapshot, Task } from "./types";
|
|
43
43
|
import { NotificationCenter } from "./components/NotificationCenter";
|
|
44
44
|
import { TaskCard } from "./components/TaskCard";
|
|
45
|
+
import { TaskBoard } from "./components/TaskBoard";
|
|
45
46
|
import { TaskDetail } from "./components/TaskDetail";
|
|
46
47
|
import { RelativeTimeProvider } from "./components/RelativeTime";
|
|
47
48
|
|
|
@@ -70,6 +71,12 @@ const theme = createTheme({
|
|
|
70
71
|
},
|
|
71
72
|
});
|
|
72
73
|
const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
|
|
74
|
+
const VIEW_STORAGE_KEY = "taskchef.dashboard.view";
|
|
75
|
+
|
|
76
|
+
function savedView(): "board" | "list" {
|
|
77
|
+
try { return window.localStorage.getItem(VIEW_STORAGE_KEY) === "board" ? "board" : "list"; }
|
|
78
|
+
catch { return "list"; }
|
|
79
|
+
}
|
|
73
80
|
|
|
74
81
|
interface NotificationState {
|
|
75
82
|
initialized: boolean;
|
|
@@ -99,7 +106,7 @@ export function DashboardApp({
|
|
|
99
106
|
initialTasks = [],
|
|
100
107
|
}: {
|
|
101
108
|
connect?: boolean;
|
|
102
|
-
initialFilters?: {
|
|
109
|
+
initialFilters?: { project?: string; status?: string };
|
|
103
110
|
initialTasks?: Task[];
|
|
104
111
|
}) {
|
|
105
112
|
const [settings, setSettings] = useState(() => window.location.hash === '#settings');
|
|
@@ -115,7 +122,9 @@ export function DashboardApp({
|
|
|
115
122
|
const [version, setVersion] = useState<string | null>(null);
|
|
116
123
|
const [project, setProject] = useState(initialFilters.project ?? "");
|
|
117
124
|
const [status, setStatus] = useState(initialFilters.status ?? "");
|
|
118
|
-
const [date, setDate] = useState(
|
|
125
|
+
const [date, setDate] = useState("all");
|
|
126
|
+
const [preferredView, setPreferredView] = useState(savedView);
|
|
127
|
+
const [completedLimit, setCompletedLimit] = useState(5);
|
|
119
128
|
const [now, setNow] = useState(() => Date.now());
|
|
120
129
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
|
121
130
|
const [detailOpened, setDetailOpened] = useState(false);
|
|
@@ -227,12 +236,20 @@ export function DashboardApp({
|
|
|
227
236
|
...[...new Set(tasks.map((task) => task.project.name))].sort().map((value) => ({ label: value, value })),
|
|
228
237
|
], [tasks]);
|
|
229
238
|
const visible = useMemo(() => filterTasks(tasks, { project, status, date, now }), [tasks, project, status, date, now]);
|
|
239
|
+
const boardTasks = useMemo(() => filterTasks(tasks, { project, date, now }), [tasks, project, date, now]);
|
|
240
|
+
const board = preferredView === "board";
|
|
230
241
|
const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
|
|
231
242
|
const statusData = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
|
|
232
243
|
label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
|
|
233
244
|
value,
|
|
234
245
|
}));
|
|
235
246
|
|
|
247
|
+
function changeView(value: string) {
|
|
248
|
+
if (value !== "board" && value !== "list") return;
|
|
249
|
+
setPreferredView(value);
|
|
250
|
+
try { window.localStorage.setItem(VIEW_STORAGE_KEY, value); } catch { /* Preference remains in memory. */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
236
253
|
async function handleOpenCodex(task: Task) {
|
|
237
254
|
setDetailBusy(true);
|
|
238
255
|
try {
|
|
@@ -303,30 +320,30 @@ export function DashboardApp({
|
|
|
303
320
|
>
|
|
304
321
|
<RelativeTimeProvider now={now}>
|
|
305
322
|
<AppShell className="taskchef-shell" padding={0}>
|
|
306
|
-
<
|
|
307
|
-
<
|
|
308
|
-
<
|
|
309
|
-
<
|
|
310
|
-
<Group gap="sm" mt={5} wrap="nowrap">
|
|
323
|
+
<Box className="taskchef-header-shell" component="header">
|
|
324
|
+
<Container className={`taskchef-header${board ? " taskchef-header-board" : ""}`} size="100%">
|
|
325
|
+
<Group className="taskchef-header-layout" justify="space-between" wrap="nowrap">
|
|
326
|
+
<Group className="taskchef-header-copy" gap="xs" wrap="nowrap">
|
|
311
327
|
<BrandIcon />
|
|
312
|
-
<Title order={1}
|
|
328
|
+
<Title className="taskchef-brand-title" order={1}>
|
|
329
|
+
<span>TaskChef Dashboard</span>
|
|
330
|
+
{version && <span className="taskchef-version">v{version}</span>}
|
|
331
|
+
</Title>
|
|
313
332
|
</Group>
|
|
314
|
-
|
|
315
|
-
<Stack align="flex-end" className="taskchef-header-actions" gap={0} justify="space-between">
|
|
316
|
-
<Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
|
|
317
|
-
<IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
|
|
318
|
-
<Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
|
|
319
|
-
</Group>
|
|
320
|
-
<Group className="taskchef-header-icon-row" gap={2} justify="flex-end" wrap="nowrap">
|
|
333
|
+
<Group className="taskchef-header-actions" gap={2} justify="flex-end" wrap="nowrap">
|
|
321
334
|
<Tooltip label="Settings"><ActionIcon className="taskchef-icon-button" component="a" href="#settings" aria-label="Settings" aria-current={settings ? 'page' : undefined} variant="subtle" color="gray" size="lg"><IconSettings size={18} stroke={1.8} /></ActionIcon></Tooltip>
|
|
322
335
|
<ThemeToggle />
|
|
336
|
+
<Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
|
|
337
|
+
<IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
|
|
338
|
+
<Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
|
|
339
|
+
</Group>
|
|
323
340
|
</Group>
|
|
324
|
-
</
|
|
325
|
-
</
|
|
326
|
-
</
|
|
341
|
+
</Group>
|
|
342
|
+
</Container>
|
|
343
|
+
</Box>
|
|
327
344
|
|
|
328
345
|
<AppShell.Main>
|
|
329
|
-
<Container className="taskchef-main" pb={80} size=
|
|
346
|
+
<Container className={`taskchef-main${board ? " taskchef-main-board" : ""}`} pb={80} size="100%">
|
|
330
347
|
{projectIndex?.status === "unavailable" && (
|
|
331
348
|
<Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
|
|
332
349
|
Task history is still visible. Verify the index and inspect backups before making project changes.
|
|
@@ -342,27 +359,22 @@ export function DashboardApp({
|
|
|
342
359
|
</Alert>
|
|
343
360
|
)}
|
|
344
361
|
{settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
|
|
345
|
-
<Paper className="taskchef-toolbar"
|
|
362
|
+
<Paper className="taskchef-toolbar" radius={0}>
|
|
346
363
|
<Stack gap="sm">
|
|
347
|
-
<Group
|
|
348
|
-
<
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
onChange={(value) => setDate(value ?? "all")}
|
|
354
|
-
size="sm"
|
|
355
|
-
value={date}
|
|
356
|
-
/>
|
|
364
|
+
<Group className="taskchef-toolbar-view">
|
|
365
|
+
<SegmentedControl aria-label="View" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={preferredView} withItemsBorders={false} />
|
|
366
|
+
</Group>
|
|
367
|
+
<Group className="taskchef-toolbar-filters" gap="sm" wrap="nowrap">
|
|
368
|
+
<Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} size="xs" value={project} />
|
|
369
|
+
<Select aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} size="xs" value={date} />
|
|
357
370
|
</Group>
|
|
358
|
-
<Box>
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
</Box>
|
|
371
|
+
{!board && <Box className="taskchef-toolbar-status">
|
|
372
|
+
<SegmentedControl aria-label="Status" data={statusData} onChange={setStatus} size="xs" value={status} withItemsBorders={false} />
|
|
373
|
+
</Box>}
|
|
362
374
|
</Stack>
|
|
363
375
|
</Paper>
|
|
364
376
|
|
|
365
|
-
<TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />
|
|
377
|
+
{!board && <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />}
|
|
366
378
|
|
|
367
379
|
{message && (
|
|
368
380
|
<Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
|
|
@@ -373,17 +385,23 @@ export function DashboardApp({
|
|
|
373
385
|
</Alert>
|
|
374
386
|
)}
|
|
375
387
|
|
|
376
|
-
|
|
388
|
+
{board ? <TaskBoard
|
|
389
|
+
completedLimit={completedLimit}
|
|
390
|
+
onMoreCompleted={() => setCompletedLimit((limit) => limit + 5)}
|
|
391
|
+
onOpenCodex={handleOpenCodex}
|
|
392
|
+
onOpenDetail={(value) => void loadDetail(value)}
|
|
393
|
+
tasks={boardTasks}
|
|
394
|
+
/> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list" component="section" gap="sm" mt="xs">
|
|
377
395
|
{visible.map((task: Task) => (
|
|
378
396
|
<TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
|
|
379
397
|
))}
|
|
380
398
|
{visible.length === 0 && (
|
|
381
399
|
<Paper className="taskchef-empty" p={48} ta="center" withBorder>
|
|
382
400
|
<Title order={2} size="h4">No tasks match these filters</Title>
|
|
383
|
-
<Text c="dimmed" mt={4} size="sm">Choose a different project,
|
|
401
|
+
<Text c="dimmed" mt={4} size="sm">Choose a different project, update window, or status.</Text>
|
|
384
402
|
</Paper>
|
|
385
403
|
)}
|
|
386
|
-
</Stack>
|
|
404
|
+
</Stack>}
|
|
387
405
|
</>}
|
|
388
406
|
</Container>
|
|
389
407
|
</AppShell.Main>
|
|
@@ -485,9 +503,9 @@ function BrandIcon() {
|
|
|
485
503
|
alt=""
|
|
486
504
|
aria-hidden
|
|
487
505
|
className="taskchef-brand-icon"
|
|
488
|
-
height="
|
|
506
|
+
height="28"
|
|
489
507
|
src={colorScheme === "dark" ? "/assets/taskchef-dark.svg" : "/assets/taskchef.svg"}
|
|
490
|
-
width="
|
|
508
|
+
width="28"
|
|
491
509
|
/>
|
|
492
510
|
);
|
|
493
511
|
}
|
|
@@ -19,18 +19,19 @@ vi.mock('@mantine/core', async () => {
|
|
|
19
19
|
|
|
20
20
|
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
|
21
21
|
|
|
22
|
-
test('shows the version
|
|
22
|
+
test('shows the ccusage version pinned by TaskChef without an availability status', async () => {
|
|
23
23
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
24
24
|
ok: true,
|
|
25
25
|
json: async () => ({
|
|
26
26
|
profiles: [],
|
|
27
|
-
usageProvider: { provider: 'ccusage',
|
|
27
|
+
usageProvider: { provider: 'ccusage', version: '20.0.21' },
|
|
28
28
|
}),
|
|
29
29
|
}));
|
|
30
30
|
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
31
31
|
expect(await screen.findByText('ccusage 20.0.21')).toBeInTheDocument();
|
|
32
32
|
expect(screen.getByText('Usage provider')).toBeInTheDocument();
|
|
33
|
-
expect(screen.getByText(/Version
|
|
33
|
+
expect(screen.getByText(/Version pinned by TaskChef/)).toBeInTheDocument();
|
|
34
|
+
expect(screen.queryByText('available')).not.toBeInTheDocument();
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
test('refresh shows configuration failures and does not label them honored', async () => {
|
|
@@ -8,7 +8,7 @@ const effortLabels: Record<string, string> = { low: 'Light', medium: 'Medium', h
|
|
|
8
8
|
type ModelOption = { value: string; label: string; efforts: string[] };
|
|
9
9
|
type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
|
|
10
10
|
type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
|
|
11
|
-
type UsageProvider = { provider: string;
|
|
11
|
+
type UsageProvider = { provider: string; version: string | null };
|
|
12
12
|
|
|
13
13
|
export function ModelSettings() {
|
|
14
14
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
@@ -97,12 +97,9 @@ export function ModelSettings() {
|
|
|
97
97
|
</>}
|
|
98
98
|
{usageProvider && <Paper withBorder p="md">
|
|
99
99
|
<Stack gap="xs">
|
|
100
|
-
<
|
|
101
|
-
<Title order={3} size="h4">Usage provider</Title>
|
|
102
|
-
<Badge color={usageProvider.status === 'available' ? 'teal' : 'red'}>{usageProvider.status}</Badge>
|
|
103
|
-
</Group>
|
|
100
|
+
<Title order={3} size="h4">Usage provider</Title>
|
|
104
101
|
<Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
|
|
105
|
-
<Text c="dimmed" size="sm">Version
|
|
102
|
+
<Text c="dimmed" size="sm">Version pinned by TaskChef for token and cost estimates.</Text>
|
|
106
103
|
</Stack>
|
|
107
104
|
</Paper>}
|
|
108
105
|
</Stack>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ActionIcon, Box, Button, Group, Paper, Portal, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
|
2
2
|
import { IconBell, IconX } from "@tabler/icons-react";
|
|
3
3
|
import { notificationDismissLabel, notificationOpenLabel, notificationTitle } from "../../state.js";
|
|
4
|
-
import { formatRelativeTime } from "../../time.js";
|
|
4
|
+
import { formatExactTime, formatRelativeTime } from "../../time.js";
|
|
5
5
|
import type { NotificationSnapshot, Task } from "../types";
|
|
6
6
|
import { StatusBadge } from "./StatusBadge";
|
|
7
7
|
|
|
@@ -65,7 +65,7 @@ export function NotificationCenter({
|
|
|
65
65
|
<Text lineClamp={1} size="sm">{notification.title}</Text>
|
|
66
66
|
{notification.summary && <Text className="taskchef-preserve-lines" id={summaryId} lineClamp={3} size="xs">{notification.summary}</Text>}
|
|
67
67
|
<Text c="dimmed" id={metadataId} size="xs">
|
|
68
|
-
{formatRelativeTime(notification.timestamp)}{available ? "" : " · Task no longer available"}
|
|
68
|
+
<Tooltip label={formatExactTime(notification.timestamp)}><span>{formatRelativeTime(notification.timestamp)}</span></Tooltip>{available ? "" : " · Task no longer available"}
|
|
69
69
|
</Text>
|
|
70
70
|
</button>
|
|
71
71
|
<Tooltip label="Dismiss notification">
|
|
@@ -15,6 +15,7 @@ test("uses the shared open-chat presentation and accessible task label", () => {
|
|
|
15
15
|
|
|
16
16
|
const button = screen.getByRole("button", { name: "Open chat for Review checkout reconciliation" });
|
|
17
17
|
expect(button).toHaveTextContent("Open chat");
|
|
18
|
+
expect(button).toHaveClass("taskchef-open-chat");
|
|
18
19
|
expect(button).toHaveAttribute("data-variant", "default");
|
|
19
20
|
expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
|
20
21
|
fireEvent.click(button);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Button } from "@mantine/core";
|
|
2
|
-
import {
|
|
2
|
+
import { IconArrowUpRight } from "@tabler/icons-react";
|
|
3
3
|
|
|
4
4
|
export function OpenChatButton({
|
|
5
5
|
loading,
|
|
@@ -13,7 +13,8 @@ export function OpenChatButton({
|
|
|
13
13
|
return (
|
|
14
14
|
<Button
|
|
15
15
|
aria-label={`Open chat for ${taskTitle}`}
|
|
16
|
-
|
|
16
|
+
className="taskchef-open-chat"
|
|
17
|
+
leftSection={<IconArrowUpRight aria-hidden size={15} stroke={1.6} />}
|
|
17
18
|
loading={loading}
|
|
18
19
|
onClick={onClick}
|
|
19
20
|
size="compact-sm"
|
|
@@ -12,12 +12,10 @@ export function RelativeTimeProvider({ children, now }: { children: ReactNode; n
|
|
|
12
12
|
export function RelativeTime({
|
|
13
13
|
icon,
|
|
14
14
|
label,
|
|
15
|
-
tooltipLabel,
|
|
16
15
|
value,
|
|
17
16
|
}: {
|
|
18
17
|
icon?: ReactNode;
|
|
19
18
|
label: string;
|
|
20
|
-
tooltipLabel?: string;
|
|
21
19
|
value: string | null | undefined;
|
|
22
20
|
}) {
|
|
23
21
|
const [exact, setExact] = useState(false);
|
|
@@ -25,13 +23,9 @@ export function RelativeTime({
|
|
|
25
23
|
const text = exact ? formatExactTime(value) : formatRelativeTime(value);
|
|
26
24
|
const exactText = formatExactTime(value);
|
|
27
25
|
const unavailable = exactText === "—";
|
|
28
|
-
const tooltip =
|
|
29
|
-
? "Updated time unavailable"
|
|
30
|
-
: exact
|
|
31
|
-
? "Show relative time"
|
|
32
|
-
: `Exact time: ${exactText}. Show exact time`;
|
|
26
|
+
const tooltip = unavailable ? "Updated time unavailable" : exact ? formatRelativeTime(value) : exactText;
|
|
33
27
|
return (
|
|
34
|
-
<Tooltip events={{ focus: true, hover: true, touch: false }} label={
|
|
28
|
+
<Tooltip events={{ focus: true, hover: true, touch: false }} label={tooltip}>
|
|
35
29
|
<UnstyledButton
|
|
36
30
|
aria-label={unavailable
|
|
37
31
|
? `${label}: unavailable.`
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
+
import { fixtureTask } from "../fixtures";
|
|
5
|
+
import { TaskBoard } from "./TaskBoard";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
function task(id: number, status: ReturnType<typeof fixtureTask>["status"], title = `Task ${id}`) {
|
|
10
|
+
return fixtureTask({
|
|
11
|
+
id: `11111111-1111-4111-8111-${String(id).padStart(12, "0")}`,
|
|
12
|
+
title,
|
|
13
|
+
status,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pointer(target: Element, type: string, pointerType: string, clientX: number, pointerId = 1) {
|
|
18
|
+
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX });
|
|
19
|
+
Object.assign(event, { pointerType, pointerId, isPrimary: true });
|
|
20
|
+
fireEvent(target, event);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("groups by task status, counts all completed tasks, and reveals five more", () => {
|
|
24
|
+
const tasks = [task(1, "working"), task(2, "needs_input"), ...Array.from({ length: 11 }, (_, index) => task(index + 3, "completed")), task(20, "failed"), task(21, null)];
|
|
25
|
+
const more = vi.fn();
|
|
26
|
+
const { rerender } = render(<MantineProvider><TaskBoard tasks={tasks} completedLimit={5} onMoreCompleted={more} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
27
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("tabindex", "0");
|
|
28
|
+
expect(screen.getByRole("region", { name: "Working, 1 tasks" })).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByRole("region", { name: "Needs input, 1 tasks" })).toBeInTheDocument();
|
|
30
|
+
const completed = screen.getByRole("region", { name: "Completed, 11 tasks" });
|
|
31
|
+
expect(within(completed).getAllByRole("article")).toHaveLength(5);
|
|
32
|
+
fireEvent.click(within(completed).getByRole("button", { name: "Show 5 more · 5 of 11" }));
|
|
33
|
+
expect(more).toHaveBeenCalledOnce();
|
|
34
|
+
rerender(<MantineProvider><TaskBoard tasks={tasks} completedLimit={10} onMoreCompleted={more} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
35
|
+
expect(within(screen.getByRole("region", { name: "Completed, 11 tasks" })).getAllByRole("article")).toHaveLength(10);
|
|
36
|
+
expect(screen.getByRole("region", { name: "Failed, 1 tasks" })).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole("region", { name: "Unresolved, 1 tasks" })).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("uses current request for working and latest historical result for finished tasks", () => {
|
|
41
|
+
const working = task(1, "working", "Working task");
|
|
42
|
+
const completed = task(2, "completed", "Finished task");
|
|
43
|
+
completed.latestTurn = { ...completed.latestTurn!, requestSummary: "New request", result: { status: "completed", summary: "Latest reported result", updatedAt: completed.updatedAt } };
|
|
44
|
+
completed.lastResult = { status: "completed", summary: "Old reported result", updatedAt: completed.updatedAt };
|
|
45
|
+
const onOpenDetail = vi.fn();
|
|
46
|
+
const onOpenCodex = vi.fn();
|
|
47
|
+
render(<MantineProvider><TaskBoard tasks={[working, completed]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={onOpenCodex} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
48
|
+
const workingLane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
49
|
+
expect(workingLane).toHaveTextContent("Review the operator-facing task state.");
|
|
50
|
+
expect(workingLane).not.toHaveTextContent("In progress");
|
|
51
|
+
const completedLane = screen.getByRole("region", { name: "Completed, 1 tasks" });
|
|
52
|
+
expect(completedLane).toHaveTextContent("Latest reported result");
|
|
53
|
+
expect(completedLane).not.toHaveTextContent("Old reported result");
|
|
54
|
+
const completedCard = within(completedLane).getByRole("article");
|
|
55
|
+
expect(completedCard.firstElementChild).toHaveClass("taskchef-board-title");
|
|
56
|
+
expect(completedCard.children[1]).toHaveClass("taskchef-board-project");
|
|
57
|
+
fireEvent.click(within(completedLane).getByRole("button", { name: "Finished task" }));
|
|
58
|
+
expect(onOpenDetail).toHaveBeenCalledWith(completed);
|
|
59
|
+
fireEvent.click(within(completedLane).getByRole("button", { name: "Open chat for Finished task" }));
|
|
60
|
+
expect(onOpenCodex).toHaveBeenCalledWith(completed);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("unlinked working tasks show pending text without a chat action", () => {
|
|
64
|
+
const working = task(1, "working");
|
|
65
|
+
working.threadId = null;
|
|
66
|
+
const failed = task(2, "failed");
|
|
67
|
+
failed.threadId = "invalid";
|
|
68
|
+
render(<MantineProvider><TaskBoard tasks={[working, failed]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
69
|
+
expect(screen.getByRole("region", { name: "Working, 1 tasks" })).toHaveTextContent("Chat link pending");
|
|
70
|
+
expect(screen.queryByRole("button", { name: /Open chat/ })).not.toBeInTheDocument();
|
|
71
|
+
expect(screen.getByRole("region", { name: "Failed, 1 tasks" })).not.toHaveTextContent("Chat link pending");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("unexpected status appears in Unresolved instead of disappearing", () => {
|
|
75
|
+
const unexpected = task(1, "working");
|
|
76
|
+
unexpected.status = "paused" as typeof unexpected.status;
|
|
77
|
+
render(<MantineProvider><TaskBoard tasks={[unexpected]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
78
|
+
expect(within(screen.getByRole("region", { name: "Unresolved, 1 tasks" })).getByRole("article")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("mouse drag pans from empty lane space without activating a card", () => {
|
|
82
|
+
const onOpenDetail = vi.fn();
|
|
83
|
+
render(<MantineProvider><TaskBoard tasks={[task(1, "working")]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
84
|
+
const board = screen.getByRole("region", { name: "Task board" });
|
|
85
|
+
const lane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
86
|
+
Object.defineProperties(board, { scrollWidth: { value: 1700 }, clientWidth: { value: 700 } });
|
|
87
|
+
const capture = vi.fn();
|
|
88
|
+
const release = vi.fn();
|
|
89
|
+
board.setPointerCapture = capture;
|
|
90
|
+
board.hasPointerCapture = () => true;
|
|
91
|
+
board.releasePointerCapture = release;
|
|
92
|
+
|
|
93
|
+
pointer(lane, "pointerdown", "mouse", 200);
|
|
94
|
+
expect(capture).toHaveBeenCalledWith(1);
|
|
95
|
+
pointer(board, "pointermove", "mouse", 197);
|
|
96
|
+
expect(board.scrollLeft).toBe(0);
|
|
97
|
+
pointer(board, "pointermove", "mouse", 120);
|
|
98
|
+
expect(board.scrollLeft).toBe(80);
|
|
99
|
+
pointer(board, "pointerup", "mouse", 120);
|
|
100
|
+
expect(release).toHaveBeenCalledWith(1);
|
|
101
|
+
expect(board).not.toHaveClass("taskchef-board-dragging");
|
|
102
|
+
fireEvent.click(within(lane).getByRole("button", { name: "Task 1" }));
|
|
103
|
+
expect(onOpenDetail).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("card controls and touch gestures do not start mouse panning", () => {
|
|
107
|
+
const onOpenDetail = vi.fn();
|
|
108
|
+
render(<MantineProvider><TaskBoard tasks={[task(1, "working")]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
109
|
+
const board = screen.getByRole("region", { name: "Task board" });
|
|
110
|
+
const lane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
111
|
+
Object.defineProperties(board, { scrollWidth: { value: 1700 }, clientWidth: { value: 700 } });
|
|
112
|
+
const capture = vi.fn();
|
|
113
|
+
board.setPointerCapture = capture;
|
|
114
|
+
const title = within(lane).getByRole("button", { name: "Task 1" });
|
|
115
|
+
pointer(title, "pointerdown", "mouse", 200);
|
|
116
|
+
pointer(lane, "pointerdown", "touch", 200, 2);
|
|
117
|
+
pointer(board, "pointermove", "touch", 120, 2);
|
|
118
|
+
expect(capture).not.toHaveBeenCalled();
|
|
119
|
+
expect(board.scrollLeft).toBe(0);
|
|
120
|
+
fireEvent.click(title);
|
|
121
|
+
expect(onOpenDetail).toHaveBeenCalledOnce();
|
|
122
|
+
});
|