taskchef 7.23.3 → 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.
Files changed (33) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/package.json +24 -2
  3. package/src/dashboard/app.js +2 -82
  4. package/src/dashboard/dist/app.js +164 -0
  5. package/src/dashboard/dist/app.js.map +1 -0
  6. package/src/dashboard/dist/index.html +16 -0
  7. package/src/dashboard/dist/styles.css +1 -0
  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 +63 -0
  12. package/src/dashboard/react/components/GitHubLinks.tsx +25 -0
  13. package/src/dashboard/react/components/NotificationCenter.test.tsx +71 -0
  14. package/src/dashboard/react/components/NotificationCenter.tsx +111 -0
  15. package/src/dashboard/react/components/RelativeTime.tsx +28 -0
  16. package/src/dashboard/react/components/ShimmerText.tsx +9 -0
  17. package/src/dashboard/react/components/StatusBadge.tsx +7 -0
  18. package/src/dashboard/react/components/TaskCard.tsx +62 -0
  19. package/src/dashboard/react/components/TaskDetail.test.tsx +62 -0
  20. package/src/dashboard/react/components/TaskDetail.tsx +251 -0
  21. package/src/dashboard/react/components/UsagePanel.tsx +41 -0
  22. package/src/dashboard/react/fixtures.ts +52 -0
  23. package/src/dashboard/react/index.html +15 -0
  24. package/src/dashboard/react/main.tsx +12 -0
  25. package/src/dashboard/react/overlay-state.test.ts +79 -0
  26. package/src/dashboard/react/overlay-state.ts +38 -0
  27. package/src/dashboard/react/presentation.test.tsx +128 -0
  28. package/src/dashboard/react/presentation.ts +80 -0
  29. package/src/dashboard/react/styles.css +123 -0
  30. package/src/dashboard/react/test-setup.ts +36 -0
  31. package/src/dashboard/react/types.ts +106 -0
  32. package/src/dashboard/styles.css +16 -3
  33. 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
+ }