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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +24 -2
- package/src/dashboard/dist/app.js +164 -0
- package/src/dashboard/dist/app.js.map +1 -0
- package/src/dashboard/dist/index.html +16 -0
- package/src/dashboard/dist/styles.css +1 -0
- package/src/dashboard/github-links.js +12 -4
- package/src/dashboard/react/App.tsx +432 -0
- package/src/dashboard/react/api.test.ts +24 -0
- package/src/dashboard/react/api.ts +95 -0
- package/src/dashboard/react/components/ActivityTimeline.tsx +66 -0
- package/src/dashboard/react/components/GitHubLinks.tsx +46 -0
- package/src/dashboard/react/components/LinkedText.tsx +63 -0
- package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
- package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
- package/src/dashboard/react/components/RelativeTime.tsx +28 -0
- package/src/dashboard/react/components/ShimmerText.tsx +9 -0
- package/src/dashboard/react/components/StatusBadge.tsx +7 -0
- package/src/dashboard/react/components/TaskCard.tsx +65 -0
- package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
- package/src/dashboard/react/components/TaskDetail.tsx +251 -0
- package/src/dashboard/react/components/UsagePanel.tsx +73 -0
- package/src/dashboard/react/fixtures.ts +52 -0
- package/src/dashboard/react/index.html +15 -0
- package/src/dashboard/react/main.tsx +12 -0
- package/src/dashboard/react/overlay-state.test.ts +79 -0
- package/src/dashboard/react/overlay-state.ts +38 -0
- package/src/dashboard/react/presentation.test.tsx +241 -0
- package/src/dashboard/react/presentation.ts +102 -0
- package/src/dashboard/react/styles.css +128 -0
- package/src/dashboard/react/test-setup.ts +36 -0
- package/src/dashboard/react/types.ts +106 -0
- 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
|
+
}
|