taskchef 8.3.1 → 8.4.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/docs/dashboard-lifecycle.md +17 -0
- package/package.json +1 -1
- package/src/dashboard/dist/app.js +39 -39
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +121 -9
- package/src/dashboard/react/App.tsx +58 -40
- package/src/dashboard/react/components/ActivityTimeline.tsx +0 -1
- package/src/dashboard/react/components/ModelSettings.test.tsx +50 -1
- package/src/dashboard/react/components/ModelSettings.tsx +8 -3
- package/src/dashboard/react/components/NotificationCenter.tsx +2 -2
- package/src/dashboard/react/components/OpenChatButton.test.tsx +1 -0
- package/src/dashboard/react/components/OpenChatButton.tsx +3 -2
- package/src/dashboard/react/components/RelativeTime.tsx +2 -8
- package/src/dashboard/react/components/TaskBoard.test.tsx +122 -0
- package/src/dashboard/react/components/TaskBoard.tsx +147 -0
- package/src/dashboard/react/components/TaskCard.tsx +8 -4
- package/src/dashboard/react/components/TaskCardStats.test.tsx +10 -3
- package/src/dashboard/react/components/TaskCardStats.tsx +0 -1
- package/src/dashboard/react/components/TaskDetail.tsx +1 -1
- package/src/dashboard/react/styles.css +67 -14
- package/src/dashboard/state.js +5 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { MantineProvider } from "@mantine/core";
|
|
2
|
+
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
|
+
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
+
import { fixtureTask } from "../fixtures";
|
|
5
|
+
import { TaskBoard } from "./TaskBoard";
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
function task(id: number, status: ReturnType<typeof fixtureTask>["status"], title = `Task ${id}`) {
|
|
10
|
+
return fixtureTask({
|
|
11
|
+
id: `11111111-1111-4111-8111-${String(id).padStart(12, "0")}`,
|
|
12
|
+
title,
|
|
13
|
+
status,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function pointer(target: Element, type: string, pointerType: string, clientX: number, pointerId = 1) {
|
|
18
|
+
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX });
|
|
19
|
+
Object.assign(event, { pointerType, pointerId, isPrimary: true });
|
|
20
|
+
fireEvent(target, event);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("groups by task status, counts all completed tasks, and reveals five more", () => {
|
|
24
|
+
const tasks = [task(1, "working"), task(2, "needs_input"), ...Array.from({ length: 11 }, (_, index) => task(index + 3, "completed")), task(20, "failed"), task(21, null)];
|
|
25
|
+
const more = vi.fn();
|
|
26
|
+
const { rerender } = render(<MantineProvider><TaskBoard tasks={tasks} completedLimit={5} onMoreCompleted={more} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
27
|
+
expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("tabindex", "0");
|
|
28
|
+
expect(screen.getByRole("region", { name: "Working, 1 tasks" })).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByRole("region", { name: "Needs input, 1 tasks" })).toBeInTheDocument();
|
|
30
|
+
const completed = screen.getByRole("region", { name: "Completed, 11 tasks" });
|
|
31
|
+
expect(within(completed).getAllByRole("article")).toHaveLength(5);
|
|
32
|
+
fireEvent.click(within(completed).getByRole("button", { name: "Show 5 more · 5 of 11" }));
|
|
33
|
+
expect(more).toHaveBeenCalledOnce();
|
|
34
|
+
rerender(<MantineProvider><TaskBoard tasks={tasks} completedLimit={10} onMoreCompleted={more} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
35
|
+
expect(within(screen.getByRole("region", { name: "Completed, 11 tasks" })).getAllByRole("article")).toHaveLength(10);
|
|
36
|
+
expect(screen.getByRole("region", { name: "Failed, 1 tasks" })).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByRole("region", { name: "Unresolved, 1 tasks" })).toBeInTheDocument();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("uses current request for working and latest historical result for finished tasks", () => {
|
|
41
|
+
const working = task(1, "working", "Working task");
|
|
42
|
+
const completed = task(2, "completed", "Finished task");
|
|
43
|
+
completed.latestTurn = { ...completed.latestTurn!, requestSummary: "New request", result: { status: "completed", summary: "Latest reported result", updatedAt: completed.updatedAt } };
|
|
44
|
+
completed.lastResult = { status: "completed", summary: "Old reported result", updatedAt: completed.updatedAt };
|
|
45
|
+
const onOpenDetail = vi.fn();
|
|
46
|
+
const onOpenCodex = vi.fn();
|
|
47
|
+
render(<MantineProvider><TaskBoard tasks={[working, completed]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={onOpenCodex} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
48
|
+
const workingLane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
49
|
+
expect(workingLane).toHaveTextContent("Review the operator-facing task state.");
|
|
50
|
+
expect(workingLane).not.toHaveTextContent("In progress");
|
|
51
|
+
const completedLane = screen.getByRole("region", { name: "Completed, 1 tasks" });
|
|
52
|
+
expect(completedLane).toHaveTextContent("Latest reported result");
|
|
53
|
+
expect(completedLane).not.toHaveTextContent("Old reported result");
|
|
54
|
+
const completedCard = within(completedLane).getByRole("article");
|
|
55
|
+
expect(completedCard.firstElementChild).toHaveClass("taskchef-board-title");
|
|
56
|
+
expect(completedCard.children[1]).toHaveClass("taskchef-board-project");
|
|
57
|
+
fireEvent.click(within(completedLane).getByRole("button", { name: "Finished task" }));
|
|
58
|
+
expect(onOpenDetail).toHaveBeenCalledWith(completed);
|
|
59
|
+
fireEvent.click(within(completedLane).getByRole("button", { name: "Open chat for Finished task" }));
|
|
60
|
+
expect(onOpenCodex).toHaveBeenCalledWith(completed);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("unlinked working tasks show pending text without a chat action", () => {
|
|
64
|
+
const working = task(1, "working");
|
|
65
|
+
working.threadId = null;
|
|
66
|
+
const failed = task(2, "failed");
|
|
67
|
+
failed.threadId = "invalid";
|
|
68
|
+
render(<MantineProvider><TaskBoard tasks={[working, failed]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
69
|
+
expect(screen.getByRole("region", { name: "Working, 1 tasks" })).toHaveTextContent("Chat link pending");
|
|
70
|
+
expect(screen.queryByRole("button", { name: /Open chat/ })).not.toBeInTheDocument();
|
|
71
|
+
expect(screen.getByRole("region", { name: "Failed, 1 tasks" })).not.toHaveTextContent("Chat link pending");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("unexpected status appears in Unresolved instead of disappearing", () => {
|
|
75
|
+
const unexpected = task(1, "working");
|
|
76
|
+
unexpected.status = "paused" as typeof unexpected.status;
|
|
77
|
+
render(<MantineProvider><TaskBoard tasks={[unexpected]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()} /></MantineProvider>);
|
|
78
|
+
expect(within(screen.getByRole("region", { name: "Unresolved, 1 tasks" })).getByRole("article")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("mouse drag pans from empty lane space without activating a card", () => {
|
|
82
|
+
const onOpenDetail = vi.fn();
|
|
83
|
+
render(<MantineProvider><TaskBoard tasks={[task(1, "working")]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
84
|
+
const board = screen.getByRole("region", { name: "Task board" });
|
|
85
|
+
const lane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
86
|
+
Object.defineProperties(board, { scrollWidth: { value: 1700 }, clientWidth: { value: 700 } });
|
|
87
|
+
const capture = vi.fn();
|
|
88
|
+
const release = vi.fn();
|
|
89
|
+
board.setPointerCapture = capture;
|
|
90
|
+
board.hasPointerCapture = () => true;
|
|
91
|
+
board.releasePointerCapture = release;
|
|
92
|
+
|
|
93
|
+
pointer(lane, "pointerdown", "mouse", 200);
|
|
94
|
+
expect(capture).toHaveBeenCalledWith(1);
|
|
95
|
+
pointer(board, "pointermove", "mouse", 197);
|
|
96
|
+
expect(board.scrollLeft).toBe(0);
|
|
97
|
+
pointer(board, "pointermove", "mouse", 120);
|
|
98
|
+
expect(board.scrollLeft).toBe(80);
|
|
99
|
+
pointer(board, "pointerup", "mouse", 120);
|
|
100
|
+
expect(release).toHaveBeenCalledWith(1);
|
|
101
|
+
expect(board).not.toHaveClass("taskchef-board-dragging");
|
|
102
|
+
fireEvent.click(within(lane).getByRole("button", { name: "Task 1" }));
|
|
103
|
+
expect(onOpenDetail).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("card controls and touch gestures do not start mouse panning", () => {
|
|
107
|
+
const onOpenDetail = vi.fn();
|
|
108
|
+
render(<MantineProvider><TaskBoard tasks={[task(1, "working")]} completedLimit={5} onMoreCompleted={vi.fn()} onOpenCodex={vi.fn()} onOpenDetail={onOpenDetail} /></MantineProvider>);
|
|
109
|
+
const board = screen.getByRole("region", { name: "Task board" });
|
|
110
|
+
const lane = screen.getByRole("region", { name: "Working, 1 tasks" });
|
|
111
|
+
Object.defineProperties(board, { scrollWidth: { value: 1700 }, clientWidth: { value: 700 } });
|
|
112
|
+
const capture = vi.fn();
|
|
113
|
+
board.setPointerCapture = capture;
|
|
114
|
+
const title = within(lane).getByRole("button", { name: "Task 1" });
|
|
115
|
+
pointer(title, "pointerdown", "mouse", 200);
|
|
116
|
+
pointer(lane, "pointerdown", "touch", 200, 2);
|
|
117
|
+
pointer(board, "pointermove", "touch", 120, 2);
|
|
118
|
+
expect(capture).not.toHaveBeenCalled();
|
|
119
|
+
expect(board.scrollLeft).toBe(0);
|
|
120
|
+
fireEvent.click(title);
|
|
121
|
+
expect(onOpenDetail).toHaveBeenCalledOnce();
|
|
122
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Box, Button, Paper, Stack, Text, Title } from "@mantine/core";
|
|
2
|
+
import { useRef, type PointerEvent as ReactPointerEvent } from "react";
|
|
3
|
+
import { IconArrowUpRight } from "@tabler/icons-react";
|
|
4
|
+
import { hasLinkedCodexThread, latestTurnPresentation } from "../../state.js";
|
|
5
|
+
import type { Task, TaskStatus } from "../types";
|
|
6
|
+
import { LinkedText } from "./LinkedText";
|
|
7
|
+
import { RelativeTime } from "./RelativeTime";
|
|
8
|
+
|
|
9
|
+
const lanes: { status: TaskStatus; label: string }[] = [
|
|
10
|
+
{ status: "working", label: "Working" },
|
|
11
|
+
{ status: "needs_input", label: "Needs input" },
|
|
12
|
+
{ status: "completed", label: "Completed" },
|
|
13
|
+
{ status: "failed", label: "Failed" },
|
|
14
|
+
{ status: null, label: "Unresolved" },
|
|
15
|
+
];
|
|
16
|
+
const knownStatuses = new Set<TaskStatus>(["working", "needs_input", "completed", "failed"]);
|
|
17
|
+
|
|
18
|
+
function laneFor(task: Task): TaskStatus {
|
|
19
|
+
return knownStatuses.has(task.status) ? task.status : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function TaskBoard({
|
|
23
|
+
completedLimit,
|
|
24
|
+
onMoreCompleted,
|
|
25
|
+
onOpenCodex,
|
|
26
|
+
onOpenDetail,
|
|
27
|
+
tasks,
|
|
28
|
+
}: {
|
|
29
|
+
completedLimit: number;
|
|
30
|
+
onMoreCompleted: () => void;
|
|
31
|
+
onOpenCodex: (task: Task) => void;
|
|
32
|
+
onOpenDetail: (task: Task) => void;
|
|
33
|
+
tasks: Task[];
|
|
34
|
+
}) {
|
|
35
|
+
const boardRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const dragRef = useRef<{ pointerId: number; startX: number; scrollLeft: number; moved: boolean } | null>(null);
|
|
37
|
+
const suppressClickRef = useRef(false);
|
|
38
|
+
|
|
39
|
+
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
|
|
40
|
+
if (event.pointerType !== "mouse" || event.button !== 0 || !event.isPrimary) return;
|
|
41
|
+
const target = event.target as Element;
|
|
42
|
+
if (target.closest("button, a, input, textarea, select, [contenteditable], .taskchef-board-card, h1, h2, h3, p, span")) return;
|
|
43
|
+
const board = boardRef.current;
|
|
44
|
+
if (!board || board.scrollWidth <= board.clientWidth) return;
|
|
45
|
+
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, scrollLeft: board.scrollLeft, moved: false };
|
|
46
|
+
board.setPointerCapture(event.pointerId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
|
|
50
|
+
const drag = dragRef.current;
|
|
51
|
+
const board = boardRef.current;
|
|
52
|
+
if (!drag || !board || drag.pointerId !== event.pointerId) return;
|
|
53
|
+
const delta = event.clientX - drag.startX;
|
|
54
|
+
if (!drag.moved && Math.abs(delta) < 6) return;
|
|
55
|
+
if (!drag.moved) {
|
|
56
|
+
drag.moved = true;
|
|
57
|
+
board.classList.add("taskchef-board-dragging");
|
|
58
|
+
}
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
board.scrollLeft = drag.scrollLeft - delta;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function endPointer(event: ReactPointerEvent<HTMLDivElement>) {
|
|
64
|
+
const drag = dragRef.current;
|
|
65
|
+
const board = boardRef.current;
|
|
66
|
+
if (!drag || !board || drag.pointerId !== event.pointerId) return;
|
|
67
|
+
dragRef.current = null;
|
|
68
|
+
board.classList.remove("taskchef-board-dragging");
|
|
69
|
+
if (board.hasPointerCapture(event.pointerId)) board.releasePointerCapture(event.pointerId);
|
|
70
|
+
if (drag.moved) {
|
|
71
|
+
suppressClickRef.current = true;
|
|
72
|
+
window.setTimeout(() => { suppressClickRef.current = false; }, 0);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const visibleLanes = lanes.filter((lane) => lane.status !== null || tasks.some((task) => laneFor(task) === null));
|
|
77
|
+
return (
|
|
78
|
+
<Box
|
|
79
|
+
aria-label="Task board"
|
|
80
|
+
className="taskchef-board"
|
|
81
|
+
component="section"
|
|
82
|
+
onClickCapture={(event) => {
|
|
83
|
+
if (!suppressClickRef.current) return;
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
suppressClickRef.current = false;
|
|
87
|
+
}}
|
|
88
|
+
onPointerCancel={endPointer}
|
|
89
|
+
onPointerDown={onPointerDown}
|
|
90
|
+
onPointerMove={onPointerMove}
|
|
91
|
+
onPointerUp={endPointer}
|
|
92
|
+
ref={boardRef}
|
|
93
|
+
tabIndex={0}
|
|
94
|
+
>
|
|
95
|
+
{visibleLanes.map(({ status, label }) => {
|
|
96
|
+
const matching = tasks.filter((task) => laneFor(task) === status);
|
|
97
|
+
const shown = status === "completed" ? matching.slice(0, completedLimit) : matching;
|
|
98
|
+
return (
|
|
99
|
+
<Box aria-label={`${label}, ${matching.length} tasks`} className="taskchef-board-lane" component="section" key={label}>
|
|
100
|
+
<Box className="taskchef-board-lane-heading">
|
|
101
|
+
<Title order={2} size="h5">{label}</Title>
|
|
102
|
+
<Text aria-label={`${matching.length} tasks`} c="dimmed" size="sm">{matching.length}</Text>
|
|
103
|
+
</Box>
|
|
104
|
+
<Stack gap="sm">
|
|
105
|
+
{shown.map((task) => <BoardCard key={task.id} onOpenCodex={onOpenCodex} onOpenDetail={onOpenDetail} task={task} />)}
|
|
106
|
+
{matching.length === 0 && <Text c="dimmed" className="taskchef-board-empty" size="sm">No tasks</Text>}
|
|
107
|
+
{status === "completed" && matching.length > shown.length && (
|
|
108
|
+
<Button className="taskchef-board-more" onClick={onMoreCompleted} size="compact-sm" variant="subtle">
|
|
109
|
+
Show {Math.min(5, matching.length - shown.length)} more · {shown.length} of {matching.length}
|
|
110
|
+
</Button>
|
|
111
|
+
)}
|
|
112
|
+
</Stack>
|
|
113
|
+
</Box>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
</Box>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function BoardCard({ task, onOpenCodex, onOpenDetail }: {
|
|
121
|
+
task: Task;
|
|
122
|
+
onOpenCodex: (task: Task) => void;
|
|
123
|
+
onOpenDetail: (task: Task) => void;
|
|
124
|
+
}) {
|
|
125
|
+
const latest = latestTurnPresentation(task);
|
|
126
|
+
const excerpt = task.status === "working" ? latest.requestSummary : latest.resultSummary;
|
|
127
|
+
const linked = hasLinkedCodexThread(task);
|
|
128
|
+
return (
|
|
129
|
+
<Paper className="taskchef-board-card" component="article" px="sm" pt="sm" pb={6} withBorder>
|
|
130
|
+
<Title className="taskchef-board-title" order={3} size="h5">
|
|
131
|
+
<button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">{task.title}</button>
|
|
132
|
+
</Title>
|
|
133
|
+
<Text className="taskchef-board-project" size="xs">{task.project.name}</Text>
|
|
134
|
+
<Text className="taskchef-board-excerpt taskchef-preserve-lines" size="sm">
|
|
135
|
+
<LinkedText task={task} text={excerpt} />
|
|
136
|
+
</Text>
|
|
137
|
+
<Box className="taskchef-board-card-footer">
|
|
138
|
+
<RelativeTime label="Updated time" value={task.meaningfulUpdatedAt ?? task.updatedAt} />
|
|
139
|
+
{linked ? (
|
|
140
|
+
<button aria-label={`Open chat for ${task.title}`} className="taskchef-board-chat" onClick={() => onOpenCodex(task)} title="Open chat" type="button">
|
|
141
|
+
<IconArrowUpRight aria-hidden size={19} stroke={1.6} />
|
|
142
|
+
</button>
|
|
143
|
+
) : task.status === "working" ? <Text c="dimmed" className="taskchef-board-link-pending" size="xs">Chat link pending</Text> : null}
|
|
144
|
+
</Box>
|
|
145
|
+
</Paper>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Box, Paper, Stack, Text, Title } from "@mantine/core";
|
|
2
|
+
import { IconArrowUpRight } from "@tabler/icons-react";
|
|
2
3
|
import { latestTurnPresentation } from "../../state.js";
|
|
3
4
|
import type { Task } from "../types";
|
|
4
5
|
import { GitHubLinks } from "./GitHubLinks";
|
|
5
6
|
import { LinkedText } from "./LinkedText";
|
|
6
|
-
import { OpenChatButton } from "./OpenChatButton";
|
|
7
7
|
import { ShimmerText } from "./ShimmerText";
|
|
8
8
|
import { StatusBadge } from "./StatusBadge";
|
|
9
9
|
import { TaskCardStats } from "./TaskCardStats";
|
|
@@ -19,7 +19,7 @@ export function TaskCard({
|
|
|
19
19
|
}) {
|
|
20
20
|
const latest = latestTurnPresentation(task);
|
|
21
21
|
return (
|
|
22
|
-
<Paper className="taskchef-task-row" component="article"
|
|
22
|
+
<Paper className="taskchef-task-row" component="article" pb={8} pt="md" px="sm" radius="md" withBorder>
|
|
23
23
|
<Stack className="taskchef-task-main" gap="sm">
|
|
24
24
|
<Box>
|
|
25
25
|
<Box className="taskchef-card-heading">
|
|
@@ -30,7 +30,6 @@ export function TaskCard({
|
|
|
30
30
|
</Title>
|
|
31
31
|
<Stack align="flex-end" className="taskchef-card-metadata" gap={4}>
|
|
32
32
|
<StatusBadge status={task.status} />
|
|
33
|
-
<OpenChatButton onClick={() => onOpenCodex(task)} taskTitle={task.title} />
|
|
34
33
|
</Stack>
|
|
35
34
|
</Box>
|
|
36
35
|
<Text c="dimmed" mt={2} size="xs">{task.project.name}</Text>
|
|
@@ -50,7 +49,12 @@ export function TaskCard({
|
|
|
50
49
|
</Box>
|
|
51
50
|
|
|
52
51
|
<GitHubLinks task={task} />
|
|
53
|
-
<
|
|
52
|
+
<Box className="taskchef-list-card-footer">
|
|
53
|
+
<TaskCardStats task={task} />
|
|
54
|
+
<button aria-label={`Open chat for ${task.title}`} className="taskchef-board-chat" onClick={() => onOpenCodex(task)} title="Open chat" type="button">
|
|
55
|
+
<IconArrowUpRight aria-hidden size={19} stroke={1.6} />
|
|
56
|
+
</button>
|
|
57
|
+
</Box>
|
|
54
58
|
</Stack>
|
|
55
59
|
</Paper>
|
|
56
60
|
);
|
|
@@ -128,7 +128,7 @@ describe("task card stats", () => {
|
|
|
128
128
|
expect(container.querySelectorAll(".taskchef-shimmer")).toHaveLength(1);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
test("places
|
|
131
|
+
test("places statistics and icon-only Open chat together in the card footer", () => {
|
|
132
132
|
const openChat = vi.fn();
|
|
133
133
|
const openDetail = vi.fn();
|
|
134
134
|
const { container } = render(<MantineProvider><TaskCard
|
|
@@ -137,9 +137,16 @@ describe("task card stats", () => {
|
|
|
137
137
|
task={fixtureTask()}
|
|
138
138
|
/></MantineProvider>);
|
|
139
139
|
const metadata = container.querySelector(".taskchef-card-metadata") as HTMLElement;
|
|
140
|
+
const footer = container.querySelector(".taskchef-list-card-footer") as HTMLElement;
|
|
141
|
+
const stats = container.querySelector(".taskchef-card-stats") as HTMLElement;
|
|
140
142
|
expect(metadata.children[0]).toHaveClass("taskchef-status-badge");
|
|
141
|
-
expect(
|
|
142
|
-
|
|
143
|
+
expect(metadata.children).toHaveLength(1);
|
|
144
|
+
expect(footer.children[0]).toBe(stats);
|
|
145
|
+
const button = within(footer).getByRole("button", { name: /open chat/i });
|
|
146
|
+
expect(footer.children[1]).toBe(button);
|
|
147
|
+
expect(button).toHaveClass("taskchef-board-chat");
|
|
148
|
+
expect(button).not.toHaveTextContent("Open chat");
|
|
149
|
+
fireEvent.click(button);
|
|
143
150
|
fireEvent.click(screen.getByRole("button", { name: /^review checkout reconciliation$/i }));
|
|
144
151
|
expect(openChat).toHaveBeenCalledOnce();
|
|
145
152
|
expect(openDetail).toHaveBeenCalledOnce();
|
|
@@ -19,7 +19,6 @@ export function TaskCardStats({ task }: { task: Task }) {
|
|
|
19
19
|
<RelativeTime
|
|
20
20
|
icon={<IconHistory aria-hidden size={14} />}
|
|
21
21
|
label={`Updated time for ${task.title}`}
|
|
22
|
-
tooltipLabel="Updated at"
|
|
23
22
|
value={task.meaningfulUpdatedAt ?? task.updatedAt}
|
|
24
23
|
/>
|
|
25
24
|
</StatCell>
|
|
@@ -97,7 +97,7 @@ export function TaskDetail({
|
|
|
97
97
|
<Menu position="bottom-start" shadow="md" withinPortal zIndex={360}>
|
|
98
98
|
<Menu.Target>
|
|
99
99
|
<Tooltip label="More task actions">
|
|
100
|
-
<ActionIcon aria-label="More task actions" disabled={busy} variant="default">
|
|
100
|
+
<ActionIcon aria-label="More task actions" className="taskchef-detail-more" disabled={busy} variant="default">
|
|
101
101
|
<IconDots aria-hidden size={17} />
|
|
102
102
|
</ActionIcon>
|
|
103
103
|
</Tooltip>
|
|
@@ -16,27 +16,32 @@ body {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
.taskchef-shell { min-height: 100vh; background: var(--taskchef-page); }
|
|
19
|
-
.taskchef-shell
|
|
20
|
-
.taskchef-header {
|
|
19
|
+
.taskchef-header-shell { border-bottom: 1px solid var(--taskchef-border); }
|
|
20
|
+
.taskchef-header { max-width: 1510px; padding: 10px clamp(12px, 3vw, 48px); }
|
|
21
|
+
.taskchef-header-layout { min-width: 0; min-height: 36px; gap: 8px; }
|
|
21
22
|
.taskchef-header-copy { min-width: 0; }
|
|
22
|
-
.taskchef-header-actions {
|
|
23
|
-
.taskchef-connection {
|
|
24
|
-
.taskchef-header-icon-row { justify-content: flex-end; }
|
|
23
|
+
.taskchef-header-actions { flex: none; }
|
|
24
|
+
.taskchef-connection { flex: none; margin-inline-start: 5px; }
|
|
25
25
|
.taskchef-icon-button { flex: none; }
|
|
26
|
-
.taskchef-
|
|
27
|
-
.taskchef-
|
|
28
|
-
.taskchef-
|
|
29
|
-
.taskchef-
|
|
30
|
-
.taskchef-toolbar
|
|
26
|
+
.taskchef-brand-title { min-width: 0; color: var(--taskchef-accent); font-size: 0.875rem; font-weight: 750; letter-spacing: 0.035em; line-height: 1; text-transform: uppercase; white-space: nowrap; }
|
|
27
|
+
.taskchef-version { margin-inline-start: 0.45rem; color: var(--taskchef-muted); font-size: 0.78em; font-weight: 600; letter-spacing: 0; text-transform: none; }
|
|
28
|
+
.taskchef-brand-icon, .taskchef-brand-icon img { display: block; flex: none; width: 28px; height: 28px; }
|
|
29
|
+
.taskchef-main { max-width: 1510px; padding-top: 8px; padding-inline: clamp(16px, 3vw, 48px); }
|
|
30
|
+
.taskchef-toolbar { max-width: none; margin-bottom: 22px; padding: 8px 0 var(--mantine-spacing-md); border: 0; border-bottom: 1px solid var(--taskchef-border); background: transparent; box-shadow: none; }
|
|
31
|
+
.taskchef-toolbar-filters { width: min(100%, 488px); min-width: 0; }
|
|
32
|
+
.taskchef-toolbar-filters .mantine-InputWrapper-root { flex: 1 1 0; width: 0; min-width: 0; max-width: 240px; }
|
|
31
33
|
.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); }
|
|
32
34
|
.taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
|
|
33
|
-
.taskchef-results-summary { margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
|
|
35
|
+
.taskchef-results-summary { max-width: 1080px; margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
|
|
36
|
+
.taskchef-list { max-width: 1080px; }
|
|
34
37
|
.taskchef-task-row { container-type: inline-size; background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
35
38
|
.taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
|
|
36
39
|
.taskchef-task-main { min-width: 0; flex: 1; }
|
|
37
40
|
.taskchef-card-heading,
|
|
38
41
|
.taskchef-turn-heading { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: var(--mantine-spacing-sm); }
|
|
39
42
|
.taskchef-card-metadata { width: max-content; min-width: 0; max-width: min(25rem, 45cqw); }
|
|
43
|
+
.taskchef-list-card-footer { display: flex; align-items: end; gap: 4px; min-width: 0; }
|
|
44
|
+
.taskchef-list-card-footer .taskchef-card-stats { flex: 1 1 auto; min-width: 0; margin-top: 2px; }
|
|
40
45
|
.taskchef-status-badge { flex: none; font-size: var(--mantine-font-size-xs); }
|
|
41
46
|
.taskchef-title-button {
|
|
42
47
|
all: unset;
|
|
@@ -68,6 +73,47 @@ body {
|
|
|
68
73
|
.taskchef-stat + .taskchef-stat { border-inline-start: 1px solid var(--taskchef-border); }
|
|
69
74
|
.taskchef-empty { border-style: dashed; background: transparent; }
|
|
70
75
|
|
|
76
|
+
.taskchef-toolbar .mantine-Select-input { width: 100%; height: 30px; min-height: 30px; padding-inline-start: 12px; font-size: var(--mantine-font-size-xs); }
|
|
77
|
+
.taskchef-toolbar-view .mantine-SegmentedControl-root, .taskchef-toolbar-status .mantine-SegmentedControl-root { width: max-content; max-width: 100%; height: 30px; padding: 0; }
|
|
78
|
+
.taskchef-toolbar-view .mantine-SegmentedControl-label, .taskchef-toolbar-status .mantine-SegmentedControl-label { display: flex; align-items: center; height: 100%; padding-inline: 12px; font-size: var(--mantine-font-size-xs); text-align: start; }
|
|
79
|
+
.taskchef-board {
|
|
80
|
+
display: grid;
|
|
81
|
+
grid-auto-flow: column;
|
|
82
|
+
grid-auto-columns: min(340px, calc(100% - 36px));
|
|
83
|
+
align-items: stretch;
|
|
84
|
+
gap: 18px;
|
|
85
|
+
width: 100%;
|
|
86
|
+
min-width: 0;
|
|
87
|
+
min-height: max(360px, calc(100dvh - 270px));
|
|
88
|
+
overflow-x: auto;
|
|
89
|
+
overscroll-behavior-x: contain;
|
|
90
|
+
scroll-snap-type: x mandatory;
|
|
91
|
+
scrollbar-width: thin;
|
|
92
|
+
}
|
|
93
|
+
.taskchef-board:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
94
|
+
.taskchef-board-dragging { scroll-snap-type: none; user-select: none; cursor: grabbing; }
|
|
95
|
+
.taskchef-board-lane { min-width: 0; scroll-snap-align: start; cursor: grab; }
|
|
96
|
+
.taskchef-board-lane-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 0 13px 6px; margin-bottom: 6px; }
|
|
97
|
+
.taskchef-board-lane-heading h2 { font-size: var(--mantine-font-size-sm); font-weight: 680; }
|
|
98
|
+
.taskchef-board-empty { padding: 12px 4px; }
|
|
99
|
+
.taskchef-board-card { display: flex; flex-direction: column; min-width: 0; cursor: auto; background: light-dark(#fff, #202421); border-color: var(--taskchef-border); box-shadow: 0 1px 2px light-dark(rgb(22 31 25 / 3%), transparent); }
|
|
100
|
+
.taskchef-board-card:hover { border-color: light-dark(#bdc8c0, #5b685e); }
|
|
101
|
+
.taskchef-board-project { color: var(--taskchef-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
102
|
+
.taskchef-board-title { line-height: 1.3; }
|
|
103
|
+
.taskchef-board-project { margin-top: 8px; }
|
|
104
|
+
.taskchef-board-title .taskchef-title-button { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; }
|
|
105
|
+
.taskchef-board-excerpt { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; margin-top: 18px; color: var(--taskchef-text); }
|
|
106
|
+
.taskchef-board-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; margin-top: auto; padding-top: 10px; }
|
|
107
|
+
.taskchef-board-card-footer .taskchef-time { padding-inline-start: 0; }
|
|
108
|
+
.taskchef-board-chat { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--taskchef-muted); cursor: pointer; }
|
|
109
|
+
.taskchef-board-chat:hover { background: var(--taskchef-surface-muted); color: var(--taskchef-text); }
|
|
110
|
+
.taskchef-board-chat:focus-visible { outline: 2px solid var(--mantine-color-teal-6); outline-offset: 2px; }
|
|
111
|
+
.taskchef-open-chat, .taskchef-detail-more { height: 26px; min-height: 26px; }
|
|
112
|
+
.taskchef-open-chat .mantine-Button-section[data-position="left"] { margin-inline-end: 4px; }
|
|
113
|
+
.taskchef-detail-more { width: 26px; min-width: 26px; }
|
|
114
|
+
.taskchef-board-link-pending { text-align: right; }
|
|
115
|
+
.taskchef-board-more { align-self: flex-start; padding-inline: 4px; color: var(--taskchef-muted); }
|
|
116
|
+
|
|
71
117
|
.taskchef-notifications {
|
|
72
118
|
position: fixed;
|
|
73
119
|
z-index: 400;
|
|
@@ -120,7 +166,7 @@ body {
|
|
|
120
166
|
.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; }
|
|
121
167
|
|
|
122
168
|
@media (max-width: 48em) {
|
|
123
|
-
.taskchef-toolbar .mantine-InputWrapper-root { max-width: none; }
|
|
169
|
+
.taskchef-toolbar-filters .mantine-InputWrapper-root { max-width: none; }
|
|
124
170
|
.taskchef-toolbar .mantine-Group-root { align-items: stretch; }
|
|
125
171
|
.taskchef-toolbar .mantine-SegmentedControl-root { overflow-x: auto; }
|
|
126
172
|
.taskchef-notifications { right: 16px; bottom: 16px; width: min(330px, calc(100vw - 32px)); }
|
|
@@ -130,8 +176,15 @@ body {
|
|
|
130
176
|
}
|
|
131
177
|
|
|
132
178
|
@media (max-width: 34em) {
|
|
133
|
-
.taskchef-header { padding-
|
|
134
|
-
.taskchef-
|
|
179
|
+
.taskchef-header { padding-inline: 8px; padding-block: 7px; }
|
|
180
|
+
.taskchef-header-layout { min-height: 32px; gap: 4px; }
|
|
181
|
+
.taskchef-header-copy { gap: 5px; }
|
|
182
|
+
.taskchef-brand-title { font-size: clamp(0.625rem, 2.7vw, 0.75rem); letter-spacing: 0.015em; line-height: 1.15; white-space: normal; }
|
|
183
|
+
.taskchef-version { margin-inline-start: 0.3rem; }
|
|
184
|
+
.taskchef-brand-icon, .taskchef-brand-icon img { width: 24px; height: 24px; }
|
|
185
|
+
.taskchef-header-actions { gap: 0; }
|
|
186
|
+
.taskchef-icon-button { width: 32px; min-width: 32px; height: 32px; min-height: 32px; }
|
|
187
|
+
.taskchef-connection { gap: 5px; margin-inline-start: 2px; }
|
|
135
188
|
}
|
|
136
189
|
|
|
137
190
|
@container (max-width: 27.5rem) {
|
package/src/dashboard/state.js
CHANGED
|
@@ -49,10 +49,13 @@ export function taskStatusLabel(task) {
|
|
|
49
49
|
return task.status === null ? "unresolved" : task.status.replaceAll("_", " ");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function hasLinkedCodexThread(task) {
|
|
53
|
+
return typeof task.threadId === "string" && CODEX_THREAD_ID_PATTERN.test(task.threadId);
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
export function isArchiveTaskEligible(task) {
|
|
53
57
|
return task.status !== "working"
|
|
54
|
-
&&
|
|
55
|
-
&& CODEX_THREAD_ID_PATTERN.test(task.threadId);
|
|
58
|
+
&& hasLinkedCodexThread(task);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
export function canArchiveTask(task) {
|