taskchef 7.23.4 → 7.24.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.
Files changed (33) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/package.json +24 -2
  3. package/src/dashboard/dist/app.js +164 -0
  4. package/src/dashboard/dist/app.js.map +1 -0
  5. package/src/dashboard/dist/index.html +16 -0
  6. package/src/dashboard/dist/styles.css +1 -0
  7. package/src/dashboard/github-links.js +12 -4
  8. package/src/dashboard/react/App.tsx +432 -0
  9. package/src/dashboard/react/api.test.ts +24 -0
  10. package/src/dashboard/react/api.ts +95 -0
  11. package/src/dashboard/react/components/ActivityTimeline.tsx +66 -0
  12. package/src/dashboard/react/components/GitHubLinks.tsx +46 -0
  13. package/src/dashboard/react/components/LinkedText.tsx +63 -0
  14. package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
  15. package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
  16. package/src/dashboard/react/components/RelativeTime.tsx +28 -0
  17. package/src/dashboard/react/components/ShimmerText.tsx +9 -0
  18. package/src/dashboard/react/components/StatusBadge.tsx +7 -0
  19. package/src/dashboard/react/components/TaskCard.tsx +65 -0
  20. package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
  21. package/src/dashboard/react/components/TaskDetail.tsx +251 -0
  22. package/src/dashboard/react/components/UsagePanel.tsx +73 -0
  23. package/src/dashboard/react/fixtures.ts +52 -0
  24. package/src/dashboard/react/index.html +15 -0
  25. package/src/dashboard/react/main.tsx +12 -0
  26. package/src/dashboard/react/overlay-state.test.ts +79 -0
  27. package/src/dashboard/react/overlay-state.ts +38 -0
  28. package/src/dashboard/react/presentation.test.tsx +241 -0
  29. package/src/dashboard/react/presentation.ts +102 -0
  30. package/src/dashboard/react/styles.css +128 -0
  31. package/src/dashboard/react/test-setup.ts +36 -0
  32. package/src/dashboard/react/types.ts +106 -0
  33. package/src/dashboard.js +29 -18
