taskchef 8.3.2 → 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.
@@ -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" p="md" radius="md" withBorder>
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
- <TaskCardStats task={task} />
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 Open chat beneath the status badge and keeps callbacks working", () => {
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(within(metadata).getByRole("button", { name: /open chat/i })).toBe(metadata.children[1]);
142
- fireEvent.click(within(metadata).getByRole("button", { name: /open chat/i }));
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 header { border-bottom: 1px solid var(--taskchef-border); }
20
- .taskchef-header { padding-top: 28px; padding-bottom: 22px; }
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 { align-self: stretch; flex: none; }
23
- .taskchef-connection { align-self: flex-end; }
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-version { margin-inline-start: 0.45rem; color: var(--taskchef-muted); font-weight: 600; letter-spacing: 0; text-transform: none; }
27
- .taskchef-brand-icon, .taskchef-brand-icon img { display: block; width: 42px; height: 42px; }
28
- .taskchef-main { padding-top: var(--mantine-spacing-md); }
29
- .taskchef-toolbar { padding-top: var(--mantine-spacing-md); background: light-dark(rgb(255 255 255 / 86%), rgb(34 39 36 / 88%)); }
30
- .taskchef-toolbar .mantine-InputWrapper-root { flex: 1 1 190px; max-width: 240px; }
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-top: 22px; padding-bottom: 18px; }
134
- .taskchef-brand-icon, .taskchef-brand-icon img { width: 36px; height: 36px; }
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) {
@@ -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
- && typeof task.threadId === "string"
55
- && CODEX_THREAD_ID_PATTERN.test(task.threadId);
58
+ && hasLinkedCodexThread(task);
56
59
  }
57
60
 
58
61
  export function canArchiveTask(task) {