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.
@@ -1,4 +1,4 @@
1
- import { act, cleanup, render, screen, within } from "@testing-library/react";
1
+ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
2
2
  import type { ElementType, ReactNode } from "react";
3
3
  import { afterEach, beforeEach, expect, test, vi } from "vitest";
4
4
  import { DashboardApp } from "./App";
@@ -16,6 +16,12 @@ const apiMocks = vi.hoisted(() => ({
16
16
  vi.mock("./api", () => apiMocks);
17
17
  vi.mock("./components/NotificationCenter", () => ({ NotificationCenter: () => null }));
18
18
  vi.mock("./components/TaskCard", () => ({ TaskCard: () => null }));
19
+ vi.mock("./components/TaskBoard", () => ({ TaskBoard: ({ tasks, completedLimit, onMoreCompleted }: { tasks: ReturnType<typeof fixtureTask>[]; completedLimit: number; onMoreCompleted: () => void }) => (
20
+ <section aria-label="Task board" data-completed-limit={completedLimit}>
21
+ {tasks.map((task) => <span key={task.id}>{task.title}</span>)}
22
+ <button onClick={onMoreCompleted}>Show more</button>
23
+ </section>
24
+ ) }));
19
25
  vi.mock("./components/TaskDetail", () => ({ TaskDetail: () => null }));
20
26
  vi.mock("@tabler/icons-react", async () => {
21
27
  const { createElement } = await vi.importActual<typeof import("react")>("react");
@@ -135,10 +141,12 @@ vi.mock("@mantine/core", async () => {
135
141
  afterEach(cleanup);
136
142
  beforeEach(() => {
137
143
  vi.clearAllMocks();
144
+ window.localStorage.clear();
145
+ window.innerWidth = 1024;
138
146
  apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
139
147
  });
140
148
 
141
- test("places the filtered task count below the toolbar and updates it from live snapshots", () => {
149
+ test("places the filtered task count below the toolbar and updates it from live snapshots", async () => {
142
150
  const recent = new Date().toISOString();
143
151
  const tasks = [
144
152
  fixtureTask({
@@ -167,25 +175,31 @@ test("places the filtered task count below the toolbar and updates it from live
167
175
 
168
176
  render(
169
177
  <DashboardApp
170
- initialFilters={{ date: "24h", project: "Another project", status: "completed" }}
178
+ initialFilters={{ project: "Another project", status: "completed" }}
171
179
  initialTasks={tasks}
172
180
  />,
173
181
  );
182
+ const header = screen.getByRole("banner");
183
+ const title = within(header).getByRole("heading", { level: 1 });
174
184
  const headerActions = document.querySelector<HTMLElement>(".taskchef-header-actions")!;
175
- const headerIconRow = within(headerActions).getByRole("link", { name: "Settings" }).parentElement?.parentElement;
176
- expect(headerActions.firstElementChild).toBe(within(headerActions).getByRole("status"));
177
- expect(headerActions.lastElementChild).toHaveClass("taskchef-header-icon-row");
178
- expect(headerIconRow).toBe(headerActions.lastElementChild);
185
+ expect(title).toHaveTextContent("TaskChef Dashboard");
186
+ expect(await within(title).findByText("v7.25.1")).toHaveClass("taskchef-version");
187
+ expect(header.querySelector(".taskchef-brand-icon")).toHaveAttribute("width", "28");
188
+ expect(headerActions.children[0]).toContainElement(within(headerActions).getByRole("link", { name: "Settings" }));
189
+ expect(headerActions.children[1]).toContainElement(within(headerActions).getByRole("button", { name: "Use dark theme" }));
190
+ expect(headerActions.lastElementChild).toBe(within(headerActions).getByRole("status"));
179
191
  expect(within(headerActions).getByRole("link", { name: "Settings" })).toHaveClass("taskchef-icon-button");
180
192
  expect(within(headerActions).getByRole("button", { name: "Use dark theme" })).toHaveClass("taskchef-icon-button");
181
193
  const projectFilter = screen.getByRole("combobox", { name: "Project" });
182
- const dateFilter = screen.getByRole("combobox", { name: "Updated" });
194
+ const viewSwitch = screen.getByRole("radiogroup", { name: "View" });
183
195
  const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
184
196
  const summary = document.querySelector<HTMLElement>(".taskchef-results-summary");
185
197
  const taskList = screen.getByRole("region", { name: "Tasks" });
186
198
 
187
199
  expect(projectFilter).toHaveValue("Another project");
188
- expect(dateFilter).toHaveValue("Latest 24 hours");
200
+ expect(screen.getByRole("combobox", { name: "Updated" })).toHaveValue("All time");
201
+ expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(viewSwitch);
202
+ expect(viewSwitch.compareDocumentPosition(projectFilter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
189
203
  expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
190
204
  expect(summary).toHaveTextContent("Tasks: 1 of 3");
191
205
  expect(toolbar).not.toContainElement(summary);
@@ -210,3 +224,101 @@ test("places the filtered task count below the toolbar and updates it from live
210
224
  expect(summary).toHaveTextContent("Tasks: 2 of 2");
211
225
  expect(summary).toHaveAttribute("aria-live", "polite");
212
226
  }, 60_000);
227
+
228
+ test("Board ignores the List status filter and restores it on return", () => {
229
+ window.innerWidth = 1280;
230
+ const tasks = [
231
+ fixtureTask({ id: "one", title: "Working task" }),
232
+ fixtureTask({ id: "two", title: "Completed task", status: "completed" }),
233
+ ];
234
+ render(<DashboardApp connect={false} initialFilters={{ status: "completed" }} initialTasks={tasks} />);
235
+ expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
236
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
237
+ const toolbar = document.querySelector<HTMLElement>(".taskchef-toolbar");
238
+ expect(toolbar?.firstElementChild?.firstElementChild).toContainElement(screen.getByRole("radiogroup", { name: "View" }));
239
+ expect(screen.getByRole("radiogroup", { name: "View" }).compareDocumentPosition(screen.getByRole("combobox", { name: "Project" })) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
240
+ expect(screen.queryByRole("radiogroup", { name: "Status" })).not.toBeInTheDocument();
241
+ expect(screen.queryByText("Tasks: 1 of 2")).not.toBeInTheDocument();
242
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Working task");
243
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Completed task");
244
+ fireEvent.click(screen.getByRole("radio", { name: "List" }));
245
+ expect(screen.getByRole("radio", { name: /Completed/ })).toBeChecked();
246
+ expect(screen.getByText("Tasks: 1 of 2")).toBeInTheDocument();
247
+ });
248
+
249
+ test("Board preference and view switch remain available across screen widths", () => {
250
+ window.innerWidth = 1280;
251
+ const { unmount } = render(<DashboardApp connect={false} />);
252
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
253
+ expect(window.localStorage.getItem("taskchef.dashboard.view")).toBe("board");
254
+ expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
255
+ act(() => { window.innerWidth = 375; window.dispatchEvent(new Event("resize")); });
256
+ expect(screen.getByRole("radiogroup", { name: "View" })).toBeInTheDocument();
257
+ expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
258
+ fireEvent.click(screen.getByRole("radio", { name: "List" }));
259
+ expect(screen.getByRole("region", { name: "Tasks" })).toBeInTheDocument();
260
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
261
+ act(() => { window.innerWidth = 1200; window.dispatchEvent(new Event("resize")); });
262
+ expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
263
+ unmount();
264
+ window.innerWidth = 375;
265
+ render(<DashboardApp connect={false} />);
266
+ expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
267
+ });
268
+
269
+ test("storage failure keeps the selected view in memory", () => {
270
+ window.innerWidth = 1280;
271
+ const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
272
+ render(<DashboardApp connect={false} />);
273
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
274
+ expect(screen.getByRole("region", { name: "Task board" })).toBeInTheDocument();
275
+ setItem.mockRestore();
276
+ });
277
+
278
+ test("completed expansion survives view changes and resets for project", () => {
279
+ window.innerWidth = 1280;
280
+ render(<DashboardApp connect={false} initialTasks={[fixtureTask({ project: { name: "Project A", path: "/tmp/a", githubRepos: [] } })]} />);
281
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
282
+ const board = screen.getByRole("region", { name: "Task board" });
283
+ fireEvent.click(screen.getByRole("button", { name: "Show more" }));
284
+ expect(board).toHaveAttribute("data-completed-limit", "10");
285
+ fireEvent.click(screen.getByRole("radio", { name: "List" }));
286
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
287
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "10");
288
+ fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "Project A" } });
289
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "5");
290
+ });
291
+
292
+ test("Updated filters both views and List counts, and resets completed expansion", () => {
293
+ const recent = new Date().toISOString();
294
+ const tasks = [
295
+ fixtureTask({ id: "recent", title: "Recent task", status: "completed", updatedAt: recent, meaningfulUpdatedAt: recent }),
296
+ fixtureTask({ id: "old", title: "Older task", status: "completed", updatedAt: "2020-01-01T00:00:00.000Z", meaningfulUpdatedAt: "2020-01-01T00:00:00.000Z" }),
297
+ ];
298
+ render(<DashboardApp connect={false} initialTasks={tasks} />);
299
+ const updated = screen.getByRole("combobox", { name: "Updated" });
300
+ expect(updated).toHaveValue("All time");
301
+ expect(screen.getByText("Tasks: 2 of 2")).toBeInTheDocument();
302
+ fireEvent.change(updated, { target: { value: "Latest 24 hours" } });
303
+ expect(screen.getByText("Tasks: 1 of 2")).toBeInTheDocument();
304
+ expect(screen.getByRole("radio", { name: "Completed 1" })).toBeInTheDocument();
305
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
306
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Recent task");
307
+ expect(screen.getByRole("region", { name: "Task board" })).not.toHaveTextContent("Older task");
308
+ fireEvent.click(screen.getByRole("button", { name: "Show more" }));
309
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "10");
310
+ fireEvent.change(updated, { target: { value: "Latest 7 days" } });
311
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveAttribute("data-completed-limit", "5");
312
+ fireEvent.change(updated, { target: { value: "All time" } });
313
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Older task");
314
+ fireEvent.click(screen.getByRole("radio", { name: "List" }));
315
+ expect(screen.getByText("Tasks: 2 of 2")).toBeInTheDocument();
316
+ });
317
+
318
+ test("old tasks remain available in List and Board", () => {
319
+ const oldTask = fixtureTask({ id: "old", title: "Older task", updatedAt: "2020-01-01T00:00:00.000Z", meaningfulUpdatedAt: "2020-01-01T00:00:00.000Z" });
320
+ render(<DashboardApp connect={false} initialTasks={[oldTask]} />);
321
+ expect(screen.getByText("Tasks: 1 of 1")).toBeInTheDocument();
322
+ fireEvent.click(screen.getByRole("radio", { name: "Board" }));
323
+ expect(screen.getByRole("region", { name: "Task board" })).toHaveTextContent("Older task");
324
+ });
@@ -42,6 +42,7 @@ import { usageStillCalculating } from "./presentation";
42
42
  import type { DashboardSnapshot, NotificationSnapshot, Task } from "./types";
43
43
  import { NotificationCenter } from "./components/NotificationCenter";
44
44
  import { TaskCard } from "./components/TaskCard";
45
+ import { TaskBoard } from "./components/TaskBoard";
45
46
  import { TaskDetail } from "./components/TaskDetail";
46
47
  import { RelativeTimeProvider } from "./components/RelativeTime";
47
48
 
@@ -70,6 +71,12 @@ const theme = createTheme({
70
71
  },
71
72
  });
72
73
  const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
74
+ const VIEW_STORAGE_KEY = "taskchef.dashboard.view";
75
+
76
+ function savedView(): "board" | "list" {
77
+ try { return window.localStorage.getItem(VIEW_STORAGE_KEY) === "board" ? "board" : "list"; }
78
+ catch { return "list"; }
79
+ }
73
80
 
74
81
  interface NotificationState {
75
82
  initialized: boolean;
@@ -99,7 +106,7 @@ export function DashboardApp({
99
106
  initialTasks = [],
100
107
  }: {
101
108
  connect?: boolean;
102
- initialFilters?: { date?: string; project?: string; status?: string };
109
+ initialFilters?: { project?: string; status?: string };
103
110
  initialTasks?: Task[];
104
111
  }) {
105
112
  const [settings, setSettings] = useState(() => window.location.hash === '#settings');
@@ -115,7 +122,9 @@ export function DashboardApp({
115
122
  const [version, setVersion] = useState<string | null>(null);
116
123
  const [project, setProject] = useState(initialFilters.project ?? "");
117
124
  const [status, setStatus] = useState(initialFilters.status ?? "");
118
- const [date, setDate] = useState(initialFilters.date ?? "all");
125
+ const [date, setDate] = useState("all");
126
+ const [preferredView, setPreferredView] = useState(savedView);
127
+ const [completedLimit, setCompletedLimit] = useState(5);
119
128
  const [now, setNow] = useState(() => Date.now());
120
129
  const [selectedTask, setSelectedTask] = useState<Task | null>(null);
121
130
  const [detailOpened, setDetailOpened] = useState(false);
@@ -227,12 +236,20 @@ export function DashboardApp({
227
236
  ...[...new Set(tasks.map((task) => task.project.name))].sort().map((value) => ({ label: value, value })),
228
237
  ], [tasks]);
229
238
  const visible = useMemo(() => filterTasks(tasks, { project, status, date, now }), [tasks, project, status, date, now]);
239
+ const boardTasks = useMemo(() => filterTasks(tasks, { project, date, now }), [tasks, project, date, now]);
240
+ const board = preferredView === "board";
230
241
  const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
231
242
  const statusData = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
232
243
  label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
233
244
  value,
234
245
  }));
235
246
 
247
+ function changeView(value: string) {
248
+ if (value !== "board" && value !== "list") return;
249
+ setPreferredView(value);
250
+ try { window.localStorage.setItem(VIEW_STORAGE_KEY, value); } catch { /* Preference remains in memory. */ }
251
+ }
252
+
236
253
  async function handleOpenCodex(task: Task) {
237
254
  setDetailBusy(true);
238
255
  try {
@@ -303,30 +320,30 @@ export function DashboardApp({
303
320
  >
304
321
  <RelativeTimeProvider now={now}>
305
322
  <AppShell className="taskchef-shell" padding={0}>
306
- <Container className="taskchef-header" component="header" size={1080}>
307
- <Group className="taskchef-header-layout" align="stretch" justify="space-between" wrap="nowrap">
308
- <Box className="taskchef-header-copy">
309
- <Text c="var(--taskchef-accent)" fw={750} size="xs" tt="uppercase">TaskChef {version && <span className="taskchef-version">v{version}</span>}</Text>
310
- <Group gap="sm" mt={5} wrap="nowrap">
323
+ <Box className="taskchef-header-shell" component="header">
324
+ <Container className={`taskchef-header${board ? " taskchef-header-board" : ""}`} size="100%">
325
+ <Group className="taskchef-header-layout" justify="space-between" wrap="nowrap">
326
+ <Group className="taskchef-header-copy" gap="xs" wrap="nowrap">
311
327
  <BrandIcon />
312
- <Title order={1} size="h2">Dashboard</Title>
328
+ <Title className="taskchef-brand-title" order={1}>
329
+ <span>TaskChef Dashboard</span>
330
+ {version && <span className="taskchef-version">v{version}</span>}
331
+ </Title>
313
332
  </Group>
314
- </Box>
315
- <Stack align="flex-end" className="taskchef-header-actions" gap={0} justify="space-between">
316
- <Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
317
- <IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
318
- <Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
319
- </Group>
320
- <Group className="taskchef-header-icon-row" gap={2} justify="flex-end" wrap="nowrap">
333
+ <Group className="taskchef-header-actions" gap={2} justify="flex-end" wrap="nowrap">
321
334
  <Tooltip label="Settings"><ActionIcon className="taskchef-icon-button" component="a" href="#settings" aria-label="Settings" aria-current={settings ? 'page' : undefined} variant="subtle" color="gray" size="lg"><IconSettings size={18} stroke={1.8} /></ActionIcon></Tooltip>
322
335
  <ThemeToggle />
336
+ <Group aria-live="polite" className="taskchef-connection" gap={7} role="status" wrap="nowrap">
337
+ <IconCircleFilled aria-hidden color={connected ? "var(--mantine-color-teal-6)" : "var(--mantine-color-yellow-6)"} size={9} />
338
+ <Text c="dimmed" size="xs">{connected ? "Live" : connect ? "Connecting…" : "Fixture preview"}</Text>
339
+ </Group>
323
340
  </Group>
324
- </Stack>
325
- </Group>
326
- </Container>
341
+ </Group>
342
+ </Container>
343
+ </Box>
327
344
 
328
345
  <AppShell.Main>
329
- <Container className="taskchef-main" pb={80} size={1080}>
346
+ <Container className={`taskchef-main${board ? " taskchef-main-board" : ""}`} pb={80} size="100%">
330
347
  {projectIndex?.status === "unavailable" && (
331
348
  <Alert color="yellow" icon={<IconAlertTriangle size={18} />} mb="md" title="Project index unavailable">
332
349
  Task history is still visible. Verify the index and inspect backups before making project changes.
@@ -342,27 +359,22 @@ export function DashboardApp({
342
359
  </Alert>
343
360
  )}
344
361
  {settings ? <Stack gap="md"><Button component="a" href="#" variant="subtle" size="sm" leftSection={<IconArrowLeft size={16} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
345
- <Paper className="taskchef-toolbar" p="sm" radius="md" withBorder>
362
+ <Paper className="taskchef-toolbar" radius={0}>
346
363
  <Stack gap="sm">
347
- <Group align="end" gap="sm">
348
- <Select aria-label="Project" data={projects} label="Project" onChange={(value) => setProject(value ?? "")} size="sm" value={project} />
349
- <Select
350
- aria-label="Updated"
351
- data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]}
352
- label="Updated"
353
- onChange={(value) => setDate(value ?? "all")}
354
- size="sm"
355
- value={date}
356
- />
364
+ <Group className="taskchef-toolbar-view">
365
+ <SegmentedControl aria-label="View" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={preferredView} withItemsBorders={false} />
366
+ </Group>
367
+ <Group className="taskchef-toolbar-filters" gap="sm" wrap="nowrap">
368
+ <Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} size="xs" value={project} />
369
+ <Select aria-label="Updated" data={[{ label: "Latest 24 hours", value: "24h" }, { label: "Latest 7 days", value: "7d" }, { label: "All time", value: "all" }]} onChange={(value) => { setDate(value ?? "all"); setCompletedLimit(5); }} size="xs" value={date} />
357
370
  </Group>
358
- <Box>
359
- <Text className="taskchef-filter-label" mb={5}>Status</Text>
360
- <SegmentedControl aria-label="Status" data={statusData} fullWidth onChange={setStatus} size="xs" value={status} />
361
- </Box>
371
+ {!board && <Box className="taskchef-toolbar-status">
372
+ <SegmentedControl aria-label="Status" data={statusData} onChange={setStatus} size="xs" value={status} withItemsBorders={false} />
373
+ </Box>}
362
374
  </Stack>
363
375
  </Paper>
364
376
 
365
- <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />
377
+ {!board && <TaskResultsSummary totalCount={tasks.length} visibleCount={visible.length} />}
366
378
 
367
379
  {message && (
368
380
  <Alert color="yellow" icon={<IconAlertTriangle aria-hidden size={17} />} mt="md" role="status">
@@ -373,17 +385,23 @@ export function DashboardApp({
373
385
  </Alert>
374
386
  )}
375
387
 
376
- <Stack aria-describedby="task-results-summary" aria-label="Tasks" component="section" gap="sm" mt="xs">
388
+ {board ? <TaskBoard
389
+ completedLimit={completedLimit}
390
+ onMoreCompleted={() => setCompletedLimit((limit) => limit + 5)}
391
+ onOpenCodex={handleOpenCodex}
392
+ onOpenDetail={(value) => void loadDetail(value)}
393
+ tasks={boardTasks}
394
+ /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list" component="section" gap="sm" mt="xs">
377
395
  {visible.map((task: Task) => (
378
396
  <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
379
397
  ))}
380
398
  {visible.length === 0 && (
381
399
  <Paper className="taskchef-empty" p={48} ta="center" withBorder>
382
400
  <Title order={2} size="h4">No tasks match these filters</Title>
383
- <Text c="dimmed" mt={4} size="sm">Choose a different project, date, or status.</Text>
401
+ <Text c="dimmed" mt={4} size="sm">Choose a different project, update window, or status.</Text>
384
402
  </Paper>
385
403
  )}
386
- </Stack>
404
+ </Stack>}
387
405
  </>}
388
406
  </Container>
389
407
  </AppShell.Main>
@@ -485,9 +503,9 @@ function BrandIcon() {
485
503
  alt=""
486
504
  aria-hidden
487
505
  className="taskchef-brand-icon"
488
- height="42"
506
+ height="28"
489
507
  src={colorScheme === "dark" ? "/assets/taskchef-dark.svg" : "/assets/taskchef.svg"}
490
- width="42"
508
+ width="28"
491
509
  />
492
510
  );
493
511
  }
@@ -59,7 +59,6 @@ export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef:
59
59
  <RelativeTime
60
60
  icon={<IconHistory aria-hidden size={14} />}
61
61
  label="Turn update time"
62
- tooltipLabel="Updated at"
63
62
  value={presentation.updatedAt}
64
63
  />
65
64
  </StatCell>
@@ -11,7 +11,7 @@ vi.mock('@mantine/core', async () => {
11
11
  const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
12
12
  return {
13
13
  MantineProvider: block, Tooltip: block, Badge: block, Group: block, Paper: block, SimpleGrid: block, Stack: block, Text: block, Title: block,
14
- Select: ({ 'aria-busy': busy, 'aria-label': label, data = [], disabled, onChange, readOnly, value }: { 'aria-busy'?: boolean; 'aria-label': string; data?: Array<string | { value: string; label: string }>; disabled?: boolean; onChange: (value: string | null) => void; readOnly?: boolean; value: string | null }) => createElement('select', { 'aria-busy': busy, 'aria-label': label, disabled, 'aria-readonly': readOnly, value: value ?? '', onChange: (event: { currentTarget: { value: string } }) => onChange(event.currentTarget.value) }, [createElement('option', { key: '', value: '' }), ...data.map((item) => typeof item === 'string' ? createElement('option', { key: item, value: item }, item) : createElement('option', { key: item.value, value: item.value }, item.label))]),
14
+ Select: ({ 'aria-busy': busy, 'aria-label': label, data = [], disabled, onChange, readOnly, value }: { 'aria-busy'?: boolean; 'aria-label': string; data?: Array<string | { value: string; label: string; disabled?: boolean }>; disabled?: boolean; onChange: (value: string | null) => void; readOnly?: boolean; value: string | null }) => createElement('select', { 'aria-busy': busy, 'aria-label': label, disabled, 'aria-readonly': readOnly, value: value ?? '', onChange: (event: { currentTarget: { value: string } }) => onChange(event.currentTarget.value) }, [createElement('option', { key: '', value: '' }), ...data.map((item) => typeof item === 'string' ? createElement('option', { key: item, value: item }, item) : createElement('option', { key: item.value, value: item.value, disabled: item.disabled }, item.label))]),
15
15
  Alert: ({ children }: { children?: import('react').ReactNode }) => createElement('div', { role: 'alert' }, children),
16
16
  ActionIcon: ({ children, loading, onClick, 'aria-label': label, ...props }: { children?: import('react').ReactNode; loading?: boolean; onClick: () => void; 'aria-label': string; className?: string }) => createElement('button', { ...props, disabled: loading, onClick, 'aria-label': label }, children),
17
17
  };
@@ -112,3 +112,52 @@ test('serializes role saves so an older response cannot replace a newer selectio
112
112
  fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
113
113
  await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(2));
114
114
  });
115
+
116
+ const effortRole = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
117
+ const effortProfile = { id: 'personal', roles: [effortRole], problems: [], modelOptions: [
118
+ { value: 'gpt-one', label: 'GPT One', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] },
119
+ { value: 'gpt-two', label: 'GPT Two', efforts: ['max', 'medium'] },
120
+ ] };
121
+
122
+ test.each([['Light', 'low'], ['Medium', 'medium'], ['High', 'high'], ['Extra High', 'xhigh'], ['Ultra', 'ultra']])('saves %s as native %s and omits max choices', async (label, native) => {
123
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [effortProfile] }) }));
124
+ apiMocks.updateModelRole.mockResolvedValue({ profile: { ...effortProfile, roles: [{ ...effortRole, effort: native }] } });
125
+ render(<ModelSettings />);
126
+ const effort = await screen.findByRole('combobox', { name: 'planner reasoning effort' });
127
+ expect(screen.getByRole('option', { name: label })).toHaveValue(native);
128
+ expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
129
+ fireEvent.change(effort, { target: { value: native } });
130
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-one', native));
131
+ });
132
+
133
+ test('model changes filter supported efforts and never default to max', async () => {
134
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [effortProfile] }) }));
135
+ apiMocks.updateModelRole.mockResolvedValue({ profile: { ...effortProfile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'medium' }] } });
136
+ render(<ModelSettings />);
137
+ fireEvent.change(await screen.findByRole('combobox', { name: 'planner model' }), { target: { value: 'gpt-two' } });
138
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium'));
139
+ expect(screen.getByRole('option', { name: 'Medium' })).toHaveValue('medium');
140
+ expect(screen.queryByRole('option', { name: 'Light' })).not.toBeInTheDocument();
141
+ expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
142
+ });
143
+
144
+ test('saved max is displayed without being selectable and survives refresh and supported model changes', async () => {
145
+ const profile = { ...effortProfile, roles: [{ ...effortRole, effort: 'max' }] };
146
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
147
+ apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'max' }] } });
148
+ render(<ModelSettings />);
149
+ const effort = await screen.findByRole('combobox', { name: 'planner reasoning effort' });
150
+ expect(effort).toHaveValue('max');
151
+ expect(screen.getByRole('option', { name: 'Max (current setting)' })).toBeDisabled();
152
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
153
+ await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
154
+ expect(apiMocks.updateModelRole).not.toHaveBeenCalled();
155
+ await waitFor(() => expect(effort).toHaveAttribute('aria-readonly', 'false'));
156
+ fireEvent.change(screen.getByRole('combobox', { name: 'planner model' }), { target: { value: 'gpt-two' } });
157
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'max'));
158
+ await waitFor(() => expect(effort).toHaveAttribute('aria-readonly', 'false'));
159
+ apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'medium' }] } });
160
+ fireEvent.change(effort, { target: { value: 'medium' } });
161
+ await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenLastCalledWith('planner', 'gpt-two', 'medium'));
162
+ expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
163
+ });
@@ -3,6 +3,8 @@ import { IconRefresh } from '@tabler/icons-react';
3
3
  import { useEffect, useState } from 'react';
