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.
- package/.codex-plugin/plugin.json +1 -1
- package/package.json +24 -2
- package/src/dashboard/app.js +2 -82
- 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/styles.css +16 -3
- package/src/dashboard.js +29 -18
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Task } from "./types";
|
|
2
|
+
|
|
3
|
+
export function fixtureTask(overrides: Partial<Task> = {}): Task {
|
|
4
|
+
const timestamp = "2026-08-30T08:00:00.000Z";
|
|
5
|
+
const id = overrides.id ?? "11111111-1111-4111-8111-111111111111";
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
schemaVersion: 10,
|
|
9
|
+
title: "Review checkout reconciliation",
|
|
10
|
+
instruction: "Verify the isolated dashboard fixture without using live TaskChef data.",
|
|
11
|
+
project: {
|
|
12
|
+
name: "TaskChef Preview",
|
|
13
|
+
path: "/tmp/taskchef-preview-project",
|
|
14
|
+
githubRepos: ["https://github.com/example/taskchef-preview"],
|
|
15
|
+
},
|
|
16
|
+
createdAt: timestamp,
|
|
17
|
+
updatedAt: timestamp,
|
|
18
|
+
meaningfulUpdatedAt: timestamp,
|
|
19
|
+
updatedBy: "mcp",
|
|
20
|
+
status: "working",
|
|
21
|
+
summary: null,
|
|
22
|
+
threadId: "019ffb69-57a6-7801-8b7a-8ff4c32a398c",
|
|
23
|
+
turnRef: "turn-one",
|
|
24
|
+
turnId: null,
|
|
25
|
+
lastResult: null,
|
|
26
|
+
latestTurn: {
|
|
27
|
+
requestSummary: "Review the operator-facing task state.",
|
|
28
|
+
result: null,
|
|
29
|
+
startedAt: timestamp,
|
|
30
|
+
turnRef: "turn-one",
|
|
31
|
+
turnId: null,
|
|
32
|
+
},
|
|
33
|
+
turns: [{
|
|
34
|
+
requestSummary: "Review the operator-facing task state.",
|
|
35
|
+
result: null,
|
|
36
|
+
startedAt: timestamp,
|
|
37
|
+
turnRef: "turn-one",
|
|
38
|
+
turnId: null,
|
|
39
|
+
}],
|
|
40
|
+
results: [],
|
|
41
|
+
usage: { generationTurnRef: "turn-one", status: "pending" },
|
|
42
|
+
relatedGitHubLinks: [{
|
|
43
|
+
label: "example/taskchef-preview#24",
|
|
44
|
+
owner: "example",
|
|
45
|
+
repository: "taskchef-preview",
|
|
46
|
+
number: "24",
|
|
47
|
+
type: "pull",
|
|
48
|
+
url: "https://github.com/example/taskchef-preview/pull/24",
|
|
49
|
+
}],
|
|
50
|
+
...overrides,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<meta name="theme-color" content="#f3f5f2" />
|
|
8
|
+
<meta name="taskchef-style-nonce" content="__TASKCHEF_STYLE_NONCE__" />
|
|
9
|
+
<title>TaskChef Dashboard</title>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
<script type="module" src="/main.tsx"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import "@mantine/core/styles.css";
|
|
2
|
+
import "@mantine/notifications/styles.css";
|
|
3
|
+
import { StrictMode } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import DashboardApp from "./App";
|
|
6
|
+
import "./styles.css";
|
|
7
|
+
|
|
8
|
+
createRoot(document.getElementById("root")!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<DashboardApp />
|
|
11
|
+
</StrictMode>,
|
|
12
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { NotificationSnapshot } from "./types";
|
|
3
|
+
import {
|
|
4
|
+
clearNotificationHistory,
|
|
5
|
+
notificationAppeared,
|
|
6
|
+
notificationClicked,
|
|
7
|
+
type OverlayState,
|
|
8
|
+
} from "./overlay-state";
|
|
9
|
+
|
|
10
|
+
const taskUpdate: NotificationSnapshot = {
|
|
11
|
+
id: "task-one-completed",
|
|
12
|
+
taskId: "task-one",
|
|
13
|
+
title: "Review reconciliation",
|
|
14
|
+
status: "completed",
|
|
15
|
+
event: "completed",
|
|
16
|
+
turnRef: "turn-two",
|
|
17
|
+
turnId: null,
|
|
18
|
+
timestamp: "2026-08-30T08:10:00.000Z",
|
|
19
|
+
summary: "Review completed.",
|
|
20
|
+
};
|
|
21
|
+
const unrelated: NotificationSnapshot = {
|
|
22
|
+
...taskUpdate,
|
|
23
|
+
id: "task-two-failed",
|
|
24
|
+
taskId: "task-two",
|
|
25
|
+
title: "Validate fixture",
|
|
26
|
+
status: "failed",
|
|
27
|
+
event: "failed",
|
|
28
|
+
turnRef: "turn-three",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function state(overrides: Partial<OverlayState> = {}): OverlayState {
|
|
32
|
+
return {
|
|
33
|
+
detailTaskId: null,
|
|
34
|
+
detailOpened: false,
|
|
35
|
+
highlightTurnRef: null,
|
|
36
|
+
notifications: [],
|
|
37
|
+
refreshGeneration: 0,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("overlay coordination", () => {
|
|
43
|
+
test("a later notification changes visibility without changing the open detail", () => {
|
|
44
|
+
const open = state({ detailTaskId: "task-one", detailOpened: true });
|
|
45
|
+
expect(notificationAppeared(open, [taskUpdate])).toEqual({
|
|
46
|
+
...open,
|
|
47
|
+
notifications: [taskUpdate],
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("clicking a notification dismisses only it and opens its detail above prior content", () => {
|
|
52
|
+
const next = notificationClicked(state({ notifications: [taskUpdate, unrelated] }), taskUpdate, true);
|
|
53
|
+
expect(next.detailOpened).toBe(true);
|
|
54
|
+
expect(next.detailTaskId).toBe("task-one");
|
|
55
|
+
expect(next.highlightTurnRef).toBe("turn-two");
|
|
56
|
+
expect(next.notifications).toEqual([unrelated]);
|
|
57
|
+
expect(next.refreshGeneration).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("clicking an update for the already-open task refreshes and highlights without closing unrelated messages", () => {
|
|
61
|
+
const current = state({
|
|
62
|
+
detailTaskId: "task-one",
|
|
63
|
+
detailOpened: true,
|
|
64
|
+
notifications: [taskUpdate, unrelated],
|
|
65
|
+
refreshGeneration: 4,
|
|
66
|
+
});
|
|
67
|
+
const next = notificationClicked(current, taskUpdate, true);
|
|
68
|
+
expect(next.detailTaskId).toBe(current.detailTaskId);
|
|
69
|
+
expect(next.detailOpened).toBe(true);
|
|
70
|
+
expect(next.highlightTurnRef).toBe("turn-two");
|
|
71
|
+
expect(next.refreshGeneration).toBe(5);
|
|
72
|
+
expect(next.notifications).toEqual([unrelated]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("clearing notification history leaves detail state untouched", () => {
|
|
76
|
+
const current = state({ detailTaskId: "task-one", detailOpened: true, notifications: [taskUpdate] });
|
|
77
|
+
expect(clearNotificationHistory(current)).toEqual({ ...current, notifications: [] });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { NotificationSnapshot } from "./types";
|
|
2
|
+
|
|
3
|
+
export interface OverlayState {
|
|
4
|
+
detailTaskId: string | null;
|
|
5
|
+
detailOpened: boolean;
|
|
6
|
+
highlightTurnRef: string | null;
|
|
7
|
+
notifications: NotificationSnapshot[];
|
|
8
|
+
refreshGeneration: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function notificationAppeared(
|
|
12
|
+
state: OverlayState,
|
|
13
|
+
notifications: NotificationSnapshot[],
|
|
14
|
+
): OverlayState {
|
|
15
|
+
// Visibility changes only; focus remains where the operator left it.
|
|
16
|
+
return { ...state, notifications };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function notificationClicked(
|
|
20
|
+
state: OverlayState,
|
|
21
|
+
notification: NotificationSnapshot,
|
|
22
|
+
taskAvailable: boolean,
|
|
23
|
+
): OverlayState {
|
|
24
|
+
const notifications = state.notifications.filter(({ id }) => id !== notification.id);
|
|
25
|
+
if (!taskAvailable) return { ...state, notifications };
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
detailTaskId: notification.taskId,
|
|
29
|
+
detailOpened: true,
|
|
30
|
+
highlightTurnRef: notification.turnRef ?? notification.turnId,
|
|
31
|
+
notifications,
|
|
32
|
+
refreshGeneration: state.refreshGeneration + 1,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearNotificationHistory(state: OverlayState): OverlayState {
|
|
37
|
+
return { ...state, notifications: [] };
|
|
38
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { fixtureTask } from "./fixtures";
|
|
5
|
+
import { turnUsageView, usageStillCalculating, usageView } from "./presentation";
|
|
6
|
+
import { ShimmerText } from "./components/ShimmerText";
|
|
7
|
+
import { GitHubLinks } from "./components/GitHubLinks";
|
|
8
|
+
import { UsagePanel } from "./components/UsagePanel";
|
|
9
|
+
|
|
10
|
+
describe("token and working presentation", () => {
|
|
11
|
+
test("keeps pending, calculating, ready cost, and unavailable wording distinct", () => {
|
|
12
|
+
expect(usageView(fixtureTask()).label).toBe("Token usage pending");
|
|
13
|
+
expect(usageView(fixtureTask({
|
|
14
|
+
status: "completed",
|
|
15
|
+
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
16
|
+
})).label).toBe("Calculating token usage");
|
|
17
|
+
expect(usageView(fixtureTask({
|
|
18
|
+
status: "completed",
|
|
19
|
+
usage: {
|
|
20
|
+
generationTurnRef: "turn-one",
|
|
21
|
+
status: "available",
|
|
22
|
+
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
23
|
+
},
|
|
24
|
+
})).label).toBe("12,345 tokens · Estimated cost $0.1234");
|
|
25
|
+
expect(usageView(fixtureTask({ status: "completed", usage: { status: "unavailable" } })).label)
|
|
26
|
+
.toBe("Token usage unavailable");
|
|
27
|
+
expect(usageView(fixtureTask({
|
|
28
|
+
status: "completed",
|
|
29
|
+
usage: { status: "unavailable", reason: "This task has no linked Codex thread." },
|
|
30
|
+
})).label).toBe("This task has no linked Codex thread.");
|
|
31
|
+
expect(usageView(fixtureTask({ status: "completed", usage: undefined })).label)
|
|
32
|
+
.toBe("Calculating token usage");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps cumulative usage visible as known so far during a newer working turn", () => {
|
|
36
|
+
const view = usageView(fixtureTask({
|
|
37
|
+
status: "working",
|
|
38
|
+
turnRef: "turn-two",
|
|
39
|
+
usage: {
|
|
40
|
+
generationTurnRef: "turn-one",
|
|
41
|
+
status: "calculating",
|
|
42
|
+
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
expect(view.kind).toBe("ready");
|
|
46
|
+
expect(view.label).toBe("12,345 tokens · Estimated cost $0.1234 · known so far");
|
|
47
|
+
if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("distinguishes available, pending, calculating, and unavailable per-turn usage", () => {
|
|
51
|
+
const task = fixtureTask({
|
|
52
|
+
usage: {
|
|
53
|
+
status: "available",
|
|
54
|
+
turns: {
|
|
55
|
+
"turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const completedTurn = {
|
|
60
|
+
...task.turns![0],
|
|
61
|
+
result: {
|
|
62
|
+
status: "completed" as const,
|
|
63
|
+
summary: "Done.",
|
|
64
|
+
updatedAt: task.updatedAt,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.1200");
|
|
68
|
+
expect(turnUsageView(task, { ...completedTurn, result: null }).label).toBe("Turn usage pending");
|
|
69
|
+
expect(turnUsageView({
|
|
70
|
+
...task,
|
|
71
|
+
usage: { status: "calculating", turns: { "turn-one": { status: "calculating" } } },
|
|
72
|
+
}, completedTurn).label).toBe("Calculating turn usage");
|
|
73
|
+
expect(turnUsageView({ ...task, usage: { status: "unavailable", turns: {} } }, completedTurn).label)
|
|
74
|
+
.toBe("Turn usage unavailable");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("polling is reserved for terminal usage that is actually calculating", () => {
|
|
78
|
+
expect(usageStillCalculating(fixtureTask())).toBe(false);
|
|
79
|
+
expect(usageStillCalculating(fixtureTask({
|
|
80
|
+
status: "completed",
|
|
81
|
+
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
82
|
+
}))).toBe(true);
|
|
83
|
+
expect(usageStillCalculating(fixtureTask({
|
|
84
|
+
status: "completed",
|
|
85
|
+
usage: { status: "unavailable", turns: { old: { status: "calculating" } } },
|
|
86
|
+
}))).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("marks working text with the approved text shimmer state", () => {
|
|
90
|
+
render(<ShimmerText>In progress</ShimmerText>);
|
|
91
|
+
expect(screen.getByText("In progress")).toHaveClass("taskchef-shimmer");
|
|
92
|
+
expect(screen.getByText("In progress")).toHaveAttribute("data-animation", "text-shimmer");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("summarizes ready usage as four operator-facing cards", () => {
|
|
96
|
+
render(
|
|
97
|
+
<MantineProvider>
|
|
98
|
+
<UsagePanel task={fixtureTask({
|
|
99
|
+
status: "completed",
|
|
100
|
+
usage: {
|
|
101
|
+
generationTurnRef: "turn-one",
|
|
102
|
+
status: "available",
|
|
103
|
+
task: {
|
|
104
|
+
inputTokens: 100,
|
|
105
|
+
cachedInputTokens: 200,
|
|
106
|
+
outputTokens: 30,
|
|
107
|
+
reasoningOutputTokens: 10,
|
|
108
|
+
totalTokens: 330,
|
|
109
|
+
estimatedCostUsd: 0.12,
|
|
110
|
+
models: { "gpt-5.6-sol": {}, "gpt-5.6-luna": {} },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
})} />
|
|
114
|
+
</MantineProvider>,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
118
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.1200");
|
|
119
|
+
expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
|
|
120
|
+
expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
|
|
121
|
+
expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("uses the same borderless link treatment wherever links are rendered", () => {
|
|
125
|
+
render(<MantineProvider><GitHubLinks task={fixtureTask()} /></MantineProvider>);
|
|
126
|
+
expect(screen.getByRole("link")).toHaveClass("taskchef-github-link");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Task, UsageNumbers, UsageProjection } from "./types";
|
|
2
|
+
|
|
3
|
+
export type UsageView =
|
|
4
|
+
| { kind: "pending"; label: "Token usage pending" }
|
|
5
|
+
| { kind: "calculating"; label: "Calculating token usage" }
|
|
6
|
+
| { kind: "ready"; knownSoFar: boolean; label: string; usage: UsageNumbers }
|
|
7
|
+
| { kind: "unavailable"; label: string };
|
|
8
|
+
|
|
9
|
+
export function usageView(task: Task): UsageView {
|
|
10
|
+
const usage: UsageProjection | null | undefined = task.usage;
|
|
11
|
+
const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
|
|
12
|
+
const priorGeneration = usage?.generationTurnRef != null
|
|
13
|
+
&& currentTurnRef != null
|
|
14
|
+
&& usage.generationTurnRef !== currentTurnRef;
|
|
15
|
+
if (task.status === "working") {
|
|
16
|
+
if (usage?.task) return readyUsageView(usage.task, true);
|
|
17
|
+
return { kind: "pending", label: "Token usage pending" };
|
|
18
|
+
}
|
|
19
|
+
if (usage?.status === "calculating" || priorGeneration) {
|
|
20
|
+
return { kind: "calculating", label: "Calculating token usage" };
|
|
21
|
+
}
|
|
22
|
+
if (usage?.status === "available" && usage.task) {
|
|
23
|
+
return readyUsageView(usage.task, false);
|
|
24
|
+
}
|
|
25
|
+
if (!usage && task.threadId && ["completed", "failed", "needs_input"].includes(task.status ?? "")) {
|
|
26
|
+
return { kind: "calculating", label: "Calculating token usage" };
|
|
27
|
+
}
|
|
28
|
+
return { kind: "unavailable", label: usage?.reason ?? "Token usage unavailable" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readyUsageView(usage: UsageNumbers, knownSoFar: boolean): UsageView {
|
|
32
|
+
const tokens = new Intl.NumberFormat().format(usage.totalTokens);
|
|
33
|
+
const cost = usage.estimatedCostUsd == null
|
|
34
|
+
? "Estimated cost unavailable"
|
|
35
|
+
: `Estimated cost $${usage.estimatedCostUsd.toFixed(4)}`;
|
|
36
|
+
return {
|
|
37
|
+
kind: "ready",
|
|
38
|
+
knownSoFar,
|
|
39
|
+
label: `${tokens} tokens · ${cost}${knownSoFar ? " · known so far" : ""}`,
|
|
40
|
+
usage,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[number]) {
|
|
45
|
+
if (turn.result === null) return { kind: "pending" as const, label: "Turn usage pending" };
|
|
46
|
+
const identity = turn.turnRef ?? turn.turnId;
|
|
47
|
+
const usage = identity ? task.usage?.turns?.[identity] : null;
|
|
48
|
+
if (usage?.status === "calculating") {
|
|
49
|
+
return { kind: "calculating" as const, label: "Calculating turn usage" };
|
|
50
|
+
}
|
|
51
|
+
if (usage?.status === "available" && typeof usage.totalTokens === "number") {
|
|
52
|
+
const tokens = new Intl.NumberFormat().format(usage.totalTokens);
|
|
53
|
+
const cost = usage.estimatedCostUsd == null
|
|
54
|
+
? "Estimated cost unavailable"
|
|
55
|
+
: `Estimated cost $${usage.estimatedCostUsd.toFixed(4)}`;
|
|
56
|
+
return { kind: "ready" as const, label: `${tokens} tokens · ${cost}` };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
kind: "unavailable" as const,
|
|
60
|
+
label: usage?.reason ?? "Turn usage unavailable",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function usageStillCalculating(task: Task) {
|
|
65
|
+
return task.status !== "working"
|
|
66
|
+
&& (task.usage?.status === "calculating"
|
|
67
|
+
|| Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function statusColor(status: Task["status"] | "interrupted") {
|
|
71
|
+
if (status === "completed") return "teal";
|
|
72
|
+
if (status === "needs_input") return "yellow";
|
|
73
|
+
if (status === "failed") return "red";
|
|
74
|
+
if (status === "working") return "blue";
|
|
75
|
+
return "gray";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function statusLabel(status: Task["status"] | "interrupted") {
|
|
79
|
+
return status === null ? "Unresolved" : status.replaceAll("_", " ");
|
|
80
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--taskchef-page: light-dark(#f3f5f2, #171a18);
|
|
3
|
+
--taskchef-surface-muted: light-dark(#f6f7f5, #252a27);
|
|
4
|
+
--taskchef-border: light-dark(#dfe3de, #363c38);
|
|
5
|
+
--taskchef-text: light-dark(#202521, #edf1ee);
|
|
6
|
+
--taskchef-muted: light-dark(#657068, #aab4ad);
|
|
7
|
+
--taskchef-accent: light-dark(#087f5b, #38d9a9);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
background: var(--taskchef-page);
|
|
15
|
+
color: var(--taskchef-text);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.taskchef-shell { min-height: 100vh; background: var(--taskchef-page); }
|
|
19
|
+
.taskchef-shell header { border-bottom: 1px solid var(--taskchef-border); }
|
|
20
|
+
.taskchef-header { padding-top: 28px; padding-bottom: 22px; }
|
|
21
|
+
.taskchef-header-copy { min-width: 0; }
|
|
22
|
+
.taskchef-header-actions { align-self: stretch; flex: none; }
|
|
23
|
+
.taskchef-connection { align-self: flex-end; }
|
|
24
|
+
.taskchef-version { margin-inline-start: 0.45rem; color: var(--taskchef-muted); font-weight: 600; letter-spacing: 0; text-transform: none; }
|
|
25
|
+
.taskchef-brand-icon, .taskchef-brand-icon img { display: block; width: 42px; height: 42px; }
|
|
26
|
+
.taskchef-main { padding-top: var(--mantine-spacing-md); }
|
|
27
|
+
.taskchef-toolbar { padding-top: var(--mantine-spacing-md); background: light-dark(rgb(255 255 255 / 86%), rgb(34 39 36 / 88%)); }
|
|
28
|
+
.taskchef-toolbar .mantine-InputWrapper-root { flex: 1 1 190px; max-width: 240px; }
|
|
29
|
+
.taskchef-toolbar .mantine-InputWrapper-label, .taskchef-filter-label { font-size: var(--mantine-font-size-sm); font-weight: 600; line-height: var(--mantine-line-height-sm); }
|
|
30
|
+
.taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
|
|
31
|
+
.taskchef-task-count-field { align-self: flex-end; min-width: 90px; }
|
|
32
|
+
.taskchef-count-value { display: flex; align-items: center; height: 36px; white-space: nowrap; }
|
|
33
|
+
.taskchef-task-row { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
34
|
+
.taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
|
|
35
|
+
.taskchef-task-main { min-width: 0; flex: 1; }
|
|
36
|
+
.taskchef-status-badge { flex: none; }
|
|
37
|
+
.taskchef-title-button {
|
|
38
|
+
all: unset;
|
|
39
|
+
border-radius: 3px;
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
overflow-wrap: anywhere;
|
|
42
|
+
}
|
|
43
|
+
.taskchef-title-button:hover { color: var(--taskchef-accent); text-decoration: underline; text-underline-offset: 3px; }
|
|
44
|
+
.taskchef-title-button:focus-visible { outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 35%, transparent); outline-offset: 3px; }
|
|
45
|
+
.taskchef-summary-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 3px; max-width: 84ch; }
|
|
46
|
+
.taskchef-summary-grid .taskchef-field-label:not(:first-child) { margin-top: 7px; }
|
|
47
|
+
.taskchef-field-label { font-weight: 700; letter-spacing: 0.035em; text-transform: uppercase; }
|
|
48
|
+
.taskchef-preserve-lines { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
49
|
+
.taskchef-github-link { color: var(--taskchef-accent); text-decoration: none; }
|
|
50
|
+
.taskchef-github-link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
|
51
|
+
.taskchef-time { display: inline-flex; align-items: center; gap: 4px; width: fit-content; padding: 2px 3px; border-radius: 4px; color: var(--taskchef-muted); font-size: 0.7rem; line-height: 1.2; }
|
|
52
|
+
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
53
|
+
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
54
|
+
.taskchef-time-label { white-space: nowrap; }
|
|
55
|
+
.taskchef-empty { border-style: dashed; background: transparent; }
|
|
56
|
+
|
|
57
|
+
.taskchef-notifications {
|
|
58
|
+
position: fixed;
|
|
59
|
+
z-index: 400;
|
|
60
|
+
right: 18px;
|
|
61
|
+
bottom: 18px;
|
|
62
|
+
width: min(360px, calc(100vw - 48px));
|
|
63
|
+
background: light-dark(#fff, #202421);
|
|
64
|
+
}
|
|
65
|
+
.taskchef-notification { background: var(--taskchef-surface-muted); }
|
|
66
|
+
.taskchef-notification-list { padding-inline-end: 10px; }
|
|
67
|
+
.taskchef-notification-open { all: unset; display: block; min-width: 0; flex: 1; border-radius: var(--mantine-radius-sm); cursor: pointer; }
|
|
68
|
+
.taskchef-notification-open:focus-visible { outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 35%, transparent); outline-offset: 2px; }
|
|
69
|
+
|
|
70
|
+
.taskchef-subtle-panel { background: var(--taskchef-surface-muted); }
|
|
71
|
+
.taskchef-metric { min-width: 0; background: transparent; }
|
|
72
|
+
.taskchef-metric-value { overflow-wrap: anywhere; }
|
|
73
|
+
.taskchef-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
74
|
+
.taskchef-turn { background: var(--taskchef-surface-muted); }
|
|
75
|
+
.taskchef-turn-highlighted { border-color: var(--mantine-color-teal-6); box-shadow: inset 3px 0 var(--mantine-color-teal-6); outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 22%, transparent); }
|
|
76
|
+
.taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
77
|
+
.taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
78
|
+
.taskchef-code-panel { max-height: 280px; margin: 0; padding: 12px; overflow: auto; border-radius: var(--mantine-radius-md); background: var(--taskchef-surface-muted); white-space: pre-wrap; overflow-wrap: anywhere; font: 0.78rem/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
79
|
+
.taskchef-metadata { display: grid; grid-template-columns: minmax(120px, 150px) minmax(0, 1fr); margin: 0; font-size: var(--mantine-font-size-sm); }
|
|
80
|
+
.taskchef-metadata dt, .taskchef-metadata dd { padding: 8px 0; border-bottom: 1px solid var(--taskchef-border); overflow-wrap: anywhere; }
|
|
81
|
+
.taskchef-metadata dt { color: var(--taskchef-muted); }
|
|
82
|
+
.taskchef-metadata dd { margin: 0; }
|
|
83
|
+
|
|
84
|
+
.taskchef-shimmer {
|
|
85
|
+
display: inline-block;
|
|
86
|
+
width: fit-content;
|
|
87
|
+
max-width: 100%;
|
|
88
|
+
color: var(--taskchef-muted);
|
|
89
|
+
background: linear-gradient(100deg, var(--taskchef-muted) 48%, var(--taskchef-text) 50%, var(--taskchef-muted) 52%);
|
|
90
|
+
background-size: 250% 100%;
|
|
91
|
+
background-position: 100% 0;
|
|
92
|
+
background-clip: text;
|
|
93
|
+
-webkit-background-clip: text;
|
|
94
|
+
-webkit-text-fill-color: transparent;
|
|
95
|
+
animation: taskchef-text-shimmer 6.4s linear infinite;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@keyframes taskchef-text-shimmer {
|
|
99
|
+
0% { background-position: 100% 0; }
|
|
100
|
+
72%, 100% { background-position: -100% 0; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@media (prefers-reduced-motion: reduce) {
|
|
104
|
+
.taskchef-shimmer { background: none; animation: none; -webkit-text-fill-color: currentcolor; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.taskchef-visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
108
|
+
|
|
109
|
+
@media (max-width: 48em) {
|
|
110
|
+
.taskchef-toolbar .mantine-InputWrapper-root { max-width: none; }
|
|
111
|
+
.taskchef-toolbar .mantine-Group-root { align-items: stretch; }
|
|
112
|
+
.taskchef-task-count-field { min-width: 76px; }
|
|
113
|
+
.taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
|
|
114
|
+
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
115
|
+
.taskchef-metadata { grid-template-columns: 1fr; }
|
|
116
|
+
.taskchef-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
117
|
+
.taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@media (max-width: 34em) {
|
|
121
|
+
.taskchef-header { padding-top: 22px; padding-bottom: 18px; }
|
|
122
|
+
.taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
|
|
123
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
|
2
|
+
|
|
3
|
+
class ResizeObserverStub {
|
|
4
|
+
observe() {}
|
|
5
|
+
unobserve() {}
|
|
6
|
+
disconnect() {}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
Object.defineProperty(globalThis, "ResizeObserver", {
|
|
10
|
+
configurable: true,
|
|
11
|
+
value: ResizeObserverStub,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
Object.defineProperty(globalThis, "matchMedia", {
|
|
15
|
+
configurable: true,
|
|
16
|
+
value: (query: string) => ({
|
|
17
|
+
matches: query.includes("prefers-reduced-motion") ? false : false,
|
|
18
|
+
media: query,
|
|
19
|
+
onchange: null,
|
|
20
|
+
addEventListener() {},
|
|
21
|
+
removeEventListener() {},
|
|
22
|
+
addListener() {},
|
|
23
|
+
removeListener() {},
|
|
24
|
+
dispatchEvent: () => false,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
Object.defineProperty(globalThis.HTMLElement.prototype, "scrollIntoView", {
|
|
29
|
+
configurable: true,
|
|
30
|
+
value() {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
Object.defineProperty(globalThis.CSS, "escape", {
|
|
34
|
+
configurable: true,
|
|
35
|
+
value: (value: string) => value.replaceAll('"', '\\"'),
|
|
36
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type TaskStatus = "working" | "needs_input" | "completed" | "failed" | null;
|
|
2
|
+
|
|
3
|
+
export interface Project {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
githubRepos: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TaskResult {
|
|
11
|
+
status: "needs_input" | "completed" | "failed" | "interrupted";
|
|
12
|
+
summary: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
turnRef?: string | null;
|
|
15
|
+
turnId?: string | null;
|
|
16
|
+
provenance?: { kind: string } | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskTurn {
|
|
20
|
+
requestSummary: string | null;
|
|
21
|
+
result: TaskResult | null;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
turnRef: string | null;
|
|
24
|
+
turnId: string | null;
|
|
25
|
+
provenance?: { kind: string } | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface GitHubLink {
|
|
29
|
+
label: string;
|
|
30
|
+
url: string;
|
|
31
|
+
type?: "issue" | "pull" | "generic";
|
|
32
|
+
owner?: string;
|
|
33
|
+
repository?: string;
|
|
34
|
+
number?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UsageNumbers {
|
|
38
|
+
inputTokens?: number;
|
|
39
|
+
cachedInputTokens?: number;
|
|
40
|
+
outputTokens?: number;
|
|
41
|
+
reasoningOutputTokens?: number;
|
|
42
|
+
totalTokens: number;
|
|
43
|
+
estimatedCostUsd?: number | null;
|
|
44
|
+
models?: Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface UsageProjection {
|
|
48
|
+
generationTurnRef?: string | null;
|
|
49
|
+
status: "available" | "calculating" | "pending" | "unavailable";
|
|
50
|
+
task?: UsageNumbers | null;
|
|
51
|
+
turns?: Record<string, Partial<UsageNumbers> & {
|
|
52
|
+
status?: "available" | "calculating" | "pending" | "unavailable";
|
|
53
|
+
reason?: string;
|
|
54
|
+
}>;
|
|
55
|
+
reason?: string;
|
|
56
|
+
message?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Task {
|
|
60
|
+
schemaVersion?: number;
|
|
61
|
+
id: string;
|
|
62
|
+
title: string;
|
|
63
|
+
instruction: string;
|
|
64
|
+
project: Project;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
updatedAt: string;
|
|
67
|
+
meaningfulUpdatedAt?: string;
|
|
68
|
+
updatedBy?: string;
|
|
69
|
+
status: TaskStatus;
|
|
70
|
+
summary: string | null;
|
|
71
|
+
threadId: string | null;
|
|
72
|
+
turnRef: string | null;
|
|
73
|
+
turnId: string | null;
|
|
74
|
+
lastResult: TaskResult | null;
|
|
75
|
+
latestTurn: TaskTurn | null;
|
|
76
|
+
turns?: TaskTurn[];
|
|
77
|
+
results?: TaskResult[];
|
|
78
|
+
usage?: UsageProjection | null;
|
|
79
|
+
relatedGitHubLinks?: GitHubLink[];
|
|
80
|
+
relatedGitHubLinksTruncated?: boolean;
|
|
81
|
+
relatedGitHubRepository?: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface NotificationSnapshot {
|
|
85
|
+
id: string;
|
|
86
|
+
taskId: string;
|
|
87
|
+
title: string;
|
|
88
|
+
status: TaskStatus;
|
|
89
|
+
event: string;
|
|
90
|
+
turnRef: string | null;
|
|
91
|
+
turnId: string | null;
|
|
92
|
+
timestamp: string | null;
|
|
93
|
+
summary: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DashboardSnapshot {
|
|
97
|
+
healthy?: boolean;
|
|
98
|
+
tasks: Task[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ManualTransitionResponse {
|
|
102
|
+
ok: boolean;
|
|
103
|
+
code?: string;
|
|
104
|
+
message?: string;
|
|
105
|
+
task?: Task | null;
|
|
106
|
+
}
|