@@ -0,0 +1,46 @@
1
+ import { Anchor, Box, Group, Text } from "@mantine/core";
2
+ import {
3
+ githubReferenceAccessibleLabel,
4
+ githubReferenceDisplayLabels,
5
+ groupRelatedGitHubLinks,
6
+ } from "../../github-links.js";
7
+ import type { GitHubLink, Task } from "../types";
8
+
9
+ export function GitHubLinks({ task }: { task: Task }) {
10
+ const links = task.relatedGitHubLinks ?? [];
11
+ if (links.length === 0 && !task.relatedGitHubLinksTruncated) return null;
12
+ const groups = groupRelatedGitHubLinks(links) as GitHubLink[][];
13
+ const displayLabels = githubReferenceDisplayLabels(groups.flat(), { related: true }) as string[];
14
+ let linkIndex = 0;
15
+ return (
16
+ <Box aria-label={`Related GitHub links for ${task.title}`} className="taskchef-github-groups" component="nav">
17
+ {groups.map((group) => (
18
+ <Group className="taskchef-github-group" gap={5} key={`${group[0].owner}/${group[0].repository}`} wrap="wrap">
19
+ {group.map((link) => {
20
+ const displayLabel = displayLabels[linkIndex];
21
+ linkIndex += 1;
22
+ const kind = link.type === "pull"
23
+ ? ", GitHub pull request"
24
+ : link.type === "issue"
25
+ ? ", GitHub issue"
26
+ : " on GitHub";
27
+ return (
28
+ <Anchor
29
+ aria-label={`${githubReferenceAccessibleLabel(link)}${kind} (opens in a new tab)`}
30
+ className="taskchef-github-link"
31
+ href={link.url}
32
+ key={link.url}
33
+ rel="noopener noreferrer"
34
+ size="xs"
35
+ target="_blank"
36
+ >
37
+ {displayLabel}
38
+ </Anchor>
39
+ );
40
+ })}
41
+ </Group>
42
+ ))}
43
+ {task.relatedGitHubLinksTruncated && <Text c="dimmed" size="xs">More links in task history</Text>}
44
+ </Box>
45
+ );
46
+ }
@@ -0,0 +1,63 @@
1
+ import { Anchor } from "@mantine/core";
2
+ import {
3
+ githubReferenceAccessibleLabel,
4
+ githubReferenceDisplayLabels,
5
+ referenceSegments,
6
+ } from "../../github-links.js";
7
+ import type { Task } from "../types";
8
+
9
+ interface ReferenceSegment {
10
+ kind: "ambiguous" | "link" | "text";
11
+ label?: string;
12
+ owner?: string;
13
+ reason?: string;
14
+ repository?: string;
15
+ text: string;
16
+ type?: "generic" | "issue" | "pull" | "repository";
17
+ url?: string;
18
+ }
19
+
20
+ export function LinkedText({ task, text }: { task: Task; text: string }) {
21
+ const segments = referenceSegments(text, {
22
+ projectRepositories: task.project.githubRepos,
23
+ taskRepository: task.relatedGitHubRepository,
24
+ }) as ReferenceSegment[];
25
+ const links = segments.filter((segment) => segment.kind === "link");
26
+ const displayLabels = githubReferenceDisplayLabels(links) as string[];
27
+ let linkIndex = 0;
28
+ return segments.map((segment, index) => {
29
+ if (segment.kind === "text") return segment.text;
30
+ if (segment.kind === "ambiguous") {
31
+ return (
32
+ <span
33
+ aria-label={segment.reason}
34
+ className="taskchef-github-reference-ambiguous"
35
+ key={`${index}:${segment.text}`}
36
+ tabIndex={0}
37
+ title={segment.reason}
38
+ >
39
+ {segment.text}
40
+ </span>
41
+ );
42
+ }
43
+ const displayLabel = displayLabels[linkIndex];
44
+ linkIndex += 1;
45
+ const kind = segment.type === "pull"
46
+ ? ", GitHub pull request"
47
+ : segment.type === "issue"
48
+ ? ", GitHub issue"
49
+ : segment.owner ? " on GitHub" : "";
50
+ return (
51
+ <Anchor
52
+ aria-label={`${githubReferenceAccessibleLabel(segment)}${kind} (opens in a new tab)`}
53
+ className="taskchef-github-link"
54
+ href={segment.url}
55
+ key={`${index}:${segment.url}`}
56
+ rel="noopener noreferrer"
57
+ target="_blank"
58
+ >
59
+ {displayLabel}
60
+ </Anchor>
61
+ );
62
+ });
63
+ }
@@ -0,0 +1,71 @@
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 { fixtureTask } from "../fixtures";
5
+ import type { NotificationSnapshot } from "../types";
6
+ import { NotificationCenter } from "./NotificationCenter";
7
+
8
+ const notification: NotificationSnapshot = {
9
+ id: "task-completed",
10
+ taskId: "11111111-1111-4111-8111-111111111111",
11
+ title: "Review checkout reconciliation",
12
+ status: "completed",
13
+ event: "completed",
14
+ turnRef: "turn-one",
15
+ turnId: null,
16
+ timestamp: "2026-08-30T08:10:00.000Z",
17
+ summary: "Review completed.",
18
+ };
19
+
20
+ afterEach(cleanup);
21
+
22
+ test("notification portal stays non-modal until the operator chooses an action", () => {
23
+ const onOpen = vi.fn();
24
+ const onDismiss = vi.fn();
25
+ render(
26
+ <MantineProvider>
27
+ <button autoFocus type="button">Detail action</button>
28
+ <NotificationCenter
29
+ notifications={[notification]}
30
+ onClear={() => {}}
31
+ onDismiss={onDismiss}
32
+ onOpen={onOpen}
33
+ tasks={[fixtureTask()]}
34
+ />
35
+ </MantineProvider>,
36
+ );
37
+ expect(screen.getByRole("button", { name: "Detail action" })).toHaveFocus();
38
+ const panel = screen.getByRole("region", { name: "Task notifications" });
39
+ expect(panel).toBeVisible();
40
+ expect(panel.querySelector(".taskchef-notification-list")).toBeInTheDocument();
41
+ expect(screen.getByRole("button", { name: /Open current task details/ })).toHaveAccessibleDescription(
42
+ /Review completed\. \d+ hours ago/,
43
+ );
44
+ expect(panel.nextElementSibling).toHaveTextContent(
45
+ /Task completed\. Review checkout reconciliation\. Review completed\. Turn ref turn-one\. \d+ hours ago\./,
46
+ );
47
+ fireEvent.click(screen.getByRole("button", { name: /Open current task details/ }));
48
+ expect(onOpen).toHaveBeenCalledWith(notification);
49
+ fireEvent.click(screen.getByRole("button", { name: /Dismiss Task completed notification/ }));
50
+ expect(onDismiss).toHaveBeenCalledWith(notification);
51
+ });
52
+
53
+ test("can render inside a focus-trapped task detail", () => {
54
+ render(
55
+ <MantineProvider>
56
+ <div data-testid="detail-dialog">
57
+ <NotificationCenter
58
+ notifications={[notification]}
59
+ onClear={() => {}}
60
+ onDismiss={() => {}}
61
+ onOpen={() => {}}
62
+ tasks={[fixtureTask()]}
63
+ withinPortal={false}
64
+ />
65
+ </div>
66
+ </MantineProvider>,
67
+ );
68
+ expect(screen.getByTestId("detail-dialog")).toContainElement(
69
+ screen.getByRole("region", { name: "Task notifications" }),
70
+ );
71
+ });
@@ -0,0 +1,111 @@
1
+ import { ActionIcon, Box, Button, Group, Paper, Portal, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
2
+ import { IconBell, IconX } from "@tabler/icons-react";
3
+ import { notificationDismissLabel, notificationOpenLabel, notificationTitle } from "../../state.js";
4
+ import { formatRelativeTime } from "../../time.js";
5
+ import type { NotificationSnapshot, Task } from "../types";
6
+ import { StatusBadge } from "./StatusBadge";
7
+
8
+ export function NotificationCenter({
9
+ notifications,
10
+ announcements = notifications,
11
+ onClear,
12
+ onDismiss,
13
+ onOpen,
14
+ tasks,
15
+ withinPortal = true,
16
+ }: {
17
+ announcements?: NotificationSnapshot[];
18
+ notifications: NotificationSnapshot[];
19
+ onClear: () => void;
20
+ onDismiss: (notification: NotificationSnapshot) => void;
21
+ onOpen: (notification: NotificationSnapshot) => void;
22
+ tasks: Task[];
23
+ withinPortal?: boolean;
24
+ }) {
25
+ if (notifications.length === 0) return null;
26
+ const content = (
27
+ <>
28
+ <Paper
29
+ aria-label="Task notifications"
30
+ className="taskchef-notifications"
31
+ p="sm"
32
+ radius="md"
33
+ role="region"
34
+ shadow="xl"
35
+ withBorder
36
+ >
37
+ <Group justify="space-between" mb="xs">
38
+ <Group gap={6}>
39
+ <IconBell aria-hidden size={16} />
40
+ <Text fw={700} size="sm">Updates</Text>
41
+ </Group>
42
+ <Button onClick={onClear} size="compact-xs" variant="subtle">Clear all</Button>
43
+ </Group>
44
+ <ScrollArea.Autosize className="taskchef-notification-scroll" mah="min(55vh, 520px)" type="auto">
45
+ <Stack className="taskchef-notification-list" gap={6}>
46
+ {notifications.map((notification) => {
47
+ const available = tasks.some((task) => task.id === notification.taskId);
48
+ const descriptionId = `notification-${Math.abs(hash(notification.id))}`;
49
+ const summaryId = `${descriptionId}-summary`;
50
+ const metadataId = `${descriptionId}-metadata`;
51
+ return (
52
+ <Paper className="taskchef-notification" key={notification.id} p="xs" radius="sm">
53
+ <Group align="flex-start" gap="xs" wrap="nowrap">
54
+ <button
55
+ aria-describedby={[notification.summary ? summaryId : null, metadataId].filter(Boolean).join(" ")}
56
+ aria-label={notificationOpenLabel(notification, available)}
57
+ className="taskchef-notification-open"
58
+ onClick={() => onOpen(notification)}
59
+ type="button"
60
+ >
61
+ <Group gap="xs" justify="space-between" wrap="nowrap">
62
+ <Text fw={700} size="sm">{notificationTitle(notification)}</Text>
63
+ <StatusBadge status={notification.status} />
64
+ </Group>
65
+ <Text lineClamp={1} size="sm">{notification.title}</Text>
66
+ {notification.summary && <Text className="taskchef-preserve-lines" id={summaryId} lineClamp={3} size="xs">{notification.summary}</Text>}
67
+ <Text c="dimmed" id={metadataId} size="xs">
68
+ {formatRelativeTime(notification.timestamp)}{available ? "" : " · Task no longer available"}
69
+ </Text>
70
+ </button>
71
+ <Tooltip label="Dismiss notification">
72
+ <ActionIcon
73
+ aria-label={notificationDismissLabel(notification)}
74
+ color="gray"
75
+ onClick={() => onDismiss(notification)}
76
+ size="compact-sm"
77
+ variant="subtle"
78
+ >
79
+ <IconX aria-hidden size={14} />
80
+ </ActionIcon>
81
+ </Tooltip>
82
+ </Group>
83
+ </Paper>
84
+ );
85
+ })}
86
+ </Stack>
87
+ </ScrollArea.Autosize>
88
+ </Paper>
89
+ <Box aria-atomic="true" aria-live="polite" className="taskchef-visually-hidden" role="status">
90
+ {announcements.map(notificationAnnouncement).join(" ")}
91
+ </Box>
92
+ </>
93
+ );
94
+ return withinPortal ? <Portal>{content}</Portal> : content;
95
+ }
96
+
97
+ function notificationAnnouncement(notification: NotificationSnapshot) {
98
+ return [
99
+ notificationTitle(notification),
100
+ notification.title,
101
+ notification.summary,
102
+ notification.turnRef ? `Turn ref ${notification.turnRef}` : null,
103
+ formatRelativeTime(notification.timestamp),
104
+ ].filter(Boolean).map((part) => String(part).replace(/[.\s]+$/, "")).join(". ") + ".";
105
+ }
106
+
107
+ function hash(value: string) {
108
+ let result = 0;
109
+ for (const character of value) result = ((result << 5) - result + character.charCodeAt(0)) | 0;
110
+ return result;
111
+ }
@@ -0,0 +1,28 @@
1
+ import { Tooltip, UnstyledButton } from "@mantine/core";
2
+ import { IconClock } from "@tabler/icons-react";
3
+ import { createContext, useContext, useState, type ReactNode } from "react";
4
+ import { formatExactTime, formatRelativeTime } from "../../time.js";
5
+
6
+ const RelativeTimeClock = createContext<number | null>(null);
7
+
8
+ export function RelativeTimeProvider({ children, now }: { children: ReactNode; now: number }) {
9
+ return <RelativeTimeClock value={now}>{children}</RelativeTimeClock>;
10
+ }
11
+
12
+ export function RelativeTime({ label, value }: { label: string; value: string | null | undefined }) {
13
+ const [exact, setExact] = useState(false);
14
+ useContext(RelativeTimeClock);
15
+ const text = exact ? formatExactTime(value) : formatRelativeTime(value);
16
+ return (
17
+ <Tooltip label={exact ? "Show relative time" : "Show exact time"}>
18
+ <UnstyledButton
19
+ aria-label={`${label}: ${text}. ${exact ? "Show relative time" : "Show exact time"}`}
20
+ className="taskchef-time"
21
+ onClick={() => setExact((value) => !value)}
22
+ >
23
+ <IconClock aria-hidden size={12} />
24
+ <span className="taskchef-time-label">{text}</span>
25
+ </UnstyledButton>
26
+ </Tooltip>
27
+ );
28
+ }
@@ -0,0 +1,9 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+
3
+ export function ShimmerText({ children, className = "", ...props }: ComponentPropsWithoutRef<"span">) {
4
+ return (
5
+ <span className={`taskchef-shimmer ${className}`.trim()} data-animation="text-shimmer" {...props}>
6
+ {children}
7
+ </span>
8
+ );
9
+ }
@@ -0,0 +1,7 @@
1
+ import { Badge } from "@mantine/core";
2
+ import { statusColor, statusLabel } from "../presentation";
3
+ import type { Task } from "../types";
4
+
5
+ export function StatusBadge({ status }: { status: Task["status"] | "interrupted" }) {
6
+ return <Badge className="taskchef-status-badge" color={statusColor(status)} size="sm" variant="light">{statusLabel(status)}</Badge>;
7
+ }
@@ -0,0 +1,65 @@
1
+ import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
2
+ import { IconExternalLink } from "@tabler/icons-react";
3
+ import { latestTurnPresentation } from "../../state.js";
4
+ import type { Task } from "../types";
5
+ import { GitHubLinks } from "./GitHubLinks";
6
+ import { LinkedText } from "./LinkedText";
7
+ import { RelativeTime } from "./RelativeTime";
8
+ import { ShimmerText } from "./ShimmerText";
9
+ import { StatusBadge } from "./StatusBadge";
10
+
11
+ export function TaskCard({
12
+ onOpenCodex,
13
+ onOpenDetail,
14
+ task,
15
+ }: {
16
+ onOpenCodex: (task: Task) => void;
17
+ onOpenDetail: (task: Task) => void;
18
+ task: Task;
19
+ }) {
20
+ const latest = latestTurnPresentation(task);
21
+ return (
22
+ <Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
23
+ <Stack className="taskchef-task-main" gap="sm">
24
+ <Box>
25
+ <Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
26
+ <Title order={3} size="h5">
27
+ <button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">
28
+ {task.title}
29
+ </button>
30
+ </Title>
31
+ <StatusBadge status={task.status} />
32
+ </Group>
33
+ <Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
34
+ </Box>
35
+
36
+ <Box className="taskchef-summary-grid">
37
+ <Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
38
+ <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
39
+ <LinkedText task={task} text={latest.requestSummary} />
40
+ </Text>
41
+ <Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
42
+ <Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
43
+ {latest.resultStatus === "working"
44
+ ? <ShimmerText>{latest.resultSummary}</ShimmerText>
45
+ : <LinkedText task={task} text={latest.resultSummary} />}
46
+ </Text>
47
+ </Box>
48
+
49
+ <GitHubLinks task={task} />
50
+ <Group gap="md" justify="space-between">
51
+ <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
52
+ <Button
53
+ aria-label={`Open chat for ${task.title}`}
54
+ leftSection={<IconExternalLink aria-hidden size={15} />}
55
+ onClick={() => onOpenCodex(task)}
56
+ size="compact-sm"
57
+ variant="default"
58
+ >
59
+ Open chat
60
+ </Button>
61
+ </Group>
62
+ </Stack>
63
+ </Paper>
64
+ );
65
+ }
@@ -0,0 +1,62 @@
1
+ import { MantineProvider } from "@mantine/core";
2
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { afterEach, expect, test, vi } from "vitest";
5
+ import { ManualTransitionConfirmation, type TerminalStatus } from "./TaskDetail";
6
+
7
+ afterEach(cleanup);
8
+
9
+ test("clears a manual-transition confirmation only after success", async () => {
10
+ const onTransition = vi.fn().mockResolvedValue({ ok: true });
11
+ render(<ConfirmationHarness onTransition={onTransition} />);
12
+ expect(screen.getByRole("alert")).toHaveTextContent("Mark task completed?");
13
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
14
+
15
+ await waitFor(() => expect(onTransition).toHaveBeenCalledWith("completed", expect.any(String)));
16
+ await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
17
+ await waitFor(() => expect(screen.getByRole("heading", { name: "Task title" })).toHaveFocus());
18
+ });
19
+
20
+ test("retains a manual-transition confirmation after failure", async () => {
21
+ const onTransition = vi.fn().mockResolvedValue({ ok: false });
22
+ render(<ConfirmationHarness onTransition={onTransition} />);
23
+ fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
24
+
25
+ await waitFor(() => expect(onTransition).toHaveBeenCalledWith("completed", expect.any(String)));
26
+ expect(screen.getByRole("alert")).toHaveTextContent("Mark task completed?");
27
+ });
28
+
29
+ test("reuses an action ID for retryable failures and rotates it after a stale task", async () => {
30
+ const onTransition = vi.fn()
31
+ .mockResolvedValueOnce({ ok: false })
32
+ .mockResolvedValueOnce({ ok: false, rotateActionId: true })
33
+ .mockResolvedValueOnce({ ok: false });
34
+ render(<ConfirmationHarness onTransition={onTransition} />);
35
+ const confirm = screen.getByRole("button", { name: "Confirm" });
36
+ fireEvent.click(confirm);
37
+ await waitFor(() => expect(onTransition).toHaveBeenCalledTimes(1));
38
+ const firstActionId = onTransition.mock.calls[0][1];
39
+ fireEvent.click(confirm);
40
+ await waitFor(() => expect(onTransition).toHaveBeenCalledTimes(2));
41
+ expect(onTransition.mock.calls[1][1]).toBe(firstActionId);
42
+ fireEvent.click(confirm);
43
+ await waitFor(() => expect(onTransition).toHaveBeenCalledTimes(3));
44
+ expect(onTransition.mock.calls[2][1]).not.toBe(firstActionId);
45
+ });
46
+
47
+ function ConfirmationHarness({ onTransition }: { onTransition: (status: TerminalStatus, actionId: string) => Promise<{ ok: boolean; rotateActionId?: boolean }> }) {
48
+ const [opened, setOpened] = useState(true);
49
+ return (
50
+ <MantineProvider>
51
+ <h2 id="task-detail-title" tabIndex={-1}>Task title</h2>
52
+ {opened && (
53
+ <ManualTransitionConfirmation
54
+ busy={false}
55
+ onCancel={() => setOpened(false)}
56
+ onTransition={onTransition}
57
+ status="completed"
58
+ />
59
+ )}
60
+ </MantineProvider>
61
+ );
62
+ }