taskchef 8.5.3 → 8.6.1
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 +2 -0
- package/docs/mcp-app.md +11 -0
- package/package.json +6 -1
- package/src/mcp-app/dist/index.html +301 -0
- package/src/mcp-app/react/index.html +2 -0
- package/src/mcp-app/react/main.test.tsx +185 -0
- package/src/mcp-app/react/main.tsx +213 -0
- package/src/mcp-app/react/styles.css +35 -0
- package/src/mcp-app.js +156 -0
- package/src/mcp.js +3 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|
2
|
+
import "@testing-library/jest-dom/vitest";
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor, within, cleanup } from "@testing-library/react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import type { Task } from "../../dashboard/react/types";
|
|
6
|
+
|
|
7
|
+
const server = vi.hoisted(() => ({ call: vi.fn() }));
|
|
8
|
+
vi.mock("@mantine/core", async () => {
|
|
9
|
+
const React = await import("react");
|
|
10
|
+
const wrap = (tag: string) => ({ children, component, ...props }: Record<string, unknown>) => React.createElement(component as string || tag, { "aria-label": props["aria-label"], role: props.role, id: props.id }, children as React.ReactNode);
|
|
11
|
+
return {
|
|
12
|
+
ActionIcon: ({ children, onClick, ...props }: Record<string, unknown>) => React.createElement("button", { "aria-label": props["aria-label"], onClick }, children as React.ReactNode),
|
|
13
|
+
Alert: wrap("div"), Box: wrap("div"), Button: wrap("button"), Group: wrap("div"),
|
|
14
|
+
MantineProvider: ({ children }: { children: React.ReactNode }) => children,
|
|
15
|
+
Paper: wrap("div"), Stack: wrap("div"), Text: wrap("p"), Title: wrap("h2"),
|
|
16
|
+
SegmentedControl: ({ data, onChange, value, ...props }: { data: Array<{ label: string; value: string }>; onChange: (value: string) => void; value: string; "aria-label": string }) => React.createElement("div", { role: "radiogroup", "aria-label": props["aria-label"] }, data.map((item) => React.createElement("button", { key: item.value, role: "radio", "aria-checked": item.value === value, onClick: () => onChange(item.value) }, item.label))),
|
|
17
|
+
Select: ({ data, onChange, value, ...props }: { data: Array<{ label: string; value: string }>; onChange: (value: string) => void; value: string; "aria-label": string }) => React.createElement("select", { "aria-label": props["aria-label"], value, onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onChange(event.target.value) }, data.map((item) => React.createElement("option", { key: item.value, value: item.value }, item.label))),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
vi.mock("@modelcontextprotocol/ext-apps", () => ({
|
|
21
|
+
App: class {
|
|
22
|
+
connect() { return Promise.resolve(); }
|
|
23
|
+
callServerTool(input: { name: string; arguments: Record<string, unknown> }) { return server.call(input); }
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("../../dashboard/react/components/TaskCard", () => ({
|
|
27
|
+
TaskCard: ({ task, onOpenCodex, onOpenDetail }: { task: Task; onOpenCodex: (task: Task) => void; onOpenDetail: (task: Task) => void }) => <article><button onClick={() => onOpenDetail(task)}>{task.title}</button><button onClick={() => onOpenCodex(task)}>Open chat for {task.title}</button></article>,
|
|
28
|
+
}));
|
|
29
|
+
vi.mock("../../dashboard/react/components/TaskBoard", () => ({
|
|
30
|
+
TaskBoard: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task) => void }) => <section aria-label="Task board">{tasks.map((task) => <button key={task.id} onClick={() => onOpenDetail(task)}>{task.title}</button>)}</section>,
|
|
31
|
+
}));
|
|
32
|
+
vi.mock("../../dashboard/react/components/TaskDetail", () => ({
|
|
33
|
+
TaskDetail: ({ task, opened, onClose, onTransition }: {
|
|
34
|
+
task: Task | null; opened: boolean; onClose: () => void;
|
|
35
|
+
onTransition: (status: "completed" | "failed", actionId: string) => Promise<unknown>;
|
|
36
|
+
}) => {
|
|
37
|
+
const [confirm, setConfirm] = useState(false);
|
|
38
|
+
return opened && task ? <section aria-label="Task detail"><h2>{task.title}</h2><p>{task.status}</p><button onClick={onClose}>Close</button><button onClick={() => setConfirm(true)}>Mark completed</button>{confirm && <button onClick={() => { void onTransition("completed", crypto.randomUUID()); setConfirm(false); }}>Confirm</button>}</section> : null;
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { TaskChefApp } from "./main";
|
|
43
|
+
|
|
44
|
+
const task = (id: string, status: Task["status"] = "working", updatedAt = "2026-10-01T00:00:00Z", project = "Example"): Task => ({
|
|
45
|
+
id, title: `Task ${id}`, instruction: "Do the work", status, updatedAt, createdAt: updatedAt,
|
|
46
|
+
project: { name: project, path: `/${project}`, githubRepos: [] }, summary: "Initial summary",
|
|
47
|
+
threadId: null, turnRef: null, turnId: null, lastResult: null, latestTurn: null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
let tasks: Task[];
|
|
51
|
+
let details: Map<string, Task>;
|
|
52
|
+
let transition: () => Promise<unknown>;
|
|
53
|
+
let detailFailure: string | null;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
window.localStorage.clear();
|
|
56
|
+
tasks = [task("one")];
|
|
57
|
+
details = new Map(tasks.map((item) => [item.id, item]));
|
|
58
|
+
transition = async () => ({ structuredContent: { task: task("one", "completed") } });
|
|
59
|
+
detailFailure = null;
|
|
60
|
+
server.call.mockImplementation(({ name, arguments: args }) => {
|
|
61
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { snapshot: { tasks, healthy: true } } });
|
|
62
|
+
if (name === "taskchef_app_task" && detailFailure) return Promise.resolve({ isError: true, content: [{ type: "text", text: detailFailure }] });
|
|
63
|
+
if (name === "taskchef_app_task") return Promise.resolve({ structuredContent: { task: details.get(args.taskId as string) } });
|
|
64
|
+
if (name === "taskchef_app_transition") return transition();
|
|
65
|
+
if (name === "taskchef_app_open_chat") return Promise.resolve({ isError: true, content: [{ type: "text", text: "Codex could not be opened." }] });
|
|
66
|
+
throw new Error(`Unexpected tool: ${name}`);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => { cleanup(); server.call.mockReset(); });
|
|
70
|
+
function mount() { render(<TaskChefApp />); }
|
|
71
|
+
|
|
72
|
+
test("switches list and board and filters by project, date, and status with contextual counts", async () => {
|
|
73
|
+
tasks = [task("one", "working", new Date().toISOString(), "Alpha"), task("two", "completed", "2026-01-01T00:00:00Z", "Alpha"), task("three", "failed", new Date().toISOString(), "Beta")];
|
|
74
|
+
details = new Map(tasks.map((item) => [item.id, item]));
|
|
75
|
+
mount();
|
|
76
|
+
expect(await screen.findByRole("button", { name: "Task one" })).toBeVisible();
|
|
77
|
+
expect(screen.getByText("Tasks: 3 of 3")).toBeVisible();
|
|
78
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "Alpha" } });
|
|
79
|
+
expect(screen.getByText("Tasks: 2 of 3")).toBeVisible();
|
|
80
|
+
expect(screen.getByRole("radio", { name: "All 2" })).toBeVisible();
|
|
81
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Updated" }), { target: { value: "24h" } });
|
|
82
|
+
expect(screen.getByText("Tasks: 1 of 3")).toBeVisible();
|
|
83
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Updated" }), { target: { value: "all" } });
|
|
84
|
+
fireEvent.change(screen.getByRole("combobox", { name: "Project" }), { target: { value: "" } });
|
|
85
|
+
fireEvent.click(screen.getByRole("radio", { name: /Failed/ }));
|
|
86
|
+
expect(screen.getByText("Tasks: 1 of 3")).toBeVisible();
|
|
87
|
+
expect(screen.queryByRole("button", { name: "Task one" })).not.toBeInTheDocument();
|
|
88
|
+
fireEvent.click(screen.getByRole("radio", { name: "Board" }));
|
|
89
|
+
expect(within(screen.getByRole("region", { name: "Task board" })).getAllByRole("button")).toHaveLength(3);
|
|
90
|
+
expect(screen.queryByRole("radiogroup", { name: "Status" })).not.toBeInTheDocument();
|
|
91
|
+
fireEvent.click(screen.getByRole("radio", { name: "List" }));
|
|
92
|
+
expect(screen.getByRole("button", { name: "Task three" })).toBeVisible();
|
|
93
|
+
expect(screen.queryByRole("button", { name: "Task two" })).not.toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("refresh updates detail and removes a disappeared selection", async () => {
|
|
97
|
+
mount();
|
|
98
|
+
fireEvent.click(await screen.findByRole("button", { name: "Task one" }));
|
|
99
|
+
await screen.findByRole("region", { name: "Task detail" });
|
|
100
|
+
details.set("one", task("one", "needs_input", "2026-10-02T00:00:00Z"));
|
|
101
|
+
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
|
102
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("needs_input"));
|
|
103
|
+
tasks = [];
|
|
104
|
+
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
|
105
|
+
await waitFor(() => expect(screen.queryByRole("region", { name: "Task detail" })).not.toBeInTheDocument());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("refresh sends the last revision and keeps selection detail current on unchanged snapshots", async () => {
|
|
109
|
+
let revision = 1;
|
|
110
|
+
server.call.mockImplementation(({ name, arguments: args }) => {
|
|
111
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: args.revision === revision
|
|
112
|
+
? { unchanged: true, revision }
|
|
113
|
+
: { snapshot: { tasks, healthy: true, revision } } });
|
|
114
|
+
if (name === "taskchef_app_task") return Promise.resolve({ structuredContent: { task: details.get(args.taskId as string) } });
|
|
115
|
+
throw new Error(`Unexpected tool: ${name}`);
|
|
116
|
+
});
|
|
117
|
+
mount();
|
|
118
|
+
fireEvent.click(await screen.findByRole("button", { name: "Task one" }));
|
|
119
|
+
details.set("one", task("one", "needs_input"));
|
|
120
|
+
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
|
121
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("needs_input"));
|
|
122
|
+
expect(server.call).toHaveBeenCalledWith({ name: "taskchef_app_snapshot", arguments: { revision: 1 } });
|
|
123
|
+
tasks = [task("two")];
|
|
124
|
+
revision = 2;
|
|
125
|
+
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
|
126
|
+
await waitFor(() => expect(screen.queryByRole("region", { name: "Task detail" })).not.toBeInTheDocument());
|
|
127
|
+
expect(screen.getByRole("button", { name: "Task two" })).toBeVisible();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("an older selection detail cannot replace a newer refresh detail", async () => {
|
|
131
|
+
let releaseFirst!: (value: unknown) => void;
|
|
132
|
+
const first = new Promise((resolve) => { releaseFirst = resolve; });
|
|
133
|
+
let detailCalls = 0;
|
|
134
|
+
server.call.mockImplementation(({ name }) => {
|
|
135
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { snapshot: { tasks, healthy: true } } });
|
|
136
|
+
if (name === "taskchef_app_task") return ++detailCalls === 1
|
|
137
|
+
? first
|
|
138
|
+
: Promise.resolve({ structuredContent: { task: task("one", "needs_input") } });
|
|
139
|
+
throw new Error(`Unexpected tool: ${name}`);
|
|
140
|
+
});
|
|
141
|
+
mount();
|
|
142
|
+
fireEvent.click(await screen.findByRole("button", { name: "Task one" }));
|
|
143
|
+
await waitFor(() => expect(detailCalls).toBe(1));
|
|
144
|
+
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
|
|
145
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("needs_input"));
|
|
146
|
+
await act(async () => { releaseFirst({ structuredContent: { task: task("one", "working") } }); await first; });
|
|
147
|
+
expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("needs_input");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("shows a card Open chat failure without opening detail", async () => {
|
|
151
|
+
mount();
|
|
152
|
+
fireEvent.click(await screen.findByRole("button", { name: "Open chat for Task one" }));
|
|
153
|
+
expect(await screen.findByRole("alert")).toHaveTextContent("Codex could not be opened.");
|
|
154
|
+
expect(screen.queryByRole("region", { name: "Task detail" })).not.toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("transition conflict refreshes the current task", async () => {
|
|
158
|
+
mount();
|
|
159
|
+
fireEvent.click(await screen.findByRole("button", { name: "Task one" }));
|
|
160
|
+
await screen.findByRole("region", { name: "Task detail" });
|
|
161
|
+
transition = async () => {
|
|
162
|
+
details.set("one", task("one", "failed", "2026-10-02T00:00:00Z"));
|
|
163
|
+
throw new Error("Task changed concurrently.");
|
|
164
|
+
};
|
|
165
|
+
fireEvent.click(screen.getByRole("button", { name: "Mark completed" }));
|
|
166
|
+
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));
|
|
167
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("failed"));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("an older selection response cannot replace the current task", async () => {
|
|
171
|
+
tasks = [task("one"), task("two")];
|
|
172
|
+
let release!: (value: unknown) => void;
|
|
173
|
+
const first = new Promise((resolve) => { release = resolve; });
|
|
174
|
+
server.call.mockImplementation(({ name, arguments: args }) => {
|
|
175
|
+
if (name === "taskchef_app_snapshot") return Promise.resolve({ structuredContent: { snapshot: { tasks, healthy: true } } });
|
|
176
|
+
if (name === "taskchef_app_task") return args.taskId === "one" ? first : Promise.resolve({ structuredContent: { task: task("two") } });
|
|
177
|
+
throw new Error(`Unexpected tool: ${name}`);
|
|
178
|
+
});
|
|
179
|
+
mount();
|
|
180
|
+
fireEvent.click(await screen.findByRole("button", { name: "Task one" }));
|
|
181
|
+
fireEvent.click(screen.getByRole("button", { name: "Task two" }));
|
|
182
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("Task two"));
|
|
183
|
+
release({ structuredContent: { task: task("one") } });
|
|
184
|
+
await waitFor(() => expect(screen.getByRole("region", { name: "Task detail" })).toHaveTextContent("Task two"));
|
|
185
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { App } from "@modelcontextprotocol/ext-apps";
|
|
2
|
+
import { ActionIcon, Alert, Box, Button, Group, MantineProvider, Paper, SegmentedControl, Select, Stack, Text, Title } from "@mantine/core";
|
|
3
|
+
import { IconRefresh } from "@tabler/icons-react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import type { DashboardSnapshot, Task } from "../../dashboard/react/types";
|
|
7
|
+
import { filterTasks, manualTransitionExpectedState, statusFilterCounts, STATUS_FILTERS, taskMatchesManualTransitionExpected } from "../../dashboard/state.js";
|
|
8
|
+
import { TaskBoard } from "../../dashboard/react/components/TaskBoard";
|
|
9
|
+
import { TaskCard } from "../../dashboard/react/components/TaskCard";
|
|
10
|
+
import { TaskDetail } from "../../dashboard/react/components/TaskDetail";
|
|
11
|
+
import { RelativeTimeProvider } from "../../dashboard/react/components/RelativeTime";
|
|
12
|
+
import brandIcon from "../../../assets/taskchef-dark.svg";
|
|
13
|
+
import "@mantine/core/styles.css";
|
|
14
|
+
import "../../dashboard/react/styles.css";
|
|
15
|
+
import "./styles.css";
|
|
16
|
+
|
|
17
|
+
const bridge = new App({ name: "TaskChef board", version: "1.0.0" });
|
|
18
|
+
const connected = bridge.connect();
|
|
19
|
+
|
|
20
|
+
async function call<T>(name: string, args: Record<string, unknown> = {}): Promise<T> {
|
|
21
|
+
await connected;
|
|
22
|
+
const result = await bridge.callServerTool({ name, arguments: args });
|
|
23
|
+
if (result.isError) throw new Error(result.content?.find((item) => item.type === "text")?.text ?? "TaskChef request failed.");
|
|
24
|
+
return result.structuredContent as T;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VIEW_KEY = "taskchef.app.view";
|
|
28
|
+
function initialView(): "board" | "list" {
|
|
29
|
+
try { return window.localStorage.getItem(VIEW_KEY) === "board" ? "board" : "list"; }
|
|
30
|
+
catch { return "list"; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function TaskChefApp() {
|
|
34
|
+
const [tasks, setTasks] = useState<Task[]>([]);
|
|
35
|
+
const [selected, setSelected] = useState<Task | null>(null);
|
|
36
|
+
const [opened, setOpened] = useState(false);
|
|
37
|
+
const [busy, setBusy] = useState(false);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const [detailError, setDetailError] = useState<string | null>(null);
|
|
40
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
41
|
+
const [project, setProject] = useState("");
|
|
42
|
+
const [date, setDate] = useState("all");
|
|
43
|
+
const [status, setStatus] = useState("");
|
|
44
|
+
const [view, setView] = useState(initialView);
|
|
45
|
+
const [completedLimit, setCompletedLimit] = useState(5);
|
|
46
|
+
const [now, setNow] = useState(() => Date.now());
|
|
47
|
+
const selectedRef = useRef<Task | null>(null);
|
|
48
|
+
const selectionVersion = useRef(0);
|
|
49
|
+
const detailRequestVersion = useRef(0);
|
|
50
|
+
const refreshVersion = useRef(0);
|
|
51
|
+
const revisionRef = useRef<number | null>(null);
|
|
52
|
+
const refresh = useCallback(async () => {
|
|
53
|
+
const version = ++refreshVersion.current;
|
|
54
|
+
const initialSelection = selectionVersion.current;
|
|
55
|
+
const data = await call<{ snapshot: DashboardSnapshot & { revision: number }; unchanged?: never } | { unchanged: true; revision: number; snapshot?: never }>(
|
|
56
|
+
"taskchef_app_snapshot", revisionRef.current === null ? {} : { revision: revisionRef.current },
|
|
57
|
+
);
|
|
58
|
+
if (version !== refreshVersion.current) return;
|
|
59
|
+
if (data.snapshot) {
|
|
60
|
+
revisionRef.current = data.snapshot.revision;
|
|
61
|
+
setTasks(data.snapshot.tasks);
|
|
62
|
+
setError(data.snapshot.healthy === false ? "Task log is temporarily unavailable. Showing the last valid snapshot." : null);
|
|
63
|
+
} else setError(null);
|
|
64
|
+
setNow(Date.now());
|
|
65
|
+
const selectedTask = selectedRef.current;
|
|
66
|
+
if (!selectedTask || initialSelection !== selectionVersion.current) return;
|
|
67
|
+
const selection = selectionVersion.current;
|
|
68
|
+
const clearRemovedTask = () => {
|
|
69
|
+
selectionVersion.current += 1;
|
|
70
|
+
selectedRef.current = null;
|
|
71
|
+
setSelected(null);
|
|
72
|
+
setOpened(false);
|
|
73
|
+
setDetailError(null);
|
|
74
|
+
};
|
|
75
|
+
if (data.snapshot && data.snapshot.healthy !== false && !data.snapshot.tasks.some((task) => task.id === selectedTask.id)) {
|
|
76
|
+
clearRemovedTask();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const detailRequest = ++detailRequestVersion.current;
|
|
80
|
+
try {
|
|
81
|
+
const detail = await call<{ task: Task }>("taskchef_app_task", { taskId: selectedTask.id });
|
|
82
|
+
if (version !== refreshVersion.current || selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current || selectedRef.current?.id !== detail.task.id) return;
|
|
83
|
+
selectedRef.current = detail.task;
|
|
84
|
+
setSelected(detail.task);
|
|
85
|
+
setDetailError(null);
|
|
86
|
+
} catch (cause) {
|
|
87
|
+
if (version !== refreshVersion.current || selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current) return;
|
|
88
|
+
if (cause instanceof Error && cause.message === "Task not found.") clearRemovedTask();
|
|
89
|
+
else setDetailError(String(cause));
|
|
90
|
+
}
|
|
91
|
+
}, []);
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
void refresh().catch((cause) => setError(String(cause)));
|
|
94
|
+
const timer = window.setInterval(() => { void refresh().catch((cause) => setError(String(cause))); }, 5000);
|
|
95
|
+
return () => window.clearInterval(timer);
|
|
96
|
+
}, [refresh]);
|
|
97
|
+
const projects = useMemo(() => [
|
|
98
|
+
{ label: "All projects", value: "" },
|
|
99
|
+
...[...new Set(tasks.map((task) => task.project.name))].sort().map((value) => ({ label: value, value })),
|
|
100
|
+
], [tasks]);
|
|
101
|
+
const visible: Task[] = useMemo(() => filterTasks(tasks, { project, date, status, now }), [tasks, project, date, status, now]);
|
|
102
|
+
const boardTasks: Task[] = useMemo(() => filterTasks(tasks, { project, date, now }), [tasks, project, date, now]);
|
|
103
|
+
const counts = useMemo(() => statusFilterCounts(tasks, { project, date, now }), [tasks, project, date, now]);
|
|
104
|
+
const statusOptions = STATUS_FILTERS.map(({ label, value }: { label: string; value: string }) => ({
|
|
105
|
+
label: counts[value] > 0 ? `${label} ${counts[value]}` : label,
|
|
106
|
+
value,
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
function changeView(value: string) {
|
|
110
|
+
if (value !== "board" && value !== "list") return;
|
|
111
|
+
setView(value);
|
|
112
|
+
try { window.localStorage.setItem(VIEW_KEY, value); } catch { /* Keep the selection in memory. */ }
|
|
113
|
+
}
|
|
114
|
+
async function select(task: Task) {
|
|
115
|
+
const selection = ++selectionVersion.current;
|
|
116
|
+
const detailRequest = ++detailRequestVersion.current;
|
|
117
|
+
selectedRef.current = task;
|
|
118
|
+
setSelected(task);
|
|
119
|
+
setOpened(true);
|
|
120
|
+
setDetailError(null);
|
|
121
|
+
setNotice(null);
|
|
122
|
+
try {
|
|
123
|
+
const result = await call<{ task: Task }>("taskchef_app_task", { taskId: task.id });
|
|
124
|
+
if (selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current) return;
|
|
125
|
+
selectedRef.current = result.task;
|
|
126
|
+
setSelected(result.task);
|
|
127
|
+
} catch (cause) {
|
|
128
|
+
if (selection === selectionVersion.current && detailRequest === detailRequestVersion.current) setDetailError(String(cause));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function openChat(task: Task) {
|
|
132
|
+
setBusy(true);
|
|
133
|
+
try {
|
|
134
|
+
const result = await call<{ message: string }>("taskchef_app_open_chat", { taskId: task.id });
|
|
135
|
+
setNotice(result.message);
|
|
136
|
+
} catch (cause) {
|
|
137
|
+
if (selectedRef.current?.id === task.id) setDetailError(String(cause));
|
|
138
|
+
else setError(String(cause));
|
|
139
|
+
}
|
|
140
|
+
finally { setBusy(false); }
|
|
141
|
+
}
|
|
142
|
+
async function transition(targetStatus: "completed" | "failed", actionId: string) {
|
|
143
|
+
const requestTask = selectedRef.current;
|
|
144
|
+
if (!requestTask) return { ok: false };
|
|
145
|
+
const selection = selectionVersion.current;
|
|
146
|
+
const expected = manualTransitionExpectedState(requestTask);
|
|
147
|
+
setBusy(true);
|
|
148
|
+
setDetailError(null);
|
|
149
|
+
try {
|
|
150
|
+
const result = await call<{ task: Task }>("taskchef_app_transition", {
|
|
151
|
+
taskId: requestTask.id, actionId,
|
|
152
|
+
expected, targetStatus,
|
|
153
|
+
});
|
|
154
|
+
if (selection === selectionVersion.current && taskMatchesManualTransitionExpected(selectedRef.current, expected)) {
|
|
155
|
+
selectedRef.current = result.task;
|
|
156
|
+
setSelected(result.task);
|
|
157
|
+
}
|
|
158
|
+
setTasks((current) => current.map((task) => task.id === result.task.id && taskMatchesManualTransitionExpected(task, expected) ? { ...task, ...result.task } : task));
|
|
159
|
+
setNotice(`Marked ${result.task.status?.replace("_", " ")}.`);
|
|
160
|
+
void refresh().catch((cause) => setError(String(cause)));
|
|
161
|
+
return { ok: true };
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
await refresh().catch(() => {});
|
|
164
|
+
setDetailError(`${String(cause)} The task has been refreshed; review its current state before trying again.`);
|
|
165
|
+
return { ok: false, rotateActionId: true };
|
|
166
|
+
} finally { setBusy(false); }
|
|
167
|
+
}
|
|
168
|
+
function closeDetail() {
|
|
169
|
+
if (busy) return;
|
|
170
|
+
selectionVersion.current += 1;
|
|
171
|
+
selectedRef.current = null;
|
|
172
|
+
setOpened(false);
|
|
173
|
+
setSelected(null);
|
|
174
|
+
setDetailError(null);
|
|
175
|
+
}
|
|
176
|
+
return <MantineProvider forceColorScheme="dark">
|
|
177
|
+
<RelativeTimeProvider now={now}>
|
|
178
|
+
<Box className="taskchef-app-shell">
|
|
179
|
+
<header className="taskchef-app-header">
|
|
180
|
+
<Group gap="xs" wrap="nowrap"><img alt="" aria-hidden className="taskchef-app-mark" src={brandIcon} /><Title order={1}>TaskChef <span>Dashboard</span></Title></Group>
|
|
181
|
+
<ActionIcon aria-label="Refresh" onClick={() => void refresh().catch((cause) => setError(String(cause)))} variant="subtle"><IconRefresh size={17} /></ActionIcon>
|
|
182
|
+
</header>
|
|
183
|
+
<main className={`taskchef-app-main${view === "list" ? " taskchef-app-main-list" : ""}`}>
|
|
184
|
+
<Paper className="taskchef-toolbar" radius={0}>
|
|
185
|
+
<Stack gap="sm">
|
|
186
|
+
<SegmentedControl aria-label="View" className="taskchef-app-view" data={[{ label: "Board", value: "board" }, { label: "List", value: "list" }]} onChange={changeView} size="xs" value={view} withItemsBorders={false} />
|
|
187
|
+
<Group className="taskchef-app-filters" gap="xs" wrap="nowrap">
|
|
188
|
+
<Select aria-label="Project" data={projects} onChange={(value) => { setProject(value ?? ""); setCompletedLimit(5); }} value={project} size="xs" />
|
|
189
|
+
<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} size="xs" />
|
|
190
|
+
</Group>
|
|
191
|
+
{view === "list" && <Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>}
|
|
192
|
+
</Stack>
|
|
193
|
+
</Paper>
|
|
194
|
+
{view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {tasks.length}</Text>}
|
|
195
|
+
{error && <Alert color="yellow" role="alert" mt="sm">{error}</Alert>}
|
|
196
|
+
{notice && !opened && <Alert color="teal" role="status" mt="sm">{notice}</Alert>}
|
|
197
|
+
{view === "board" ? <TaskBoard completedLimit={completedLimit} onMoreCompleted={() => setCompletedLimit((limit) => limit + 5)} onOpenCodex={(task) => void openChat(task)} onOpenDetail={(task) => void select(task)} tasks={boardTasks} />
|
|
198
|
+
: <Stack aria-describedby="task-results-summary" aria-label="Tasks" className="taskchef-list" component="section" gap="sm" mt="xs">
|
|
199
|
+
{visible.map((task) => <TaskCard key={task.id} onOpenCodex={(item) => void openChat(item)} onOpenDetail={(item) => void select(item)} task={task} />)}
|
|
200
|
+
{visible.length === 0 && <Paper className="taskchef-empty" p="lg" ta="center" withBorder><Title order={2} size="h5">No tasks match these filters</Title><Text c="dimmed" size="sm">Choose a different project, update window, or status.</Text></Paper>}
|
|
201
|
+
</Stack>}
|
|
202
|
+
</main>
|
|
203
|
+
</Box>
|
|
204
|
+
<TaskDetail busy={busy} error={detailError} highlightTurnRef={null} onClose={closeDetail} onCopy={() => {
|
|
205
|
+
if (!selected) return;
|
|
206
|
+
void navigator.clipboard.writeText(selected.id).then(() => setNotice("Task ID copied."), () => setNotice("Clipboard unavailable. Copy the ID from metadata."));
|
|
207
|
+
}} onOpenCodex={() => selected && void openChat(selected)} onTransition={transition} opened={opened} task={selected} notice={notice} />
|
|
208
|
+
</RelativeTimeProvider>
|
|
209
|
+
</MantineProvider>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const root = document.getElementById("root");
|
|
213
|
+
if (root) createRoot(root).render(<TaskChefApp />);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
:root { color-scheme: dark; --taskchef-page-gutter: 12px; }
|
|
2
|
+
html, body, #root { min-width: 0; min-height: 100%; }
|
|
3
|
+
body { background: var(--taskchef-page); }
|
|
4
|
+
.taskchef-app-shell { min-height: 100vh; background: var(--taskchef-page); }
|
|
5
|
+
.taskchef-app-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
|
|
6
|
+
.taskchef-app-header h1 { color: var(--taskchef-accent); font-size: 13px; font-weight: 750; letter-spacing: .025em; line-height: 1.1; text-transform: uppercase; }
|
|
7
|
+
.taskchef-app-header h1 span { color: var(--taskchef-muted); font-weight: 600; }
|
|
8
|
+
.taskchef-app-mark { display: block; flex: none; width: 25px; height: 25px; }
|
|
9
|
+
.taskchef-app-main { min-width: 0; padding: 6px 12px 28px; }
|
|
10
|
+
.taskchef-app-main-list { max-width: 624px; margin-inline: auto; }
|
|
11
|
+
.taskchef-app-main .taskchef-toolbar { margin-bottom: 8px; padding-bottom: 10px; }
|
|
12
|
+
.taskchef-app-main .mantine-SegmentedControl-root { max-width: 100%; }
|
|
13
|
+
.taskchef-app-view { width: max-content; }
|
|
14
|
+
.taskchef-app-filters { min-width: 0; width: min(100%, 488px); }
|
|
15
|
+
.taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1 0; width: 0; min-width: 0; max-width: 240px; }
|
|
16
|
+
.taskchef-app-status { max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
|
17
|
+
.taskchef-app-status .mantine-SegmentedControl-root { width: max-content; min-width: max-content; }
|
|
18
|
+
.taskchef-app-status .mantine-SegmentedControl-label { padding-inline: 9px; font-size: 11px; white-space: nowrap; }
|
|
19
|
+
.taskchef-app-main .taskchef-results-summary { margin: 4px 0 12px; }
|
|
20
|
+
.taskchef-app-main .taskchef-board { grid-auto-columns: min(300px, calc(100% - 24px)); gap: 12px; min-height: 400px; padding-bottom: 12px; }
|
|
21
|
+
.taskchef-app-main .taskchef-board-lane-heading { padding-inline: 4px; }
|
|
22
|
+
.taskchef-app-main .taskchef-task-row { padding-inline: 10px; }
|
|
23
|
+
.taskchef-app-main .taskchef-card-heading { gap: 5px; }
|
|
24
|
+
.taskchef-app-main .taskchef-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
25
|
+
.taskchef-app-main .taskchef-stat { padding: 6px 3px; }
|
|
26
|
+
.taskchef-app-main .taskchef-stat:nth-child(3) { border-inline-start: 0; }
|
|
27
|
+
.taskchef-app-main .taskchef-stat:nth-child(n + 3) { border-top: 1px solid var(--taskchef-border); }
|
|
28
|
+
.taskchef-app-main .taskchef-list-card-footer { align-items: center; }
|
|
29
|
+
.taskchef-app-main .taskchef-board-card { min-height: 140px; }
|
|
30
|
+
@media (max-width: 360px) {
|
|
31
|
+
.taskchef-app-main { padding-inline: 8px; }
|
|
32
|
+
.taskchef-app-filters { flex-wrap: wrap; }
|
|
33
|
+
.taskchef-app-filters .mantine-InputWrapper-root { flex-basis: 100%; max-width: none; }
|
|
34
|
+
.taskchef-app-main .taskchef-board { grid-auto-columns: min(300px, calc(100% - 18px)); }
|
|
35
|
+
}
|
package/src/mcp-app.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DashboardMonitor, UsageSummaryMonitor } from "./dashboard.js";
|
|
5
|
+
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
6
|
+
import { createUsageTracker } from "./usage-tracker.js";
|
|
7
|
+
import { isCodexThreadDeepLinkId, openThreadInCodex, openWorkspaceInCodex } from "./codex-app.js";
|
|
8
|
+
import { canonicalDirectory, canonicalGitRoot, manuallyTransitionTask, readConfig } from "./workspace.js";
|
|
9
|
+
|
|
10
|
+
export const TASKCHEF_APP_URI = "ui://taskchef/task-board/v2";
|
|
11
|
+
const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
12
|
+
const htmlPath = fileURLToPath(new URL("./mcp-app/dist/index.html", import.meta.url));
|
|
13
|
+
const taskIdSchema = z.string().regex(/^[a-zA-Z0-9._-]+$/).max(512);
|
|
14
|
+
const expectedSchema = z.object({
|
|
15
|
+
status: z.enum(["working", "needs_input", "completed", "failed"]),
|
|
16
|
+
turnRef: z.string().nullable(),
|
|
17
|
+
threadId: z.string().nullable(),
|
|
18
|
+
updatedAt: z.string().min(1),
|
|
19
|
+
}).strict();
|
|
20
|
+
|
|
21
|
+
export function registerTaskChefApp(server, {
|
|
22
|
+
workspace,
|
|
23
|
+
createMonitor = (root) => new DashboardMonitor(root),
|
|
24
|
+
createUsageSummaryMonitor = (root) => new UsageSummaryMonitor(root),
|
|
25
|
+
createTaskUsageTracker = (root) => createUsageTracker({ workspace: root }),
|
|
26
|
+
transition = manuallyTransitionTask,
|
|
27
|
+
openThread = openThreadInCodex,
|
|
28
|
+
openProject = openWorkspaceInCodex,
|
|
29
|
+
readConfiguration = readConfig,
|
|
30
|
+
} = {}) {
|
|
31
|
+
let monitor;
|
|
32
|
+
let usageSummaryMonitor;
|
|
33
|
+
let usageTracker;
|
|
34
|
+
let starting;
|
|
35
|
+
let appRevision = 0;
|
|
36
|
+
async function currentMonitor() {
|
|
37
|
+
if (!monitor) {
|
|
38
|
+
starting ??= (async () => {
|
|
39
|
+
const candidate = createMonitor(workspace);
|
|
40
|
+
try {
|
|
41
|
+
await candidate.start();
|
|
42
|
+
const summary = createUsageSummaryMonitor(candidate.workspace);
|
|
43
|
+
try { await summary.start(); }
|
|
44
|
+
catch (error) { summary.close(); throw error; }
|
|
45
|
+
usageSummaryMonitor = summary;
|
|
46
|
+
usageTracker = createTaskUsageTracker(candidate.workspace);
|
|
47
|
+
appRevision = 1;
|
|
48
|
+
candidate.on?.("snapshot", () => { appRevision += 1; });
|
|
49
|
+
summary.on?.("change", () => { appRevision += 1; });
|
|
50
|
+
monitor = candidate;
|
|
51
|
+
return candidate;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
candidate.close();
|
|
54
|
+
usageSummaryMonitor?.close();
|
|
55
|
+
usageTracker?.close?.();
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
})().finally(() => { starting = null; });
|
|
59
|
+
return starting;
|
|
60
|
+
}
|
|
61
|
+
await monitor.refresh();
|
|
62
|
+
return monitor;
|
|
63
|
+
}
|
|
64
|
+
const originalClose = server.close.bind(server);
|
|
65
|
+
server.close = async () => {
|
|
66
|
+
monitor?.close();
|
|
67
|
+
usageSummaryMonitor?.close();
|
|
68
|
+
usageTracker?.close?.();
|
|
69
|
+
await originalClose();
|
|
70
|
+
};
|
|
71
|
+
const appOnly = { ui: { resourceUri: TASKCHEF_APP_URI, visibility: ["app"] } };
|
|
72
|
+
function appSnapshot() {
|
|
73
|
+
const snapshot = monitor.snapshot();
|
|
74
|
+
return { ...snapshot, revision: appRevision, tasks: snapshot.tasks.map((task) => ({
|
|
75
|
+
...task,
|
|
76
|
+
usage: usageSummaryMonitor.project(task),
|
|
77
|
+
})) };
|
|
78
|
+
}
|
|
79
|
+
server.registerResource("TaskChef task board", TASKCHEF_APP_URI, {
|
|
80
|
+
description: "TaskChef sidebar task board",
|
|
81
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
82
|
+
}, async () => ({ contents: [{
|
|
83
|
+
uri: TASKCHEF_APP_URI,
|
|
84
|
+
mimeType: RESOURCE_MIME_TYPE,
|
|
85
|
+
text: await readFile(htmlPath, "utf8"),
|
|
86
|
+
}] }));
|
|
87
|
+
server.registerTool("open_taskchef_board", {
|
|
88
|
+
title: process.env.TASKCHEF_PREVIEW === "1" ? "TaskChef Preview" : "TaskChef",
|
|
89
|
+
description: "Show the local TaskChef task board in the Codex sidebar.",
|
|
90
|
+
inputSchema: {},
|
|
91
|
+
_meta: { ui: { resourceUri: TASKCHEF_APP_URI }, "openai/ui": { entrypoints: [{ type: "global" }] } },
|
|
92
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
93
|
+
}, async () => {
|
|
94
|
+
await currentMonitor();
|
|
95
|
+
const snapshot = appSnapshot();
|
|
96
|
+
return { structuredContent: { taskCount: snapshot.tasks.length }, content: [{ type: "text", text: `TaskChef board: ${snapshot.tasks.length} tasks.` }] };
|
|
97
|
+
});
|
|
98
|
+
server.registerTool("taskchef_app_snapshot", {
|
|
99
|
+
title: "Refresh TaskChef board", description: "Read the local task board.",
|
|
100
|
+
inputSchema: { revision: z.number().int().nonnegative().optional() }, _meta: appOnly,
|
|
101
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
102
|
+
}, async ({ revision }) => {
|
|
103
|
+
await currentMonitor();
|
|
104
|
+
void Promise.resolve(usageTracker.preload?.(monitor.tasks)).catch(() => {});
|
|
105
|
+
if (revision !== undefined && revision === appRevision && monitor.unhealthy === false) {
|
|
106
|
+
return { structuredContent: { unchanged: true, revision: appRevision }, content: [] };
|
|
107
|
+
}
|
|
108
|
+
return { structuredContent: { snapshot: appSnapshot() }, content: [] };
|
|
109
|
+
});
|
|
110
|
+
server.registerTool("taskchef_app_task", {
|
|
111
|
+
title: "Read TaskChef task", description: "Read one task's details.",
|
|
112
|
+
inputSchema: { taskId: taskIdSchema }, _meta: appOnly,
|
|
113
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
114
|
+
}, async ({ taskId }) => {
|
|
115
|
+
const task = (await currentMonitor()).tasks.find((candidate) => candidate.id === taskId);
|
|
116
|
+
if (!task) throw new Error("Task not found.");
|
|
117
|
+
const usage = await usageTracker.get(task).catch(() => ({
|
|
118
|
+
status: "unavailable", reason: "Task usage is temporarily unavailable.", task: null, turns: {},
|
|
119
|
+
}));
|
|
120
|
+
return { structuredContent: { task: { ...task, ...taskGitHubProjection(task), usage } }, content: [] };
|
|
121
|
+
});
|
|
122
|
+
server.registerTool("taskchef_app_transition", {
|
|
123
|
+
title: "Mark TaskChef task", description: "Apply a confirmed manual status transition.",
|
|
124
|
+
inputSchema: {
|
|
125
|
+
taskId: taskIdSchema,
|
|
126
|
+
actionId: z.string().uuid(),
|
|
127
|
+
expected: expectedSchema,
|
|
128
|
+
targetStatus: z.enum(["completed", "failed"]),
|
|
129
|
+
}, _meta: appOnly,
|
|
130
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
131
|
+
}, async ({ taskId, actionId, expected, targetStatus }) => {
|
|
132
|
+
const result = await transition(workspace, taskId, { actionId, expected, targetStatus });
|
|
133
|
+
await (await currentMonitor()).refresh({ force: true });
|
|
134
|
+
return { structuredContent: { task: { ...result.task, ...taskGitHubProjection(result.task) }, idempotent: result.idempotent }, content: [] };
|
|
135
|
+
});
|
|
136
|
+
server.registerTool("taskchef_app_open_chat", {
|
|
137
|
+
title: "Open TaskChef chat", description: "Open the task's Codex chat or configured project.",
|
|
138
|
+
inputSchema: { taskId: taskIdSchema }, _meta: appOnly,
|
|
139
|
+
annotations: { readOnlyHint: false, openWorldHint: false },
|
|
140
|
+
}, async ({ taskId }) => {
|
|
141
|
+
const task = (await currentMonitor()).tasks.find((candidate) => candidate.id === taskId);
|
|
142
|
+
if (!task) throw new Error("Task not found.");
|
|
143
|
+
if (isCodexThreadDeepLinkId(task.threadId)) {
|
|
144
|
+
await openThread(task.threadId);
|
|
145
|
+
return { structuredContent: { message: "Opened chat in Codex." }, content: [] };
|
|
146
|
+
}
|
|
147
|
+
const trusted = (await readConfiguration(workspace, { checkPaths: false })).projects
|
|
148
|
+
.find((project) => project.path === task.project.path);
|
|
149
|
+
if (!trusted) throw new Error("This historical task no longer matches a configured project.");
|
|
150
|
+
const canonicalPath = trusted.isGitRepository
|
|
151
|
+
? await canonicalGitRoot(trusted.path) : await canonicalDirectory(trusted.path);
|
|
152
|
+
if (canonicalPath !== trusted.path) throw new Error("This configured project has moved.");
|
|
153
|
+
await openProject(canonicalPath);
|
|
154
|
+
return { structuredContent: { message: "Opened project in Codex. Select the recorded thread there." }, content: [] };
|
|
155
|
+
});
|
|
156
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createDashboardManager } from "./dashboard-manager.js";
|
|
|
18
18
|
import { resolveWorkspacePath } from "./workspace-path.js";
|
|
19
19
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
20
20
|
import { createUsageTracker } from "./usage-tracker.js";
|
|
21
|
+
import { registerTaskChefApp } from "./mcp-app.js";
|
|
21
22
|
|
|
22
23
|
const projectSchema = z.object({
|
|
23
24
|
name: z.string(),
|
|
@@ -518,6 +519,8 @@ export function createTaskChefMcpServer({
|
|
|
518
519
|
},
|
|
519
520
|
);
|
|
520
521
|
|
|
522
|
+
registerTaskChefApp(server, { workspace });
|
|
523
|
+
|
|
521
524
|
const originalConnect = server.connect.bind(server);
|
|
522
525
|
const autostartDashboard = createDashboardAutostart({
|
|
523
526
|
workspace,
|