4
4
  import { updateModelRole } from '../api';
5
5
 
6
+ const effortLabels: Record<string, string> = { low: 'Light', medium: 'Medium', high: 'High', xhigh: 'Extra High', ultra: 'Ultra' };
7
+
6
8
  type ModelOption = { value: string; label: string; efforts: string[] };
7
9
  type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
8
10
  type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
@@ -77,11 +79,14 @@ export function ModelSettings() {
77
79
  <Select label="Model" aria-label={`${role.role} model`} aria-busy={loading || saving !== null} data={profile.modelOptions ?? []} value={typeof role.model === 'string' ? role.model : null} readOnly={loading || saving !== null} allowDeselect={false} searchable onChange={(model) => {
78
80
  if (loading || saving || !model) return;
79
81
  const option = profile.modelOptions?.find((candidate) => candidate.value === model);
80
- const effort = typeof role.effort === 'string' && option?.efforts.includes(role.effort) ? role.effort : option?.efforts[0];
82
+ const effort = typeof role.effort === 'string' && option?.efforts.includes(role.effort) ? role.effort : option?.efforts.find((value) => value !== 'max');
81
83
  if (effort) void save(role, model, effort);
82
84
  }} />
83
- <Select label="Reasoning effort" aria-label={`${role.role} reasoning effort`} aria-busy={loading || saving !== null} data={(profile.modelOptions?.find((option) => option.value === role.model)?.efforts ?? []).map((value) => ({ value, label: value }))} value={typeof role.effort === 'string' ? role.effort : null} disabled={typeof role.model !== 'string'} readOnly={loading || saving !== null} allowDeselect={false} onChange={(effort) => {
84
- if (!loading && !saving && effort && typeof role.model === 'string') void save(role, role.model, effort);
85
+ <Select label="Reasoning effort" aria-label={`${role.role} reasoning effort`} aria-busy={loading || saving !== null} data={[
86
+ ...(profile.modelOptions?.find((option) => option.value === role.model)?.efforts ?? []).filter((value) => value !== 'max').map((value) => ({ value, label: effortLabels[value] ?? value })),
87
+ ...(role.effort === 'max' ? [{ value: 'max', label: 'Max (current setting)', disabled: true }] : []),
88
+ ]} value={typeof role.effort === 'string' ? role.effort : null} disabled={typeof role.model !== 'string'} readOnly={loading || saving !== null} allowDeselect={false} onChange={(effort) => {
89
+ if (!loading && !saving && effort && effort !== 'max' && typeof role.model === 'string') void save(role, role.model, effort);
85
90
  }} />
86
91
  </SimpleGrid>
87
92
  <Text size="sm" style={{ overflowWrap: 'anywhere' }}>Source: {role.displaySource ?? role.source ?? 'No role file'}</Text>
@@ -1,7 +1,7 @@
1
1
  import { ActionIcon, Box, Button, Group, Paper, Portal, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
2
2
  import { IconBell, IconX } from "@tabler/icons-react";
3
3
  import { notificationDismissLabel, notificationOpenLabel, notificationTitle } from "../../state.js";
4
- import { formatRelativeTime } from "../../time.js";
4
+ import { formatExactTime, formatRelativeTime } from "../../time.js";
5
5
  import type { NotificationSnapshot, Task } from "../types";
6
6
  import { StatusBadge } from "./StatusBadge";
7
7
 
@@ -65,7 +65,7 @@ export function NotificationCenter({
65
65
  <Text lineClamp={1} size="sm">{notification.title}</Text>
66
66
  {notification.summary && <Text className="taskchef-preserve-lines" id={summaryId} lineClamp={3} size="xs">{notification.summary}</Text>}
67
67
  <Text c="dimmed" id={metadataId} size="xs">
68
- {formatRelativeTime(notification.timestamp)}{available ? "" : " · Task no longer available"}
68
+ <Tooltip label={formatExactTime(notification.timestamp)}><span>{formatRelativeTime(notification.timestamp)}</span></Tooltip>{available ? "" : " · Task no longer available"}
69
69
  </Text>
70
70
  </button>
71
71
  <Tooltip label="Dismiss notification">
@@ -15,6 +15,7 @@ test("uses the shared open-chat presentation and accessible task label", () => {
15
15
 
16
16
  const button = screen.getByRole("button", { name: "Open chat for Review checkout reconciliation" });
17
17
  expect(button).toHaveTextContent("Open chat");
18
+ expect(button).toHaveClass("taskchef-open-chat");
18
19
  expect(button).toHaveAttribute("data-variant", "default");
19
20
  expect(button.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
20
21
  fireEvent.click(button);
@@ -1,5 +1,5 @@
1
1
  import { Button } from "@mantine/core";
2
- import { IconExternalLink } from "@tabler/icons-react";
2
+ import { IconArrowUpRight } from "@tabler/icons-react";
3
3
 
4
4
  export function OpenChatButton({
5
5
  loading,
@@ -13,7 +13,8 @@ export function OpenChatButton({
13
13
  return (
14
14
  <Button
15
15
  aria-label={`Open chat for ${taskTitle}`}
16
- leftSection={<IconExternalLink aria-hidden size={15} />}
16
+ className="taskchef-open-chat"
17
+ leftSection={<IconArrowUpRight aria-hidden size={15} stroke={1.6} />}
17
18
  loading={loading}
18
19
  onClick={onClick}
19
20
  size="compact-sm"
@@ -12,12 +12,10 @@ export function RelativeTimeProvider({ children, now }: { children: ReactNode; n
12
12
  export function RelativeTime({
13
13
  icon,
14
14
  label,
15
- tooltipLabel,
16
15
  value,
17
16
  }: {
18
17
  icon?: ReactNode;
19
18
  label: string;
20
- tooltipLabel?: string;
21
19
  value: string | null | undefined;
22
20
  }) {
23
21
  const [exact, setExact] = useState(false);
@@ -25,13 +23,9 @@ export function RelativeTime({
25
23
  const text = exact ? formatExactTime(value) : formatRelativeTime(value);
26
24
  const exactText = formatExactTime(value);
27
25
  const unavailable = exactText === "—";
28
- const tooltip = exactText === "—"
29
- ? "Updated time unavailable"
30
- : exact
31
- ? "Show relative time"
32
- : `Exact time: ${exactText}. Show exact time`;
26
+ const tooltip = unavailable ? "Updated time unavailable" : exact ? formatRelativeTime(value) : exactText;
33
27
  return (
34
- <Tooltip events={{ focus: true, hover: true, touch: false }} label={tooltipLabel ?? tooltip}>
28
+ <Tooltip events={{ focus: true, hover: true, touch: false }} label={tooltip}>
35
29
  <UnstyledButton
36
30
  aria-label={unavailable
37
31
  ? `${label}: unavailable.`