taskchef 7.25.1 → 7.26.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.
@@ -0,0 +1,202 @@
1
+ import { act, cleanup, render, screen } from "@testing-library/react";
2
+ import type { ElementType, ReactNode } from "react";
3
+ import { afterEach, beforeEach, expect, test, vi } from "vitest";
4
+ import { DashboardApp } from "./App";
5
+ import { fixtureTask } from "./fixtures";
6
+ import type { DashboardSnapshot } from "./types";
7
+
8
+ const apiMocks = vi.hoisted(() => ({
9
+ connectDashboard: vi.fn(),
10
+ dashboardVersion: vi.fn(),
11
+ manualTransition: vi.fn(),
12
+ openInCodex: vi.fn(),
13
+ taskDetail: vi.fn(),
14
+ }));
15
+
16
+ vi.mock("./api", () => apiMocks);
17
+ vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
18
+ vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
19
+ vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
20
+ vi.mock("@tabler/icons-react", async () => {
21
+ const { createElement } = await vi.importActual<typeof import("react")>("react");
22
+ const Icon = (props: Record<string, unknown>) => createElement("svg", props);
23
+ return {
24
+ IconAlertTriangle: Icon,
25
+ IconCircleFilled: Icon,
26
+ IconMoon: Icon,
27
+ IconSun: Icon,
28
+ };
29
+ });
30
+ vi.mock("@mantine/core", async () => {
31
+ const { createElement } = await vi.importActual<typeof import("react")>("react");
32
+ const primitive = (tag: string) => ({
33
+ align: _align,
34
+ c: _color,
35
+ children,
36
+ component,
37
+ fw: _fontWeight,
38
+ gap: _gap,
39
+ justify: _justify,
40
+ mb: _marginBottom,
41
+ mt: _marginTop,
42
+ p: _padding,
43
+ pb: _paddingBottom,
44
+ radius: _radius,
45
+ size: _size,
46
+ ta: _textAlign,
47
+ tt: _textTransform,
48
+ withBorder: _withBorder,
49
+ wrap: _wrap,
50
+ ...props
51
+ }: Record<string, unknown> & { children?: ReactNode; component?: ElementType }) => (
52
+ createElement(component ?? tag, props, children)
53
+ );
54
+ const AppShell = Object.assign(primitive("div"), { Main: primitive("main") });
55
+ const Select = ({
56
+ "aria-label": ariaLabel,
57
+ data,
58
+ label,
59
+ onChange,
60
+ value,
61
+ }: {
62
+ "aria-label": string;
63
+ data: Array<string | { label: string; value: string }>;
64
+ label: string;
65
+ onChange: (value: string | null) => void;
66
+ value: string;
67
+ }) => {
68
+ const options = data.map((item) => typeof item === "string"
69
+ ? { label: item, value: item }
70
+ : item);
71
+ const selected = options.find((item) => item.value === value)?.label ?? value;
72
+ return createElement("label", null, label, createElement(
73
+ "select",
74
+ {
75
+ "aria-label": ariaLabel,
76
+ onChange: (event: { currentTarget: { value: string } }) => {
77
+ const next = options.find((item) => item.label === event.currentTarget.value);
78
+ onChange(next?.value ?? null);
79
+ },
80
+ value: selected,
81
+ },
82
+ options.map((item) => createElement("option", { key: item.value, value: item.label }, item.label)),
83
+ ));
84
+ };
85
+ const SegmentedControl = ({
86
+ "aria-label": ariaLabel,
87
+ data,
88
+ onChange,
89
+ value,
90
+ }: {
91
+ "aria-label": string;
92
+ data: Array<{ label: string; value: string }>;
93
+ onChange: (value: string) => void;
94
+ value: string;
95
+ }) => createElement(
96
+ "div",
97
+ { "aria-label": ariaLabel, role: "radiogroup" },
98
+ data.map((item) => createElement(
99
+ "label",
100
+ { key: item.value },
101
+ createElement("input", {
102
+ checked: item.value === value,
103
+ name: ariaLabel,
104
+ onChange: () => onChange(item.value),
105
+ type: "radio",
106
+ }),
107
+ item.label,
108
+ )),
109
+ );
110
+ return {
111
+ ActionIcon: primitive("button"),
112
+ Alert: primitive("div"),
113
+ AppShell,
114
+ Box: primitive("div"),
115
+ CloseButton: primitive("button"),
116
+ Container: primitive("div"),
117
+ Group: primitive("div"),
118
+ MantineProvider: ({ children }: { children?: ReactNode }) => createElement("div", null, children),
119
+ Paper: primitive("div"),
120
+ SegmentedControl,
121
+ Select,
122
+ Stack: primitive("div"),
123
+ Text: primitive("p"),
124
+ Title: primitive("h1"),
125
+ Tooltip: primitive("div"),
126
+ createTheme: (theme: unknown) => theme,
127
+ useComputedColorScheme: () => "light",
128
+ useMantineColorScheme: () => ({ setColorScheme: vi.fn() }),
129
+ };
130
+ });
131
+
132
+ afterEach(cleanup);
133
+ beforeEach(() => {
134
+ vi.clearAllMocks();
135
+ apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
136
+ });
137
+
138
+ test("places the filtered task count below the toolbar and updates it from live snapshots", () => {
139
+ const recent = new Date().toISOString();
140
+ const tasks = [
141
+ fixtureTask({
142
+ id: "11111111-1111-4111-8111-111111111111",
143
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
144
+ status: "completed",
145
+ meaningfulUpdatedAt: recent,
146
+ updatedAt: recent,
147
+ }),
148
+ fixtureTask({
149
+ id: "22222222-2222-4222-8222-222222222222",
150
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
151
+ meaningfulUpdatedAt: recent,
152
+ updatedAt: recent,
153
+ }),
154
+ fixtureTask({
155
+ id: "33333333-3333-4333-8333-333333333333",
156
+ status: "completed",
157
+ }),
158
+ ];
159
+ let onSnapshot: ((snapshot: DashboardSnapshot) => void) | undefined;
160
+ apiMocks.connectDashboard.mockImplementation((handlers) => {
161
+ onSnapshot = handlers.onSnapshot;
162
+ return { close: vi.fn() };
163
+ });
164
+
165
+ render(
166
+ <DashboardApp
167
+ initialFilters={{ date: "24h", project: "Another project", status: "completed" }}
168
+ initialTasks={tasks}
169
+ />,
170
+ );
171
+ const projectFilter = screen.getByRole("combobox", { name: "Project" });
172
+ const dateFilter = screen.getByRole("combobox", { name: "Updated" });
173
+ const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
174
+ const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
175
+ const taskList = screen.getByRole("region", { name: "Tasks" });
176
+
177
+ expect(projectFilter).toHaveValue("Another project");
178
+ expect(dateFilter).toHaveValue("Latest 24 hours");
179
+ expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
180
+ expect(summary).toHaveTextContent("Tasks: 1 of 3");
181
+ expect(toolbar).not.toContainElement(summary);
182
+ expect(toolbar?.nextElementSibling).toBe(summary);
183
+ expect(taskList).toHaveAttribute("aria-describedby", summary?.id);
184
+
185
+ act(() => {
186
+ onSnapshot?.({
187
+ tasks: [
188
+ tasks[0],
189
+ fixtureTask({
190
+ id: "44444444-4444-4444-8444-444444444444",
191
+ project: { name: "Another project", path: "/tmp/another", githubRepos: [] },
192
+ status: "completed",
193
+ meaningfulUpdatedAt: recent,
194
+ updatedAt: recent,
195
+ }),
196
+ ],
197
+ });
198
+ });
199
+
200
+ expect(summary).toHaveTextContent("Tasks: 2 of 2");
201
+ expect(summary).toHaveAttribute("aria-live", "polite");
202
+ }, 60_000);
@@ -77,20 +77,36 @@ interface NotificationState {
77
77
  signatures: Map<string, string>;
78
78
  }
