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,241 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { fixtureTask } from "./fixtures";
|
|
5
|
+
import {
|
|
6
|
+
formatCompactTokens,
|
|
7
|
+
formatEstimatedCost,
|
|
8
|
+
turnUsageView,
|
|
9
|
+
usageStillCalculating,
|
|
10
|
+
usageView,
|
|
11
|
+
} from "./presentation";
|
|
12
|
+
import { ShimmerText } from "./components/ShimmerText";
|
|
13
|
+
import { GitHubLinks } from "./components/GitHubLinks";
|
|
14
|
+
import { LinkedText } from "./components/LinkedText";
|
|
15
|
+
import { UsagePanel } from "./components/UsagePanel";
|
|
16
|
+
|
|
17
|
+
describe("token and working presentation", () => {
|
|
18
|
+
test("keeps pending, calculating, ready cost, and unavailable wording distinct", () => {
|
|
19
|
+
expect(usageView(fixtureTask()).label).toBe("Token usage pending");
|
|
20
|
+
expect(usageView(fixtureTask({
|
|
21
|
+
status: "completed",
|
|
22
|
+
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
23
|
+
})).label).toBe("Calculating token usage");
|
|
24
|
+
expect(usageView(fixtureTask({
|
|
25
|
+
status: "completed",
|
|
26
|
+
usage: {
|
|
27
|
+
generationTurnRef: "turn-one",
|
|
28
|
+
status: "available",
|
|
29
|
+
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
30
|
+
},
|
|
31
|
+
})).label).toBe("12.35K tokens · Estimated cost $0.12");
|
|
32
|
+
expect(usageView(fixtureTask({ status: "completed", usage: { status: "unavailable" } })).label)
|
|
33
|
+
.toBe("Token usage unavailable");
|
|
34
|
+
expect(usageView(fixtureTask({
|
|
35
|
+
status: "completed",
|
|
36
|
+
usage: { status: "unavailable", reason: "This task has no linked Codex thread." },
|
|
37
|
+
})).label).toBe("This task has no linked Codex thread.");
|
|
38
|
+
expect(usageView(fixtureTask({ status: "completed", usage: undefined })).label)
|
|
39
|
+
.toBe("Calculating token usage");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("keeps cumulative usage visible as known so far during a newer working turn", () => {
|
|
43
|
+
const view = usageView(fixtureTask({
|
|
44
|
+
status: "working",
|
|
45
|
+
turnRef: "turn-two",
|
|
46
|
+
usage: {
|
|
47
|
+
generationTurnRef: "turn-one",
|
|
48
|
+
status: "calculating",
|
|
49
|
+
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
50
|
+
},
|
|
51
|
+
}));
|
|
52
|
+
expect(view.kind).toBe("ready");
|
|
53
|
+
expect(view.label).toBe("12.35K tokens · Estimated cost $0.12 · known so far");
|
|
54
|
+
if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("distinguishes available, pending, calculating, and unavailable per-turn usage", () => {
|
|
58
|
+
const task = fixtureTask({
|
|
59
|
+
usage: {
|
|
60
|
+
status: "available",
|
|
61
|
+
turns: {
|
|
62
|
+
"turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const completedTurn = {
|
|
67
|
+
...task.turns![0],
|
|
68
|
+
result: {
|
|
69
|
+
status: "completed" as const,
|
|
70
|
+
summary: "Done.",
|
|
71
|
+
updatedAt: task.updatedAt,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.12");
|
|
75
|
+
expect(turnUsageView(task, { ...completedTurn, result: null }).label).toBe("Turn usage pending");
|
|
76
|
+
expect(turnUsageView({
|
|
77
|
+
...task,
|
|
78
|
+
usage: { status: "calculating", turns: { "turn-one": { status: "calculating" } } },
|
|
79
|
+
}, completedTurn).label).toBe("Calculating turn usage");
|
|
80
|
+
expect(turnUsageView({ ...task, usage: { status: "unavailable", turns: {} } }, completedTurn).label)
|
|
81
|
+
.toBe("Turn usage unavailable");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("polling is reserved for terminal usage that is actually calculating", () => {
|
|
85
|
+
expect(usageStillCalculating(fixtureTask())).toBe(false);
|
|
86
|
+
expect(usageStillCalculating(fixtureTask({
|
|
87
|
+
status: "completed",
|
|
88
|
+
usage: { generationTurnRef: "turn-one", status: "calculating" },
|
|
89
|
+
}))).toBe(true);
|
|
90
|
+
expect(usageStillCalculating(fixtureTask({
|
|
91
|
+
status: "completed",
|
|
92
|
+
usage: { status: "unavailable", turns: { old: { status: "calculating" } } },
|
|
93
|
+
}))).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("marks working text with the approved text shimmer state", () => {
|
|
97
|
+
render(<ShimmerText>In progress</ShimmerText>);
|
|
98
|
+
expect(screen.getByText("In progress")).toHaveClass("taskchef-shimmer");
|
|
99
|
+
expect(screen.getByText("In progress")).toHaveAttribute("data-animation", "text-shimmer");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("summarizes ready usage as four operator-facing cards", () => {
|
|
103
|
+
render(
|
|
104
|
+
<MantineProvider>
|
|
105
|
+
<UsagePanel task={fixtureTask({
|
|
106
|
+
status: "completed",
|
|
107
|
+
usage: {
|
|
108
|
+
generationTurnRef: "turn-one",
|
|
109
|
+
status: "available",
|
|
110
|
+
task: {
|
|
111
|
+
inputTokens: 100,
|
|
112
|
+
cachedInputTokens: 200,
|
|
113
|
+
outputTokens: 30,
|
|
114
|
+
reasoningOutputTokens: 10,
|
|
115
|
+
totalTokens: 330,
|
|
116
|
+
estimatedCostUsd: 0.12,
|
|
117
|
+
models: { "gpt-5.6-sol": {}, "gpt-5.6-luna": {} },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
})} />
|
|
121
|
+
</MantineProvider>,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
125
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
|
|
126
|
+
expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
|
|
127
|
+
expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
|
|
128
|
+
expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("uses the same borderless link treatment wherever links are rendered", () => {
|
|
132
|
+
render(<MantineProvider><GitHubLinks task={fixtureTask()} /></MantineProvider>);
|
|
133
|
+
expect(screen.getByRole("link")).toHaveClass("taskchef-github-link");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("formats compact token boundaries without scientific notation", () => {
|
|
137
|
+
expect([
|
|
138
|
+
0,
|
|
139
|
+
999,
|
|
140
|
+
1_000,
|
|
141
|
+
1_100,
|
|
142
|
+
12_345,
|
|
143
|
+
999_999,
|
|
144
|
+
1_320_000,
|
|
145
|
+
1_000_000_000,
|
|
146
|
+
].map((value) => formatCompactTokens(value, "en-US"))).toEqual([
|
|
147
|
+
"0",
|
|
148
|
+
"999",
|
|
149
|
+
"1K",
|
|
150
|
+
"1.1K",
|
|
151
|
+
"12.35K",
|
|
152
|
+
"1M",
|
|
153
|
+
"1.32M",
|
|
154
|
+
"1B",
|
|
155
|
+
]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("formats estimates to two decimals across zero, sub-cent, missing, and large values", () => {
|
|
159
|
+
expect(formatEstimatedCost(0, "en-US")).toBe("$0.00");
|
|
160
|
+
expect(formatEstimatedCost(0.0049, "en-US")).toBe("$0.00");
|
|
161
|
+
expect(formatEstimatedCost(12.344, "en-US")).toBe("$12.34");
|
|
162
|
+
expect(formatEstimatedCost(12.345, "en-US")).toBe("$12.35");
|
|
163
|
+
expect(formatEstimatedCost(null, "en-US")).toBe("—");
|
|
164
|
+
expect(formatEstimatedCost(Number.NaN, "en-US")).toBe("—");
|
|
165
|
+
expect(formatEstimatedCost(1_234_567_890.125, "en-US")).toBe("$1,234,567,890.13");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("exposes full token precision and the unrounded estimate on ready metric cards", () => {
|
|
169
|
+
render(
|
|
170
|
+
<MantineProvider>
|
|
171
|
+
<UsagePanel task={fixtureTask({
|
|
172
|
+
status: "completed",
|
|
173
|
+
usage: {
|
|
174
|
+
status: "available",
|
|
175
|
+
task: { totalTokens: 1_324_567, estimatedCostUsd: 12.3449 },
|
|
176
|
+
},
|
|
177
|
+
})} />
|
|
178
|
+
</MantineProvider>,
|
|
179
|
+
);
|
|
180
|
+
expect(screen.getByLabelText("1,324,567 tokens")).toHaveTextContent("1.32M");
|
|
181
|
+
expect(screen.getByLabelText("1,324,567 tokens")).toHaveAttribute("title", "1,324,567 tokens");
|
|
182
|
+
expect(screen.getByLabelText("Estimated cost $12.34")).toHaveTextContent("$12.34");
|
|
183
|
+
expect(screen.getByLabelText("Estimated cost $12.34")).toHaveAttribute(
|
|
184
|
+
"title",
|
|
185
|
+
"Unrounded estimate: $12.3449",
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("groups, deduplicates, and naturally sorts compact related links by repository", () => {
|
|
190
|
+
const task = fixtureTask({
|
|
191
|
+
relatedGitHubLinks: [
|
|
192
|
+
["taskchef", "83", "pull"],
|
|
193
|
+
["guzuoshou-workspace", "124", "issue"],
|
|
194
|
+
["taskchef", "79", "issue"],
|
|
195
|
+
["guzuoshou-workspace", "108", "issue"],
|
|
196
|
+
["taskchef", "80", "issue"],
|
|
197
|
+
["guzuoshou-workspace", "109", "pull"],
|
|
198
|
+
["taskchef", "79", "pull"],
|
|
199
|
+
].map(([repository, number, type]) => ({
|
|
200
|
+
label: `favoyang/${repository}#${number}`,
|
|
201
|
+
number,
|
|
202
|
+
owner: "favoyang",
|
|
203
|
+
repository,
|
|
204
|
+
type: type as "issue" | "pull",
|
|
205
|
+
url: `https://github.com/favoyang/${repository}/${type === "pull" ? "pull" : "issues"}/${number}`,
|
|
206
|
+
})),
|
|
207
|
+
});
|
|
208
|
+
const { container } = render(<MantineProvider><GitHubLinks task={task} /></MantineProvider>);
|
|
209
|
+
const groups = container.querySelectorAll(".taskchef-github-group");
|
|
210
|
+
expect(groups).toHaveLength(2);
|
|
211
|
+
expect(within(groups[0] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
212
|
+
.toEqual(["taskchef #79", "#80", "#83"]);
|
|
213
|
+
expect(within(groups[1] as HTMLElement).getAllByRole("link").map((link) => link.textContent))
|
|
214
|
+
.toEqual(["guzuoshou-workspace #108", "#109", "#124"]);
|
|
215
|
+
const first = screen.getByRole("link", { name: /favoyang\/taskchef Issue #79/ });
|
|
216
|
+
expect(first).toHaveAttribute("href", "https://github.com/favoyang/taskchef/issues/79");
|
|
217
|
+
expect(first).toHaveAttribute("target", "_blank");
|
|
218
|
+
expect(first).toHaveAttribute("rel", "noopener noreferrer");
|
|
219
|
+
expect(screen.queryByText(/PR #|Issue #/)).not.toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("keeps established inline request and result link labels", () => {
|
|
223
|
+
const task = fixtureTask({
|
|
224
|
+
project: {
|
|
225
|
+
name: "TaskChef Preview",
|
|
226
|
+
path: "/tmp/taskchef-preview-project",
|
|
227
|
+
githubRepos: ["https://github.com/favoyang/taskchef"],
|
|
228
|
+
},
|
|
229
|
+
relatedGitHubRepository: "favoyang/taskchef",
|
|
230
|
+
});
|
|
231
|
+
const { container } = render(
|
|
232
|
+
<MantineProvider>
|
|
233
|
+
<div>
|
|
234
|
+
<LinkedText task={task} text="Review https://github.com/favoyang/taskchef/pull/83." />
|
|
235
|
+
</div>
|
|
236
|
+
</MantineProvider>,
|
|
237
|
+
);
|
|
238
|
+
expect(within(container).getByRole("link", { name: /favoyang\/taskchef PR #83/ }))
|
|
239
|
+
.toHaveTextContent("taskchef PR #83");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Task, UsageNumbers, UsageProjection } from "./types";
|
|
2
|
+
|
|
3
|
+
export function formatCompactTokens(value: number, locales?: Intl.LocalesArgument) {
|
|
4
|
+
return new Intl.NumberFormat(locales, {
|
|
5
|
+
maximumFractionDigits: 2,
|
|
6
|
+
notation: "compact",
|
|
7
|
+
}).format(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatFullTokens(value: number, locales?: Intl.LocalesArgument) {
|
|
11
|
+
return new Intl.NumberFormat(locales, { maximumFractionDigits: 2 }).format(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatEstimatedCost(value: number | null | undefined, locales?: Intl.LocalesArgument) {
|
|
15
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
|
|
16
|
+
return new Intl.NumberFormat(locales, {
|
|
17
|
+
currency: "USD",
|
|
18
|
+
currencyDisplay: "narrowSymbol",
|
|
19
|
+
maximumFractionDigits: 2,
|
|
20
|
+
minimumFractionDigits: 2,
|
|
21
|
+
style: "currency",
|
|
22
|
+
}).format(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UsageView =
|
|
26
|
+
| { kind: "pending"; label: "Token usage pending" }
|
|
27
|
+
| { kind: "calculating"; label: "Calculating token usage" }
|
|
28
|
+
| { kind: "ready"; knownSoFar: boolean; label: string; usage: UsageNumbers }
|
|
29
|
+
| { kind: "unavailable"; label: string };
|
|
30
|
+
|
|
31
|
+
export function usageView(task: Task): UsageView {
|
|
32
|
+
const usage: UsageProjection | null | undefined = task.usage;
|
|
33
|
+
const currentTurnRef = task.turnRef ?? task.latestTurn?.turnRef ?? null;
|
|
34
|
+
const priorGeneration = usage?.generationTurnRef != null
|
|
35
|
+
&& currentTurnRef != null
|
|
36
|
+
&& usage.generationTurnRef !== currentTurnRef;
|
|
37
|
+
if (task.status === "working") {
|
|
38
|
+
if (usage?.task) return readyUsageView(usage.task, true);
|
|
39
|
+
return { kind: "pending", label: "Token usage pending" };
|
|
40
|
+
}
|
|
41
|
+
if (usage?.status === "calculating" || priorGeneration) {
|
|
42
|
+
return { kind: "calculating", label: "Calculating token usage" };
|
|
43
|
+
}
|
|
44
|
+
if (usage?.status === "available" && usage.task) {
|
|
45
|
+
return readyUsageView(usage.task, false);
|
|
46
|
+
}
|
|
47
|
+
if (!usage && task.threadId && ["completed", "failed", "needs_input"].includes(task.status ?? "")) {
|
|
48
|
+
return { kind: "calculating", label: "Calculating token usage" };
|
|
49
|
+
}
|
|
50
|
+
return { kind: "unavailable", label: usage?.reason ?? "Token usage unavailable" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readyUsageView(usage: UsageNumbers, knownSoFar: boolean): UsageView {
|
|
54
|
+
const tokens = formatCompactTokens(usage.totalTokens);
|
|
55
|
+
const cost = usage.estimatedCostUsd == null
|
|
56
|
+
? "Estimated cost unavailable"
|
|
57
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
58
|
+
return {
|
|
59
|
+
kind: "ready",
|
|
60
|
+
knownSoFar,
|
|
61
|
+
label: `${tokens} tokens · ${cost}${knownSoFar ? " · known so far" : ""}`,
|
|
62
|
+
usage,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[number]) {
|
|
67
|
+
if (turn.result === null) return { kind: "pending" as const, label: "Turn usage pending" };
|
|
68
|
+
const identity = turn.turnRef ?? turn.turnId;
|
|
69
|
+
const usage = identity ? task.usage?.turns?.[identity] : null;
|
|
70
|
+
if (usage?.status === "calculating") {
|
|
71
|
+
return { kind: "calculating" as const, label: "Calculating turn usage" };
|
|
72
|
+
}
|
|
73
|
+
if (usage?.status === "available" && typeof usage.totalTokens === "number") {
|
|
74
|
+
const tokens = formatFullTokens(usage.totalTokens);
|
|
75
|
+
const cost = usage.estimatedCostUsd == null
|
|
76
|
+
? "Estimated cost unavailable"
|
|
77
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
78
|
+
return { kind: "ready" as const, label: `${tokens} tokens · ${cost}` };
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: "unavailable" as const,
|
|
82
|
+
label: usage?.reason ?? "Turn usage unavailable",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function usageStillCalculating(task: Task) {
|
|
87
|
+
return task.status !== "working"
|
|
88
|
+
&& (task.usage?.status === "calculating"
|
|
89
|
+
|| Object.values(task.usage?.turns ?? {}).some((turn) => turn.status === "calculating"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function statusColor(status: Task["status"] | "interrupted") {
|
|
93
|
+
if (status === "completed") return "teal";
|
|
94
|
+
if (status === "needs_input") return "yellow";
|
|
95
|
+
if (status === "failed") return "red";
|
|
96
|
+
if (status === "working") return "blue";
|
|
97
|
+
return "gray";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function statusLabel(status: Task["status"] | "interrupted") {
|
|
101
|
+
return status === null ? "Unresolved" : status.replaceAll("_", " ");
|
|
102
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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-github-link:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; border-radius: 2px; }
|
|
52
|
+
.taskchef-github-groups { display: grid; min-width: 0; gap: 3px; }
|
|
53
|
+
.taskchef-github-group { min-width: 0; }
|
|
54
|
+
.taskchef-github-reference-ambiguous { border-bottom: 2px dotted var(--mantine-color-yellow-7); cursor: help; }
|
|
55
|
+
.taskchef-github-reference-ambiguous:focus-visible { outline: 2px solid var(--mantine-color-yellow-7); outline-offset: 2px; }
|
|
56
|
+
.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; }
|
|
57
|
+
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
58
|
+
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
59
|
+
.taskchef-time-label { white-space: nowrap; }
|
|
60
|
+
.taskchef-empty { border-style: dashed; background: transparent; }
|
|
61
|
+
|
|
62
|
+
.taskchef-notifications {
|
|
63
|
+
position: fixed;
|
|
64
|
+
z-index: 400;
|
|
65
|
+
right: 18px;
|
|
66
|
+
bottom: 18px;
|
|
67
|
+
width: min(360px, calc(100vw - 48px));
|
|
68
|
+
background: light-dark(#fff, #202421);
|
|
69
|
+
}
|
|
70
|
+
.taskchef-notification { background: var(--taskchef-surface-muted); }
|
|
71
|
+
.taskchef-notification-list { padding-inline-end: 10px; }
|
|
72
|
+
.taskchef-notification-open { all: unset; display: block; min-width: 0; flex: 1; border-radius: var(--mantine-radius-sm); cursor: pointer; }
|
|
73
|
+
.taskchef-notification-open:focus-visible { outline: 3px solid color-mix(in srgb, var(--mantine-color-teal-6) 35%, transparent); outline-offset: 2px; }
|
|
74
|
+
|
|
75
|
+
.taskchef-subtle-panel { background: var(--taskchef-surface-muted); }
|
|
76
|
+
.taskchef-metric { min-width: 0; background: transparent; }
|
|
77
|
+
.taskchef-metric-value { overflow-wrap: anywhere; }
|
|
78
|
+
.taskchef-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--mantine-spacing-xs); }
|
|
79
|
+
.taskchef-turn { background: var(--taskchef-surface-muted); }
|
|
80
|
+
.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); }
|
|
81
|
+
.taskchef-turn-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
82
|
+
.taskchef-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
83
|
+
.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; }
|
|
84
|
+
.taskchef-metadata { display: grid; grid-template-columns: minmax(120px, 150px) minmax(0, 1fr); margin: 0; font-size: var(--mantine-font-size-sm); }
|
|
85
|
+
.taskchef-metadata dt, .taskchef-metadata dd { padding: 8px 0; border-bottom: 1px solid var(--taskchef-border); overflow-wrap: anywhere; }
|
|
86
|
+
.taskchef-metadata dt { color: var(--taskchef-muted); }
|
|
87
|
+
.taskchef-metadata dd { margin: 0; }
|
|
88
|
+
|
|
89
|
+
.taskchef-shimmer {
|
|
90
|
+
display: inline-block;
|
|
91
|
+
width: fit-content;
|
|
92
|
+
max-width: 100%;
|
|
93
|
+
color: var(--taskchef-muted);
|
|
94
|
+
background: linear-gradient(100deg, var(--taskchef-muted) 48%, var(--taskchef-text) 50%, var(--taskchef-muted) 52%);
|
|
95
|
+
background-size: 250% 100%;
|
|
96
|
+
background-position: 100% 0;
|
|
97
|
+
background-clip: text;
|
|
98
|
+
-webkit-background-clip: text;
|
|
99
|
+
-webkit-text-fill-color: transparent;
|
|
100
|
+
animation: taskchef-text-shimmer 6.4s linear infinite;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@keyframes taskchef-text-shimmer {
|
|
104
|
+
0% { background-position: 100% 0; }
|
|
105
|
+
72%, 100% { background-position: -100% 0; }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@media (prefers-reduced-motion: reduce) {
|
|
109
|
+
.taskchef-shimmer { background: none; animation: none; -webkit-text-fill-color: currentcolor; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.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; }
|
|
113
|
+
|
|
114
|
+
@media (max-width: 48em) {
|
|
115
|
+
.taskchef-toolbar .mantine-InputWrapper-root { max-width: none; }
|
|
116
|
+
.taskchef-toolbar .mantine-Group-root { align-items: stretch; }
|
|
117
|
+
.taskchef-task-count-field { min-width: 76px; }
|
|
118
|
+
.taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
|
|
119
|
+
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
120
|
+
.taskchef-metadata { grid-template-columns: 1fr; }
|
|
121
|
+
.taskchef-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
122
|
+
.taskchef-metadata dt { padding-bottom: 0; border-bottom: 0; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@media (max-width: 34em) {
|
|
126
|
+
.taskchef-header { padding-top: 22px; padding-bottom: 18px; }
|
|
127
|
+
.taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
|
|
128
|
+
}
|
|
@@ -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
|
+
}
|