taskchef 8.4.0 → 8.4.2

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.
@@ -32,6 +32,7 @@ vi.mock("@tabler/icons-react", async () => {
32
32
  IconAlertTriangle: Icon,
33
33
  IconCircleFilled: Icon,
34
34
  IconMoon: Icon,
35
+ IconRefresh: Icon,
35
36
  IconSun: Icon,
36
37
  };
37
38
  });
@@ -45,6 +46,7 @@ vi.mock("@mantine/core", async () => {
45
46
  fw: _fontWeight,
46
47
  gap: _gap,
47
48
  justify: _justify,
49
+ leftSection: _leftSection,
48
50
  mb: _marginBottom,
49
51
  mt: _marginTop,
50
52
  p: _padding,
@@ -146,6 +148,16 @@ beforeEach(() => {
146
148
  apiMocks.dashboardVersion.mockResolvedValue("7.25.1");
147
149
  });
148
150
 
151
+ test("Settings uses the same capped content layout as List", () => {
152
+ window.location.hash = "#settings";
153
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [] }) }));
154
+ const { container } = render(<DashboardApp connect={false} />);
155
+ const settingsPage = container.querySelector(".taskchef-settings-page");
156
+ expect(settingsPage).toHaveClass("taskchef-content");
157
+ expect(within(settingsPage as HTMLElement).getByRole("link", { name: "Back to tasks" })).toBeInTheDocument();
158
+ window.location.hash = "";
159
+ });
160
+
149
161
  test("places the filtered task count below the toolbar and updates it from live snapshots", async () => {
150
162
  const recent = new Date().toISOString();
151
163
  const tasks = [
@@ -205,6 +217,8 @@ test("places the filtered task count below the toolbar and updates it from live
205
217
  expect(toolbar).not.toContainElement(summary);
206
218
  expect(toolbar?.nextElementSibling).toBe(summary);
207
219
  expect(taskList).toHaveAttribute("aria-describedby", summary?.id);
220
+ expect(taskList).toHaveClass("taskchef-content");
221
+ expect(summary).toHaveClass("taskchef-content");
208
222
 
209
223
  act(() => {
210
224
  onSnapshot?.({
@@ -68,6 +68,7 @@ const theme = createTheme({
68
68
  components: {
69
69
  Button: { defaultProps: { radius: "md" } },
70
70
  Paper: { defaultProps: { radius: "md" } },
71
+ Select: { defaultProps: { size: "xs" } },
71
72
  },
72
73
  });
73
74
  const styleNonce = document.querySelector<HTMLMetaElement>('meta[name="taskchef-style-nonce"]')?.content;
@@ -94,7 +95,7 @@ export function TaskResultsSummary({
94
95
  visibleCount: number;
95
96
  }) {
96
97
  return (
97
- <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">
98
+ <Text aria-live="polite" className="taskchef-results-summary taskchef-content" id="task-results-summary">
98
99
  Tasks: {visibleCount} of {totalCount}
99
100
  </Text>
100
101
  );
@@ -358,15 +359,15 @@ export function DashboardApp({
358
359
  ))}
359
360
  </Alert>
360
361
  )}
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> : <>
362
+ {settings ? <Stack className="taskchef-settings-page taskchef-content" gap="sm"><Button component="a" href="#" variant="subtle" size="xs" leftSection={<IconArrowLeft size={14} />} style={{ alignSelf: 'flex-start' }}>Back to tasks</Button><ModelSettings /></Stack> : <>
362
363
  <Paper className="taskchef-toolbar" radius={0}>
363
364
  <Stack gap="sm">
364
365
  <Group className="taskchef-toolbar-view">
365
366
  <SegmentedControl aria-label="View" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={preferredView} withItemsBorders={false} />
366
367
  </Group>
367
368
  <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} />
369
+ <Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} value={project} />
370
+ <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); }} value={date} />
370
371
  </Group>
371
372
  {!board && <Box className="taskchef-toolbar-status">
372
373
  <SegmentedControl aria-label="Status" data={statusData} onChange={setStatus} size="xs" value={status} withItemsBorders={false} />
@@ -391,7 +392,7 @@ export function DashboardApp({
391
392
  onOpenCodex={handleOpenCodex}
392
393
  onOpenDetail={(value) => void loadDetail(value)}
393
394
  tasks={boardTasks}
394
- /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list" component="section" gap="sm" mt="xs">
395
+ /> : <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list taskchef-content" component="section" gap="sm" mt="xs">
395
396
  {visible.map((task: Task) => (
396
397
  <TaskCard key={task.id} onOpenCodex={handleOpenCodex} onOpenDetail={(value) => void loadDetail(value)} task={task} />
397
398
  ))}
@@ -8,7 +8,7 @@ vi.mock('../api', () => apiMocks);
8
8
 
9
9
  vi.mock('@mantine/core', async () => {
10
10
  const { createElement } = await import('react');
11
- const block = ({ children }: { children?: import('react').ReactNode }) => createElement('div', null, children);
11
+ const block = ({ children, className, 'aria-label': ariaLabel }: { children?: import('react').ReactNode; className?: string; 'aria-label'?: string }) => createElement('div', { className, 'aria-label': ariaLabel }, 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
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))]),
@@ -19,18 +19,35 @@ vi.mock('@mantine/core', async () => {
19
19
 
20
20
  afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
21
21
 
22
- test('shows the version reported by the active usage provider', async () => {
22
+ test('shows the ccusage version pinned by TaskChef without an availability status', async () => {
23
23
  vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
24
24
  ok: true,
25
25
  json: async () => ({
26
26
  profiles: [],
27
- usageProvider: { provider: 'ccusage', status: 'available', version: '20.0.21' },
27
+ usageProvider: { provider: 'ccusage', version: '20.0.21' },
28
28
  }),
29
29
  }));
30
30
  render(<MantineProvider><ModelSettings /></MantineProvider>);
31
31
  expect(await screen.findByText('ccusage 20.0.21')).toBeInTheDocument();
32
32
  expect(screen.getByText('Usage provider')).toBeInTheDocument();
33
- expect(screen.getByText(/Version reported by the executable/)).toBeInTheDocument();
33
+ expect(screen.getByText(/Version pinned by TaskChef/)).toBeInTheDocument();
34
+ expect(screen.queryByText('available')).not.toBeInTheDocument();
35
+ });
36
+
37
+ test('uses the shared dashboard content and section treatment', async () => {
38
+ const role = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
39
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
40
+ ok: true,
41
+ json: async () => ({
42
+ profiles: [{ id: 'personal', roles: [role], problems: [], modelOptions: [{ value: 'gpt-one', label: 'GPT One', efforts: ['low'] }] }],
43
+ usageProvider: { provider: 'ccusage', status: 'available', version: '20.0.21' },
44
+ }),
45
+ }));
46
+ const { container } = render(<MantineProvider><ModelSettings /></MantineProvider>);
47
+ expect(await screen.findByRole('combobox', { name: 'planner model' })).toBeInTheDocument();
48
+ expect(container.querySelector('[aria-label="Model settings"]')).toHaveClass('taskchef-settings');
49
+ expect(container.querySelector('.taskchef-settings-fields')).toBeInTheDocument();
50
+ expect(container.querySelectorAll('.taskchef-section')).toHaveLength(2);
34
51
  });
35
52
 
36
53
  test('refresh shows configuration failures and does not label them honored', async () => {
@@ -8,7 +8,7 @@ const effortLabels: Record<string, string> = { low: 'Light', medium: 'Medium', h
8
8
  type ModelOption = { value: string; label: string; efforts: string[] };
9
9
  type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
10
10
  type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
11
- type UsageProvider = { provider: string; status: 'available' | 'unavailable'; version: string | null };
11
+ type UsageProvider = { provider: string; version: string | null };
12
12
 
13
13
  export function ModelSettings() {
14
14
  const [profiles, setProfiles] = useState<Profile[]>([]);
@@ -59,9 +59,9 @@ export function ModelSettings() {
59
59
  setSaving(null);
60
60
  }
61
61
  };
62
- return <Stack gap="md" aria-label="Model settings">
62
+ return <Stack className="taskchef-settings" gap="sm" aria-label="Model settings">
63
63
  <Group justify="space-between">
64
- <Title order={2} size="h3">Model roles</Title>
64
+ <Title order={2} size="h5">Model roles</Title>
65
65
  <ActionIcon aria-label="Refresh" aria-busy={loading} aria-disabled={loading || saving !== null} className="taskchef-icon-button" color="gray" variant="subtle" size="lg" onClick={() => {
66
66
  if (loading || saving) return;
67
67
  setLoading(true);
@@ -72,10 +72,10 @@ export function ModelSettings() {
72
72
  {error && <Alert color="red" role="alert">{error}</Alert>}
73
73
  {profile && <>
74
74
  {profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
75
- {profile.roles.map((role) => <Paper key={role.role} withBorder p="md">
75
+ {profile.roles.map((role) => <Paper className="taskchef-section" key={role.role} withBorder p="sm">
76
76
  <Stack gap="xs">
77
- <Group justify="space-between"><Title order={3} size="h4" tt="capitalize">{role.role}</Title><Badge color={['invalid', 'unavailable'].includes(role.status) ? 'red' : role.status === 'missing' ? 'gray' : 'teal'}>{role.status}</Badge></Group>
78
- <SimpleGrid cols={{ base: 1, sm: 2 }}>
77
+ <Group justify="space-between"><Title order={3} size="h5" tt="capitalize">{role.role}</Title><Badge color={['invalid', 'unavailable'].includes(role.status) ? 'red' : role.status === 'missing' ? 'gray' : 'teal'}>{role.status}</Badge></Group>
78
+ <SimpleGrid className="taskchef-settings-fields" cols={2}>
79
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) => {
80
80
  if (loading || saving || !model) return;
81
81
  const option = profile.modelOptions?.find((candidate) => candidate.value === model);
@@ -95,14 +95,11 @@ export function ModelSettings() {
95
95
  </Stack>
96
96
  </Paper>)}
97
97
  </>}
98
- {usageProvider && <Paper withBorder p="md">
98
+ {usageProvider && <Paper className="taskchef-section" withBorder p="sm">
99
99
  <Stack gap="xs">
100
- <Group justify="space-between">
101
- <Title order={3} size="h4">Usage provider</Title>
102
- <Badge color={usageProvider.status === 'available' ? 'teal' : 'red'}>{usageProvider.status}</Badge>
103
- </Group>
104
- <Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
105
- <Text c="dimmed" size="sm">Version reported by the executable TaskChef uses for token and cost estimates.</Text>
100
+ <Title order={3} size="h5">Usage provider</Title>
101
+ <Text size="sm">{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
102
+ <Text c="dimmed" size="sm">Version pinned by TaskChef for token and cost estimates.</Text>
106
103
  </Stack>
107
104
  </Paper>}
108
105
  </Stack>;
@@ -5,6 +5,9 @@
5
5
  --taskchef-text: light-dark(#202521, #edf1ee);
6
6
  --taskchef-muted: light-dark(#657068, #aab4ad);
7
7
  --taskchef-accent: light-dark(#087f5b, #38d9a9);
8
+ --taskchef-page-max-width: 1510px;
9
+ --taskchef-content-max-width: 1080px;
10
+ --taskchef-page-gutter: clamp(16px, 3vw, 48px);
8
11
  }
9
12
 
10
13
  * { box-sizing: border-box; }
@@ -17,7 +20,7 @@ body {
17
20
 
18
21
  .taskchef-shell { min-height: 100vh; background: var(--taskchef-page); }
19
22
  .taskchef-header-shell { border-bottom: 1px solid var(--taskchef-border); }
20
- .taskchef-header { max-width: 1510px; padding: 10px clamp(12px, 3vw, 48px); }
23
+ .taskchef-header { max-width: var(--taskchef-page-max-width); padding: 10px var(--taskchef-page-gutter); }
21
24
  .taskchef-header-layout { min-width: 0; min-height: 36px; gap: 8px; }
22
25
  .taskchef-header-copy { min-width: 0; }
23
26
  .taskchef-header-actions { flex: none; }
@@ -26,15 +29,23 @@ body {
26
29
  .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
30
  .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
31
  .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); }
32
+ .taskchef-main { max-width: var(--taskchef-page-max-width); padding-top: 8px; padding-inline: var(--taskchef-page-gutter); }
33
+ .taskchef-content { width: 100%; max-width: var(--taskchef-content-max-width); }
30
34
  .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
35
  .taskchef-toolbar-filters { width: min(100%, 488px); min-width: 0; }
32
36
  .taskchef-toolbar-filters .mantine-InputWrapper-root { flex: 1 1 0; width: 0; min-width: 0; max-width: 240px; }
33
37
  .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); }
34
38
  .taskchef-toolbar .mantine-SegmentedControl-root { max-width: 650px; }
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; }
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); }
39
+ .taskchef-results-summary { max-width: var(--taskchef-content-max-width); margin-top: 12px; color: var(--taskchef-muted); font-size: var(--mantine-font-size-xs); line-height: 1.35; white-space: nowrap; }
40
+ .taskchef-list { max-width: var(--taskchef-content-max-width); }
41
+ .taskchef-settings-fields {
42
+ grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
43
+ gap: var(--mantine-spacing-sm);
44
+ width: min(100%, 36rem);
45
+ }
46
+ .taskchef-task-row { container-type: inline-size; }
47
+ .taskchef-task-row,
48
+ .taskchef-section { background: light-dark(#fff, #202421); box-shadow: 0 1px 0 light-dark(rgb(22 31 25 / 3%), transparent); }
38
49
  .taskchef-task-row:hover { border-color: light-dark(var(--mantine-color-teal-4), var(--mantine-color-teal-8)); }
39
50
  .taskchef-task-main { min-width: 0; flex: 1; }
40
51
  .taskchef-card-heading,
@@ -73,7 +84,7 @@ body {
73
84
  .taskchef-stat + .taskchef-stat { border-inline-start: 1px solid var(--taskchef-border); }
74
85
  .taskchef-empty { border-style: dashed; background: transparent; }
75
86
 
76
- .taskchef-toolbar .mantine-Select-input { width: 100%; height: 30px; min-height: 30px; padding-inline-start: 12px; font-size: var(--mantine-font-size-xs); }
87
+ .taskchef-toolbar .mantine-Select-input { width: 100%; }
77
88
  .taskchef-toolbar-view .mantine-SegmentedControl-root, .taskchef-toolbar-status .mantine-SegmentedControl-root { width: max-content; max-width: 100%; height: 30px; padding: 0; }
78
89
  .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
90
  .taskchef-board {
package/src/dashboard.js CHANGED
@@ -24,11 +24,15 @@ import {
24
24
  readConfig,
25
25
  readTask,
26
26
  } from "./workspace.js";
27
- import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
27
+ import {
28
+ DASHBOARD_SERVER_VERSION,
29
+ PINNED_CCUSAGE_VERSION,
30
+ TASKCHEF_VERSION,
31
+ } from "./version.js";
28
32
  import { taskGitHubProjection } from "./dashboard/github-links.js";
29
33
  import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
30
34
  import { createUsageTracker } from "./usage-tracker.js";
31
- import { readCcusageRuntimeInfo, readUsageStore, usageStorePath } from "./usage.js";
35
+ import { readUsageStore, usageStorePath } from "./usage.js";
32
36
  import { reportedWorkSummary } from "./reported-work.js";
33
37
  import {
34
38
  DASHBOARD_CONTROL_SESSION_PATH,
@@ -794,7 +798,6 @@ export async function createDashboardServer({
794
798
  usageSummaryMonitor = null,
795
799
  usagePreloadIntervalMs = 30_000,
796
800
  resolveRoles = resolveModelRoles,
797
- resolveUsageProvider = readCcusageRuntimeInfo,
798
801
  updateRole = updateModelRole,
799
802
  control = null,
800
803
  } = {}) {
@@ -861,8 +864,6 @@ export async function createDashboardServer({
861
864
  }
862
865
  const clients = new Set();
863
866
  const archiveRequests = new Set();
864
- let usageProviderPromise = null;
865
-
866
867
  const broadcast = (event, value) => {
867
868
  const payload = ssePayload(event, value);
868
869
  for (const client of clients) client.write(payload);
@@ -989,12 +990,11 @@ export async function createDashboardServer({
989
990
 
990
991
  if (url.pathname === "/api/settings" && method === "GET") {
991
992
  const profiles = [{ id: "personal", project: "Personal", ...await resolveRoles({ includeCatalog: true }) }];
992
- usageProviderPromise ??= Promise.resolve(resolveUsageProvider()).catch(() => ({
993
+ const usageProvider = {
993
994
  provider: "ccusage",
994
- status: "unavailable",
995
- version: null,
996
- }));
997
- sendJson(response, 200, { profiles, usageProvider: await usageProviderPromise });
995
+ version: PINNED_CCUSAGE_VERSION,
996
+ };
997
+ sendJson(response, 200, { profiles, usageProvider });
998
998
  return;
999
999
  }
1000
1000
 
package/src/version.js CHANGED
@@ -4,4 +4,5 @@ const require = createRequire(import.meta.url);
4
4
  const packageMetadata = require("../package.json");
5
5
 
6
6
  export const TASKCHEF_VERSION = packageMetadata.version;
7
+ export const PINNED_CCUSAGE_VERSION = packageMetadata.optionalDependencies?.ccusage ?? null;
7
8
  export const DASHBOARD_SERVER_VERSION = "4";