taskchef 8.4.1 → 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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +1 -0
- package/docs/dashboard-ui.md +25 -0
- package/package.json +2 -1
- package/src/dashboard/dist/app.js +1 -1
- package/src/dashboard/dist/app.js.map +1 -1
- package/src/dashboard/dist/styles.css +1 -1
- package/src/dashboard/react/App.test.tsx +14 -0
- package/src/dashboard/react/App.tsx +6 -5
- package/src/dashboard/react/components/ModelSettings.test.tsx +17 -1
- package/src/dashboard/react/components/ModelSettings.tsx +8 -8
- package/src/dashboard/react/styles.css +17 -6
|
@@ -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="
|
|
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); }}
|
|
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); }}
|
|
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',
|
|
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))]),
|
|
@@ -34,6 +34,22 @@ test('shows the ccusage version pinned by TaskChef without an availability statu
|
|
|
34
34
|
expect(screen.queryByText('available')).not.toBeInTheDocument();
|
|
35
35
|
});
|
|
36
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);
|
|
51
|
+
});
|
|
52
|
+
|
|
37
53
|
test('refresh shows configuration failures and does not label them honored', async () => {
|
|
38
54
|
const role = { role: 'reviewer', source: '/fixture/reviewer.toml', effectiveSource: '/fixture/reviewer.toml', model: 'fixture', effort: 'low', status: 'configured', availability: 'not checked', fallback: 'inherit parent settings', problems: [] as string[] };
|
|
39
55
|
const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
|
|
@@ -59,9 +59,9 @@ export function ModelSettings() {
|
|
|
59
59
|
setSaving(null);
|
|
60
60
|
}
|
|
61
61
|
};
|
|
62
|
-
return <Stack gap="
|
|
62
|
+
return <Stack className="taskchef-settings" gap="sm" aria-label="Model settings">
|
|
63
63
|
<Group justify="space-between">
|
|
64
|
-
<Title order={2} size="
|
|
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="
|
|
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="
|
|
78
|
-
<SimpleGrid cols={
|
|
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,10 +95,10 @@ export function ModelSettings() {
|
|
|
95
95
|
</Stack>
|
|
96
96
|
</Paper>)}
|
|
97
97
|
</>}
|
|
98
|
-
{usageProvider && <Paper withBorder p="
|
|
98
|
+
{usageProvider && <Paper className="taskchef-section" withBorder p="sm">
|
|
99
99
|
<Stack gap="xs">
|
|
100
|
-
<Title order={3} size="
|
|
101
|
-
<Text>{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
|
|
100
|
+
<Title order={3} size="h5">Usage provider</Title>
|
|
101
|
+
<Text size="sm">{usageProvider.provider}{usageProvider.version ? ` ${usageProvider.version}` : ' version unavailable'}</Text>
|
|
102
102
|
<Text c="dimmed" size="sm">Version pinned by TaskChef for token and cost estimates.</Text>
|
|
103
103
|
</Stack>
|
|
104
104
|
</Paper>}
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
36
|
-
.taskchef-list { max-width:
|
|
37
|
-
.taskchef-
|
|
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%;
|
|
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 {
|