taskchef 9.3.0 → 9.5.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 +88 -5
- package/package.json +1 -1
- package/src/calendar-history.js +30 -0
- package/src/codex-rollout.js +3 -3
- package/src/codex-session-scanner.js +61 -4
- package/src/codex-session-usage.js +54 -20
- package/src/mcp-app/dist/index.html +145 -138
- package/src/mcp-app/react/CalendarView.test.tsx +59 -0
- package/src/mcp-app/react/CalendarView.tsx +91 -0
- package/src/mcp-app/react/UsageView.test.tsx +124 -0
- package/src/mcp-app/react/UsageView.tsx +129 -0
- package/src/mcp-app/react/main.test.tsx +137 -2
- package/src/mcp-app/react/main.tsx +29 -15
- package/src/mcp-app/react/preferences.ts +34 -0
- package/src/mcp-app/react/styles.css +86 -11
- package/src/mcp-app.js +46 -0
- package/src/usage-analytics.js +47 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { usePreference } from "./preferences";
|
|
2
|
+
import { CalendarView, type CalendarData, type CalendarRequest } from "./CalendarView";
|
|
1
3
|
import { cachedImageLoader } from "../../ui/image-cache";
|
|
2
4
|
import { App } from "@modelcontextprotocol/ext-apps";
|
|
3
5
|
import { ActionIcon, Alert, Box, Button, Group, MantineProvider, Modal, createTheme, Paper, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
|
4
|
-
import { IconPin, IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
|
|
6
|
+
import { IconCalendarEvent, IconPin, IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react";
|
|
5
7
|
import { createRoot } from "react-dom/client";
|
|
6
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
7
9
|
import type { DashboardSnapshot, Task, Project } from "../../ui/types";
|
|
@@ -14,6 +16,7 @@ import { NextNotificationCenter, type NextNotification, type NextNotificationSta
|
|
|
14
16
|
import { GitHubConnection, type GitHubAuth } from "./GitHubConnection";
|
|
15
17
|
import { AppearancePicker } from "./AppearancePicker";
|
|
16
18
|
import { savedVisualTheme, themeVariables, visualTheme, VISUAL_THEME_KEY } from "./themes";
|
|
19
|
+
import { UsageView, type UsageResponse, type usageRequest } from "./UsageView";
|
|
17
20
|
import { ProjectPicker } from "./ProjectPicker";
|
|
18
21
|
import brandIcon from "../../../assets/taskchef-dark.svg";
|
|
19
22
|
import "@mantine/core/styles.css";
|
|
@@ -40,6 +43,9 @@ async function call<T>(name: string, args: Record<string, unknown> = {}): Promis
|
|
|
40
43
|
return result.structuredContent as T;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
const readCalendar: CalendarRequest = args => call<CalendarData>("taskchef_app_calendar", args);
|
|
47
|
+
const readUsage = (args: ReturnType<typeof usageRequest>) => call<UsageResponse>("taskchef_app_usage", args);
|
|
48
|
+
|
|
43
49
|
const THEME_KEY = "taskchef.app.theme";
|
|
44
50
|
type ThemeChoice = "dark" | "light" | "system";
|
|
45
51
|
function initialTheme(): ThemeChoice {
|
|
@@ -113,7 +119,7 @@ export function TaskChefApp() {
|
|
|
113
119
|
const [scan, setScan] = useState<ScanStats | null>(null);
|
|
114
120
|
const [selected, setSelected] = useState<Task | null>(null);
|
|
115
121
|
const [opened, setOpened] = useState(false);
|
|
116
|
-
const [pinnedOnly, setPinnedOnly] =
|
|
122
|
+
const [pinnedOnly, setPinnedOnly] = usePreference("filter.pinned");
|
|
117
123
|
const [busy, setBusy] = useState(false);
|
|
118
124
|
const [error, setError] = useState<string | null>(null);
|
|
119
125
|
|
|
@@ -155,12 +161,14 @@ export function TaskChefApp() {
|
|
|
155
161
|
receiveNotifications(result.notifications);
|
|
156
162
|
} catch (notificationError) { setError(`${String(cause)}. ${String(notificationError)}`); }
|
|
157
163
|
}
|
|
158
|
-
const [project, setProject] =
|
|
159
|
-
const [search, setSearch] =
|
|
160
|
-
const [
|
|
164
|
+
const [project, setProject] = usePreference("filter.project");
|
|
165
|
+
const [search, setSearch] = usePreference("filter.search");
|
|
166
|
+
const [showScheduled, setShowScheduled] = usePreference("calendar.scheduled");
|
|
167
|
+
const [date, setDate] = usePreference("filter.updated");
|
|
161
168
|
const [status, setStatus] = useState("");
|
|
162
|
-
// Keep the list
|
|
163
|
-
const [view, setView] = useState<"board" | "list">("board");
|
|
169
|
+
// Keep the legacy list hidden; Board and Usage share the native shell.
|
|
170
|
+
const [view, setView] = useState<"board" | "list" | "usage" | "calendar">("board");
|
|
171
|
+
const [usageRefreshKey, setUsageRefreshKey] = useState(0);
|
|
164
172
|
const [completedLimit, setCompletedLimit] = useState(5);
|
|
165
173
|
const [archivedLimit, setArchivedLimit] = useState(5);
|
|
166
174
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -172,6 +180,7 @@ export function TaskChefApp() {
|
|
|
172
180
|
const detailRequestVersion = useRef(0);
|
|
173
181
|
const refreshVersion = useRef(0);
|
|
174
182
|
const revisionRef = useRef<number | null>(null);
|
|
183
|
+
const calendarHistoryRevision = useRef(0);
|
|
175
184
|
const displayModeRef = useRef(displayMode);
|
|
176
185
|
const previousDisplayMode = useRef(displayMode);
|
|
177
186
|
displayModeRef.current = displayMode;
|
|
@@ -206,6 +215,7 @@ export function TaskChefApp() {
|
|
|
206
215
|
return;
|
|
207
216
|
}
|
|
208
217
|
} else { setScan(data.scan); setError(null); }
|
|
218
|
+
if ((data.snapshot?.scan ?? data.scan)?.cacheHit === false) calendarHistoryRevision.current += 1;
|
|
209
219
|
setNow(Date.now());
|
|
210
220
|
const selectedTask = selectedRef.current;
|
|
211
221
|
if (!selectedTask || initialSelection !== selectionVersion.current) return;
|
|
@@ -295,7 +305,6 @@ export function TaskChefApp() {
|
|
|
295
305
|
}, [displayMode, refresh]);
|
|
296
306
|
const eligibleTasks = useMemo(() => tasks.filter((task) => eligibleForView(task, showCli, showArchived, showExec)), [tasks, showCli, showArchived, showExec]);
|
|
297
307
|
useEffect(() => {
|
|
298
|
-
setProject("");
|
|
299
308
|
setCompletedLimit(5);
|
|
300
309
|
setArchivedLimit(5);
|
|
301
310
|
if (!showArchived) setStatus((current) => current === "archived" ? "" : current);
|
|
@@ -326,12 +335,13 @@ export function TaskChefApp() {
|
|
|
326
335
|
{ label: "No project", value: "projectless" },
|
|
327
336
|
];
|
|
328
337
|
}, [eligibleTasks, registeredProjects]);
|
|
329
|
-
useEffect(() => { if (project && !projects.some((item) => item.value === project)) setProject(""); }, [project, projects]);
|
|
338
|
+
useEffect(() => { if (!loading && !error && project && !projects.some((item) => item.value === project)) setProject(""); }, [project, projects, loading, error]);
|
|
330
339
|
const projectTasks = useMemo(() => eligibleTasks.filter((task) => !project || (task.project.id || task.project.path || task.project.name) === project), [eligibleTasks, project]);
|
|
331
340
|
const boardTasks: Task[] = useMemo(() => {
|
|
332
341
|
const terms = search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
|
333
|
-
return filterTasks(projectTasks, { date, now }).filter((task: Task) => {
|
|
342
|
+
return filterTasks(projectTasks, { date: view === "calendar" ? "all" : date, now }).filter((task: Task) => {
|
|
334
343
|
if (pinnedOnly && !task.observed?.pinned) return false;
|
|
344
|
+
if (view === "calendar" && !showScheduled && task.scheduled) return false;
|
|
335
345
|
const content = [
|
|
336
346
|
task.title, task.promptExcerpt ?? "", task.replyExcerpt ?? "", task.project.name,
|
|
337
347
|
...(task.project.githubRepos ?? []),
|
|
@@ -339,7 +349,7 @@ export function TaskChefApp() {
|
|
|
339
349
|
].join("\n").toLocaleLowerCase();
|
|
340
350
|
return terms.every((term) => content.includes(term));
|
|
341
351
|
});
|
|
342
|
-
}, [projectTasks, date, now, search, pinnedOnly]);
|
|
352
|
+
}, [projectTasks, date, now, search, pinnedOnly, view, showScheduled]);
|
|
343
353
|
const visible = useMemo(() => boardTasks.filter((task) => !status || (task.status ?? "unverified") === status), [boardTasks, status]);
|
|
344
354
|
const statusOptions = [{ label: `All ${boardTasks.length}`, value: "" }, ...lanes.map(({ label, status: value }) => ({
|
|
345
355
|
label: `${label} ${boardTasks.filter((task) => task.status === value).length}`, value: value ?? "unverified",
|
|
@@ -420,11 +430,13 @@ export function TaskChefApp() {
|
|
|
420
430
|
<Box className={`taskchef-app-shell${displayMode === "inline" ? " taskchef-app-inline" : ""}`}>
|
|
421
431
|
<header className="taskchef-app-header">
|
|
422
432
|
<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>
|
|
423
|
-
{displayMode !== "inline" && <
|
|
433
|
+
{displayMode !== "inline" && <SegmentedControl className="taskchef-app-view" aria-label="View" withItemsBorders={false} data={[{ label: "Board", value: "board" }, { label: "Calendar", value: "calendar" }, { label: "Usage", value: "usage" }]} value={view} onChange={value => setView(value === "usage" || value === "calendar" ? value : "board")} size="xs" />}
|
|
434
|
+
{displayMode !== "inline" && view !== "usage" && <Group className="taskchef-app-filters" gap="xs" wrap="wrap">
|
|
424
435
|
<ProjectPicker data={projects} onChange={(value) => { setProject(value); setCompletedLimit(5); }} value={project} />
|
|
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" />
|
|
436
|
+
{view !== "calendar" && <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" />}
|
|
426
437
|
<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-
|
|
438
|
+
<Button className="taskchef-app-toggle" 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>
|
|
439
|
+
{view === "calendar" && <Button className="taskchef-app-toggle" leftSection={<IconCalendarEvent size={14} aria-hidden />} aria-pressed={showScheduled} title="Show scheduled chats" size="xs" variant={showScheduled ? "filled" : "outline"} onClick={() => setShowScheduled(value => !value)}>Scheduled</Button>}
|
|
428
440
|
</Group>}
|
|
429
441
|
<Group className="taskchef-app-actions" gap="xs" wrap="nowrap">
|
|
430
442
|
<GitHubConnection hideTrigger openSignal={githubOpenSignal} auth={githubAuth} request={githubRequest} openLink={githubOpenLink} refresh={githubRefresh} openSettings={githubOpenSettings} />
|
|
@@ -433,7 +445,7 @@ export function TaskChefApp() {
|
|
|
433
445
|
try { await call("taskchef_app_open_settings", {}); }
|
|
434
446
|
catch (cause) { await actionError("settings", cause); }
|
|
435
447
|
})()} variant="subtle"><IconSettings size={17} /></ActionIcon>
|
|
436
|
-
<ActionIcon aria-label="Refresh" onClick={() => void refresh(true).catch((cause) => setError(String(cause)))} variant="subtle"><IconRefresh size={17} /></ActionIcon>
|
|
448
|
+
<ActionIcon aria-label="Refresh" onClick={() => { setUsageRefreshKey(value => value + 1); void refresh(true).catch((cause) => setError(String(cause))); }} variant="subtle"><IconRefresh size={17} /></ActionIcon>
|
|
437
449
|
<AppearancePicker value={themeChoice} onChange={chooseTheme} visualThemeId={selectedTheme.id} appearance={colorScheme} onVisualThemeChange={chooseVisualTheme} />
|
|
438
450
|
</Group>
|
|
439
451
|
</header>
|
|
@@ -449,6 +461,7 @@ export function TaskChefApp() {
|
|
|
449
461
|
{view === "list" && <Paper className="taskchef-toolbar" radius={0}>
|
|
450
462
|
<Box className="taskchef-app-status"><SegmentedControl aria-label="Status" data={statusOptions} onChange={setStatus} size="xs" value={status} withItemsBorders={false} /></Box>
|
|
451
463
|
</Paper>}
|
|
464
|
+
{view === "calendar" ? <CalendarView request={readCalendar} showProject={!project} tasks={boardTasks} refreshKey={`${revisionRef.current}:${calendarHistoryRevision.current}:${new Date(now).toDateString()}:${usageRefreshKey}:${showCli}:${showExec}:${showArchived}`} onOpenDetail={task => void select(task)} /> : view === "usage" ? <UsageView key={`${showCli}:${showExec}`} request={readUsage} refreshKey={usageRefreshKey} /> : <>
|
|
452
465
|
{!!scan?.scheduleErrors && <Alert color="yellow" role="alert">{scan.scheduleErrors} schedule files could not be read; schedule placement may be incomplete.</Alert>}
|
|
453
466
|
{view === "list" && <Text aria-live="polite" className="taskchef-results-summary" id="task-results-summary">Tasks: {visible.length} of {eligibleTasks.length}</Text>}
|
|
454
467
|
{error && <Alert color="red" role="alert" mt="sm">{error}</Alert>}
|
|
@@ -457,6 +470,7 @@ export function TaskChefApp() {
|
|
|
457
470
|
{visible.map((task) => <TaskCard key={task.id} onOpenCodex={(item) => void openChat(item)} onOpenDetail={(item) => void select(item)} task={task} />)}
|
|
458
471
|
{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>}
|
|
459
472
|
</Stack>)}
|
|
473
|
+
</>}
|
|
460
474
|
</main>
|
|
461
475
|
</>}
|
|
462
476
|
{taskchefVersion && displayMode !== "inline" && <footer className="taskchef-app-version" aria-label="TaskChef version">v{taskchefVersion}</footer>}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Preferences = {
|
|
4
|
+
"filter.project": string;
|
|
5
|
+
"filter.search": string;
|
|
6
|
+
"filter.updated": string;
|
|
7
|
+
"filter.pinned": boolean;
|
|
8
|
+
"calendar.scheduled": boolean;
|
|
9
|
+
"usage.range": "7d" | "30d" | "90d" | "mtd" | "last-month";
|
|
10
|
+
};
|
|
11
|
+
const defaults: Preferences = {
|
|
12
|
+
"filter.project": "", "filter.search": "", "filter.updated": "all",
|
|
13
|
+
"filter.pinned": false, "calendar.scheduled": true, "usage.range": "30d",
|
|
14
|
+
};
|
|
15
|
+
function readPreference<K extends keyof Preferences>(key: K): Preferences[K] {
|
|
16
|
+
try {
|
|
17
|
+
const value: unknown = JSON.parse(window.localStorage.getItem(`taskchef.app.${key}`) ?? "null");
|
|
18
|
+
if (typeof value !== typeof defaults[key]) return defaults[key];
|
|
19
|
+
if (key === "filter.updated" && !["all", "24h", "7d"].includes(value as string)) return defaults[key];
|
|
20
|
+
if (key === "usage.range" && !["7d", "30d", "90d", "mtd", "last-month"].includes(value as string)) return defaults[key];
|
|
21
|
+
return value as Preferences[K];
|
|
22
|
+
} catch { return defaults[key]; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The six view preferences share the same local storage behavior as Appearance.
|
|
26
|
+
// Unavailable storage leaves controls usable for the current mounted view.
|
|
27
|
+
export function usePreference<K extends keyof Preferences>(key: K) {
|
|
28
|
+
const [value, setValue] = useState<Preferences[K]>(() => readPreference(key));
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
try { window.localStorage.setItem(`taskchef.app.${key}`, JSON.stringify(value)); }
|
|
31
|
+
catch { /* Keep the user's current choice in memory. */ }
|
|
32
|
+
}, [key, value]);
|
|
33
|
+
return [value, setValue] as const;
|
|
34
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
html, body, #root { min-width: 0; min-height: 100%; }
|
|
16
16
|
body { background: var(--taskchef-page); }
|
|
17
17
|
.taskchef-app-shell { min-height: 100vh; background: var(--taskchef-page); }
|
|
18
|
-
.taskchef-app-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
|
|
18
|
+
.taskchef-app-header { display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto; grid-template-areas: "brand view filters actions"; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--taskchef-border); }
|
|
19
19
|
.taskchef-app-brand { grid-area: brand; }
|
|
20
20
|
.taskchef-app-actions { grid-area: actions; }
|
|
21
21
|
.taskchef-app-header h1 { color: var(--taskchef-text); font-size: 14px; font-weight: 600; line-height: 1.4; white-space: nowrap; }
|
|
@@ -26,14 +26,18 @@ body { background: var(--taskchef-page); }
|
|
|
26
26
|
.taskchef-app-main-list { max-width: 624px; margin-inline: auto; }
|
|
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
|
-
.taskchef-app-view { width: max-content; }
|
|
30
|
-
.taskchef-app-filters { grid-area: filters; min-width: 0; width: min(100%, 680px); }
|
|
29
|
+
.taskchef-app-view { grid-area: view; width: max-content; }
|
|
30
|
+
.taskchef-app-filters { grid-area: filters; border-inline-start: 1px solid var(--taskchef-border); padding-inline-start: 12px; min-width: 0; width: min(100%, 680px); }
|
|
31
31
|
.taskchef-app-filters .mantine-InputWrapper-root { flex: 1 1 100px; min-width: 100px; }
|
|
32
|
-
.taskchef-app-
|
|
32
|
+
.taskchef-app-toggle { flex: 0 0 auto; white-space: nowrap; }
|
|
33
33
|
.taskchef-app-filters .taskchef-app-date { flex: 0 0 132px; }
|
|
34
|
-
@media (max-width:
|
|
35
|
-
.taskchef-app-header { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "brand actions" "filters filters"; gap: 8px; }
|
|
36
|
-
.taskchef-app-filters { width: 100%; }
|
|
34
|
+
@media (max-width: 1000px) {
|
|
35
|
+
.taskchef-app-header { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "brand view actions" "filters filters filters"; gap: 8px; }
|
|
36
|
+
.taskchef-app-filters { width: 100%; border-inline-start: 0; padding-inline-start: 0; border-top: 1px solid var(--taskchef-border); padding-top: 8px; }
|
|
37
|
+
}
|
|
38
|
+
@media (max-width: 500px) {
|
|
39
|
+
.taskchef-app-header { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "brand view" "actions actions" "filters filters"; }
|
|
40
|
+
.taskchef-app-actions { justify-self: end; }
|
|
37
41
|
}
|
|
38
42
|
.taskchef-app-status { max-width: 100%; overflow-x: auto; scrollbar-width: thin; }
|
|
39
43
|
.taskchef-app-status .mantine-SegmentedControl-root { width: max-content; min-width: max-content; }
|
|
@@ -107,7 +111,7 @@ body { background: var(--taskchef-page); }
|
|
|
107
111
|
.taskchef-reply-cover { background: var(--taskchef-page); }
|
|
108
112
|
|
|
109
113
|
@media (max-width: 380px) {
|
|
110
|
-
.taskchef-app-header { grid-template-areas: "brand
|
|
114
|
+
.taskchef-app-header { grid-template-areas: "brand view" "actions actions" "filters filters"; }
|
|
111
115
|
.taskchef-app-inline .taskchef-app-header { grid-template-areas: "brand brand" "actions actions"; }
|
|
112
116
|
.taskchef-app-actions { justify-self: end; }
|
|
113
117
|
}
|
|
@@ -190,9 +194,9 @@ body { background: var(--taskchef-page); }
|
|
|
190
194
|
.next-appearance-panel { background: var(--taskchef-elevated); }
|
|
191
195
|
|
|
192
196
|
:root[data-taskchef-palette] .mantine-Button-root[data-variant="filled"] { color: var(--taskchef-surface); }
|
|
193
|
-
:root[data-taskchef-palette] .taskchef-app-
|
|
194
|
-
:root[data-taskchef-palette] .taskchef-app-
|
|
195
|
-
:root[data-taskchef-palette] .taskchef-app-
|
|
197
|
+
:root[data-taskchef-palette] .taskchef-app-toggle { background: transparent; color: var(--taskchef-text); border-color: var(--taskchef-border); }
|
|
198
|
+
:root[data-taskchef-palette] .taskchef-app-toggle:hover { background: var(--taskchef-hover); }
|
|
199
|
+
:root[data-taskchef-palette] .taskchef-app-toggle[aria-pressed="true"] { background: var(--taskchef-accent); color: var(--taskchef-surface); border-color: var(--taskchef-accent); }
|
|
196
200
|
:root[data-taskchef-palette] .taskchef-board-card:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
|
|
197
201
|
|
|
198
202
|
:root[data-taskchef-palette] body { font-family: var(--taskchef-font); }
|
|
@@ -221,3 +225,74 @@ body { background: var(--taskchef-page); }
|
|
|
221
225
|
.taskchef-turn-error-compact { gap: 6px; margin-top: 8px; padding: 0; border: 0; border-radius: 0; background: transparent; font-size: var(--mantine-font-size-xs); }
|
|
222
226
|
|
|
223
227
|
.taskchef-turn-error-compact > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
|
|
228
|
+
|
|
229
|
+
.usage-view { max-width: 1100px; margin: 0 auto; color: var(--taskchef-text); }
|
|
230
|
+
.usage-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
|
231
|
+
.usage-view button:disabled { opacity: .5; cursor: default; }
|
|
232
|
+
.usage-view h2 { font-size: 24px; margin: 4px 0 8px; }
|
|
233
|
+
.usage-view h3 { font-size: 14px; margin: 0 0 12px; }
|
|
234
|
+
.usage-view p, .usage-view small { color: var(--taskchef-muted); font-size: 12px; line-height: 1.5; margin: 8px 0; }
|
|
235
|
+
.usage-ranges, .usage-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0; }
|
|
236
|
+
.usage-view button { font: inherit; font-size: 12px; color: var(--taskchef-text); background: transparent; border: 1px solid var(--taskchef-border); border-radius: 6px; padding: 6px 10px; cursor: pointer; }
|
|
237
|
+
.usage-view button:hover, .usage-view button[aria-pressed="true"] { background: var(--taskchef-hover); border-color: var(--taskchef-muted); }
|
|
238
|
+
.usage-view button:focus-visible, .usage-chart g:focus-visible { outline: 2px solid var(--taskchef-focus); outline-offset: 2px; }
|
|
239
|
+
.usage-totals { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 16px 0; }
|
|
240
|
+
.usage-totals article, .usage-panel { padding: 18px; border: 1px solid var(--taskchef-border); border-radius: 10px; background: var(--taskchef-surface); min-width: 0; }
|
|
241
|
+
.usage-totals strong { font-size: 28px; font-weight: 600; }
|
|
242
|
+
.usage-panel { margin: 16px 0; }
|
|
243
|
+
.usage-legend { font-size: 12px; gap: 12px; }
|
|
244
|
+
.usage-legend span, .usage-legend button { display: inline-flex; align-items: center; gap: 6px; }
|
|
245
|
+
.usage-legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
|
|
246
|
+
.usage-legend button { max-width: 100%; overflow-wrap: anywhere; text-align: left; }
|
|
247
|
+
.usage-token-key, .usage-chart .usage-token-axis { color: light-dark(#a54d2e, #efb18b); fill: light-dark(#a54d2e, #efb18b); }
|
|
248
|
+
.usage-token-line { stroke: light-dark(#a54d2e, #efb18b); stroke-width: 2.5; fill: none; pointer-events: none; }
|
|
249
|
+
.usage-token-dot { fill: light-dark(#a54d2e, #efb18b); pointer-events: none; }
|
|
250
|
+
.usage-chart-scroll { overflow-x: auto; }
|
|
251
|
+
.usage-chart { width: 100%; min-width: 500px; display: block; font: 11px var(--taskchef-font, sans-serif); fill: var(--taskchef-muted); }
|
|
252
|
+
.usage-grid { stroke: var(--taskchef-border); }
|
|
253
|
+
.usage-chart-detail { min-height: 18px; }
|
|
254
|
+
.usage-ranking { overflow-x: auto; }
|
|
255
|
+
.usage-ranking table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
256
|
+
.usage-ranking th, .usage-ranking td { padding: 6px; text-align: right; font-weight: normal; }
|
|
257
|
+
.usage-ranking th:first-child { width: 70%; text-align: left; }
|
|
258
|
+
.usage-ranking td { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
259
|
+
.usage-ranking thead { color: var(--taskchef-muted); }
|
|
260
|
+
.usage-ranking button { width: 100%; display: flex; align-items: center; gap: 12px; border-color: transparent; text-align: left; }
|
|
261
|
+
.usage-ranking button > span:first-child { width: 45%; overflow-wrap: anywhere; }
|
|
262
|
+
.usage-rank-track { flex: 1; height: 8px; border-radius: 2px; background: var(--taskchef-border); overflow: hidden; }
|
|
263
|
+
.usage-rank-track > span { display: block; height: 100%; }
|
|
264
|
+
.usage-warning { padding: 10px; border: 1px solid var(--taskchef-border); border-radius: 6px; }
|
|
265
|
+
@media (max-width: 500px) { .usage-totals { grid-template-columns: 1fr; gap: 10px; } .usage-panel { padding: 12px; } .usage-rank-track { display: none; } .usage-ranking button > span:first-child { width: 100%; } }
|
|
266
|
+
|
|
267
|
+
.usage-progress { margin: 16px 0; }
|
|
268
|
+
.usage-progress progress { display: block; width: 100%; height: 6px; accent-color: var(--taskchef-accent); }
|
|
269
|
+
.usage-chart [data-project] { transition: opacity 120ms ease; }
|
|
270
|
+
@media (prefers-reduced-motion: reduce) { .usage-chart [data-project] { transition: none; } }
|
|
271
|
+
|
|
272
|
+
.calendar-view { padding: 16px; color: var(--mantine-color-text); }
|
|
273
|
+
.calendar-navigation { display: flex; justify-content: center; align-items: center; gap: 14px; }
|
|
274
|
+
.calendar-navigation h2 { margin: 0; font-size: 1.15rem; min-width: 170px; text-align: center; }
|
|
275
|
+
.calendar-navigation button { font-size: 26px; padding: 0 12px; }
|
|
276
|
+
.calendar-view button { color: inherit; cursor: pointer; background: transparent; border: 1px solid var(--mantine-color-default-border); border-radius: 5px; }
|
|
277
|
+
.calendar-view button:disabled { opacity: .35; cursor: default; }
|
|
278
|
+
.calendar-view button:focus-visible { outline: 2px solid var(--mantine-primary-color-filled); outline-offset: 2px; }
|
|
279
|
+
.calendar-scroll { overflow-x: auto; border: 1px solid var(--mantine-color-default-border); border-radius: 6px; margin-top: 14px; }
|
|
280
|
+
.calendar-month { min-width: 700px; }
|
|
281
|
+
.calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); background: var(--mantine-color-default); font-size: 12px; font-weight: 600; }
|
|
282
|
+
.calendar-weekdays > div { padding: 10px; }
|
|
283
|
+
.calendar-week { border-top: 1px solid var(--mantine-color-default-border); }
|
|
284
|
+
.calendar-week-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); position: relative; padding-bottom: 8px; row-gap: 4px; }
|
|
285
|
+
.calendar-day-backgrounds { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); pointer-events: none; }
|
|
286
|
+
.calendar-day-backgrounds > div + div { border-left: 1px solid var(--mantine-color-default-border); }
|
|
287
|
+
.calendar-weekend { background: color-mix(in srgb, var(--mantine-color-dimmed) 5%, transparent); }
|
|
288
|
+
.calendar-day { position: relative; padding: 5px 9px; font-size: 12px; }
|
|
289
|
+
.calendar-outside { opacity: .45; }
|
|
290
|
+
.calendar-today time { border-bottom: 2px solid var(--mantine-primary-color-filled); font-weight: 700; }
|
|
291
|
+
.calendar-view .calendar-bar { position: relative; margin: 0 4px; padding: 3px 7px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; font: inherit; font-size: 12px; border-left-width: 3px; }
|
|
292
|
+
.calendar-view .calendar-bar:hover { filter: brightness(1.15); }
|
|
293
|
+
.calendar-view .calendar-more { margin: 3px 8px 8px; padding: 3px 8px; font-size: 12px; }
|
|
294
|
+
@media (max-width: 600px) { .calendar-view { padding: 10px; } }
|
|
295
|
+
|
|
296
|
+
.calendar-navigation button, .calendar-navigation button:disabled { opacity: 0; }
|
|
297
|
+
.calendar-navigation:hover button, .calendar-navigation:focus-within button { opacity: 1; }
|
|
298
|
+
.calendar-navigation:hover button:disabled, .calendar-navigation:focus-within button:disabled { opacity: .35; }
|
package/src/mcp-app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { aggregateUsage } from "./usage-analytics.js";
|
|
1
2
|
import { mkdir, readFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
@@ -78,6 +79,7 @@ export function registerTaskChefApp(server, {
|
|
|
78
79
|
let ipcErrorReported = false;
|
|
79
80
|
const turnUsageCache = new Map();
|
|
80
81
|
const usagePending = new Map();
|
|
82
|
+
const analyticsJobs = new Map();
|
|
81
83
|
async function boardSnapshot(settings, force = false, scope = { date: "all", taskIds: [] }) {
|
|
82
84
|
const databaseSnapshot = await scanner.refresh({ force });
|
|
83
85
|
await liveState.refresh(databaseSnapshot.healthy === false ? [] : databaseSnapshot.tasks);
|
|
@@ -210,6 +212,50 @@ export function registerTaskChefApp(server, {
|
|
|
210
212
|
}
|
|
211
213
|
return { structuredContent: { snapshot, settings: publicSettings(settings), settingsUrl, notifications, github: githubAuth, version: TASKCHEF_VERSION }, content: [] };
|
|
212
214
|
});
|
|
215
|
+
server.registerTool("taskchef_app_calendar", {
|
|
216
|
+
title: "Read chat work calendar", description: "Read recorded turn dates for the selected month. Full idle days split chat bars.",
|
|
217
|
+
inputSchema: { month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/), timeZone: z.string().min(1).max(100) }, _meta: appOnly,
|
|
218
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
219
|
+
}, async ({ month, timeZone }) => {
|
|
220
|
+
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
221
|
+
const data = await scanner.calendarHistory({ month, timeZone, settings: await readSettings() });
|
|
222
|
+
return { structuredContent: data, content: [] };
|
|
223
|
+
});
|
|
224
|
+
server.registerTool("taskchef_app_usage", {
|
|
225
|
+
title: "Read usage history", description: "Aggregate timestamped local token usage and API-equivalent cost by day and project.",
|
|
226
|
+
inputSchema: { range: z.enum(["7d", "30d", "90d", "mtd", "last-month"]), timeZone: z.string().min(1).max(100), force: z.boolean().optional(), rescan: z.boolean().optional() }, _meta: appOnly,
|
|
227
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
228
|
+
}, async ({ range, timeZone, force, rescan }) => {
|
|
229
|
+
new Intl.DateTimeFormat("en", { timeZone }).format();
|
|
230
|
+
const settings = await readSettings();
|
|
231
|
+
// History is independent of chart range and timezone. Range changes query
|
|
232
|
+
// this shared snapshot; only expiry or explicit Refresh starts another read.
|
|
233
|
+
const key = JSON.stringify([settings.showCli, settings.showExec]);
|
|
234
|
+
let job = analyticsJobs.get(key);
|
|
235
|
+
if ((force || rescan) && job?.pending) { job.refreshAgain = true; job.rescan ||= rescan; }
|
|
236
|
+
if (!job || (!job.pending && (force || rescan || (!job.error && Date.now() - job.finishedAt >= 60_000)))) {
|
|
237
|
+
job = { families: job?.families ?? null, error: null, finishedAt: job?.finishedAt ?? 0,
|
|
238
|
+
pending: true, refreshAgain: false, rescan: !!rescan, progress: { completed: 0, total: 0 } };
|
|
239
|
+
analyticsJobs.set(key, job);
|
|
240
|
+
const current = job;
|
|
241
|
+
Promise.resolve().then(async () => {
|
|
242
|
+
do {
|
|
243
|
+
current.refreshAgain = false;
|
|
244
|
+
const rebuild = current.rescan;
|
|
245
|
+
current.rescan = false;
|
|
246
|
+
const families = await scanner.usageHistory({ settings, rescan: rebuild, onProgress: progress => { current.progress = progress; } });
|
|
247
|
+
current.families = families;
|
|
248
|
+
current.finishedAt = Date.now();
|
|
249
|
+
} while (current.refreshAgain);
|
|
250
|
+
}).catch(error => { current.error = error; }).finally(() => { current.pending = false; });
|
|
251
|
+
}
|
|
252
|
+
if (job.error) throw job.error;
|
|
253
|
+
const data = job.families ? { ...aggregateUsage(job.families, { range, timeZone }),
|
|
254
|
+
updatedAt: new Date(job.finishedAt).toISOString(),
|
|
255
|
+
...(job.pending ? { refreshing: true, progress: job.progress } : {}) }
|
|
256
|
+
: { loading: true, progress: job.progress };
|
|
257
|
+
return { structuredContent: data, content: [] };
|
|
258
|
+
});
|
|
213
259
|
server.registerTool("taskchef_app_task", {
|
|
214
260
|
title: "Read Codex chat metadata", description: "Read one local chat's metadata and its latest recorded request and response.",
|
|
215
261
|
inputSchema: { taskId: taskIdSchema, refreshGithub: z.boolean().optional() }, _meta: appOnly,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Calendar dates are evaluated in the view's timezone, including DST changes.
|
|
2
|
+
export function aggregateUsage(families, { range, timeZone, now = Date.now() }) {
|
|
3
|
+
const format = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
4
|
+
const dateKey = time => {
|
|
5
|
+
const parts = Object.fromEntries(format.formatToParts(time).map(part => [part.type, part.value]));
|
|
6
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
7
|
+
};
|
|
8
|
+
let end = dateKey(now);
|
|
9
|
+
const startDate = new Date(`${end}T00:00:00Z`);
|
|
10
|
+
if (range === 'last-month') {
|
|
11
|
+
startDate.setUTCDate(1);
|
|
12
|
+
end = new Date(startDate.getTime() - 86400000).toISOString().slice(0, 10);
|
|
13
|
+
startDate.setUTCMonth(startDate.getUTCMonth() - 1);
|
|
14
|
+
} else if (range === 'mtd') startDate.setUTCDate(1);
|
|
15
|
+
else startDate.setUTCDate(startDate.getUTCDate() - ({ '7d': 6, '30d': 29, '90d': 89 }[range] ?? 29));
|
|
16
|
+
const start = startDate.toISOString().slice(0, 10);
|
|
17
|
+
const days = [];
|
|
18
|
+
for (let cursor = startDate; cursor.toISOString().slice(0, 10) <= end; cursor = new Date(cursor.getTime() + 86400000)) {
|
|
19
|
+
days.push({ date: cursor.toISOString().slice(0, 10), tokens: 0, costUsd: 0, projects: {} });
|
|
20
|
+
}
|
|
21
|
+
const byDate = new Map(days.map(day => [day.date, day]));
|
|
22
|
+
const projects = new Map();
|
|
23
|
+
let incomplete = false, samples = 0;
|
|
24
|
+
const totals = { tokens: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
25
|
+
for (const { project, usage } of families) {
|
|
26
|
+
// Coverage can be incomplete even when no timestamped records survive.
|
|
27
|
+
incomplete ||= usage.analyticsPartial || usage.partial || false;
|
|
28
|
+
const key = project.id === 'projectless' ? 'unassigned' : project.id || project.path || 'unassigned';
|
|
29
|
+
for (const sample of usage.timeline) {
|
|
30
|
+
if (sample.time > now) continue;
|
|
31
|
+
const day = byDate.get(dateKey(sample.time));
|
|
32
|
+
if (!day) continue;
|
|
33
|
+
let item = projects.get(key);
|
|
34
|
+
if (!item) { item = { id: key, name: key === 'unassigned' ? 'Unassigned' : project.name, tokens: 0, costUsd: 0 }; projects.set(key, item); }
|
|
35
|
+
const tokens = sample.tokens.total_tokens;
|
|
36
|
+
totals.tokens += tokens; totals.inputTokens += sample.tokens.input_tokens;
|
|
37
|
+
totals.cachedInputTokens += sample.tokens.cached_input_tokens; totals.outputTokens += sample.tokens.output_tokens;
|
|
38
|
+
totals.costUsd += sample.costUsd; samples += sample.samples;
|
|
39
|
+
day.tokens += tokens; day.costUsd += sample.costUsd;
|
|
40
|
+
day.projects[key] = (day.projects[key] ?? 0) + sample.costUsd;
|
|
41
|
+
item.tokens += tokens; item.costUsd += sample.costUsd;
|
|
42
|
+
incomplete ||= sample.partial || sample.costPartial;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { start, end, timeZone, days, totals, samples, incomplete,
|
|
46
|
+
projects: [...projects.values()].sort((a, b) => b.costUsd - a.costUsd || a.name.localeCompare(b.name)) };
|
|
47
|
+
}
|