taskchef 8.7.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-plugin/plugin.json +7 -10
- package/.mcp.json +3 -2
- package/README.md +31 -158
- package/docs/mcp-app.md +21 -4
- package/docs/spec.md +38 -746
- package/docs/taskchef-next-storage.md +2 -2
- package/index.js +2 -99
- package/package.json +12 -49
- package/skills/delegate/SKILL.md +44 -0
- package/skills/delegate/agents/openai.yaml +4 -0
- package/src/codex-app.js +3 -152
- package/src/codex-session-scanner.js +2 -2
- package/src/file-lock.js +25 -0
- package/src/mcp-app/react/NextTaskDetail.test.tsx +1 -1
- package/src/mcp-app/react/NextTaskDetail.tsx +8 -8
- package/src/mcp-app/react/main.test.tsx +3 -3
- package/src/mcp-app/react/main.tsx +7 -7
- package/src/mcp-app.js +4 -4
- package/src/mcp.js +4 -542
- package/src/next-cache.js +2 -2
- package/src/next-github.js +3 -3
- package/src/next-notifications.js +2 -2
- package/src/{dashboard/react → ui}/components/CardStatusIcons.tsx +1 -1
- package/src/{dashboard/react → ui}/components/GitHubLinks.tsx +1 -1
- package/src/{dashboard/react → ui}/components/LinkedText.tsx +1 -1
- package/src/{dashboard/react → ui}/components/RelativeTime.test.tsx +1 -1
- package/src/{dashboard/react → ui}/components/RelativeTime.tsx +1 -1
- package/src/{dashboard/react → ui}/components/ReplyImagePreview.test.tsx +1 -1
- package/src/{dashboard/react → ui}/components/TaskBoard.tsx +1 -1
- package/src/{dashboard/react → ui}/components/TaskCard.tsx +1 -1
- package/src/{dashboard/react → ui}/components/TurnContent.tsx +1 -1
- package/src/{dashboard/react → ui}/presentation.ts +1 -1
- package/src/version.js +2 -1
- package/BACKLOG.md +0 -48
- package/assets/e2e-benchmark-example.json +0 -50
- package/assets/taskchef-dispatcher-instructions.md +0 -44
- package/bin/taskchef.js +0 -20
- package/docs/advanced-guide.md +0 -497
- package/docs/dashboard-lifecycle.md +0 -149
- package/docs/dashboard-ui.md +0 -40
- package/docs/firstmate-taskchef-comparison.md +0 -283
- package/docs/images/ccusage-token-consumption.png +0 -0
- package/docs/images/dashboard-identity.jpg +0 -0
- package/docs/images/dashboard-token-pending.jpg +0 -0
- package/docs/images/interrupted-turn-recovery.jpg +0 -0
- package/docs/images/notification-event-snapshots.jpg +0 -0
- package/docs/images/readme-board.png +0 -0
- package/docs/images/readme-completed.png +0 -0
- package/docs/images/readme-list.png +0 -0
- package/docs/images/readme-needs-input.png +0 -0
- package/docs/images/readme-roles.png +0 -0
- package/docs/images/reported-work-duration-desktop.jpg +0 -0
- package/docs/images/reported-work-duration-mobile.jpg +0 -0
- package/docs/images/result-history-dashboard.jpg +0 -0
- package/docs/images/taskchef-pr-icon-comparison.jpg +0 -0
- package/docs/images/taskchef-unified-status-popup.jpg +0 -0
- package/docs/workflows.md +0 -476
- package/mcp/dashboard-session.js +0 -51
- package/scripts/benchmark-dispatch-prepare.js +0 -90
- package/scripts/e2e-benchmark.js +0 -206
- package/scripts/roles/resolve_roles.py +0 -293
- package/skills/taskchef-bootstrap/SKILL.md +0 -96
- package/skills/taskchef-bootstrap/agents/openai.yaml +0 -4
- package/skills/taskchef-bootstrap/references/project-index.md +0 -154
- package/skills/taskchef-copilot/SKILL.md +0 -75
- package/skills/taskchef-copilot/agents/openai.yaml +0 -4
- package/skills/taskchef-dashboard/SKILL.md +0 -24
- package/skills/taskchef-dashboard/agents/openai.yaml +0 -4
- package/skills/taskchef-delegate/SKILL.md +0 -102
- package/skills/taskchef-delegate/agents/openai.yaml +0 -4
- package/skills/taskchef-delegate/references/model-roles.md +0 -29
- package/skills/taskchef-executor/SKILL.md +0 -154
- package/skills/taskchef-executor/agents/openai.yaml +0 -4
- package/skills/taskchef-executor/references/compatibility.md +0 -22
- package/skills/taskchef-executor/references/ending-actions.md +0 -26
- package/src/backups.js +0 -359
- package/src/cli.js +0 -733
- package/src/config-audit.js +0 -99
- package/src/config-mutations.js +0 -106
- package/src/dashboard/actions.js +0 -136
- package/src/dashboard/app.js +0 -1172
- package/src/dashboard/dist/app.js +0 -269
- package/src/dashboard/dist/app.js.map +0 -1
- package/src/dashboard/dist/index.html +0 -16
- package/src/dashboard/dist/styles.css +0 -1
- package/src/dashboard/index.html +0 -148
- package/src/dashboard/react/App.test.tsx +0 -346
- package/src/dashboard/react/App.tsx +0 -515
- package/src/dashboard/react/api.test.ts +0 -40
- package/src/dashboard/react/api.ts +0 -103
- package/src/dashboard/react/components/ActivityTimeline.tsx +0 -118
- package/src/dashboard/react/components/ModelSettings.test.tsx +0 -180
- package/src/dashboard/react/components/ModelSettings.tsx +0 -106
- package/src/dashboard/react/components/NotificationCenter.test.tsx +0 -76
- package/src/dashboard/react/components/NotificationCenter.tsx +0 -111
- package/src/dashboard/react/components/PullRequestBadges.tsx +0 -18
- package/src/dashboard/react/components/TaskDetail.test.tsx +0 -255
- package/src/dashboard/react/components/TaskDetail.tsx +0 -271
- package/src/dashboard/react/components/UsagePanel.tsx +0 -96
- package/src/dashboard/react/index.html +0 -15
- package/src/dashboard/react/main.tsx +0 -12
- package/src/dashboard/react/overlay-state.test.ts +0 -110
- package/src/dashboard/react/overlay-state.ts +0 -60
- package/src/dashboard/react/presentation.test.tsx +0 -571
- package/src/dashboard/styles.css +0 -261
- package/src/dashboard-control.js +0 -34
- package/src/dashboard-manager.js +0 -339
- package/src/dashboard-session-process.js +0 -88
- package/src/dashboard-session.js +0 -86
- package/src/dashboard.js +0 -1303
- package/src/delegation.js +0 -277
- package/src/github.js +0 -107
- package/src/model-roles.js +0 -70
- package/src/recorded-usage-tracker.js +0 -92
- package/src/usage-tracker.js +0 -650
- package/src/usage.js +0 -367
- package/src/workspace-path.js +0 -48
- package/src/workspace.js +0 -3629
- /package/src/{dashboard/react → ui}/components/AnimatedUsage.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/CardStatusIcons.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ImageGallery.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/OpenChatButton.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/OpenChatButton.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/PrGlyph.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/QueuedPrompts.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/QueuedPrompts.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ReplyCover.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ReplyCover.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ReplyImagePreview.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ReplyMarkdown.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ReplyMarkdown.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/SavedUsageFormat.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/SavedUsageFormat.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/ShimmerText.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/StatsGrid.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/StatusBadge.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/TaskBoard.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/TaskCardStats.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/TaskCardStats.tsx +0 -0
- /package/src/{dashboard/react → ui}/components/TurnContent.test.tsx +0 -0
- /package/src/{dashboard/react → ui}/fixtures.ts +0 -0
- /package/src/{dashboard → ui}/github-links.js +0 -0
- /package/src/{dashboard/react → ui}/image-cache.test.ts +0 -0
- /package/src/{dashboard/react → ui}/image-cache.ts +0 -0
- /package/src/{dashboard → ui}/state.js +0 -0
- /package/src/{dashboard/react → ui}/styles.css +0 -0
- /package/src/{dashboard/react → ui}/test-setup.ts +0 -0
- /package/src/{dashboard → ui}/time.js +0 -0
- /package/src/{dashboard/react → ui}/types.ts +0 -0
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
import { Box, Paper, Stack, Text } from "@mantine/core";
|
|
2
|
-
import {
|
|
3
|
-
IconCurrencyDollar,
|
|
4
|
-
IconHistory,
|
|
5
|
-
IconHourglass,
|
|
6
|
-
IconStack2,
|
|
7
|
-
} from "@tabler/icons-react";
|
|
8
|
-
import { mergeProjectedTurns, turnPresentation } from "../../state.js";
|
|
9
|
-
import { turnReportedWorkView, turnUsageMetricsView } from "../presentation";
|
|
10
|
-
import type { Task, TaskTurn } from "../types";
|
|
11
|
-
import { useEffect, useState } from "react";
|
|
12
|
-
import { RelativeTime } from "./RelativeTime";
|
|
13
|
-
import { LinkedText } from "./LinkedText";
|
|
14
|
-
import { ShimmerText } from "./ShimmerText";
|
|
15
|
-
import { StatCell, StaticStat, StatsGrid } from "./StatsGrid";
|
|
16
|
-
import { StatusBadge } from "./StatusBadge";
|
|
17
|
-
|
|
18
|
-
export function ActivityTimeline({ highlightTurnRef, task }: { highlightTurnRef: string | null; task: Task }) {
|
|
19
|
-
const turns = mergeProjectedTurns(task, []) as Task["turns"];
|
|
20
|
-
if (!turns?.length) return <Text c="dimmed" size="sm">No turn history has been recorded.</Text>;
|
|
21
|
-
return (
|
|
22
|
-
<Stack gap="sm">
|
|
23
|
-
{[...turns].reverse().map((turn, index) => {
|
|
24
|
-
const presentation = turnPresentation(turn);
|
|
25
|
-
const identity = turn.turnRef ?? turn.turnId ?? `turn-${index}`;
|
|
26
|
-
const highlighted = identity === highlightTurnRef;
|
|
27
|
-
const usage = turnUsageMetricsView(task, turn);
|
|
28
|
-
return (
|
|
29
|
-
<Paper
|
|
30
|
-
className={`taskchef-turn${highlighted ? " taskchef-turn-highlighted" : ""}`}
|
|
31
|
-
data-turn-ref={identity}
|
|
32
|
-
key={identity}
|
|
33
|
-
p="sm"
|
|
34
|
-
radius="md"
|
|
35
|
-
tabIndex={highlighted ? -1 : undefined}
|
|
36
|
-
withBorder
|
|
37
|
-
>
|
|
38
|
-
<Stack gap={7}>
|
|
39
|
-
<Box className="taskchef-turn-heading">
|
|
40
|
-
<Text c="dimmed" size="xs">{presentation.sourceLabel}</Text>
|
|
41
|
-
<StatusBadge status={presentation.status} />
|
|
42
|
-
</Box>
|
|
43
|
-
<Box>
|
|
44
|
-
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
45
|
-
<Text className="taskchef-preserve-lines" size="sm">
|
|
46
|
-
<LinkedText task={task} text={turn.requestSummary ?? "Request not recorded."} />
|
|
47
|
-
</Text>
|
|
48
|
-
</Box>
|
|
49
|
-
<Box>
|
|
50
|
-
<Text c="dimmed" className="taskchef-field-label" size="xs">Result</Text>
|
|
51
|
-
<Text className="taskchef-preserve-lines" size="sm">
|
|
52
|
-
{presentation.status === "working"
|
|
53
|
-
? <ShimmerText>{presentation.summary}</ShimmerText>
|
|
54
|
-
: <LinkedText task={task} text={presentation.summary} />}
|
|
55
|
-
</Text>
|
|
56
|
-
</Box>
|
|
57
|
-
<StatsGrid variant="turn">
|
|
58
|
-
<StatCell>
|
|
59
|
-
<RelativeTime
|
|
60
|
-
icon={<IconHistory aria-hidden size={14} />}
|
|
61
|
-
label="Turn update time"
|
|
62
|
-
value={presentation.updatedAt}
|
|
63
|
-
/>
|
|
64
|
-
</StatCell>
|
|
65
|
-
<StatCell>
|
|
66
|
-
<TurnReportedWork turn={turn} />
|
|
67
|
-
</StatCell>
|
|
68
|
-
<StatCell usageState={usage.kind}>
|
|
69
|
-
<StaticStat
|
|
70
|
-
accessibleLabel={usage.tokens.accessibleLabel}
|
|
71
|
-
animated={usage.animated}
|
|
72
|
-
icon={<IconStack2 aria-hidden size={14} />}
|
|
73
|
-
title="Tokens"
|
|
74
|
-
value={usage.tokens.value}
|
|
75
|
-
/>
|
|
76
|
-
</StatCell>
|
|
77
|
-
<StatCell usageState={usage.kind}>
|
|
78
|
-
<StaticStat
|
|
79
|
-
accessibleLabel={usage.cost.accessibleLabel}
|
|
80
|
-
animated={usage.animated}
|
|
81
|
-
icon={<IconCurrencyDollar aria-hidden size={14} />}
|
|
82
|
-
title="Estimated cost"
|
|
83
|
-
value={usage.cost.value}
|
|
84
|
-
/>
|
|
85
|
-
</StatCell>
|
|
86
|
-
</StatsGrid>
|
|
87
|
-
{usage.note && <Text c="dimmed" size="xs">{usage.note}</Text>}
|
|
88
|
-
</Stack>
|
|
89
|
-
</Paper>
|
|
90
|
-
);
|
|
91
|
-
})}
|
|
92
|
-
</Stack>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function TurnReportedWork({ turn }: { turn: TaskTurn }) {
|
|
97
|
-
const now = useLiveNow(turn.result === null);
|
|
98
|
-
const elapsed = turnReportedWorkView(turn, now);
|
|
99
|
-
return (
|
|
100
|
-
<StaticStat
|
|
101
|
-
accessibleLabel={elapsed.accessibleLabel}
|
|
102
|
-
icon={<IconHourglass aria-hidden size={14} />}
|
|
103
|
-
title="Duration"
|
|
104
|
-
value={elapsed.value}
|
|
105
|
-
/>
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function useLiveNow(active: boolean) {
|
|
110
|
-
const [now, setNow] = useState(() => Date.now());
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
if (!active) return undefined;
|
|
113
|
-
setNow(Date.now());
|
|
114
|
-
const interval = window.setInterval(() => setNow(Date.now()), 1_000);
|
|
115
|
-
return () => window.clearInterval(interval);
|
|
116
|
-
}, [active]);
|
|
117
|
-
return now;
|
|
118
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import { MantineProvider } from '@mantine/core';
|
|
2
|
-
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
-
import { afterEach, expect, test, vi } from 'vitest';
|
|
4
|
-
import { ModelSettings } from './ModelSettings';
|
|
5
|
-
|
|
6
|
-
const apiMocks = vi.hoisted(() => ({ updateModelRole: vi.fn() }));
|
|
7
|
-
vi.mock('../api', () => apiMocks);
|
|
8
|
-
|
|
9
|
-
vi.mock('@mantine/core', async () => {
|
|
10
|
-
const { createElement } = await import('react');
|
|
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
|
-
return {
|
|
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 }>; 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
|
-
Alert: ({ children }: { children?: import('react').ReactNode }) => createElement('div', { role: 'alert' }, children),
|
|
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
|
-
};
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
|
|
21
|
-
|
|
22
|
-
test('shows the built-in TaskChef usage engine without an availability status', async () => {
|
|
23
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
24
|
-
ok: true,
|
|
25
|
-
json: async () => ({
|
|
26
|
-
profiles: [],
|
|
27
|
-
usageProvider: { provider: 'TaskChef', version: '8.6.2' },
|
|
28
|
-
}),
|
|
29
|
-
}));
|
|
30
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
31
|
-
expect(await screen.findByText('TaskChef 8.6.2')).toBeInTheDocument();
|
|
32
|
-
expect(screen.getByText('Usage provider')).toBeInTheDocument();
|
|
33
|
-
expect(screen.getByText(/TaskChef’s accounting engine/)).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);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('refresh shows configuration failures and does not label them honored', async () => {
|
|
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[] };
|
|
55
|
-
const profile = { id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null };
|
|
56
|
-
const fetchMock = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [profile] }) })
|
|
57
|
-
.mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [{ ...profile, roles: [{ ...role, status: 'invalid', problems: ['Unsupported effort'] }] }] }) });
|
|
58
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
59
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
60
|
-
expect(await screen.findByText('configured')).toBeInTheDocument();
|
|
61
|
-
const refresh = screen.getByRole('button', { name: 'Refresh' });
|
|
62
|
-
expect(refresh).toHaveClass('taskchef-icon-button');
|
|
63
|
-
await waitFor(() => expect(refresh).toBeEnabled());
|
|
64
|
-
await act(async () => { fireEvent.click(refresh); });
|
|
65
|
-
expect(await screen.findByText('Unsupported effort')).toBeInTheDocument();
|
|
66
|
-
expect(screen.queryByText(/Requested effective settings/)).not.toBeInTheDocument();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test('request failure remains visible and can be retried', async () => {
|
|
70
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: false }).mockResolvedValueOnce({ ok: true, json: async () => ({ profiles: [] }) }));
|
|
71
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
72
|
-
expect(await screen.findByRole('alert')).toHaveTextContent('could not be loaded');
|
|
73
|
-
const refresh = screen.getByRole('button', { name: 'Refresh' });
|
|
74
|
-
await waitFor(() => expect(refresh).toBeEnabled());
|
|
75
|
-
await act(async () => { fireEvent.click(refresh); });
|
|
76
|
-
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
test('non-string TOML values show diagnostics without crashing settings', async () => {
|
|
81
|
-
const role = { role: 'reviewer', source: '/fixture/reviewer.toml', model: { slug: 'bad' }, effort: ['bad'], status: 'invalid', availability: 'not checked', fallback: 'inherit parent settings', problems: ['model must be a nonempty string'] };
|
|
82
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [{ id: 'personal', project: 'Personal', roles: [role], problems: [], catalogSource: null }] }) }));
|
|
83
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
84
|
-
expect(await screen.findByText('model must be a nonempty string')).toBeInTheDocument();
|
|
85
|
-
expect(screen.getByRole('combobox', { name: 'reviewer model' })).toHaveValue('');
|
|
86
|
-
expect(screen.getByRole('combobox', { name: 'reviewer reasoning effort' })).toBeDisabled();
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('saves dropdown changes and displays a home-relative agent source', async () => {
|
|
90
|
-
const role = { role: 'planner', source: '/Users/example/.codex/agents/planner.toml', displaySource: '~/.codex/agents/planner.toml', model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
|
|
91
|
-
const profile = { id: 'personal', roles: [role], problems: [], modelOptions: [
|
|
92
|
-
{ value: 'gpt-one', label: 'GPT One', efforts: ['low', 'high'] },
|
|
93
|
-
{ value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
|
|
94
|
-
] };
|
|
95
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
|
|
96
|
-
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...role, model: 'gpt-two', effort: 'medium' }] } });
|
|
97
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
98
|
-
const model = await screen.findByRole('combobox', { name: 'planner model' });
|
|
99
|
-
fireEvent.change(model, { target: { value: 'gpt-two' } });
|
|
100
|
-
expect(model).toHaveValue('gpt-two');
|
|
101
|
-
expect(model).toHaveAttribute('aria-readonly', 'true');
|
|
102
|
-
expect(screen.queryByText('Saving…')).not.toBeInTheDocument();
|
|
103
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium'));
|
|
104
|
-
expect(await screen.findByText('Source: ~/.codex/agents/planner.toml')).toBeInTheDocument();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('serializes role saves so an older response cannot replace a newer selection', async () => {
|
|
108
|
-
const planner = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
|
|
109
|
-
const reviewer = { ...planner, role: 'reviewer' };
|
|
110
|
-
const profile = { id: 'personal', roles: [planner, reviewer], problems: [], modelOptions: [
|
|
111
|
-
{ value: 'gpt-one', label: 'GPT One', efforts: ['low'] },
|
|
112
|
-
{ value: 'gpt-two', label: 'GPT Two', efforts: ['medium'] },
|
|
113
|
-
] };
|
|
114
|
-
let resolveFirst!: (value: { profile: typeof profile }) => void;
|
|
115
|
-
apiMocks.updateModelRole.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve; }));
|
|
116
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
|
|
117
|
-
render(<MantineProvider><ModelSettings /></MantineProvider>);
|
|
118
|
-
const plannerModel = await screen.findByRole('combobox', { name: 'planner model' });
|
|
119
|
-
const reviewerModel = screen.getByRole('combobox', { name: 'reviewer model' });
|
|
120
|
-
fireEvent.change(plannerModel, { target: { value: 'gpt-two' } });
|
|
121
|
-
fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
|
|
122
|
-
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
123
|
-
expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(1);
|
|
124
|
-
expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium');
|
|
125
|
-
expect(fetch).toHaveBeenCalledTimes(1);
|
|
126
|
-
await act(async () => resolveFirst({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, reviewer] } }));
|
|
127
|
-
await waitFor(() => expect(reviewerModel).toHaveAttribute('aria-readonly', 'false'));
|
|
128
|
-
apiMocks.updateModelRole.mockResolvedValueOnce({ profile: { ...profile, roles: [{ ...planner, model: 'gpt-two', effort: 'medium' }, { ...reviewer, model: 'gpt-two', effort: 'medium' }] } });
|
|
129
|
-
fireEvent.change(reviewerModel, { target: { value: 'gpt-two' } });
|
|
130
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledTimes(2));
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const effortRole = { role: 'planner', source: null, model: 'gpt-one', effort: 'low', status: 'configured', problems: [] as string[] };
|
|
134
|
-
const effortProfile = { id: 'personal', roles: [effortRole], problems: [], modelOptions: [
|
|
135
|
-
{ value: 'gpt-one', label: 'GPT One', efforts: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'] },
|
|
136
|
-
{ value: 'gpt-two', label: 'GPT Two', efforts: ['max', 'medium'] },
|
|
137
|
-
] };
|
|
138
|
-
|
|
139
|
-
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) => {
|
|
140
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [effortProfile] }) }));
|
|
141
|
-
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...effortProfile, roles: [{ ...effortRole, effort: native }] } });
|
|
142
|
-
render(<ModelSettings />);
|
|
143
|
-
const effort = await screen.findByRole('combobox', { name: 'planner reasoning effort' });
|
|
144
|
-
expect(screen.getByRole('option', { name: label })).toHaveValue(native);
|
|
145
|
-
expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
|
|
146
|
-
fireEvent.change(effort, { target: { value: native } });
|
|
147
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-one', native));
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('model changes filter supported efforts and never default to max', async () => {
|
|
151
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [effortProfile] }) }));
|
|
152
|
-
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...effortProfile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'medium' }] } });
|
|
153
|
-
render(<ModelSettings />);
|
|
154
|
-
fireEvent.change(await screen.findByRole('combobox', { name: 'planner model' }), { target: { value: 'gpt-two' } });
|
|
155
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'medium'));
|
|
156
|
-
expect(screen.getByRole('option', { name: 'Medium' })).toHaveValue('medium');
|
|
157
|
-
expect(screen.queryByRole('option', { name: 'Light' })).not.toBeInTheDocument();
|
|
158
|
-
expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test('saved max is displayed without being selectable and survives refresh and supported model changes', async () => {
|
|
162
|
-
const profile = { ...effortProfile, roles: [{ ...effortRole, effort: 'max' }] };
|
|
163
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ profiles: [profile] }) }));
|
|
164
|
-
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'max' }] } });
|
|
165
|
-
render(<ModelSettings />);
|
|
166
|
-
const effort = await screen.findByRole('combobox', { name: 'planner reasoning effort' });
|
|
167
|
-
expect(effort).toHaveValue('max');
|
|
168
|
-
expect(screen.getByRole('option', { name: 'Max (current setting)' })).toBeDisabled();
|
|
169
|
-
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
|
|
170
|
-
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
|
171
|
-
expect(apiMocks.updateModelRole).not.toHaveBeenCalled();
|
|
172
|
-
await waitFor(() => expect(effort).toHaveAttribute('aria-readonly', 'false'));
|
|
173
|
-
fireEvent.change(screen.getByRole('combobox', { name: 'planner model' }), { target: { value: 'gpt-two' } });
|
|
174
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenCalledWith('planner', 'gpt-two', 'max'));
|
|
175
|
-
await waitFor(() => expect(effort).toHaveAttribute('aria-readonly', 'false'));
|
|
176
|
-
apiMocks.updateModelRole.mockResolvedValue({ profile: { ...profile, roles: [{ ...effortRole, model: 'gpt-two', effort: 'medium' }] } });
|
|
177
|
-
fireEvent.change(effort, { target: { value: 'medium' } });
|
|
178
|
-
await waitFor(() => expect(apiMocks.updateModelRole).toHaveBeenLastCalledWith('planner', 'gpt-two', 'medium'));
|
|
179
|
-
expect(screen.queryByRole('option', { name: /max/i })).not.toBeInTheDocument();
|
|
180
|
-
});
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { ActionIcon, Alert, Badge, Group, Paper, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
|
2
|
-
import { IconRefresh } from '@tabler/icons-react';
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
|
-
import { updateModelRole } from '../api';
|
|
5
|
-
|
|
6
|
-
const effortLabels: Record<string, string> = { low: 'Light', medium: 'Medium', high: 'High', xhigh: 'Extra High', ultra: 'Ultra' };
|
|
7
|
-
|
|
8
|
-
type ModelOption = { value: string; label: string; efforts: string[] };
|
|
9
|
-
type Role = { role: string; source: string | null; displaySource?: string | null; model: unknown; effort: unknown; status: string; problems: string[] };
|
|
10
|
-
type Profile = { id: string; roles: Role[]; problems: string[]; modelOptions?: ModelOption[] };
|
|
11
|
-
type UsageProvider = { provider: string; version: string | null };
|
|
12
|
-
|
|
13
|
-
export function ModelSettings() {
|
|
14
|
-
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
15
|
-
const [usageProvider, setUsageProvider] = useState<UsageProvider | null>(null);
|
|
16
|
-
const [error, setError] = useState<string | null>(null);
|
|
17
|
-
const [loading, setLoading] = useState(true);
|
|
18
|
-
const [saving, setSaving] = useState<string | null>(null);
|
|
19
|
-
const [showSaving, setShowSaving] = useState<string | null>(null);
|
|
20
|
-
const [revision, setRevision] = useState(0);
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
const controller = new AbortController();
|
|
23
|
-
setLoading(true);
|
|
24
|
-
setError(null);
|
|
25
|
-
fetch('/api/settings', { signal: controller.signal }).then(async (response) => {
|
|
26
|
-
if (!response.ok) throw new Error('Settings preview could not be loaded.');
|
|
27
|
-
const data = await response.json() as { profiles: Profile[]; usageProvider?: UsageProvider };
|
|
28
|
-
setProfiles(data.profiles);
|
|
29
|
-
setUsageProvider(data.usageProvider ?? null);
|
|
30
|
-
}).catch((failure: Error) => {
|
|
31
|
-
if (!controller.signal.aborted) setError(failure.message);
|
|
32
|
-
}).finally(() => { if (!controller.signal.aborted) setLoading(false); });
|
|
33
|
-
return () => controller.abort();
|
|
34
|
-
}, [revision]);
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
setShowSaving(null);
|
|
37
|
-
if (!saving) return;
|
|
38
|
-
const timeout = window.setTimeout(() => setShowSaving(saving), 400);
|
|
39
|
-
return () => window.clearTimeout(timeout);
|
|
40
|
-
}, [saving]);
|
|
41
|
-
const profile = profiles.find((item) => item.id === 'personal');
|
|
42
|
-
const save = async (role: Role, model: string, effort: string) => {
|
|
43
|
-
setSaving(role.role);
|
|
44
|
-
setError(null);
|
|
45
|
-
setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
|
|
46
|
-
...item,
|
|
47
|
-
roles: item.roles.map((candidate) => candidate.role === role.role ? { ...candidate, model, effort } : candidate),
|
|
48
|
-
}));
|
|
49
|
-
try {
|
|
50
|
-
const result = await updateModelRole(role.role, model, effort) as { profile: Profile };
|
|
51
|
-
setProfiles([result.profile]);
|
|
52
|
-
} catch (failure) {
|
|
53
|
-
setProfiles((current) => current.map((item) => item.id !== 'personal' ? item : {
|
|
54
|
-
...item,
|
|
55
|
-
roles: item.roles.map((candidate) => candidate.role === role.role ? role : candidate),
|
|
56
|
-
}));
|
|
57
|
-
setError(failure instanceof Error ? failure.message : 'Model role could not be saved.');
|
|
58
|
-
} finally {
|
|
59
|
-
setSaving(null);
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
return <Stack className="taskchef-settings" gap="sm" aria-label="Model settings">
|
|
63
|
-
<Group justify="space-between">
|
|
64
|
-
<Title order={2} size="h5">Model roles</Title>
|
|
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
|
-
if (loading || saving) return;
|
|
67
|
-
setLoading(true);
|
|
68
|
-
setRevision((value) => value + 1);
|
|
69
|
-
}}><IconRefresh className={loading ? 'taskchef-refresh-spinning' : undefined} size={18} stroke={1.8} /></ActionIcon>
|
|
70
|
-
</Group>
|
|
71
|
-
<Text c="dimmed" size="sm">Your models for orchestration, planning, implementation, and review.</Text>
|
|
72
|
-
{error && <Alert color="red" role="alert">{error}</Alert>}
|
|
73
|
-
{profile && <>
|
|
74
|
-
{profile.problems.map((problem) => <Alert color="yellow" key={problem}>{problem}</Alert>)}
|
|
75
|
-
{profile.roles.map((role) => <Paper className="taskchef-section" key={role.role} withBorder p="sm">
|
|
76
|
-
<Stack gap="xs">
|
|
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
|
-
<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
|
-
if (loading || saving || !model) return;
|
|
81
|
-
const option = profile.modelOptions?.find((candidate) => candidate.value === model);
|
|
82
|
-
const effort = typeof role.effort === 'string' && option?.efforts.includes(role.effort) ? role.effort : option?.efforts.find((value) => value !== 'max');
|
|
83
|
-
if (effort) void save(role, model, effort);
|
|
84
|
-
}} />
|
|
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);
|
|
90
|
-
}} />
|
|
91
|
-
</SimpleGrid>
|
|
92
|
-
<Text size="sm" style={{ overflowWrap: 'anywhere' }}>Source: {role.displaySource ?? role.source ?? 'No role file'}</Text>
|
|
93
|
-
{showSaving === role.role && <Text c="dimmed" size="sm" role="status">Saving…</Text>}
|
|
94
|
-
{role.problems.map((problem) => <Alert key={problem} color="red">{problem}</Alert>)}
|
|
95
|
-
</Stack>
|
|
96
|
-
</Paper>)}
|
|
97
|
-
</>}
|
|
98
|
-
{usageProvider && <Paper className="taskchef-section" withBorder p="sm">
|
|
99
|
-
<Stack gap="xs">
|
|
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">Reads saved Codex usage with TaskChef’s accounting engine.</Text>
|
|
103
|
-
</Stack>
|
|
104
|
-
</Paper>}
|
|
105
|
-
</Stack>;
|
|
106
|
-
}
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { MantineProvider } from "@mantine/core";
|
|
2
|
-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
-
import { afterEach, expect, test, vi } from "vitest";
|
|
4
|
-
import { fixtureTask } from "../fixtures";
|
|
5
|
-
import type { NotificationSnapshot } from "../types";
|
|
6
|
-
import { NotificationCenter } from "./NotificationCenter";
|
|
7
|
-
|
|
8
|
-
const notification: NotificationSnapshot = {
|
|
9
|
-
id: "task-completed",
|
|
10
|
-
taskId: "11111111-1111-4111-8111-111111111111",
|
|
11
|
-
title: "Review checkout reconciliation",
|
|
12
|
-
status: "completed",
|
|
13
|
-
event: "completed",
|
|
14
|
-
turnRef: "turn-one",
|
|
15
|
-
turnId: null,
|
|
16
|
-
timestamp: "2026-08-30T08:10:00.000Z",
|
|
17
|
-
summary: "Review completed.",
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
afterEach(() => {
|
|
21
|
-
cleanup();
|
|
22
|
-
vi.useRealTimers();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("notification portal stays non-modal until the operator chooses an action", () => {
|
|
26
|
-
vi.useFakeTimers();
|
|
27
|
-
vi.setSystemTime(new Date("2026-08-30T12:10:00.000Z"));
|
|
28
|
-
const onOpen = vi.fn();
|
|
29
|
-
const onDismiss = vi.fn();
|
|
30
|
-
render(
|
|
31
|
-
<MantineProvider>
|
|
32
|
-
<button autoFocus type="button">Detail action</button>
|
|
33
|
-
<NotificationCenter
|
|
34
|
-
notifications={[notification]}
|
|
35
|
-
onClear={() => {}}
|
|
36
|
-
onDismiss={onDismiss}
|
|
37
|
-
onOpen={onOpen}
|
|
38
|
-
tasks={[fixtureTask()]}
|
|
39
|
-
/>
|
|
40
|
-
</MantineProvider>,
|
|
41
|
-
);
|
|
42
|
-
expect(screen.getByRole("button", { name: "Detail action" })).toHaveFocus();
|
|
43
|
-
const panel = screen.getByRole("region", { name: "Task notifications" });
|
|
44
|
-
expect(panel).toBeVisible();
|
|
45
|
-
expect(panel.querySelector(".taskchef-notification-list")).toBeInTheDocument();
|
|
46
|
-
expect(screen.getByRole("button", { name: /Open current task details/ })).toHaveAccessibleDescription(
|
|
47
|
-
/Review completed\. \d+ hours ago/,
|
|
48
|
-
);
|
|
49
|
-
expect(panel.nextElementSibling).toHaveTextContent(
|
|
50
|
-
/Task completed\. Review checkout reconciliation\. Review completed\. Turn ref turn-one\. \d+ hours ago\./,
|
|
51
|
-
);
|
|
52
|
-
fireEvent.click(screen.getByRole("button", { name: /Open current task details/ }));
|
|
53
|
-
expect(onOpen).toHaveBeenCalledWith(notification);
|
|
54
|
-
fireEvent.click(screen.getByRole("button", { name: /Dismiss Task completed notification/ }));
|
|
55
|
-
expect(onDismiss).toHaveBeenCalledWith(notification);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("can render inside a focus-trapped task detail", () => {
|
|
59
|
-
render(
|
|
60
|
-
<MantineProvider>
|
|
61
|
-
<div data-testid="detail-dialog">
|
|
62
|
-
<NotificationCenter
|
|
63
|
-
notifications={[notification]}
|
|
64
|
-
onClear={() => {}}
|
|
65
|
-
onDismiss={() => {}}
|
|
66
|
-
onOpen={() => {}}
|
|
67
|
-
tasks={[fixtureTask()]}
|
|
68
|
-
withinPortal={false}
|
|
69
|
-
/>
|
|
70
|
-
</div>
|
|
71
|
-
</MantineProvider>,
|
|
72
|
-
);
|
|
73
|
-
expect(screen.getByTestId("detail-dialog")).toContainElement(
|
|
74
|
-
screen.getByRole("region", { name: "Task notifications" }),
|
|
75
|
-
);
|
|
76
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import { ActionIcon, Box, Button, Group, Paper, Portal, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
|
2
|
-
import { IconBell, IconX } from "@tabler/icons-react";
|
|
3
|
-
import { notificationDismissLabel, notificationOpenLabel, notificationTitle } from "../../state.js";
|
|
4
|
-
import { formatExactTime, formatRelativeTime } from "../../time.js";
|
|
5
|
-
import type { NotificationSnapshot, Task } from "../types";
|
|
6
|
-
import { StatusBadge } from "./StatusBadge";
|
|
7
|
-
|
|
8
|
-
export function NotificationCenter({
|
|
9
|
-
notifications,
|
|
10
|
-
announcements = notifications,
|
|
11
|
-
onClear,
|
|
12
|
-
onDismiss,
|
|
13
|
-
onOpen,
|
|
14
|
-
tasks,
|
|
15
|
-
withinPortal = true,
|
|
16
|
-
}: {
|
|
17
|
-
announcements?: NotificationSnapshot[];
|
|
18
|
-
notifications: NotificationSnapshot[];
|
|
19
|
-
onClear: () => void;
|
|
20
|
-
onDismiss: (notification: NotificationSnapshot) => void;
|
|
21
|
-
onOpen: (notification: NotificationSnapshot) => void;
|
|
22
|
-
tasks: Task[];
|
|
23
|
-
withinPortal?: boolean;
|
|
24
|
-
}) {
|
|
25
|
-
if (notifications.length === 0) return null;
|
|
26
|
-
const content = (
|
|
27
|
-
<>
|
|
28
|
-
<Paper
|
|
29
|
-
aria-label="Task notifications"
|
|
30
|
-
className="taskchef-notifications"
|
|
31
|
-
p="sm"
|
|
32
|
-
radius="md"
|
|
33
|
-
role="region"
|
|
34
|
-
shadow="xl"
|
|
35
|
-
withBorder
|
|
36
|
-
>
|
|
37
|
-
<Group justify="space-between" mb="xs">
|
|
38
|
-
<Group gap={6}>
|
|
39
|
-
<IconBell aria-hidden size={16} />
|
|
40
|
-
<Text fw={700} size="sm">Updates</Text>
|
|
41
|
-
</Group>
|
|
42
|
-
<Button onClick={onClear} size="compact-xs" variant="subtle">Clear all</Button>
|
|
43
|
-
</Group>
|
|
44
|
-
<ScrollArea.Autosize className="taskchef-notification-scroll" mah="min(55vh, 520px)" type="auto">
|
|
45
|
-
<Stack className="taskchef-notification-list" gap={6}>
|
|
46
|
-
{notifications.map((notification) => {
|
|
47
|
-
const available = tasks.some((task) => task.id === notification.taskId);
|
|
48
|
-
const descriptionId = `notification-${Math.abs(hash(notification.id))}`;
|
|
49
|
-
const summaryId = `${descriptionId}-summary`;
|
|
50
|
-
const metadataId = `${descriptionId}-metadata`;
|
|
51
|
-
return (
|
|
52
|
-
<Paper className="taskchef-notification" key={notification.id} p="xs" radius="sm">
|
|
53
|
-
<Group align="flex-start" gap="xs" wrap="nowrap">
|
|
54
|
-
<button
|
|
55
|
-
aria-describedby={[notification.summary ? summaryId : null, metadataId].filter(Boolean).join(" ")}
|
|
56
|
-
aria-label={notificationOpenLabel(notification, available)}
|
|
57
|
-
className="taskchef-notification-open"
|
|
58
|
-
onClick={() => onOpen(notification)}
|
|
59
|
-
type="button"
|
|
60
|
-
>
|
|
61
|
-
<Group gap="xs" justify="space-between" wrap="nowrap">
|
|
62
|
-
<Text fw={700} size="sm">{notificationTitle(notification)}</Text>
|
|
63
|
-
<StatusBadge status={notification.status} />
|
|
64
|
-
</Group>
|
|
65
|
-
<Text lineClamp={1} size="sm">{notification.title}</Text>
|
|
66
|
-
{notification.summary && <Text className="taskchef-preserve-lines" id={summaryId} lineClamp={3} size="xs">{notification.summary}</Text>}
|
|
67
|
-
<Text c="dimmed" id={metadataId} size="xs">
|
|
68
|
-
<Tooltip label={formatExactTime(notification.timestamp)}><span>{formatRelativeTime(notification.timestamp)}</span></Tooltip>{available ? "" : " · Task no longer available"}
|
|
69
|
-
</Text>
|
|
70
|
-
</button>
|
|
71
|
-
<Tooltip label="Dismiss notification">
|
|
72
|
-
<ActionIcon
|
|
73
|
-
aria-label={notificationDismissLabel(notification)}
|
|
74
|
-
color="gray"
|
|
75
|
-
onClick={() => onDismiss(notification)}
|
|
76
|
-
size="compact-sm"
|
|
77
|
-
variant="subtle"
|
|
78
|
-
>
|
|
79
|
-
<IconX aria-hidden size={14} />
|
|
80
|
-
</ActionIcon>
|
|
81
|
-
</Tooltip>
|
|
82
|
-
</Group>
|
|
83
|
-
</Paper>
|
|
84
|
-
);
|
|
85
|
-
})}
|
|
86
|
-
</Stack>
|
|
87
|
-
</ScrollArea.Autosize>
|
|
88
|
-
</Paper>
|
|
89
|
-
<Box aria-atomic="true" aria-live="polite" className="taskchef-visually-hidden" role="status">
|
|
90
|
-
{announcements.map(notificationAnnouncement).join(" ")}
|
|
91
|
-
</Box>
|
|
92
|
-
</>
|
|
93
|
-
);
|
|
94
|
-
return withinPortal ? <Portal>{content}</Portal> : content;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function notificationAnnouncement(notification: NotificationSnapshot) {
|
|
98
|
-
return [
|
|
99
|
-
notificationTitle(notification),
|
|
100
|
-
notification.title,
|
|
101
|
-
notification.summary,
|
|
102
|
-
notification.turnRef ? `Turn ref ${notification.turnRef}` : null,
|
|
103
|
-
formatRelativeTime(notification.timestamp),
|
|
104
|
-
].filter(Boolean).map((part) => String(part).replace(/[.\s]+$/, "")).join(". ") + ".";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function hash(value: string) {
|
|
108
|
-
let result = 0;
|
|
109
|
-
for (const character of value) result = ((result << 5) - result + character.charCodeAt(0)) | 0;
|
|
110
|
-
return result;
|
|
111
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { Badge, Group } from "@mantine/core";
|
|
2
|
-
import { IconGitMerge, IconGitPullRequest, IconGitPullRequestDraft } from "@tabler/icons-react";
|
|
3
|
-
import type { PullRequestStatus } from "../types";
|
|
4
|
-
|
|
5
|
-
export function PullRequestBadges({ pullRequests = [] }: { pullRequests?: PullRequestStatus[] }) {
|
|
6
|
-
if (!pullRequests.length) return null;
|
|
7
|
-
return <Group gap={4} wrap="wrap" aria-label="Attached pull requests">
|
|
8
|
-
{pullRequests.map((pr) => {
|
|
9
|
-
const merged = pr.state === "merged";
|
|
10
|
-
const draft = pr.state === "draft";
|
|
11
|
-
const label = merged ? "Merged" : draft ? "Draft" : pr.state === "closed" ? "Closed, not merged" : pr.state === "unknown" ? "Status unavailable" : pr.checks === "passed" ? "Checks passed" : pr.checks === "none" ? "No checks" : pr.checks === "pending" ? "Checks pending" : pr.checks === "failed" ? "Checks failed" : "Checks unavailable";
|
|
12
|
-
const Icon = merged ? IconGitMerge : draft ? IconGitPullRequestDraft : IconGitPullRequest;
|
|
13
|
-
const color = merged ? "violet" : draft || pr.state === "unknown" ? "gray" : pr.state === "closed" || pr.checks === "failed" ? "red" : pr.checks === "passed" ? "green" : "yellow";
|
|
14
|
-
const safeLink = /^https:\/\/github\.com\/[A-Za-z0-9-]+\/[A-Za-z0-9_.-]+\/pull\/[1-9]\d*\/?$/.test(pr.url);
|
|
15
|
-
return <Badge key={pr.url} component="a" href={safeLink ? pr.url : undefined} target="_blank" rel="noopener noreferrer" variant="light" color={color} size="xs" leftSection={<Icon size={12} aria-hidden />} title={`${pr.url}\n${label}${pr.error ? `: ${pr.error}` : ""}${pr.checkedAt ? `\nChecked ${new Date(pr.checkedAt).toLocaleString()}` : ""}`} style={{ cursor: "pointer", textTransform: "none", maxWidth: "100%" }}>#{pr.url.match(/\/pull\/(\d+)/)?.[1] ?? "PR"} · {label}</Badge>;
|
|
16
|
-
})}
|
|
17
|
-
</Group>;
|
|
18
|
-
}
|