taskchef 9.0.4 → 9.2.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 +1 -1
- package/docs/mcp-app.md +39 -0
- package/package.json +1 -1
- package/src/codex-live-state.js +207 -0
- package/src/codex-projects.js +4 -2
- package/src/codex-session-scanner.js +4 -3
- package/src/mcp-app/dist/index.html +104 -94
- package/src/mcp-app/react/NextTaskDetail.test.tsx +13 -0
- package/src/mcp-app/react/NextTaskDetail.tsx +5 -2
- package/src/mcp-app/react/main.test.tsx +35 -1
- package/src/mcp-app/react/main.tsx +6 -3
- package/src/mcp-app/react/styles.css +6 -2
- package/src/mcp-app.js +15 -3
- package/src/next-notifications.js +29 -3
- package/src/ui/components/ApprovalNotice.tsx +24 -0
- package/src/ui/components/TaskBoard.test.tsx +22 -0
- package/src/ui/components/TaskBoard.tsx +4 -3
- package/src/ui/components/TaskCard.tsx +3 -1
- package/src/ui/styles.css +15 -0
- package/src/ui/types.ts +3 -1
|
@@ -136,3 +136,16 @@ test("interrupted details show the recorded error after the response and clear i
|
|
|
136
136
|
rerender(<MantineProvider><NextTaskDetail {...props} task={{ ...task, status: "working" }} /></MantineProvider>);
|
|
137
137
|
expect(screen.queryByLabelText("Latest turn error")).toBeNull();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
test("approval panel shows the live request and opens chat without answering it", () => {
|
|
141
|
+
const open=vi.fn();
|
|
142
|
+
const task={...fixtureTask(), status:'needs_input' as const, needsApproval:true, pendingApprovals:[{id:'16',turnId:'turn',title:'Browser use',message:'Allow upload to Gmail filters?',origin:'https://mail.google.com'}]};
|
|
143
|
+
const {rerender}=render(<MantineProvider><NextTaskDetail task={task} opened busy={false} error={null} onClose={()=>{}} onOpenCodex={open}/></MantineProvider>);
|
|
144
|
+
const panel=screen.getByRole('region',{name:'Pending approvals'});
|
|
145
|
+
expect(within(panel).getByText('Allow upload to Gmail filters?')).toBeVisible();
|
|
146
|
+
expect(within(panel).getByText('Origin: https://mail.google.com')).toBeVisible();
|
|
147
|
+
fireEvent.click(within(panel).getByRole('button',{name:'Open chat to approve'}));
|
|
148
|
+
expect(open).toHaveBeenCalledTimes(1);
|
|
149
|
+
rerender(<MantineProvider><NextTaskDetail task={{...task,needsApproval:false,pendingApprovals:[]}} opened busy={false} error={null} onClose={()=>{}} onOpenCodex={open}/></MantineProvider>);
|
|
150
|
+
expect(screen.queryByRole('region',{name:'Pending approvals'})).not.toBeInTheDocument();
|
|
151
|
+
});
|
|
@@ -4,6 +4,7 @@ import { IconArrowUpRight, IconClock, IconFolder, IconHelpCircle, IconHourglass,
|
|
|
4
4
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
5
5
|
import type { Task, SavedUsage } from "../../ui/types";
|
|
6
6
|
import { PullRequestInfo, nextRunLabel } from "../../ui/components/CardStatusIcons";
|
|
7
|
+
import { ApprovalPanel } from "../../ui/components/ApprovalNotice";
|
|
7
8
|
import { OpenChatButton } from "../../ui/components/OpenChatButton";
|
|
8
9
|
import { QueuedPrompts } from "../../ui/components/QueuedPrompts";
|
|
9
10
|
import { TurnContent } from "../../ui/components/TurnContent";
|
|
@@ -48,14 +49,14 @@ export function NextTaskDetail({ task, opened, busy, usageLoading = false, error
|
|
|
48
49
|
const count = task.observed?.directChildCount;
|
|
49
50
|
const source = task.observed?.source === "vscode" ? "Desktop" : task.observed?.source === "cli" ? "CLI" : task.observed?.source === "exec" ? "Exec" : null;
|
|
50
51
|
const input = task.inputSource === "ordinary" ? "Human" : task.inputSource === "scheduled" ? "Scheduled" : task.inputSource === "unverified" ? "Unknown" : null;
|
|
51
|
-
const label = task.status === "needs_input" || task.status === "interrupted" ? "Waiting for review" : task.status === "working" ? "Running" : task.status === "completed" ? "Done" : task.statusLabel;
|
|
52
|
+
const label = task.needsApproval ? "Needs approval" : task.status === "needs_input" || task.status === "interrupted" ? "Waiting for review" : task.status === "working" ? "Running" : task.status === "completed" ? "Done" : task.statusLabel;
|
|
52
53
|
const tokenIncomplete = !!task.sessionUsage && [task.sessionUsage.latest, task.sessionUsage.total].some(value => value.partial);
|
|
53
54
|
const costIncomplete = !!task.sessionUsage && [task.sessionUsage.latest, task.sessionUsage.total].some(value => value.costPartial);
|
|
54
55
|
const content = <Stack gap="md">
|
|
55
56
|
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs" pr={8}>
|
|
56
57
|
<Group gap="xs" mt={0} className="taskchef-detail-meta">
|
|
57
58
|
<Tooltip label={task.project?.path || "No project"}><Text size="sm" c="dimmed" className="taskchef-detail-project"><IconFolder size={14} /><span>{task.project?.name || "No project"}</span></Text></Tooltip>
|
|
58
|
-
{label && <Badge variant="light" color="gray">{label}</Badge>}
|
|
59
|
+
{label && <Badge variant="light" color={task.needsApproval ? "orange" : "gray"}>{label}</Badge>}
|
|
59
60
|
{task.needsAnswer && <Badge variant="light" color="blue">Needs answer</Badge>}
|
|
60
61
|
{task.status === "interrupted" && <Badge variant="light" color="orange">Interrupted</Badge>}
|
|
61
62
|
</Group>
|
|
@@ -65,8 +66,10 @@ export function NextTaskDetail({ task, opened, busy, usageLoading = false, error
|
|
|
65
66
|
</Group>
|
|
66
67
|
</Group>
|
|
67
68
|
{error && <Alert color="red">{error}</Alert>}
|
|
69
|
+
{mobile && task.needsApproval && <ApprovalPanel task={task} busy={busy} onOpen={onOpenCodex} />}
|
|
68
70
|
<Box className="taskchef-detail-layout">
|
|
69
71
|
<Box className="taskchef-detail-reply">
|
|
72
|
+
{!mobile && task.needsApproval && <ApprovalPanel task={task} busy={busy} onOpen={onOpenCodex} />}
|
|
70
73
|
<TurnContent task={task} loadImage={loadImage} workedDurationMs={duration} onLightboxChange={setLightboxOpen} onAnnotationChange={annotationChange} />
|
|
71
74
|
{task.needsAnswer && <section aria-label="Pending questions" className="taskchef-pending-questions">
|
|
72
75
|
<div className="taskchef-question-header"><IconHelpCircle size={14} aria-hidden /><span>{task.pendingQuestions?.length === 1 ? "Question" : "Questions"}</span></div>
|
|
@@ -13,7 +13,7 @@ vi.mock("@mantine/core", async () => {
|
|
|
13
13
|
createTheme: (theme: unknown) => theme,
|
|
14
14
|
ActionIcon: ({ children, onClick, ...props }: Record<string, unknown>) => React.createElement("button", { "aria-label": props["aria-label"], onClick }, children as React.ReactNode),
|
|
15
15
|
Modal: ({ opened, children }: { opened: boolean; children: React.ReactNode }) => opened ? <div role="dialog">{children}</div> : null,
|
|
16
|
-
Alert: wrap("div"), Box: wrap("div"), Button: ({ children, onClick, disabled }: {children: React.ReactNode; onClick?: () => void; disabled?: boolean}) => <button onClick={onClick} disabled={disabled}>{children}</button>, Group: wrap("div"),
|
|
16
|
+
Alert: wrap("div"), Box: wrap("div"), Button: ({ children, onClick, disabled, ...props }: {children: React.ReactNode; onClick?: () => void; disabled?: boolean; "aria-pressed"?: boolean; title?: string}) => <button onClick={onClick} disabled={disabled} aria-pressed={props["aria-pressed"]} title={props.title}>{children}</button>, Group: wrap("div"),
|
|
17
17
|
MantineProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
18
18
|
TextInput: ({ value, onChange, ...props }: { value: string; onChange: React.ChangeEventHandler<HTMLInputElement>; "aria-label": string }) => <input aria-label={props["aria-label"]} value={value} onChange={onChange} />,
|
|
19
19
|
Paper: wrap("div"), Stack: wrap("div"), Text: wrap("p"), Title: wrap("h2"),
|
|
@@ -633,3 +633,37 @@ test("native card usage setting defaults on and changes without hiding detail us
|
|
|
633
633
|
fireEvent.click(screen.getByRole("button", {name:"Task one"}));
|
|
634
634
|
expect(await screen.findByRole("region", {name:"Usage"})).toBeInTheDocument();
|
|
635
635
|
});
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
test("Pinned toggle combines with filters and updates after pin changes on refresh", async () => {
|
|
639
|
+
tasks = [task("one", "working", new Date().toISOString(), "Alpha"), task("two", "needs_input", new Date().toISOString(), "Beta"), task("old", "completed", "2026-01-01T00:00:00Z", "Alpha"), task("unknown")];
|
|
640
|
+
tasks.slice(0, 3).forEach((item, index) => { item.observed = { pinned: index !== 1, archive: false, lastTurnEvent: null, lastTurnEventAt: null, recentFileActivity: false }; });
|
|
641
|
+
mount();
|
|
642
|
+
await screen.findByRole("button", { name: "Task one" });
|
|
643
|
+
const toggle = screen.getByRole("button", { name: "Pinned" });
|
|
644
|
+
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
645
|
+
expect(toggle).toHaveAttribute("title", "Show only pinned chats");
|
|
646
|
+
fireEvent.click(toggle);
|
|
647
|
+
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
648
|
+
expect(screen.queryByRole("button", { name: "Task two" })).not.toBeInTheDocument();
|
|
649
|
+
expect(screen.queryByRole("button", { name: "Task unknown" })).not.toBeInTheDocument();
|
|
650
|
+
expect(screen.getByRole("button", { name: "Task old" })).toBeVisible();
|
|
651
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Updated" }), { target: { value: "24h" } });
|
|
652
|
+
expect(screen.queryByRole("button", { name: "Task old" })).not.toBeInTheDocument();
|
|
653
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "/Beta" } });
|
|
654
|
+
expect(screen.queryByRole("button", { name: "Task one" })).not.toBeInTheDocument();
|
|
655
|
+
expect(within(screen.getByRole("region", { name: "Task board" })).queryAllByRole("article")).toHaveLength(0);
|
|
656
|
+
tasks = tasks.map(item => item.id === "two" ? { ...item, observed: { ...item.observed!, pinned: true } } : item);
|
|
657
|
+
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refresh" })); });
|
|
658
|
+
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
659
|
+
expect(screen.getByRole("button", { name: "Task two" })).toBeVisible();
|
|
660
|
+
fireEvent.change(screen.getByLabelText("Search Chats"), { target: { value: "missing" } });
|
|
661
|
+
expect(screen.queryByRole("button", { name: "Task two" })).not.toBeInTheDocument();
|
|
662
|
+
fireEvent.change(screen.getByLabelText("Search Chats"), { target: { value: "" } });
|
|
663
|
+
tasks = tasks.map(item => item.observed ? { ...item, observed: { ...item.observed, pinned: false } } : item);
|
|
664
|
+
await act(async () => { fireEvent.click(screen.getByRole("button", { name: "Refresh" })); });
|
|
665
|
+
expect(within(screen.getByRole("region", { name: "Task board" })).queryAllByRole("article")).toHaveLength(0);
|
|
666
|
+
fireEvent.click(toggle);
|
|
667
|
+
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
668
|
+
expect(screen.getByRole("button", { name: "Task two" })).toBeVisible();
|
|
669
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cachedImageLoader } from "../../ui/image-cache";
|
|
2
2
|
import { App } from "@modelcontextprotocol/ext-apps";
|
|
3
3
|
import { ActionIcon, Alert, Box, Button, Group, MantineProvider, Modal, createTheme, Paper, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
|
4
|
-
import { IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
|
|
4
|
+
import { IconPin, IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
|
|
5
5
|
import { createRoot } from "react-dom/client";
|
|
6
6
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import type { DashboardSnapshot, Task, Project } from "../../ui/types";
|
|
@@ -113,6 +113,7 @@ export function TaskChefApp() {
|
|
|
113
113
|
const [scan, setScan] = useState<ScanStats | null>(null);
|
|
114
114
|
const [selected, setSelected] = useState<Task | null>(null);
|
|
115
115
|
const [opened, setOpened] = useState(false);
|
|
116
|
+
const [pinnedOnly, setPinnedOnly] = useState(false);
|
|
116
117
|
const [busy, setBusy] = useState(false);
|
|
117
118
|
const [error, setError] = useState<string | null>(null);
|
|
118
119
|
|
|
@@ -330,6 +331,7 @@ export function TaskChefApp() {
|
|
|
330
331
|
const boardTasks: Task[] = useMemo(() => {
|
|
331
332
|
const terms = search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
332
333
|
return filterTasks(projectTasks, { date, now }).filter((task: Task) => {
|
|
334
|
+
if (pinnedOnly && !task.observed?.pinned) return false;
|
|
333
335
|
const content = [
|
|
334
336
|
task.title, task.promptExcerpt ?? "", task.replyExcerpt ?? "", task.project.name,
|
|
335
337
|
...(task.project.githubRepos ?? []),
|
|
@@ -337,7 +339,7 @@ export function TaskChefApp() {
|
|
|
337
339
|
].join("\n").toLocaleLowerCase();
|
|
338
340
|
return terms.every((term) => content.includes(term));
|
|
339
341
|
});
|
|
340
|
-
}, [projectTasks, date, now, search]);
|
|
342
|
+
}, [projectTasks, date, now, search, pinnedOnly]);
|
|
341
343
|
const visible = useMemo(() => boardTasks.filter((task) => !status || (task.status ?? "unverified") === status), [boardTasks, status]);
|
|
342
344
|
const statusOptions = [{ label: `All ${boardTasks.length}`, value: "" }, ...lanes.map(({ label, status: value }) => ({
|
|
343
345
|
label: `${label} ${boardTasks.filter((task) => task.status === value).length}`, value: value ?? "unverified",
|
|
@@ -418,10 +420,11 @@ export function TaskChefApp() {
|
|
|
418
420
|
<Box className={`taskchef-app-shell${displayMode === "inline" ? " taskchef-app-inline" : ""}`}>
|
|
419
421
|
<header className="taskchef-app-header">
|
|
420
422
|
<Group className="taskchef-app-brand" gap="xs" wrap="nowrap"><img alt="" aria-hidden className="taskchef-app-mark" src={brandIcon} /><Title order={1}>TaskChef</Title></Group>
|
|
421
|
-
{displayMode !== "inline" && <Group className="taskchef-app-filters" gap="xs" wrap="
|
|
423
|
+
{displayMode !== "inline" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
|
|
422
424
|
<ProjectPicker data={projects} onChange={(value) => { setProject(value); setCompletedLimit(5); }} value={project} />
|
|
423
425
|
<Select className="taskchef-app-date" 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} size="xs" />
|
|
424
426
|
<TextInput className="taskchef-app-search" aria-label="Search Chats" placeholder="Search Chats" type="search" leftSection={<IconSearch size={14} aria-hidden />} value={search} onChange={(event) => { setSearch(event.currentTarget.value); setCompletedLimit(5); setArchivedLimit(5); }} size="xs" />
|
|
427
|
+
<Button className="taskchef-app-pinned" aria-pressed={pinnedOnly} title="Show only pinned chats" size="xs" variant={pinnedOnly ? "filled" : "outline"} leftSection={<IconPin size={14} aria-hidden />} onClick={() => { setPinnedOnly(value => !value); setCompletedLimit(5); setArchivedLimit(5); }}>Pinned</Button>
|
|
425
428
|
</Group>}
|
|
426
429
|
<Group className="taskchef-app-actions" gap="xs" wrap="nowrap">
|
|
427
430
|
<GitHubConnection hideTrigger openSignal={githubOpenSignal} auth={githubAuth} request={githubRequest} openLink={githubOpenLink} refresh={githubRefresh} openSettings={githubOpenSettings} />
|
|
@@ -27,8 +27,9 @@ body { background: var(--taskchef-page); }
|
|
|
27
27
|
.taskchef-app-main .taskchef-toolbar { margin-bottom: 8px; padding-bottom: 10px; }
|
|
28
28
|
.taskchef-app-main .mantine-SegmentedControl-root { max-width: 100%; }
|
|
29
29
|
.taskchef-app-view { width: max-content; }
|
|
30
|
-
.taskchef-app-filters { grid-area: filters; min-width: 0; width: min(100%,
|
|
31
|
-
.taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1
|
|
30
|
+
.taskchef-app-filters { grid-area: filters; min-width: 0; width: min(100%, 680px); }
|
|
31
|
+
.taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1 100px; min-width: 100px; }
|
|
32
|
+
.taskchef-app-pinned { flex: 0 0 auto; white-space: nowrap; }
|
|
32
33
|
.taskchef-app-filters .taskchef-app-date { flex: 0 0 132px; }
|
|
33
34
|
@media (max-width: 650px) {
|
|
34
35
|
.taskchef-app-header { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "brand actions" "filters filters"; gap: 8px; }
|
|
@@ -189,6 +190,9 @@ body { background: var(--taskchef-page); }
|
|
|
189
190
|
.next-appearance-panel { background: var(--taskchef-elevated); }
|
|
190
191
|
|
|
191
192
|
:root[data-taskchef-palette] .mantine-Button-root[data-variant="filled"] { color: var(--taskchef-surface); }
|
|
193
|
+
:root[data-taskchef-palette] .taskchef-app-pinned { background: transparent; color: var(--taskchef-text); border-color: var(--taskchef-border); }
|
|
194
|
+
:root[data-taskchef-palette] .taskchef-app-pinned:hover { background: var(--taskchef-hover); }
|
|
195
|
+
:root[data-taskchef-palette] .taskchef-app-pinned[aria-pressed="true"] { background: var(--taskchef-accent); color: var(--taskchef-surface); border-color: var(--taskchef-accent); }
|
|
192
196
|
:root[data-taskchef-palette] .taskchef-board-card:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
|
|
193
197
|
|
|
194
198
|
:root[data-taskchef-palette] body { font-family: var(--taskchef-font); }
|
package/src/mcp-app.js
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { parse as parseToml } from "smol-toml";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { NextGitHub } from "./next-github.js";
|
|
10
|
+
import { CodexLiveState } from "./codex-live-state.js";
|
|
10
11
|
import { NextNotifications } from "./next-notifications.js";
|
|
11
12
|
import { CodexSessionScanner } from "./codex-session-scanner.js";
|
|
12
13
|
import { isCodexThreadDeepLinkId, openThreadInCodex, openPluginSettingsInCodex } from "./codex-app.js";
|
|
@@ -55,6 +56,7 @@ export async function pluginSettingsUrl({ pluginRoot = fileURLToPath(new URL("..
|
|
|
55
56
|
|
|
56
57
|
export function registerTaskChefApp(server, {
|
|
57
58
|
createScanner = () => new CodexSessionScanner(),
|
|
59
|
+
createLiveState = () => new CodexLiveState(),
|
|
58
60
|
openThread = openThreadInCodex,
|
|
59
61
|
getSettingsUrl = pluginSettingsUrl,
|
|
60
62
|
openSettings = openPluginSettingsInCodex,
|
|
@@ -63,15 +65,23 @@ export function registerTaskChefApp(server, {
|
|
|
63
65
|
createGitHub = (options) => new NextGitHub(options),
|
|
64
66
|
} = {}) {
|
|
65
67
|
const scanner = createScanner();
|
|
68
|
+
const liveState = createLiveState();
|
|
66
69
|
const notificationStore = new NextNotifications(notificationsPath);
|
|
67
70
|
const github = createGitHub({ stateDir: dirname(settingsPath) });
|
|
68
71
|
let boardRevision = 0;
|
|
69
72
|
let boardSignature;
|
|
70
73
|
let githubAuth;
|
|
74
|
+
let ipcErrorReported = false;
|
|
71
75
|
const turnUsageCache = new Map();
|
|
72
76
|
const usagePending = new Map();
|
|
73
77
|
async function boardSnapshot(settings, force = false, scope = { date: "all", taskIds: [] }) {
|
|
74
|
-
const
|
|
78
|
+
const databaseSnapshot = await scanner.refresh({ force });
|
|
79
|
+
await liveState.refresh(databaseSnapshot.healthy === false ? [] : databaseSnapshot.tasks);
|
|
80
|
+
if (liveState.error && !ipcErrorReported) {
|
|
81
|
+
ipcErrorReported = true;
|
|
82
|
+
await notificationStore.errorOnce(`codex-ipc:${TASKCHEF_VERSION}`, "Live approval updates unavailable", `${liveState.error}. TaskChef will continue showing saved chat data. Restart Codex or update TaskChef to restore live approval updates.`).catch(() => { /* Optional IPC reporting must not hide saved tasks. */ });
|
|
83
|
+
}
|
|
84
|
+
const snapshot = { ...databaseSnapshot, tasks: databaseSnapshot.tasks.map(task => liveState.decorate(task)) };
|
|
75
85
|
const visibleIds = new Set(scope.taskIds ?? []);
|
|
76
86
|
const displayTasks = snapshot.tasks.map(task => {
|
|
77
87
|
if (!visibleIds.has(task.id) || task.pullRequests?.length || !scanner.earlierPullRequest) return task;
|
|
@@ -98,12 +108,13 @@ export function registerTaskChefApp(server, {
|
|
|
98
108
|
const present = new Set(tasks.map(task => task.id));
|
|
99
109
|
for (const id of turnUsageCache.keys()) if (!present.has(id)) turnUsageCache.delete(id);
|
|
100
110
|
result.snapshot = { ...result.snapshot, tasks };
|
|
101
|
-
const signature = JSON.stringify([result.snapshot.revision, result.snapshot.tasks.map((task) => [task.id, task.status, task.pullRequests, task.earlierPullRequest, task.turnUsage])]);
|
|
111
|
+
const signature = JSON.stringify([result.snapshot.revision, result.snapshot.tasks.map((task) => [task.id, task.status, task.needsApproval, task.pendingApprovals, task.pullRequests, task.earlierPullRequest, task.turnUsage])]);
|
|
102
112
|
if (signature !== boardSignature) { boardRevision += 1; boardSignature = signature; }
|
|
103
113
|
return { ...result.snapshot, revision: boardRevision };
|
|
104
114
|
}
|
|
105
115
|
async function decoratedTask(task, force = false, includeHistory = false) {
|
|
106
116
|
if (!task) return task;
|
|
117
|
+
task = liveState.decorate(task);
|
|
107
118
|
const settings = await readSettings();
|
|
108
119
|
if (!includeHistory) {
|
|
109
120
|
const { snapshot } = await github.enrich({ healthy: true, tasks: [task] }, settings, { force });
|
|
@@ -158,7 +169,7 @@ export function registerTaskChefApp(server, {
|
|
|
158
169
|
} finally { await release(); }
|
|
159
170
|
});
|
|
160
171
|
const originalClose = server.close.bind(server);
|
|
161
|
-
server.close = async () => { scanner.close(); await originalClose(); };
|
|
172
|
+
server.close = async () => { scanner.close(); liveState.close(); await originalClose(); };
|
|
162
173
|
const appOnly = { ui: { resourceUri: TASKCHEF_APP_URI, visibility: ["app"] } };
|
|
163
174
|
server.registerResource("TaskChef task board", TASKCHEF_APP_URI, {
|
|
164
175
|
description: "TaskChef read-only Codex session board", mimeType: RESOURCE_MIME_TYPE,
|
|
@@ -202,6 +213,7 @@ export function registerTaskChefApp(server, {
|
|
|
202
213
|
}, async ({ taskId, refreshGithub }) => {
|
|
203
214
|
const snapshot = await scanner.refresh();
|
|
204
215
|
if (!snapshot.healthy) throw new Error(snapshot.scan.error);
|
|
216
|
+
await liveState.refresh(snapshot.tasks);
|
|
205
217
|
const task = await decoratedTask(await scanner.taskDetail(taskId), refreshGithub === true, true);
|
|
206
218
|
if (!task) throw new Error("Task not found.");
|
|
207
219
|
return { structuredContent: { task }, content: [] };
|
|
@@ -35,8 +35,8 @@ export class NextNotifications {
|
|
|
35
35
|
return publicState(state);
|
|
36
36
|
} finally { await release(); }
|
|
37
37
|
}
|
|
38
|
-
add(state, { taskId = null, title, detail = "", kind, read = false }) {
|
|
39
|
-
state.items.unshift({ id
|
|
38
|
+
add(state, { id = randomUUID(), taskId = null, title, detail = "", kind, read = false }) {
|
|
39
|
+
state.items.unshift({ id, taskId, title, detail, kind, read, timestamp: this.now() });
|
|
40
40
|
}
|
|
41
41
|
async reconcile(getSnapshot, settings) {
|
|
42
42
|
const observedAt = this.clock();
|
|
@@ -54,7 +54,7 @@ export class NextNotifications {
|
|
|
54
54
|
const current = { turn, status: task.status ?? null, announced: [...announced] };
|
|
55
55
|
// Archiving changes the board label, but must still consume the saved terminal event.
|
|
56
56
|
const terminal = task.observed?.lastTurnEvent;
|
|
57
|
-
const event = (task.status === "needs_input" || terminal === "completed") && task.inputSource === "ordinary"
|
|
57
|
+
const event = ((task.status === "needs_input" && terminal !== "inProgress") || terminal === "completed") && task.inputSource === "ordinary"
|
|
58
58
|
? "ready" : task.status === "interrupted" || terminal === "interrupted" || terminal === "failed" ? "interrupted" : null;
|
|
59
59
|
const eligible = !task.observed?.archive && !task.manualDone
|
|
60
60
|
&& (settings.showExec || task.observed?.source !== "exec")
|
|
@@ -73,6 +73,32 @@ export class NextNotifications {
|
|
|
73
73
|
});
|
|
74
74
|
return { snapshot, notifications };
|
|
75
75
|
}
|
|
76
|
+
async errorOnce(key, title, detail) {
|
|
77
|
+
// Older release writers strip unknown fields from notifications.json.
|
|
78
|
+
const path = `${this.path}.errors.json`;
|
|
79
|
+
const readWatermark = async () => {
|
|
80
|
+
try { return z.array(z.string()).parse(JSON.parse(await readFile(path, "utf8"))); }
|
|
81
|
+
catch { return []; } // A damaged optional watermark must not prevent an alert.
|
|
82
|
+
};
|
|
83
|
+
let needsWatermark = false;
|
|
84
|
+
const result = await this.update(async state => {
|
|
85
|
+
if ((await readWatermark()).includes(key)) return;
|
|
86
|
+
const id = `system-error:${key}`;
|
|
87
|
+
// If the previous process stopped after saving the alert, finish its
|
|
88
|
+
// watermark without adding a duplicate notification.
|
|
89
|
+
if (!state.items.some(item => item.id === id)) this.add(state, { id, title, detail, kind: "error" });
|
|
90
|
+
needsWatermark = true;
|
|
91
|
+
});
|
|
92
|
+
// Commit the alert first. A failed write can never suppress an unseen alert.
|
|
93
|
+
if (needsWatermark) {
|
|
94
|
+
const release = await acquireFileLock(dirname(this.path));
|
|
95
|
+
try {
|
|
96
|
+
const announced = await readWatermark();
|
|
97
|
+
if (!announced.includes(key)) await writeDurableAtomic(path, JSON.stringify([...announced, key]));
|
|
98
|
+
} finally { await release(); }
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
76
102
|
async confirmation(task) {
|
|
77
103
|
return this.update((state) => this.add(state, { taskId: task.id, title: `${task.title} marked Done`, kind: "confirmation", read: true }));
|
|
78
104
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { IconArrowUpRight, IconShieldCheck } from "@tabler/icons-react";
|
|
2
|
+
import type { Task } from "../types";
|
|
3
|
+
|
|
4
|
+
export function ApprovalCover({ task, onOpen }: { task: Task; onOpen: () => void }) {
|
|
5
|
+
const request = task.pendingApprovals?.[0];
|
|
6
|
+
return <button type="button" className="taskchef-approval-cover" aria-label={`Open chat to approve for ${task.title}`} onClick={onOpen}>
|
|
7
|
+
<span className="taskchef-approval-heading"><IconShieldCheck size={17} aria-hidden />Needs approval</span>
|
|
8
|
+
<span className="taskchef-approval-preview">{request?.message ?? "Codex needs permission to continue."}</span>
|
|
9
|
+
<span className="taskchef-approval-link">Open chat to approve<IconArrowUpRight size={14} aria-hidden /></span>
|
|
10
|
+
</button>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ApprovalPanel({ task, busy, onOpen }: { task: Task; busy: boolean; onOpen: () => void }) {
|
|
14
|
+
return <section className="taskchef-approval-panel" aria-label="Pending approvals">
|
|
15
|
+
<div className="taskchef-approval-heading"><IconShieldCheck size={17} aria-hidden />Permission required</div>
|
|
16
|
+
{task.pendingApprovals?.length ? task.pendingApprovals.map(request => <div className="taskchef-approval-request" key={request.id}>
|
|
17
|
+
<div className="taskchef-approval-title">{request.title}</div>
|
|
18
|
+
<p>{request.message}</p>
|
|
19
|
+
{request.origin && <div className="taskchef-approval-origin">Origin: {request.origin}</div>}
|
|
20
|
+
</div>) : <p>Codex needs permission to continue.</p>}
|
|
21
|
+
<p className="taskchef-approval-guidance">Open the chat to review this request and allow or deny it.</p>
|
|
22
|
+
<button type="button" disabled={busy} className="taskchef-approval-action" onClick={onOpen}>Open chat to approve<IconArrowUpRight size={14} aria-hidden /></button>
|
|
23
|
+
</section>;
|
|
24
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TaskCard } from "./TaskCard";
|
|
1
2
|
import { MantineProvider } from "@mantine/core";
|
|
2
3
|
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
|
3
4
|
import { afterEach, expect, test, vi } from "vitest";
|
|
@@ -386,3 +387,24 @@ test("interrupted cards show the recorded capacity reason even without a reply",
|
|
|
386
387
|
rerender(<MantineProvider><TaskBoard {...props} tasks={[{ ...interrupted, status: "working" }]} /></MantineProvider>);
|
|
387
388
|
expect(screen.queryByLabelText("Latest turn error")).toBeNull();
|
|
388
389
|
});
|
|
390
|
+
|
|
391
|
+
test("approval cover replaces the image cover and opens the native chat", () => {
|
|
392
|
+
const open=vi.fn(),detail=vi.fn(),load=vi.fn();
|
|
393
|
+
const pending={...task(1,'needs_input'),needsApproval:true,replyImage:{url:'/old.png',alt:'Old cover'},pendingApprovals:[{id:'16',turnId:'turn',title:'Browser use',message:'Allow upload to Gmail filters?',origin:'https://mail.google.com'}]};
|
|
394
|
+
const props={tasks:[pending],lanes:[{status:'needs_input' as const,label:'Waiting'}],completedLimit:5,onMoreCompleted:vi.fn(),onOpenCodex:open,onOpenDetail:detail,loadImage:load};
|
|
395
|
+
const {rerender}=render(<MantineProvider><TaskBoard {...props}/></MantineProvider>);
|
|
396
|
+
fireEvent.click(screen.getByRole('button',{name:`Open chat to approve for ${pending.title}`}));
|
|
397
|
+
expect(open).toHaveBeenCalledWith(pending);
|
|
398
|
+
expect(detail).not.toHaveBeenCalled();
|
|
399
|
+
expect(load).not.toHaveBeenCalled();
|
|
400
|
+
rerender(<MantineProvider><TaskBoard {...props} tasks={[{...pending,needsApproval:false,pendingApprovals:[],replyImage:null}]}/></MantineProvider>);
|
|
401
|
+
expect(screen.queryByText('Needs approval')).not.toBeInTheDocument();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("list cards show the approval cover without stale turn content", () => {
|
|
405
|
+
const pending={...task(1,'needs_input'),needsApproval:true,promptExcerpt:'Old request',replyExcerpt:'Stale reply',observed:{archive:false,lastTurnEvent:'inProgress',lastTurnEventAt:null,recentFileActivity:false}};
|
|
406
|
+
render(<MantineProvider><TaskCard task={pending} onOpenCodex={vi.fn()} onOpenDetail={vi.fn()}/></MantineProvider>);
|
|
407
|
+
expect(screen.getByText('Needs approval')).toBeVisible();
|
|
408
|
+
expect(screen.queryByText('Stale reply')).not.toBeInTheDocument();
|
|
409
|
+
expect(screen.queryByText('Old request')).not.toBeInTheDocument();
|
|
410
|
+
});
|
|
@@ -6,6 +6,7 @@ import { hasLinkedCodexThread, latestTurnPresentation } from "../state.js";
|
|
|
6
6
|
import type { Task, TaskStatus } from "../types";
|
|
7
7
|
import { LinkedText } from "./LinkedText";
|
|
8
8
|
import { TurnContent } from "./TurnContent";
|
|
9
|
+
import { ApprovalCover } from "./ApprovalNotice";
|
|
9
10
|
import { QueuedPrompts } from "./QueuedPrompts";
|
|
10
11
|
import { ReplyCover } from "./ReplyCover";
|
|
11
12
|
import { ElapsedTime, RelativeTime } from "./RelativeTime";
|
|
@@ -52,7 +53,7 @@ export function TaskBoard({
|
|
|
52
53
|
}) {
|
|
53
54
|
const draggedTask = useRef<Task | null>(null);
|
|
54
55
|
const [dragging, setDragging] = useState<Task | null>(null);
|
|
55
|
-
const isWaiting = (task: Task) => ["needs_input", "interrupted"].includes(task.status ?? "");
|
|
56
|
+
const isWaiting = (task: Task) => !task.needsApproval && ["needs_input", "interrupted"].includes(task.status ?? "");
|
|
56
57
|
const canDrop = (task: Task, destination: TaskStatus) => destination === "archived" || (isWaiting(task) && (destination === "completed"
|
|
57
58
|
? !task.scheduled && !task.pullRequests?.some(pr => pr.state !== "merged")
|
|
58
59
|
: destination === "scheduled" && !!task.scheduled && task.inputSource === "ordinary" && !!task.turnId));
|
|
@@ -228,7 +229,7 @@ function BoardCard({ showCardUsage, task, onOpenCodex, onOpenDetail, loadImage,
|
|
|
228
229
|
const cover = task.replyImage ?? task.promptImage;
|
|
229
230
|
return (
|
|
230
231
|
<Paper draggable={draggable} onDragStart={draggable ? onDragStart : undefined} onDragEnd={onDragEnd} data-chat-id={task.id} className="taskchef-board-card" component="article" px="sm" pt="sm" pb={6} withBorder>
|
|
231
|
-
{task.observed && cover && <ReplyCover key={`${task.turnId}:${cover.url}`} task={task} image={cover} loadImage={loadImage} onOpen={() => onOpenDetail(task)} />}
|
|
232
|
+
{task.needsApproval ? <ApprovalCover task={task} onOpen={() => onOpenCodex(task)} /> : task.observed && cover && <ReplyCover key={`${task.turnId}:${cover.url}`} task={task} image={cover} loadImage={loadImage} onOpen={() => onOpenDetail(task)} />}
|
|
232
233
|
<Title className="taskchef-board-title" order={3} size="h5">
|
|
233
234
|
<button className="taskchef-title-button" onClick={() => onOpenDetail(task)} type="button">{task.title}</button>
|
|
234
235
|
{task.observed?.unread && <span className="taskchef-chat-unread-dot" role="img" aria-label="Unread chat" />}
|
|
@@ -236,7 +237,7 @@ function BoardCard({ showCardUsage, task, onOpenCodex, onOpenDetail, loadImage,
|
|
|
236
237
|
<Text className="taskchef-board-project" size="xs"><IconFolder size={14} stroke={1.5} aria-hidden /><span>{task.project.name}</span></Text>
|
|
237
238
|
{task.needsAnswer && <Tooltip label="Open chat to answer." ><Badge color="blue" size="xs" variant="light">Needs answer</Badge></Tooltip>}
|
|
238
239
|
{task.status === "interrupted" && <Badge color="orange" size="xs" variant="light">Interrupted</Badge>}
|
|
239
|
-
{task.observed ? <TurnContent task={task} compact /> : <Text component="div" className="taskchef-board-excerpt taskchef-preserve-lines" size="sm"><LinkedText task={task} text={excerpt} /></Text>}
|
|
240
|
+
{task.needsApproval ? null : task.observed ? <TurnContent task={task} compact /> : <Text component="div" className="taskchef-board-excerpt taskchef-preserve-lines" size="sm"><LinkedText task={task} text={excerpt} /></Text>}
|
|
240
241
|
<QueuedPrompts task={task} compact loadImage={loadImage} onOpen={() => onOpenDetail(task)} />
|
|
241
242
|
<Box className="taskchef-board-card-footer">
|
|
242
243
|
<CardStatusLine turnId={task.turnId} animateUsage={task.status === "working"} turnUsage={showCardUsage ? task.turnUsage : undefined} subagentCount={task.observed?.directChildCount} pullRequests={task.pullRequests} earlierPullRequest={task.earlierPullRequest} scheduled={task.scheduled} nextRunAt={task.nextRunAt} durationMs={task.observed?.latestTurnDurationMs} startedAt={task.observed && task.status === "working" ? task.observed.lastTurnEventAt : null}>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ApprovalCover } from "./ApprovalNotice";
|
|
1
2
|
import { Box, Paper, Stack, Text, Title } from "@mantine/core";
|
|
2
3
|
import { IconArrowUpRight } from "@tabler/icons-react";
|
|
3
4
|
import { latestTurnPresentation } from "../state.js";
|
|
@@ -23,6 +24,7 @@ export function TaskCard({
|
|
|
23
24
|
return (
|
|
24
25
|
<Paper className="taskchef-task-row" component="article" pb={8} pt="md" px="sm" radius="md" withBorder>
|
|
25
26
|
<Stack className="taskchef-task-main" gap="sm">
|
|
27
|
+
{task.needsApproval && <ApprovalCover task={task} onOpen={() => onOpenCodex(task)} />}
|
|
26
28
|
<Box>
|
|
27
29
|
<Box className="taskchef-card-heading">
|
|
28
30
|
<Title order={3} size="h5">
|
|
@@ -38,7 +40,7 @@ export function TaskCard({
|
|
|
38
40
|
{task.scheduled && <Text size="xs" c="violet">Active schedule</Text>}
|
|
39
41
|
</Box>
|
|
40
42
|
|
|
41
|
-
{task.observed ? <TurnContent compact task={task} /> : <Box className="taskchef-summary-grid">
|
|
43
|
+
{task.needsApproval ? null : task.observed ? <TurnContent compact task={task} /> : <Box className="taskchef-summary-grid">
|
|
42
44
|
<Text c="dimmed" className="taskchef-field-label" size="xs">Request</Text>
|
|
43
45
|
<Text className="taskchef-preserve-lines" lineClamp={3} size="sm">
|
|
44
46
|
<LinkedText task={task} text={latest.requestSummary} />
|
package/src/ui/styles.css
CHANGED
|
@@ -342,3 +342,18 @@ body {
|
|
|
342
342
|
|
|
343
343
|
.taskchef-queue-icon { flex: 0 0 16px; color: var(--mantine-color-dimmed); margin-top: 2px; }
|
|
344
344
|
.taskchef-queue-content { flex: 1; min-width: 0; }
|
|
345
|
+
|
|
346
|
+
.taskchef-approval-cover { display: flex; flex-direction: column; gap: 10px; width: 100%; padding: 14px; margin-bottom: 10px; border: 1px solid light-dark(#d4a64a, #886731); border-radius: 12px; background: light-dark(#fff6df, #30271a); color: light-dark(#6f4c12, #f1cf8b); text-align: left; cursor: pointer; }
|
|
347
|
+
.taskchef-approval-heading { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; }
|
|
348
|
+
.taskchef-approval-heading svg { flex: none; }
|
|
349
|
+
.taskchef-approval-preview { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: 12px; line-height: 1.5; }
|
|
350
|
+
.taskchef-approval-link { display: flex; align-items: center; gap: 6px; font-size: 12px; }
|
|
351
|
+
.taskchef-approval-panel { padding: 16px; border: 1px solid light-dark(#d4a64a, #886731); border-radius: 16px; background: light-dark(#fff6df, #30271a); color: light-dark(#6f4c12, #f1cf8b); }
|
|
352
|
+
.taskchef-approval-request { margin-top: 16px; }
|
|
353
|
+
.taskchef-approval-title { font-size: 12px; opacity: .85; }
|
|
354
|
+
.taskchef-approval-panel p { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
|
|
355
|
+
.taskchef-approval-origin { font-size: 12px; overflow-wrap: anywhere; }
|
|
356
|
+
.taskchef-approval-panel .taskchef-approval-guidance { margin-top: 16px; font-size: 12px; }
|
|
357
|
+
.taskchef-approval-action { display: inline-flex; align-items: center; gap: 6px; margin-top: 8px; padding: 7px 12px; border: 1px solid currentColor; border-radius: 16px; color: inherit; background: transparent; font: inherit; font-size: 12px; cursor: pointer; }
|
|
358
|
+
.taskchef-approval-action:disabled { opacity: .5; cursor: default; }
|
|
359
|
+
.taskchef-approval-cover:focus-visible, .taskchef-approval-action:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 3px; }
|
package/src/ui/types.ts
CHANGED
|
@@ -122,6 +122,8 @@ export interface Task {
|
|
|
122
122
|
replyText?: string | null;
|
|
123
123
|
replyExcerpt?: string | null;
|
|
124
124
|
annotations?: { text: string; annotation: string }[];
|
|
125
|
+
needsApproval?: boolean;
|
|
126
|
+
pendingApprovals?: { id: string; turnId: string; title: string; message: string; origin: string | null }[];
|
|
125
127
|
needsAnswer?: boolean;
|
|
126
128
|
pendingQuestions?: { id: string; title: string; options: string[] }[];
|
|
127
129
|
queuedPrompts?: { id: string; text: string; images: { url: string; alt: string }[] }[];
|
|
@@ -129,7 +131,7 @@ export interface Task {
|
|
|
129
131
|
promptImages?: { url: string; alt: string }[];
|
|
130
132
|
replyImage?: { url: string; alt: string } | null;
|
|
131
133
|
inputSource?: "scheduled" | "ordinary" | "unverified";
|
|
132
|
-
observed?: { unread?: boolean; source?: string | null; archive: boolean; lastTurnEvent: string | null; lastTurnEventAt: string | null; latestTurnDurationMs?: number | null; recordedChatDurationMs?: number | null; missingTurnDurations?: number; historicalTimePartial?: boolean; recentFileActivity: boolean; directChildCount?: number; userMessages?: number; assistantMessages?: number; sampledBytes?: number; fileBytes?: number };
|
|
134
|
+
observed?: { pinned?: boolean; unread?: boolean; source?: string | null; archive: boolean; lastTurnEvent: string | null; lastTurnEventAt: string | null; latestTurnDurationMs?: number | null; recordedChatDurationMs?: number | null; missingTurnDurations?: number; historicalTimePartial?: boolean; recentFileActivity: boolean; directChildCount?: number; userMessages?: number; assistantMessages?: number; sampledBytes?: number; fileBytes?: number };
|
|
133
135
|
pullRequests?: PullRequestStatus[];
|
|
134
136
|
detailPullRequests?: PullRequestStatus[];
|
|
135
137
|
earlierPullRequest?: PullRequestStatus;
|