taskchef 7.24.0 → 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 +1 -1
- package/src/dashboard/dist/app.js +36 -36
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/github-links.js +12 -4
- package/src/dashboard/react/components/ActivityTimeline.tsx +5 -2
- package/src/dashboard/react/components/GitHubLinks.tsx +37 -16
- package/src/dashboard/react/components/LinkedText.tsx +63 -0
- package/src/dashboard/react/components/TaskCard.tsx +5 -2
- package/src/dashboard/react/components/UsagePanel.tsx +41 -9
- package/src/dashboard/react/presentation.test.tsx +119 -6
- package/src/dashboard/react/presentation.ts +26 -4
- package/src/dashboard/react/styles.css +5 -0
|
@@ -60,12 +60,20 @@ function compareReferenceNumbers(left, right) {
|
|
|
60
60
|
|
|
61
61
|
export function groupRelatedGitHubLinks(links) {
|
|
62
62
|
const groups = new Map();
|
|
63
|
-
const
|
|
63
|
+
const seenLabels = new Set();
|
|
64
|
+
const seenNumbers = new Set();
|
|
65
|
+
const seenUrls = new Set();
|
|
64
66
|
for (const link of links ?? []) {
|
|
65
|
-
if (link.type === "repository" || seen.has(link.label)) continue;
|
|
66
|
-
seen.add(link.label);
|
|
67
67
|
const repository = repositoryIdentity(link);
|
|
68
|
-
|
|
68
|
+
const reference = link.number ? `${repository}#${link.number}` : null;
|
|
69
|
+
if (link.type === "repository"
|
|
70
|
+
|| !repository
|
|
71
|
+
|| seenLabels.has(link.label)
|
|
72
|
+
|| seenUrls.has(link.url)
|
|
73
|
+
|| (reference && seenNumbers.has(reference))) continue;
|
|
74
|
+
seenLabels.add(link.label);
|
|
75
|
+
seenUrls.add(link.url);
|
|
76
|
+
if (reference) seenNumbers.add(reference);
|
|
69
77
|
const group = groups.get(repository) ?? [];
|
|
70
78
|
group.push(link);
|
|
71
79
|
groups.set(repository, group);
|
|
@@ -3,6 +3,7 @@ import { mergeProjectedTurns, turnPresentation } from "../../state.js";
|
|
|
3
3
|
import { turnUsageView } from "../presentation";
|
|
4
4
|
import type { Task } from "../types";
|
|
5
5
|
import { RelativeTime } from "./RelativeTime";
|
|
6
|
+
import { LinkedText } from "./LinkedText";
|
|
6
7
|
import { ShimmerText } from "./ShimmerText";
|
|
7
8
|
import { StatusBadge } from "./StatusBadge";
|
|
8
9
|
|
|
@@ -34,14 +35,16 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
|
|
|
34
35
|
{presentation.sourceLabel && <Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>}
|
|
35
36
|
<Box>
|
|
36
37
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
37
|
-
<Text className="taskchef-preserve-lines" size="sm">
|
|
38
|
+
<Text className="taskchef-preserve-lines" size="sm">
|
|
39
|
+
<LinkedText task={task} text={turn.requestSummary ?? "Request not recorded."} />
|
|
40
|
+
</Text>
|
|
38
41
|
</Box>
|
|
39
42
|
<Box>
|
|
40
43
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
|
|
41
44
|
<Text className="taskchef-preserve-lines" size="sm">
|
|
42
45
|
{presentation.status === "working"
|
|
43
46
|
? <ShimmerText>{presentation.summary}</ShimmerText>
|
|
44
|
-
: presentation.summary}
|
|
47
|
+
: <LinkedText task={task} text={presentation.summary} />}
|
|
45
48
|
</Text>
|
|
46
49
|
</Box>
|
|
47
50
|
<Text
|
|
@@ -1,25 +1,46 @@
|
|
|
1
|
-
import { Anchor, Group, Text } from "@mantine/core";
|
|
2
|
-
import
|
|
1
|
+
import { Anchor, Box, Group, Text } from "@mantine/core";
|
|
2
|
+
import {
|
|
3
|
+
githubReferenceAccessibleLabel,
|
|
4
|
+
githubReferenceDisplayLabels,
|
|
5
|
+
groupRelatedGitHubLinks,
|
|
6
|
+
} from "../../github-links.js";
|
|
7
|
+
import type { GitHubLink, Task } from "../types";
|
|
3
8
|
|
|
4
9
|
export function GitHubLinks({ task }: { task: Task }) {
|
|
5
10
|
const links = task.relatedGitHubLinks ?? [];
|
|
6
11
|
if (links.length === 0 && !task.relatedGitHubLinksTruncated) return null;
|
|
12
|
+
const groups = groupRelatedGitHubLinks(links) as GitHubLink[][];
|
|
13
|
+
const displayLabels = githubReferenceDisplayLabels(groups.flat(), { related: true }) as string[];
|
|
14
|
+
let linkIndex = 0;
|
|
7
15
|
return (
|
|
8
|
-
<
|
|
9
|
-
{
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
<Box aria-label={`Related GitHub links for ${task.title}`} className="taskchef-github-groups" component="nav">
|
|
17
|
+
{groups.map((group) => (
|
|
18
|
+
<Group className="taskchef-github-group" gap={5} key={`${group[0].owner}/${group[0].repository}`} wrap="wrap">
|
|
19
|
+
{group.map((link) => {
|
|
20
|
+
const displayLabel = displayLabels[linkIndex];
|
|
21
|
+
linkIndex += 1;
|
|
22
|
+
const kind = link.type === "pull"
|
|
23
|
+
? ", GitHub pull request"
|
|
24
|
+
: link.type === "issue"
|
|
25
|
+
? ", GitHub issue"
|
|
26
|
+
: " on GitHub";
|
|
27
|
+
return (
|
|
28
|
+
<Anchor
|
|
29
|
+
aria-label={`${githubReferenceAccessibleLabel(link)}${kind} (opens in a new tab)`}
|
|
30
|
+
className="taskchef-github-link"
|
|
31
|
+
href={link.url}
|
|
32
|
+
key={link.url}
|
|
33
|
+
rel="noopener noreferrer"
|
|
34
|
+
size="xs"
|
|
35
|
+
target="_blank"
|
|
36
|
+
>
|
|
37
|
+
{displayLabel}
|
|
38
|
+
</Anchor>
|
|
39
|
+
);
|
|
40
|
+
})}
|
|
41
|
+
</Group>
|
|
21
42
|
))}
|
|
22
43
|
{task.relatedGitHubLinksTruncated && <Text c="dimmed" size="xs">More links in task history</Text>}
|
|
23
|
-
</
|
|
44
|
+
</Box>
|
|
24
45
|
);
|
|
25
46
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Anchor } from "@mantine/core";
|
|
2
|
+
import {
|
|
3
|
+
githubReferenceAccessibleLabel,
|
|
4
|
+
githubReferenceDisplayLabels,
|
|
5
|
+
referenceSegments,
|
|
6
|
+
} from "../../github-links.js";
|
|
7
|
+
import type { Task } from "../types";
|
|
8
|
+
|
|
9
|
+
interface ReferenceSegment {
|
|
10
|
+
kind: "ambiguous" | "link" | "text";
|
|
11
|
+
label?: string;
|
|
12
|
+
owner?: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
repository?: string;
|
|
15
|
+
text: string;
|
|
16
|
+
type?: "generic" | "issue" | "pull" | "repository";
|
|
17
|
+
url?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LinkedText({ task, text }: { task: Task; text: string }) {
|
|
21
|
+
const segments = referenceSegments(text, {
|
|
22
|
+
projectRepositories: task.project.githubRepos,
|
|
23
|
+
taskRepository: task.relatedGitHubRepository,
|
|
24
|
+
}) as ReferenceSegment[];
|
|
25
|
+
const links = segments.filter((segment) => segment.kind === "link");
|
|
26
|
+
const displayLabels = githubReferenceDisplayLabels(links) as string[];
|
|
27
|
+
let linkIndex = 0;
|
|
28
|
+
return segments.map((segment, index) => {
|
|
29
|
+
if (segment.kind === "text") return segment.text;
|
|
30
|
+
if (segment.kind === "ambiguous") {
|
|
31
|
+
return (
|
|
32
|
+
<span
|
|
33
|
+
aria-label={segment.reason}
|
|
34
|
+
className="taskchef-github-reference-ambiguous"
|
|
35
|
+
key={`${index}:${segment.text}`}
|
|
36
|
+
tabIndex={0}
|
|
37
|
+
title={segment.reason}
|
|
38
|
+
>
|
|
39
|
+
{segment.text}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
const displayLabel = displayLabels[linkIndex];
|
|
44
|
+
linkIndex += 1;
|
|
45
|
+
const kind = segment.type === "pull"
|
|
46
|
+
? ", GitHub pull request"
|
|
47
|
+
: segment.type === "issue"
|
|
48
|
+
? ", GitHub issue"
|
|
49
|
+
: segment.owner ? " on GitHub" : "";
|
|
50
|
+
return (
|
|
51
|
+
<Anchor
|
|
52
|
+
aria-label={`${githubReferenceAccessibleLabel(segment)}${kind} (opens in a new tab)`}
|
|
53
|
+
className="taskchef-github-link"
|
|
54
|
+
href={segment.url}
|
|
55
|
+
key={`${index}:${segment.url}`}
|
|
56
|
+
rel="noopener noreferrer"
|
|
57
|
+
target="_blank"
|
|
58
|
+
>
|
|
59
|
+
{displayLabel}
|
|
60
|
+
</Anchor>
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -3,6 +3,7 @@ import { IconExternalLink } from "@tabler/icons-react";
|
|
|
3
3
|
import { latestTurnPresentation } from "../../state.js";
|
|
4
4
|
import type { Task } from "../types";
|
|
5
5
|
import { GitHubLinks } from "./GitHubLinks";
|
|
6
|
+
import { LinkedText } from "./LinkedText";
|
|
6
7
|
import { RelativeTime } from "./RelativeTime";
|
|
7
8
|
import { ShimmerText } from "./ShimmerText";
|
|
8
9
|
import { StatusBadge } from "./StatusBadge";
|
|
@@ -34,12 +35,14 @@ export function TaskCard({
|
|
|
34
35
|
|
|
35
36
|
<Box className="taskchef-summary-grid">
|
|
36
37
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
37
|
-
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
|
|
38
|
+
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
|
|
39
|
+
<LinkedText task={task} text={latest.requestSummary} />
|
|
40
|
+
</Text>
|
|
38
41
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
|
|
39
42
|
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
|
|
40
43
|
{latest.resultStatus === "working"
|
|
41
44
|
? <ShimmerText>{latest.resultSummary}</ShimmerText>
|
|
42
|
-
: latest.resultSummary}
|
|
45
|
+
: <LinkedText task={task} text={latest.resultSummary} />}
|
|
43
46
|
</Text>
|
|
44
47
|
</Box>
|
|
45
48
|
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { Box, Group, Paper, Text } from "@mantine/core";
|
|
2
2
|
import { IconCoins } from "@tabler/icons-react";
|
|
3
3
|
import type { Task } from "../types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatCompactTokens,
|
|
6
|
+
formatEstimatedCost,
|
|
7
|
+
formatFullTokens,
|
|
8
|
+
usageView,
|
|
9
|
+
} from "../presentation";
|
|
5
10
|
import { ShimmerText } from "./ShimmerText";
|
|
6
11
|
|
|
7
12
|
export function UsagePanel({ task }: { task: Task }) {
|
|
@@ -22,18 +27,45 @@ export function UsagePanel({ task }: { task: Task }) {
|
|
|
22
27
|
const cached = usage.usage.cachedInputTokens ?? 0;
|
|
23
28
|
const input = usage.usage.inputTokens ?? 0;
|
|
24
29
|
const cacheRatio = input + cached > 0 ? `${Math.round((cached / (input + cached)) * 100)}%` : "—";
|
|
25
|
-
const values
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
const values: Array<{
|
|
31
|
+
accessibleValue?: string;
|
|
32
|
+
label: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
value: string;
|
|
35
|
+
}> = [
|
|
36
|
+
{
|
|
37
|
+
accessibleValue: `${formatFullTokens(usage.usage.totalTokens)} tokens`,
|
|
38
|
+
label: usage.knownSoFar ? "Tokens · known so far" : "Tokens",
|
|
39
|
+
title: `${formatFullTokens(usage.usage.totalTokens)} tokens`,
|
|
40
|
+
value: formatCompactTokens(usage.usage.totalTokens),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
accessibleValue: usage.usage.estimatedCostUsd == null
|
|
44
|
+
? "Estimated cost unavailable"
|
|
45
|
+
: `Estimated cost ${formatEstimatedCost(usage.usage.estimatedCostUsd)}`,
|
|
46
|
+
label: "Estimated cost",
|
|
47
|
+
title: usage.usage.estimatedCostUsd == null
|
|
48
|
+
? undefined
|
|
49
|
+
: `Unrounded estimate: $${usage.usage.estimatedCostUsd}`,
|
|
50
|
+
value: formatEstimatedCost(usage.usage.estimatedCostUsd),
|
|
51
|
+
},
|
|
52
|
+
{ label: "Model", value: Object.keys(usage.usage.models ?? {}).join(", ") || "—" },
|
|
53
|
+
{ label: "Cache ratio", value: cacheRatio },
|
|
54
|
+
];
|
|
31
55
|
return (
|
|
32
56
|
<Box aria-live="polite" className="taskchef-metrics">
|
|
33
|
-
{values.map((
|
|
57
|
+
{values.map(({ accessibleValue, label, title, value }) => (
|
|
34
58
|
<Paper className="taskchef-metric" key={label} p="sm" radius="md" withBorder>
|
|
35
59
|
<Text c="dimmed" size="xs">{label}</Text>
|
|
36
|
-
<Text
|
|
60
|
+
<Text
|
|
61
|
+
aria-label={accessibleValue}
|
|
62
|
+
className="taskchef-metric-value"
|
|
63
|
+
fw={650}
|
|
64
|
+
size="sm"
|
|
65
|
+
title={title}
|
|
66
|
+
>
|
|
67
|
+
{value}
|
|
68
|
+
</Text>
|
|
37
69
|
</Paper>
|
|
38
70
|
))}
|
|
39
71
|
</Box>
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { MantineProvider } from "@mantine/core";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { render, screen, within } from "@testing-library/react";
|
|
3
3
|
import { describe, expect, test } from "vitest";
|
|
4
4
|
import { fixtureTask } from "./fixtures";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
formatCompactTokens,
|
|
7
|
+
formatEstimatedCost,
|
|
8
|
+
turnUsageView,
|
|
9
|
+
usageStillCalculating,
|
|
10
|
+
usageView,
|
|
11
|
+
} from "./presentation";
|
|
6
12
|
import { ShimmerText } from "./components/ShimmerText";
|
|
7
13
|
import { GitHubLinks } from "./components/GitHubLinks";
|
|
14
|
+
import { LinkedText } from "./components/LinkedText";
|
|
8
15
|
import { UsagePanel } from "./components/UsagePanel";
|
|
9
16
|
|
|
10
17
|
describe("token and working presentation", () => {
|
|
@@ -21,7 +28,7 @@ describe("token and working presentation", () => {
|
|
|
21
28
|
status: "available",
|
|
22
29
|
task: { totalTokens: 12345, estimatedCostUsd: 0.1234 },
|
|
23
30
|
},
|
|
24
|
-
})).label).toBe("12
|
|
31
|
+
})).label).toBe("12.35K tokens · Estimated cost $0.12");
|
|
25
32
|
expect(usageView(fixtureTask({ status: "completed", usage: { status: "unavailable" } })).label)
|
|
26
33
|
.toBe("Token usage unavailable");
|
|
27
34
|
expect(usageView(fixtureTask({
|
|
@@ -43,7 +50,7 @@ describe("token and working presentation", () => {
|
|
|
43
50
|
},
|
|
44
51
|
}));
|
|
45
52
|
expect(view.kind).toBe("ready");
|
|
46
|
-
expect(view.label).toBe("12
|
|
53
|
+
expect(view.label).toBe("12.35K tokens · Estimated cost $0.12 · known so far");
|
|
47
54
|
if (view.kind === "ready") expect(view.knownSoFar).toBe(true);
|
|
48
55
|
});
|
|
49
56
|
|
|
@@ -64,7 +71,7 @@ describe("token and working presentation", () => {
|
|
|
64
71
|
updatedAt: task.updatedAt,
|
|
65
72
|
},
|
|
66
73
|
};
|
|
67
|
-
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.
|
|
74
|
+
expect(turnUsageView(task, completedTurn).label).toBe("330 tokens · Estimated cost $0.12");
|
|
68
75
|
expect(turnUsageView(task, { ...completedTurn, result: null }).label).toBe("Turn usage pending");
|
|
69
76
|
expect(turnUsageView({
|
|
70
77
|
...task,
|
|
@@ -115,7 +122,7 @@ describe("token and working presentation", () => {
|
|
|
115
122
|
);
|
|
116
123
|
|
|
117
124
|
expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("330");
|
|
118
|
-
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.
|
|
125
|
+
expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
|
|
119
126
|
expect(screen.getByText("Model").nextSibling).toHaveTextContent("gpt-5.6-sol, gpt-5.6-luna");
|
|
120
127
|
expect(screen.getByText("Cache ratio").nextSibling).toHaveTextContent("67%");
|
|
121
128
|
expect(screen.queryByText(/API-equivalent/i)).not.toBeInTheDocument();
|
|
@@ -125,4 +132,110 @@ describe("token and working presentation", () => {
|
|
|
125
132
|
render(<MantineProvider><GitHubLinks task={fixtureTask()} /></MantineProvider>);
|
|
126
133
|
expect(screen.getByRole("link")).toHaveClass("taskchef-github-link");
|
|
127
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
|
+
});
|
|
128
241
|
});
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import type { Task, UsageNumbers, UsageProjection } from "./types";
|
|
2
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
|
+
|
|
3
25
|
export type UsageView =
|
|
4
26
|
| { kind: "pending"; label: "Token usage pending" }
|
|
5
27
|
| { kind: "calculating"; label: "Calculating token usage" }
|
|
@@ -29,10 +51,10 @@ export function usageView(task: Task): UsageView {
|
|
|
29
51
|
}
|
|
30
52
|
|
|
31
53
|
function readyUsageView(usage: UsageNumbers, knownSoFar: boolean): UsageView {
|
|
32
|
-
const tokens =
|
|
54
|
+
const tokens = formatCompactTokens(usage.totalTokens);
|
|
33
55
|
const cost = usage.estimatedCostUsd == null
|
|
34
56
|
? "Estimated cost unavailable"
|
|
35
|
-
: `Estimated cost
|
|
57
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
36
58
|
return {
|
|
37
59
|
kind: "ready",
|
|
38
60
|
knownSoFar,
|
|
@@ -49,10 +71,10 @@ export function turnUsageView(task: Task, turn: NonNullable<Task["turns"]>[numbe
|
|
|
49
71
|
return { kind: "calculating" as const, label: "Calculating turn usage" };
|
|
50
72
|
}
|
|
51
73
|
if (usage?.status === "available" && typeof usage.totalTokens === "number") {
|
|
52
|
-
const tokens =
|
|
74
|
+
const tokens = formatFullTokens(usage.totalTokens);
|
|
53
75
|
const cost = usage.estimatedCostUsd == null
|
|
54
76
|
? "Estimated cost unavailable"
|
|
55
|
-
: `Estimated cost
|
|
77
|
+
: `Estimated cost ${formatEstimatedCost(usage.estimatedCostUsd)}`;
|
|
56
78
|
return { kind: "ready" as const, label: `${tokens} tokens · ${cost}` };
|
|
57
79
|
}
|
|
58
80
|
return {
|
|
@@ -48,6 +48,11 @@ body {
|
|
|
48
48
|
.taskchef-preserve-lines { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
49
49
|
.taskchef-github-link { color: var(--taskchef-accent); text-decoration: none; }
|
|
50
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; }
|
|
51
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; }
|
|
52
57
|
.taskchef-time:hover { color: var(--taskchef-text); background: var(--taskchef-surface-muted); }
|
|
53
58
|
.taskchef-time:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|