taskchef 7.25.0 → 7.25.2

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.
@@ -0,0 +1,90 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import { afterEach, beforeEach, expect, test, vi } from "vitest";
3
+ import { DashboardApp } from "./App";
4
+ import { fixtureTask } from "./fixtures";
5
+ import type { DashboardSnapshot } from "./types";
6
+
7
+ const apiMocks = vi.hoisted(() => ({
8
+ connectDashboard: vi.fn(),
9
+ dashboardVersion: vi.fn(),
10
+ manualTransition: vi.fn(),
11
+ openInCodex: vi.fn(),
12
+ taskDetail: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("./api", () => apiMocks);
16
+ vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
17
+ vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
18
+ vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
19
+
20
+ afterEach(cleanup);
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
24
+ });
25
+
26
+ test("places the filtered task count below the toolbar and updates it from live snapshots", () => {
27
+ const recent = new Date().toISOString();
28
+ const tasks = [
29
+ fixtureTask({
30
+ id: "11111111-1111-4111-8111-111111111111",
31
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
32
+ status: "completed",
33
+ meaningfulUpdatedAt: recent,
34
+ updatedAt: recent,
35
+ }),
36
+ fixtureTask({
37
+ id: "22222222-2222-4222-8222-222222222222",
38
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
39
+ meaningfulUpdatedAt: recent,
40
+ updatedAt: recent,
41
+ }),
42
+ fixtureTask({
43
+ id: "33333333-3333-4333-8333-333333333333",
44
+ status: "completed",
45
+ }),
46
+ ];
47
+ let onSnapshot: ((snapshot: DashboardSnapshot) => void) | undefined;
48
+ apiMocks.connectDashboard.mockImplementation((handlers) => {
49
+ onSnapshot = handlers.onSnapshot;
50
+ return { close: vi.fn() };
51
+ });
52
+
53
+ render(
54
+ <DashboardApp
55
+ initialFilters={{ date: "24h", project: "Another project", status: "completed" }}
56
+ initialTasks={tasks}
57
+ />,
58
+ );
59
+ const projectFilter = screen.getByRole("combobox", { name: "Project" });
60
+ const dateFilter = screen.getByRole("combobox", { name: "Updated" });
61
+ const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
62
+ const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
63
+ const taskList = screen.getByRole("region", { name: "Tasks" });
64
+
65
+ expect(projectFilter).toHaveValue("Another project");
66
+ expect(dateFilter).toHaveValue("Latest 24 hours");
67
+ expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
68
+ expect(summary).toHaveTextContent("Tasks: 1 of 3");
69
+ expect(toolbar).not.toContainElement(summary);
70
+ expect(toolbar?.nextElementSibling).toBe(summary);
71
+ expect(taskList).toHaveAttribute("aria-describedby", summary?.id);
72
+
73
+ act(() => {
74
+ onSnapshot?.({
75
+ tasks: [
76
+ tasks[0],
77
+ fixtureTask({
78
+ id: "44444444-4444-4444-8444-444444444444",
79
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
80
+ status: "completed",
81
+ meaningfulUpdatedAt: recent,
82
+ updatedAt: recent,
83
+ }),
84
+ ],
85
+ });
86
+ });
87
+
88
+ expect(summary).toHaveTextContent("Tasks: 2 of 2");
89
+ expect(summary).toHaveAttribute("aria-live", "polite");
90
+ }, 60_000);
@@ -77,20 +77,36 @@ interface NotificationState {
77
77
  signatures: Map<string, string>;
78
78
  }
79
79
 
80
+ export function TaskResultsSummary({
81
+ totalCount,
82
+ visibleCount,
83
+ }: {
84
+ totalCount: number;
85
+ visibleCount: number;
86
+ }) {
87
+ return (
88
+ <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">
89
+ Tasks: {visibleCount} of {totalCount}
90
+ </Text>
91
+ );
92
+ }
93
+
80
94
  export function DashboardApp({
81
95
  connect = true,
96
+ initialFilters = {},
82
97
  initialTasks = [],
83
98
  }: {
84
99
  connect?: boolean;
100
+ initialFilters?: { date?: string; project?: string; status?: string };
85
101
  initialTasks?: Task[];
86
102
  }) {
87
103
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
88
104
  const [connected, setConnected] = useState(false);
89
105
  const [message, setMessage] = useState<string | null>(null);
90
106
  const [version, setVersion] = useState<string | null>(null);
91
- const [project, setProject] = useState("");
92
- const [status, setStatus] = useState("");
93
- const [date, setDate] = useState("all");
107
+ const [project, setProject] = useState(initialFilters.project ?? "");
108
+ const [status, setStatus] = useState(initialFilters.status ?? "");
109
+ const [date, setDate] = useState(initialFilters.date ?? "all");
94
110
  const [now, setNow] = useState(() => Date.now());
95
111
  const [selectedTask, setSelectedTask] = useState<Task | null>(null);
96
112
  const [detailOpened, setDetailOpened] = useState(false);
@@ -310,10 +326,6 @@ export function DashboardApp({
310
326
  size="sm"
311
327
  value={date}
312
328
  />
313
- <Box className="taskchef-task-count-field">
314
- <Text className="taskchef-filter-label" mb={3}>Tasks</Text>
315
- <Text className="taskchef-count-value" size="sm">{visible.length} of {tasks.length}</Text>
316
- </Box>
317
329
  </Group>
318
330
  <Box>
319
331
  <Text className="taskchef-filter-label" mb={5}>Status</Text>
@@ -322,6 +334,8 @@ export function DashboardApp({
322
334
  </Stack>
323
335
  </Paper>
324
336
 
337
+ <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />
338
+
325
339
  {message && (
326
340
  <Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
327
341
  <Group gap="sm" justify="space-between" wrap="nowrap">
@@ -331,7 +345,7 @@ export function DashboardApp({
331
345
  </Alert>
332
346
  )}
333
347
 
334
- <Stack aria-label="Tasks" component="section" gap="sm" mt="md">
348
+ <Stack aria-describedby="task-results-summary" aria-label="Tasks" component="section" gap="sm" mt="xs">
335
349
  {visible.map((task: Task) => (
336
350
  <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
337
351
  ))}
@@ -0,0 +1,22 @@
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
3
+ import { afterEach, expect, test, vi } from "vitest";
4
+ import { OpenChatButton } from "./OpenChatButton";
5
+
6
+ afterEach(cleanup);
7
+
8
+ test("uses the shared open-chat presentation and accessible task label", () => {
9
+ const onClick = vi.fn();
10
+ render(
11
+ <MantineProvider>
12
+ <OpenChatButton onClick={onClick} taskTitle="Review checkout reconciliation" />
13
+ </MantineProvider>,
14
+ );
15
+
16
+ const button = screen.getByRole("button", { name: "Open chat for Review checkout reconciliation" });
17
+ expect(button).toHaveTextContent("Open chat");
18
+ expect(button).toHaveAttribute("data-variant", "default");
19
+ expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
20
+ fireEvent.click(button);
21
+ expect(onClick).toHaveBeenCalledOnce();
22
+ });
@@ -0,0 +1,25 @@
1
+ import { Button } from "@mantine/core";
2
+ import { IconExternalLink } from "@tabler/icons-react";
3
+
4
+ export function OpenChatButton({
5
+ loading,
6
+ onClick,
7
+ taskTitle,
8
+ }: {
9
+ loading?: boolean;
10
+ onClick: () => void;
11
+ taskTitle: string;
12
+ }) {
13
+ return (
14
+ <Button
15
+ aria-label={`Open chat for ${taskTitle}`}
16
+ leftSection={<IconExternalLink aria-hidden size={15} />}
17
+ loading={loading}
18
+ onClick={onClick}
19
+ size="compact-sm"
20
+ variant="default"
21
+ >
22
+ Open chat
23
+ </Button>
24
+ );
25
+ }
@@ -1,9 +1,9 @@
1
- import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
2
- import { IconExternalLink } from "@tabler/icons-react";
1
+ import { Box, Group, Paper, Stack, Text, Title } from "@mantine/core";
3
2
  import { latestTurnPresentation } from "../../state.js";
4
3
  import type { Task } from "../types";
5
4
  import { GitHubLinks } from "./GitHubLinks";
6
5
  import { LinkedText } from "./LinkedText";
6
+ import { OpenChatButton } from "./OpenChatButton";
7
7
  import { RelativeTime } from "./RelativeTime";
8
8
  import { ShimmerText } from "./ShimmerText";
9
9
  import { StatusBadge } from "./StatusBadge";
@@ -53,15 +53,7 @@ export function TaskCard({
53
53
  <GitHubLinks task={task} />
54
54
  <Group className="taskchef-card-footer" gap="md" justify="space-between">
55
55
  <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
56
- <Button
57
- aria-label={`Open chat for ${task.title}`}
58
- leftSection={<IconExternalLink aria-hidden size={15} />}
59
- onClick={() => onOpenCodex(task)}
60
- size="compact-sm"
61
- variant="default"
62
- >
63
- Open chat
64
- </Button>
56
+ <OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
65
57
  </Group>
66
58
  </Stack>
67
59
  </Paper>
@@ -19,13 +19,13 @@ import {
19
19
  IconCheck,
20
20
  IconClipboard,
21
21
  IconDots,
22
- IconExternalLink,
23
22
  IconX,
24
23
  } from "@tabler/icons-react";
25
24
  import { useEffect, useRef, useState, type ReactNode } from "react";
26
25
  import type { Task } from "../types";
27
26
  import { ActivityTimeline } from "./ActivityTimeline";
28
27
  import { GitHubLinks } from "./GitHubLinks";
28
+ import { OpenChatButton } from "./OpenChatButton";
29
29
  import { RelativeTime } from "./RelativeTime";
30
30
  import { StatusBadge } from "./StatusBadge";
31
31
  import { UsagePanel } from "./UsagePanel";
@@ -93,14 +93,7 @@ export function TaskDetail({
93
93
  <StatusBadge status={task.status} />
94
94
  </Group>
95
95
  <Group gap="xs" mt="md">
96
- <Button
97
- leftSection={<IconExternalLink aria-hidden size={15} />}
98
- loading={busy}
99
- onClick={onOpenCodex}
100
- size="compact-sm"
101
- >
102
- Open chat
103
- </Button>
96
+ <OpenChatButton loading={busy} onClick={onOpenCodex} taskTitle={task.title} />
104
97
  <Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
105
98
  <Menu.Target>
106
99
  <Tooltip label="More task actions">
@@ -41,6 +41,22 @@ describe("task list usage summary", () => {
41
41
  );
42
42
  });
43
43
 
44
+ test("keeps the known task total visible while an active turn updates usage", () => {
45
+ renderUsage(fixtureTask({
46
+ status: "working",
47
+ turnRef: "turn-two",
48
+ usage: {
49
+ generationTurnRef: "turn-one",
50
+ status: "unavailable",
51
+ reason: "Token usage calculation was interrupted.",
52
+ task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
53
+ },
54
+ }));
55
+ const summary = screen.getByText("1.32M tokens · est. $12.34 · Updating…");
56
+ expect(summary).toHaveAttribute("data-usage-state", "ready");
57
+ expect(summary).toHaveAccessibleName(/1,324,567 tokens; estimated cost \$12\.34; updating/i);
58
+ });
59
+
44
60
  test("keeps pending and calculating wording distinct and uses the restrained shimmer", () => {
45
61
  const { rerender } = renderUsage();
46
62
  expect(screen.getByText("Token usage pending")).toHaveClass("taskchef-shimmer");
@@ -119,7 +119,8 @@ export function listUsageView(task: Task): ListUsageView {
119
119
  const cost = view.usage.estimatedCostUsd == null
120
120
  ? "cost unavailable"
121
121
  : `est. ${formatEstimatedCost(view.usage.estimatedCostUsd)}`;
122
- const qualifier = view.knownSoFar ? " · known so far" : "";
122
+ const qualifier = view.knownSoFar ? " · Updating…" : "";
123
+ const accessibleQualifier = view.knownSoFar ? "; updating" : "";
123
124
  const freshness = view.usage.sourceUpdatedAt ?? view.usage.sampledAt ?? task.usage?.updatedAt ?? null;
124
125
  const freshnessLabel = freshness && !Number.isNaN(Date.parse(freshness))
125
126
  ? ` Cached usage updated ${new Date(freshness).toLocaleString()}.`
@@ -127,12 +128,12 @@ export function listUsageView(task: Task): ListUsageView {
127
128
  return {
128
129
  accessibleLabel: `${fullTokens} tokens; ${view.usage.estimatedCostUsd == null
129
130
  ? "estimated cost unavailable"
130
- : `estimated cost ${formatEstimatedCost(view.usage.estimatedCostUsd)}`}${view.knownSoFar ? "; known so far" : ""}.${freshnessLabel}`,
131
+ : `estimated cost ${formatEstimatedCost(view.usage.estimatedCostUsd)}`}${accessibleQualifier}.${freshnessLabel}`,
131
132
  kind: "ready",
132
133
  label: `${compactTokens} tokens · ${cost}${qualifier}`,
133
134
  title: `${fullTokens} tokens · ${view.usage.estimatedCostUsd == null
134
135
  ? "estimated cost unavailable"
135
- : `unrounded estimate $${view.usage.estimatedCostUsd}`}${view.knownSoFar ? " · known so far" : ""}.${freshnessLabel}`,
136
+ : `unrounded estimate $${view.usage.estimatedCostUsd}`}${qualifier}.${freshnessLabel}`,
136
137
  };
137
138
  }
138
139
 
@@ -28,8 +28,7 @@ body {
28
28
  .taskchef-toolbar .mantine-InputWrapper-root { flex: 1 1 190px; max-width: 240px; }
29
29
  .taskchef-toolbar .mantine-InputWrapper-label, .taskchef-filter-label { font-size: var(--mantine-font-size-sm); font-weight: 600; line-height: var(--mantine-line-height-sm); }
30
30
  .taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
31
- .taskchef-task-count-field { align-self: flex-end; min-width: 90px; }
32
- .taskchef-count-value { display: flex; align-items: center; height: 36px; white-space: nowrap; }
31
+ .taskchef-results-summary { margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
33
32
  .taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
34
33
  .taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
35
34
  .taskchef-task-main { min-width: 0; flex: 1; }
@@ -117,7 +116,6 @@ body {
117
116
  @media (max-width: 48em) {
118
117
  .taskchef-toolbar .mantine-InputWrapper-root { max-width: none; }
119
118
  .taskchef-toolbar .mantine-Group-root { align-items: stretch; }
120
- .taskchef-task-count-field { min-width: 76px; }
121
119
  .taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
122
120
  .taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
123
121
  .taskchef-metadata { grid-template-columns: 1fr; }
package/src/dashboard.js CHANGED
@@ -293,6 +293,7 @@ export function taskListUsageProjection(task, cached, {
293
293
  }
294
294
  if (calculatingUsageIsStale(cached, now, calculatingStaleMs)) {
295
295
  return {
296
+ ...cachedUsageSummary(cached),
296
297
  generationTurnRef: cached.generationTurnRef ?? latestTurnRef,
297
298
  status: "unavailable",
298
299
  reason: "Token usage calculation was interrupted.",