taskchef 7.23.4 → 7.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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/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 +63 -0
- package/src/dashboard/react/components/GitHubLinks.tsx +25 -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 +62 -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 +41 -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 +128 -0
- package/src/dashboard/react/presentation.ts +80 -0
- package/src/dashboard/react/styles.css +123 -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,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,62 @@
|
|
|
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 { RelativeTime } from "./RelativeTime";
|
|
7
|
+
import { ShimmerText } from "./ShimmerText";
|
|
8
|
+
import { StatusBadge } from "./StatusBadge";
|
|
9
|
+
|
|
10
|
+
export function TaskCard({
|
|
11
|
+
onOpenCodex,
|
|
12
|
+
onOpenDetail,
|
|
13
|
+
task,
|
|
14
|
+
}: {
|
|
15
|
+
onOpenCodex: (task: Task) => void;
|
|
16
|
+
onOpenDetail: (task: Task) => void;
|
|
17
|
+
task: Task;
|
|
18
|
+
}) {
|
|
19
|
+
const latest = latestTurnPresentation(task);
|
|
20
|
+
return (
|
|
21
|
+
<Paper className="taskchef-task-row" component="article" p="md" radius="md" withBorder>
|
|
22
|
+
<Stack className="taskchef-task-main" gap="sm">
|
|
23
|
+
<Box>
|
|
24
|
+
<Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
|
|
25
|
+
<Title order={3} size="h5">
|
|
26
|
+
<button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">
|
|
27
|
+
{task.title}
|
|
28
|
+
</button>
|
|
29
|
+
</Title>
|
|
30
|
+
<StatusBadge status={task.status} />
|
|
31
|
+
</Group>
|
|
32
|
+
<Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
|
|
35
|
+
<Box className="taskchef-summary-grid">
|
|
36
|
+
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
37
|
+
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">{latest.requestSummary}</Text>
|
|
38
|
+
<Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
|
|
39
|
+
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
|
|
40
|
+
{latest.resultStatus === "working"
|
|
41
|
+
? <ShimmerText>{latest.resultSummary}</ShimmerText>
|
|
42
|
+
: latest.resultSummary}
|
|
43
|
+
</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
|
|
46
|
+
<GitHubLinks task={task} />
|
|
47
|
+
<Group gap="md" justify="space-between">
|
|
48
|
+
<RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
|
|
49
|
+
<Button
|
|
50
|
+
aria-label={`Open chat for ${task.title}`}
|
|
51
|
+
leftSection={<IconExternalLink aria-hidden size={15} />}
|
|
52
|
+
onClick={() => onOpenCodex(task)}
|
|
53
|
+
size="compact-sm"
|
|
54
|
+
variant="default"
|
|
55
|
+
>
|
|
56
|
+
Open chat
|
|
57
|
+
</Button>
|
|
58
|
+
</Group>
|
|
59
|
+
</Stack>
|
|
60
|
+
</Paper>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActionIcon,
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
Button,
|
|
6
|
+
Divider,
|
|
7
|
+
Drawer,
|
|
8
|
+
Group,
|
|
9
|
+
Menu,
|
|
10
|
+
Modal,
|
|
11
|
+
ScrollArea,
|
|
12
|
+
Stack,
|
|
13
|
+
Text,
|
|
14
|
+
Title,
|
|
15
|
+
Tooltip,
|
|
16
|
+
} from "@mantine/core";
|
|
17
|
+
import { useMediaQuery } from "@mantine/hooks";
|
|
18
|
+
import {
|
|
19
|
+
IconCheck,
|
|
20
|
+
IconClipboard,
|
|
21
|
+
IconDots,
|
|
22
|
+
IconExternalLink,
|
|
23
|
+
IconX,
|
|
24
|
+
} from "@tabler/icons-react";
|
|
25
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
26
|
+
import type { Task } from "../types";
|
|
27
|
+
import { ActivityTimeline } from "./ActivityTimeline";
|
|
28
|
+
import { GitHubLinks } from "./GitHubLinks";
|
|
29
|
+
import { RelativeTime } from "./RelativeTime";
|
|
30
|
+
import { StatusBadge } from "./StatusBadge";
|
|
31
|
+
import { UsagePanel } from "./UsagePanel";
|
|
32
|
+
|
|
33
|
+
export type TerminalStatus = "completed" | "failed";
|
|
34
|
+
export interface ManualTransitionResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
rotateActionId?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function TaskDetail({
|
|
40
|
+
busy,
|
|
41
|
+
error,
|
|
42
|
+
highlightTurnRef,
|
|
43
|
+
onClose,
|
|
44
|
+
onCopy,
|
|
45
|
+
onOpenCodex,
|
|
46
|
+
onTransition,
|
|
47
|
+
opened,
|
|
48
|
+
task,
|
|
49
|
+
notifications,
|
|
50
|
+
notice,
|
|
51
|
+
}: {
|
|
52
|
+
busy: boolean;
|
|
53
|
+
error: string | null;
|
|
54
|
+
highlightTurnRef: string | null;
|
|
55
|
+
onClose: () => void;
|
|
56
|
+
onCopy: () => void;
|
|
57
|
+
onOpenCodex: () => void;
|
|
58
|
+
onTransition: (status: TerminalStatus, actionId: string) => Promise<ManualTransitionResult>;
|
|
59
|
+
opened: boolean;
|
|
60
|
+
task: Task | null;
|
|
61
|
+
notifications?: ReactNode;
|
|
62
|
+
notice?: string | null;
|
|
63
|
+
}) {
|
|
64
|
+
const mobile = useMediaQuery("(max-width: 48em)");
|
|
65
|
+
const [confirmStatus, setConfirmStatus] = useState<TerminalStatus | null>(null);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!opened) setConfirmStatus(null);
|
|
68
|
+
}, [opened]);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (opened && highlightTurnRef) {
|
|
71
|
+
requestAnimationFrame(() => {
|
|
72
|
+
document.querySelector<HTMLElement>(`[data-turn-ref="${CSS.escape(highlightTurnRef)}"]`)?.focus();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}, [highlightTurnRef, opened, task?.turns?.length, task?.updatedAt]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (confirmStatus && (!task || !manualTransitionTargets(task).includes(confirmStatus))) {
|
|
78
|
+
setConfirmStatus(null);
|
|
79
|
+
}
|
|
80
|
+
}, [confirmStatus, task?.status]);
|
|
81
|
+
|
|
82
|
+
if (!task) return null;
|
|
83
|
+
const transitionTargets = manualTransitionTargets(task);
|
|
84
|
+
const content = (
|
|
85
|
+
<Stack gap="lg">
|
|
86
|
+
{notifications}
|
|
87
|
+
<Box>
|
|
88
|
+
<Group align="flex-start" gap="sm" justify="space-between" wrap="nowrap">
|
|
89
|
+
<Box>
|
|
90
|
+
<Text c="teal" fw={700} size="xs" tt="uppercase">{task.project.name}</Text>
|
|
91
|
+
<Title id="task-detail-title" order={2} size="h3" tabIndex={-1}>{task.title}</Title>
|
|
92
|
+
</Box>
|
|
93
|
+
<StatusBadge status={task.status} />
|
|
94
|
+
</Group>
|
|
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>
|
|
104
|
+
<Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
|
|
105
|
+
<Menu.Target>
|
|
106
|
+
<Tooltip label="More task actions">
|
|
107
|
+
<ActionIcon aria-label="More task actions" disabled={busy} variant="default">
|
|
108
|
+
<IconDots aria-hidden size={17} />
|
|
109
|
+
</ActionIcon>
|
|
110
|
+
</Tooltip>
|
|
111
|
+
</Menu.Target>
|
|
112
|
+
<Menu.Dropdown>
|
|
113
|
+
<Menu.Item leftSection={<IconClipboard size={14} />} onClick={onCopy}>Copy Task ID</Menu.Item>
|
|
114
|
+
{transitionTargets.includes("completed") && (
|
|
115
|
+
<Menu.Item leftSection={<IconCheck size={14} />} onClick={() => setConfirmStatus("completed")}>Mark completed</Menu.Item>
|
|
116
|
+
)}
|
|
117
|
+
{transitionTargets.includes("failed") && (
|
|
118
|
+
<Menu.Item color="red" leftSection={<IconX size={14} />} onClick={() => setConfirmStatus("failed")}>Mark failed</Menu.Item>
|
|
119
|
+
)}
|
|
120
|
+
</Menu.Dropdown>
|
|
121
|
+
</Menu>
|
|
122
|
+
</Group>
|
|
123
|
+
<Box mt="sm">
|
|
124
|
+
<GitHubLinks task={task} />
|
|
125
|
+
</Box>
|
|
126
|
+
</Box>
|
|
127
|
+
|
|
128
|
+
{confirmStatus && (
|
|
129
|
+
<ManualTransitionConfirmation
|
|
130
|
+
key={confirmStatus}
|
|
131
|
+
busy={busy}
|
|
132
|
+
onCancel={() => setConfirmStatus(null)}
|
|
133
|
+
onTransition={onTransition}
|
|
134
|
+
status={confirmStatus}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
{notice && <Alert color="teal" role="status">{notice}</Alert>}
|
|
138
|
+
{error && <Alert color="red" role="alert">{error}</Alert>}
|
|
139
|
+
|
|
140
|
+
<section aria-labelledby="usage-heading">
|
|
141
|
+
<Title id="usage-heading" mb="xs" order={3} size="h5">Usage</Title>
|
|
142
|
+
<UsagePanel task={task} />
|
|
143
|
+
</section>
|
|
144
|
+
<Divider />
|
|
145
|
+
<section aria-labelledby="activity-heading">
|
|
146
|
+
<Title id="activity-heading" mb="sm" order={3} size="h5">Activity timeline</Title>
|
|
147
|
+
<ActivityTimeline highlightTurnRef={highlightTurnRef} task={task} />
|
|
148
|
+
</section>
|
|
149
|
+
<section aria-labelledby="instruction-heading">
|
|
150
|
+
<Title id="instruction-heading" mb="xs" order={3} size="h5">Original instruction</Title>
|
|
151
|
+
<Box className="taskchef-code-panel" component="pre">{task.instruction}</Box>
|
|
152
|
+
</section>
|
|
153
|
+
<section aria-labelledby="metadata-heading">
|
|
154
|
+
<Title id="metadata-heading" mb="xs" order={3} size="h5">Metadata</Title>
|
|
155
|
+
<dl className="taskchef-metadata">
|
|
156
|
+
<dt>Task ID</dt><dd>{task.id}</dd>
|
|
157
|
+
<dt>Thread ID</dt><dd>{task.threadId ?? "—"}</dd>
|
|
158
|
+
<dt>Current turn ref</dt><dd>{task.turnRef ?? "—"}</dd>
|
|
159
|
+
<dt>Project path</dt><dd>{task.project.path}</dd>
|
|
160
|
+
<dt>Created</dt><dd><RelativeTime label="Created time" value={task.createdAt} /></dd>
|
|
161
|
+
<dt>Updated</dt><dd><RelativeTime label="Updated time" value={task.meaningfulUpdatedAt ?? task.updatedAt} /></dd>
|
|
162
|
+
<dt>Updated by</dt><dd>{task.updatedBy ?? "—"}</dd>
|
|
163
|
+
</dl>
|
|
164
|
+
</section>
|
|
165
|
+
</Stack>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (mobile) {
|
|
169
|
+
return (
|
|
170
|
+
<Drawer
|
|
171
|
+
closeOnClickOutside={!busy}
|
|
172
|
+
closeOnEscape={!busy}
|
|
173
|
+
onClose={onClose}
|
|
174
|
+
opened={opened}
|
|
175
|
+
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
|
|
176
|
+
position="bottom"
|
|
177
|
+
scrollAreaComponent={ScrollArea.Autosize}
|
|
178
|
+
size="92%"
|
|
179
|
+
title={<span className="taskchef-visually-hidden">Task details</span>}
|
|
180
|
+
trapFocus
|
|
181
|
+
zIndex={300}
|
|
182
|
+
>
|
|
183
|
+
{content}
|
|
184
|
+
</Drawer>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return (
|
|
188
|
+
<Modal
|
|
189
|
+
aria-labelledby="task-detail-title"
|
|
190
|
+
closeOnClickOutside={!busy}
|
|
191
|
+
closeOnEscape={!busy}
|
|
192
|
+
onClose={onClose}
|
|
193
|
+
opened={opened}
|
|
194
|
+
overlayProps={{ backgroundOpacity: 0.55, blur: 2 }}
|
|
195
|
+
scrollAreaComponent={ScrollArea.Autosize}
|
|
196
|
+
size="min(820px, calc(100vw - 32px))"
|
|
197
|
+
title={<span className="taskchef-visually-hidden">Task details</span>}
|
|
198
|
+
trapFocus
|
|
199
|
+
zIndex={300}
|
|
200
|
+
>
|
|
201
|
+
{content}
|
|
202
|
+
</Modal>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function manualTransitionTargets(task: Task): TerminalStatus[] {
|
|
207
|
+
return task.status === "completed"
|
|
208
|
+
? ["failed"]
|
|
209
|
+
: task.status === "failed"
|
|
210
|
+
? ["completed"]
|
|
211
|
+
: task.status === "working" || task.status === "needs_input"
|
|
212
|
+
? ["completed", "failed"]
|
|
213
|
+
: [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function ManualTransitionConfirmation({
|
|
217
|
+
busy,
|
|
218
|
+
onCancel,
|
|
219
|
+
onTransition,
|
|
220
|
+
status,
|
|
221
|
+
}: {
|
|
222
|
+
busy: boolean;
|
|
223
|
+
onCancel: () => void;
|
|
224
|
+
onTransition: (status: TerminalStatus, actionId: string) => Promise<ManualTransitionResult>;
|
|
225
|
+
status: TerminalStatus;
|
|
226
|
+
}) {
|
|
227
|
+
const actionId = useRef(crypto.randomUUID());
|
|
228
|
+
return (
|
|
229
|
+
<Alert color={status === "failed" ? "red" : "teal"} role="alert" title={`Mark task ${status}?`}>
|
|
230
|
+
<Text mb="sm" size="sm">This interrupts active work and appends an audited manual dashboard turn.</Text>
|
|
231
|
+
<Group gap="xs">
|
|
232
|
+
<Button
|
|
233
|
+
color={status === "failed" ? "red" : "teal"}
|
|
234
|
+
loading={busy}
|
|
235
|
+
onClick={async () => {
|
|
236
|
+
const result = await onTransition(status, actionId.current);
|
|
237
|
+
if (result.ok) {
|
|
238
|
+
onCancel();
|
|
239
|
+
requestAnimationFrame(() => document.getElementById("task-detail-title")?.focus());
|
|
240
|
+
}
|
|
241
|
+
else if (result.rotateActionId) actionId.current = crypto.randomUUID();
|
|
242
|
+
}}
|
|
243
|
+
size="compact-sm"
|
|
244
|
+
>
|
|
245
|
+
Confirm
|
|
246
|
+
</Button>
|
|
247
|
+
<Button disabled={busy} onClick={onCancel} size="compact-sm" variant="default">Cancel</Button>
|
|
248
|
+
</Group>
|
|
249
|
+
</Alert>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Box, Group, Paper, Text } from "@mantine/core";
|
|
2
|
+
import { IconCoins } from "@tabler/icons-react";
|
|
3
|
+
import type { Task } from "../types";
|
|
4
|
+
import { usageView } from "../presentation";
|
|
5
|
+
import { ShimmerText } from "./ShimmerText";
|
|
6
|
+
|
|
7
|
+
export function UsagePanel({ task }: { task: Task }) {
|
|
8
|
+
const usage = usageView(task);
|
|
9
|
+
if (usage.kind === "pending" || usage.kind === "calculating") {
|
|
10
|
+
return (
|
|
11
|
+
<Paper aria-live="polite" className="taskchef-subtle-panel" p="sm" radius="md">
|
|
12
|
+
<Group gap="xs">
|
|
13
|
+
<IconCoins aria-hidden size={16} />
|
|
14
|
+
<Text size="sm"><ShimmerText>{usage.label}</ShimmerText></Text>
|
|
15
|
+
</Group>
|
|
16
|
+
</Paper>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
if (usage.kind === "unavailable") {
|
|
20
|
+
return <Text aria-live="polite" c="dimmed" size="sm">{usage.label}</Text>;
|
|
21
|
+
}
|
|
22
|
+
const cached = usage.usage.cachedInputTokens ?? 0;
|
|
23
|
+
const input = usage.usage.inputTokens ?? 0;
|
|
24
|
+
const cacheRatio = input + cached > 0 ? `${Math.round((cached / (input + cached)) * 100)}%` : "—";
|
|
25
|
+
const values = [
|
|
26
|
+
[usage.knownSoFar ? "Tokens · known so far" : "Tokens", new Intl.NumberFormat().format(usage.usage.totalTokens)],
|
|
27
|
+
["Estimated cost", usage.usage.estimatedCostUsd == null ? "—" : `$${usage.usage.estimatedCostUsd.toFixed(4)}`],
|
|
28
|
+
["Model", Object.keys(usage.usage.models ?? {}).join(", ") || "—"],
|
|
29
|
+
["Cache ratio", cacheRatio],
|
|
30
|
+
] as const;
|
|
31
|
+
return (
|
|
32
|
+
<Box aria-live="polite" className="taskchef-metrics">
|
|
33
|
+
{values.map(([label, value]) => (
|
|
34
|
+
<Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
|
|
35
|
+
<Text c="dimmed" size="xs">{label}</Text>
|
|
36
|
+
<Text className="taskchef-metric-value" fw={650} size="sm">{value}</Text>
|
|
37
|
+
</Paper>
|
|
38
|
+
))}
|
|
39
|
+
</Box>
|
|
40
|
+
);
|
|
41
|
+
}
|