79
79
 
80
+ export function TaskResultsSummary({
81
+ totalCount,
82
+ visibleCount,
83
+ }: {
84
+ totalCount: number;
85
+ visibleCount: number;
86
+ }) {
87
+ return (
88
+ <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">
89
+ Tasks: {visibleCount} of {totalCount}
90
+ </Text>
91
+ );
92
+ }
93
+
80
94
  export function DashboardApp({
81
95
  connect = true,
96
+ initialFilters = {},
82
97
  initialTasks = [],
83
98
  }: {
84
99
  connect?: boolean;
100
+ initialFilters?: { date?: string; project?: string; status?: string };
85
101
  initialTasks?: Task[];
86
102
  }) {
87
103
  const [tasks, setTasks] = useState<Task[]>(initialTasks);
88
104
  const [connected, setConnected] = useState(false);
89
105
  const [message, setMessage] = useState<string | null>(null);
90
106
  const [version, setVersion] = useState<string | null>(null);
91
- const [project, setProject] = useState("");
92
- const [status, setStatus] = useState("");
93
- const [date, setDate] = useState("all");
107
+ const [project, setProject] = useState(initialFilters.project ?? "");
108
+ const [status, setStatus] = useState(initialFilters.status ?? "");
109
+ const [date, setDate] = useState(initialFilters.date ?? "all");
94
110
  const [now, setNow] = useState(() => Date.now());
95
111
  const [selectedTask, setSelectedTask] = useState<Task | null>(null);
96
112
  const [detailOpened, setDetailOpened] = useState(false);
@@ -310,10 +326,6 @@ export function DashboardApp({
310
326
  size="sm"
311
327
  value={date}
312
328
  />
313
- <Box className="taskchef-task-count-field">
314
- <Text className="taskchef-filter-label" mb={3}>Tasks</Text>
315
- <Text className="taskchef-count-value" size="sm">{visible.length} of {tasks.length}</Text>
316
- </Box>
317
329
  </Group>
318
330
  <Box>
319
331
  <Text className="taskchef-filter-label" mb={5}>Status</Text>
@@ -322,6 +334,8 @@ export function DashboardApp({
322
334
  </Stack>
323
335
  </Paper>
324
336
 
337
+ <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />
338
+
325
339
  {message && (
326
340
  <Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
327
341
  <Group gap="sm" justify="space-between" wrap="nowrap">
@@ -331,7 +345,7 @@ export function DashboardApp({
331
345
  </Alert>
332
346
  )}
333
347
 
334
- <Stack aria-label="Tasks" component="section" gap="sm" mt="md">
348
+ <Stack aria-describedby="task-results-summary" aria-label="Tasks" component="section" gap="sm" mt="xs">
335
349
  {visible.map((task: Task) => (
336
350
  <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
337
351
  ))}
@@ -1,7 +1,8 @@
1
1
  import { Box, Paper, Stack, Text } from "@mantine/core";
2
2
  import { mergeProjectedTurns, turnPresentation } from "../../state.js";
3
- import { turnUsageView } from "../presentation";
4
- import type { Task } from "../types";
3
+ import { turnReportedWorkView, turnUsageMetricsView } from "../presentation";
4
+ import type { Task, TaskTurn } from "../types";
5
+ import { useEffect, useState } from "react";
5
6
  import { RelativeTime } from "./RelativeTime";
6
7
  import { LinkedText } from "./LinkedText";
7
8
  import { ShimmerText } from "./ShimmerText";
@@ -16,7 +17,7 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
16
17
  const presentation = turnPresentation(turn);
17
18
  const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
18
19
  const highlighted = identity === highlightTurnRef;
19
- const usage = turnUsageView(task, turn);
20
+ const usage = turnUsageMetricsView(task, turn);
20
21
  return (
21
22
  <Paper
22
23
  className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
@@ -47,15 +48,24 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
47
48
  : <LinkedText task={task} text={presentation.summary} />}
48
49
  </Text>
49
50
  </Box>
50
- <Text
51
- c="dimmed"
52
- className={usage.kind === "calculating" || usage.kind === "pending" ? "taskchef-turn-usage" : undefined}
53
- size="xs"
54
- >
55
- {usage.kind === "calculating" || usage.kind === "pending"
56
- ? <ShimmerText>{usage.label}</ShimmerText>
57
- : usage.label}
58
- </Text>
51
+ <Box className="taskchef-turn-metrics">
52
+ <TurnMetric
53
+ accessibleLabel={usage.tokens.accessibleLabel}
54
+ animated={usage.animated}
55
+ label="Tokens"
56
+ title={usage.title}
57
+ value={usage.tokens.value}
58
+ />
59
+ <TurnMetric
60
+ accessibleLabel={usage.cost.accessibleLabel}
61
+ animated={usage.animated}
62
+ label="Estimated cost"
63
+ title={usage.title}
64
+ value={usage.cost.value}
65
+ />
66
+ <TurnReportedWorkMetric turn={turn} />
67
+ </Box>
68
+ {usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
59
69
  <Text c="dimmed" className="taskchef-mono" size="xs">Turn ref: {identity ?? "—"}</Text>
60
70
  </Stack>
61
71
  </Paper>
@@ -64,3 +74,50 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
64
74
  </Stack>
65
75
  );
66
76
  }
77
+
78
+ function TurnReportedWorkMetric({ turn }: { turn: TaskTurn }) {
79
+ const now = useLiveNow(turn.result === null);
80
+ const elapsed = turnReportedWorkView(turn, now);
81
+ return (
82
+ <TurnMetric
83
+ accessibleLabel={elapsed.accessibleLabel}
84
+ label={elapsed.label}
85
+ title={elapsed.title}
86
+ value={elapsed.value}
87
+ />
88
+ );
89
+ }
90
+
91
+ function TurnMetric({
92
+ accessibleLabel,
93
+ animated = false,
94
+ label,
95
+ title,
96
+ value,
97
+ }: {
98
+ accessibleLabel?: string;
99
+ animated?: boolean;
100
+ label: string;
101
+ title?: string;
102
+ value: string;
103
+ }) {
104
+ return (
105
+ <Box className="taskchef-turn-metric" title={title}>
106
+ <Text c="dimmed" className="taskchef-field-label" size="xs">{label}</Text>
107
+ <Text aria-label={accessibleLabel} c="dimmed" size="xs">
108
+ {animated ? <ShimmerText>{value}</ShimmerText> : value}
109
+ </Text>
110
+ </Box>
111
+ );
112
+ }
113
+
114
+ function useLiveNow(active: boolean) {
115
+ const [now, setNow] = useState(() => Date.now());
116
+ useEffect(() => {
117
+ if (!active) return undefined;
118
+ setNow(Date.now());
119
+ const interval = window.setInterval(() => setNow(Date.now()), 1_000);
120
+ return () => window.clearInterval(interval);
121
+ }, [active]);
122
+ return now;
123
+ }
@@ -0,0 +1,22 @@
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 { OpenChatButton } from "./OpenChatButton";
5
+
6
+ afterEach(cleanup);
7
+
8
+ test("uses the shared open-chat presentation and accessible task label", () => {
9
+ const onClick = vi.fn();
10
+ render(
11
+ <MantineProvider>
12
+ <OpenChatButton onClick={onClick} taskTitle="Review checkout reconciliation" />
13
+ </MantineProvider>,
14
+ );
15
+
16
+ const button = screen.getByRole("button", { name: "Open chat for Review checkout reconciliation" });
17
+ expect(button).toHaveTextContent("Open chat");
18
+ expect(button).toHaveAttribute("data-variant", "default");
19
+ expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
20
+ fireEvent.click(button);
21
+ expect(onClick).toHaveBeenCalledOnce();
22
+ });
@@ -0,0 +1,25 @@
1
+ import { Button } from "@mantine/core";
2
+ import { IconExternalLink } from "@tabler/icons-react";
3
+
4
+ export function OpenChatButton({
5
+ loading,
6
+ onClick,
7
+ taskTitle,
8
+ }: {
9
+ loading?: boolean;
10
+ onClick: () => void;
11
+ taskTitle: string;
12
+ }) {
13
+ return (
14
+ <Button
15
+ aria-label={`Open chat for ${taskTitle}`}
16
+ leftSection={<IconExternalLink aria-hidden size={15} />}
17
+ loading={loading}
18
+ onClick={onClick}
19
+ size="compact-sm"
20
+ variant="default"
21
+ >
22
+ Open chat
23
+ </Button>
24
+ );
25
+ }
@@ -1,9 +1,9 @@
1
- import { Box, Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
2
- import { IconExternalLink } from "@tabler/icons-react";
1
+ import { Box, Group, Paper, Stack, Text, Title } from "@mantine/core";
3
2
  import { latestTurnPresentation } from "../../state.js";
4
3
  import type { Task } from "../types";
5
4
  import { GitHubLinks } from "./GitHubLinks";
6
5
  import { LinkedText } from "./LinkedText";
6
+ import { OpenChatButton } from "./OpenChatButton";
7
7
  import { RelativeTime } from "./RelativeTime";
8
8
  import { ShimmerText } from "./ShimmerText";
9
9
  import { StatusBadge } from "./StatusBadge";
@@ -53,15 +53,7 @@ export function TaskCard({
53
53
  <GitHubLinks task={task} />
54
54
  <Group className="taskchef-card-footer" gap="md" justify="space-between">
55
55
  <RelativeTime label={`Updated time for ${task.title}`} value={task.meaningfulUpdatedAt ?? task.updatedAt} />
56
- <Button
57
- aria-label={`Open chat for ${task.title}`}
58
- leftSection={<IconExternalLink aria-hidden size={15} />}
59
- onClick={() => onOpenCodex(task)}
60
- size="compact-sm"
61
- variant="default"
62
- >
63
- Open chat
64
- </Button>
56
+ <OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
65
57
  </Group>
66
58
  </Stack>
67
59
  </Paper>
@@ -1,10 +1,15 @@
1
1
  import { MantineProvider } from "@mantine/core";
2
- import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
3
3
  import { useState } from "react";
4
4
  import { afterEach, expect, test, vi } from "vitest";
5
+ import { fixtureTask } from "../fixtures";
6
+ import { ActivityTimeline } from "./ActivityTimeline";
5
7
  import { ManualTransitionConfirmation, type TerminalStatus } from "./TaskDetail";
6
8
 
7
- afterEach(cleanup);
9
+ afterEach(() => {
10
+ cleanup();
11
+ vi.restoreAllMocks();
12
+ });
8
13
 
9
14
  test("clears a manual-transition confirmation only after success", async () => {
10
15
  const onTransition = vi.fn().mockResolvedValue({ ok: true });
@@ -44,6 +49,90 @@ test("reuses an action ID for retryable failures and rotates it after a stale ta
44
49
  expect(onTransition.mock.calls[2][1]).not.toBe(firstActionId);
45
50
  });
46
51
 
52
+ test("covers terminal, active, and unavailable timeline metrics", () => {
53
+ {
54
+ const task = fixtureTask({
55
+ status: "completed",
56
+ turns: [{
57
+ ...fixtureTask().turns![0],
58
+ result: {
59
+ status: "completed",
60
+ summary: "Done.",
61
+ updatedAt: "2026-08-30T08:18:32.000Z",
62
+ },
63
+ }],
64
+ usage: {
65
+ status: "available",
66
+ turns: { "turn-one": { status: "available", totalTokens: 330, estimatedCostUsd: 0.12 } },
67
+ },
68
+ });
69
+ const { container } = render(
70
+ <MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>,
71
+ );
72
+ const metrics = container.querySelector(".taskchef-turn-metrics") as HTMLElement;
73
+ expect(metrics).not.toBeNull();
74
+ expect(within(metrics).getByText("Tokens").nextSibling).toHaveTextContent("330");
75
+ expect(within(metrics).getByText("Estimated cost").nextSibling).toHaveTextContent("$0.12");
76
+ expect(within(metrics).getByText("Elapsed").nextSibling).toHaveTextContent("18m 32s");
77
+ expect(within(metrics).getByLabelText(/completed turn reported wall-clock elapsed time/i)).toBeVisible();
78
+ cleanup();
79
+ }
80
+
81
+ {
82
+ let now = Date.parse("2026-08-30T08:18:32.000Z");
83
+ let tick: (() => void) | undefined;
84
+ vi.spyOn(Date, "now").mockImplementation(() => now);
85
+ vi.spyOn(window, "setInterval").mockImplementation(((handler: TimerHandler) => {
86
+ tick = handler as () => void;
87
+ return 1;
88
+ }) as typeof window.setInterval);
89
+ vi.spyOn(window, "clearInterval").mockImplementation(() => undefined);
90
+ const task = fixtureTask();
91
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
92
+ expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 32s");
93
+ expect(screen.getByText("Tokens").nextSibling).toHaveTextContent("Pending");
94
+ expect(screen.getByText("Estimated cost").nextSibling).toHaveTextContent("Pending");
95
+
96
+ act(() => {
97
+ now += 2_000;
98
+ tick?.();
99
+ });
100
+
101
+ expect(screen.getByText("Elapsed so far").nextSibling).toHaveTextContent("18m 34s");
102
+ cleanup();
103
+ vi.restoreAllMocks();
104
+ }
105
+
106
+ {
107
+ const task = fixtureTask({
108
+ status: "completed",
109
+ turns: [{
110
+ ...fixtureTask().turns![0],
111
+ startedAt: "2026-08-30T08:18:33.000Z",
112
+ result: {
113
+ status: "completed",
114
+ summary: "Done.",
115
+ updatedAt: "2026-08-30T08:18:32.000Z",
116
+ },
117
+ }],
118
+ usage: {
119
+ status: "unavailable",
120
+ turns: {
121
+ "turn-one": {
122
+ status: "unavailable",
123
+ reason: "Manual dashboard turns do not have usage boundaries.",
124
+ },
125
+ },
126
+ },
127
+ });
128
+ render(<MantineProvider><ActivityTimeline highlightTurnRef={null} task={task} /></MantineProvider>);
129
+ expect(screen.getByText("Elapsed").nextSibling).toHaveTextContent("Unavailable");
130
+ expect(screen.getByLabelText(/elapsed unavailable.*reported wall-clock/i)).toBeVisible();
131
+ expect(screen.getByText("Manual dashboard turns do not have usage boundaries.")).toBeVisible();
132
+ expect(screen.getByLabelText(/tokens unavailable: manual dashboard turns/i)).toBeVisible();
133
+ }
134
+ });
135
+
47
136
  function ConfirmationHarness({ onTransition }: { onTransition: (status: TerminalStatus, actionId: string) => Promise<{ ok: boolean; rotateActionId?: boolean }> }) {
48
137
  const [opened, setOpened] = useState(true);
49
138
  return (
@@ -19,13 +19,13 @@ import {
19
19
  IconCheck,
20
20
  IconClipboard,
21
21
  IconDots,
22
- IconExternalLink,
23
22
  IconX,
24
23
  } from "@tabler/icons-react";
25
24
  import { useEffect, useRef, useState, type ReactNode } from "react";
26
25
  import type { Task } from "../types";
27
26
  import { ActivityTimeline } from "./ActivityTimeline";
28
27
  import { GitHubLinks } from "./GitHubLinks";
28
+ import { OpenChatButton } from "./OpenChatButton";
29
29
  import { RelativeTime } from "./RelativeTime";
30
30
  import { StatusBadge } from "./StatusBadge";
31
31
  import { UsagePanel } from "./UsagePanel";
@@ -93,14 +93,7 @@ export function TaskDetail({
93
93
  <StatusBadge status={task.status} />
94
94
  </Group>
95
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>
96
+ <OpenChatButton loading={busy} onClick={onOpenCodex} taskTitle={task.title} />
104
97
  <Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
105
98
  <Menu.Target>
106
99
  <Tooltip label="More task actions">