import { cachedImageLoader } from "../../ui/image-cache"; import { App } from "@modelcontextprotocol/ext-apps"; import { ActionIcon, Alert, Box, Button, Group, MantineProvider, Modal, createTheme, Paper, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { IconRefresh, IconSearch, IconSettings } from "@tabler/icons-react"; import { createRoot } from "react-dom/client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DashboardSnapshot, Task, Project } from "../../ui/types"; import { filterTasks } from "../../ui/state.js"; import { TaskBoard } from "../../ui/components/TaskBoard"; import { TaskCard } from "../../ui/components/TaskCard"; import { NextTaskDetail } from "./NextTaskDetail"; import { RelativeTimeProvider } from "../../ui/components/RelativeTime"; import { NextNotificationCenter, type NextNotification, type NextNotificationState } from "./NextNotificationCenter"; import { GitHubConnection, type GitHubAuth } from "./GitHubConnection"; import { AppearancePicker } from "./AppearancePicker"; import { savedVisualTheme, themeVariables, visualTheme, VISUAL_THEME_KEY } from "./themes"; import { ProjectPicker } from "./ProjectPicker"; import brandIcon from "../../../assets/taskchef-dark.svg"; import "@mantine/core/styles.css"; import "../../ui/styles.css"; import "./styles.css"; const theme = createTheme({ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', headings: { fontFamily: "inherit" }, colors: { dark: ["#dcdcdc", "#cdcdcd", "#afafaf", "#808080", "#414141", "#353535", "#282828", "#212121", "#181818", "#131313"] }, }); const bridge = new App({ name: "TaskChef", version: "1.0.0" }); const connected = bridge.connect(); function logLifecycle(event: string, displayMode = bridge.getHostContext?.()?.displayMode ?? "unknown") { console.warn(`[TaskChef] TaskChef lifecycle: ${event} ${JSON.stringify({ visibility: document.visibilityState, displayMode })}`); } async function call(name: string, args: Record = {}): Promise { await connected; const result = await bridge.callServerTool({ name, arguments: args }); if (result.isError) throw new Error(result.content?.find((item) => item.type === "text")?.text ?? "TaskChef request failed."); return result.structuredContent as T; } const THEME_KEY = "taskchef.app.theme"; type ThemeChoice = "dark" | "light" | "system"; function initialTheme(): ThemeChoice { try { const saved = window.localStorage.getItem(THEME_KEY); return saved === "light" || saved === "system" ? saved : "dark"; } catch { return "dark"; } } const VIEW_KEY = "taskchef.app.view"; function initialView(): "board" | "list" { try { return window.localStorage.getItem(VIEW_KEY) === "board" ? "board" : "list"; } catch { return "list"; } } interface VisibilitySettings { showExec: boolean; showCli: boolean; showArchived: boolean; showCalendarDates?: boolean; showCardUsage?: boolean; } function eligibleForView(task: Task, showCli: boolean, showArchived: boolean, showExec: boolean) { return (showExec || task.observed?.source !== "exec") && (showCli || task.observed?.source !== "cli") && (showArchived || !task.observed?.archive); } const loadReplyImage = cachedImageLoader(async (task: Task, url = task.replyImage?.url): Promise => { const result = await call<{ dataUrl: string | null }>("taskchef_app_image", { taskId: task.id, expectedTurnId: task.turnId, expectedUrl: url }); return result.dataUrl; }); const NEXT_LANES = [{ status: "scheduled", label: "Scheduled", emptyMessage: "No scheduled chats" }, { status: "working", label: "Running", emptyMessage: "No chats running" }, { status: "needs_input", label: "Waiting for review", emptyMessage: "No chats waiting for review" }, { status: "completed", label: "Done", emptyMessage: "No completed chats" }, { status: "archived", label: "Archived", emptyMessage: "No archived chats" }, { status: null, label: "Unverified", emptyMessage: "No unverified chats" }] as const; interface ScanStats { cacheHit?: boolean; scheduleErrors?: number; source?: "database"; mode: string; checkedAt: string; error?: string; intervalSeconds?: number; fullIntervalSeconds?: number; indexedFiles?: number; activeFiles?: number | null; archivedFiles?: number | null; parsedFiles?: number | null; visibleFiles?: number; unreadFiles?: number; errors?: number; } export function TaskChefApp() { const [themeChoice, setThemeChoice] = useState(initialTheme); const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? true); const colorScheme = themeChoice === "system" ? (systemDark ? "dark" : "light") : themeChoice; useEffect(() => { const media = window.matchMedia?.("(prefers-color-scheme: dark)"); if (!media) return; const update = () => setSystemDark(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); const [visualThemes, setVisualThemes] = useState(() => ({ light: savedVisualTheme("light"), dark: savedVisualTheme("dark") })); const selectedTheme = visualTheme(visualThemes[colorScheme], colorScheme); const variables = useMemo(() => themeVariables(selectedTheme, colorScheme), [selectedTheme, colorScheme]); function chooseVisualTheme(id: string) { setVisualThemes(current => ({ ...current, [colorScheme]: id })); try { window.localStorage.setItem(`${VISUAL_THEME_KEY}.${colorScheme}`, id); } catch { /* Keep selection in memory. */ } } useEffect(() => { document.documentElement.dataset.taskchefPalette = selectedTheme.id; for (const [key, value] of Object.entries(variables)) document.documentElement.style.setProperty(key, value); document.documentElement.dataset.taskchefTheme = colorScheme; return () => { delete document.documentElement.dataset.taskchefTheme; delete document.documentElement.dataset.taskchefPalette; for (const key of Object.keys(variables)) document.documentElement.style.removeProperty(key); }; }, [colorScheme, selectedTheme, variables]); function chooseTheme(value: string | null) { const choice: ThemeChoice = value === "light" || value === "system" ? value : "dark"; setThemeChoice(choice); try { window.localStorage.setItem(THEME_KEY, choice); } catch { /* Keep the choice for this view if storage is unavailable. */ } } const [displayMode, setDisplayMode] = useState(bridge.getHostContext?.()?.displayMode ?? "unknown"); const [githubOpenSignal, setGitHubOpenSignal] = useState(0); const [githubAuth, setGitHubAuth] = useState({ configured: false, connected: false, login: null }); const [tasks, setTasks] = useState([]); const [loading, setLoading] = useState(true); const [taskchefVersion, setTaskchefVersion] = useState(null); const [registeredProjects, setRegisteredProjects] = useState([]); const [{ showExec, showCli, showArchived, showCalendarDates = false, showCardUsage = true }, setVisibility] = useState({ showExec: false, showCli: false, showArchived: false }); const [scan, setScan] = useState(null); const [selected, setSelected] = useState(null); const [opened, setOpened] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); const [archiveHelp, setArchiveHelp] = useState(null); const [notifications, setNotifications] = useState({ revision: 0, items: [] }); const [toasts, setToasts] = useState([]); const notificationRevision = useRef(-1); const seenNotifications = useRef | null>(null); const toastTimers = useRef(new Map()); const dismissToast = useCallback((id: string) => { window.clearTimeout(toastTimers.current.get(id)); toastTimers.current.delete(id); setToasts((items) => items.filter((item) => item.id !== id)); }, []); const receiveNotifications = useCallback((next?: NextNotificationState) => { if (!next || next.revision < notificationRevision.current) return; notificationRevision.current = next.revision; setNotifications(next); const added = seenNotifications.current ? next.items.filter((item) => !seenNotifications.current!.has(item.id)) : []; seenNotifications.current = new Set(next.items.map((item) => item.id)); if (added.length) { setToasts((items) => [...added, ...items].slice(0, 3)); for (const item of added) toastTimers.current.set(item.id, window.setTimeout(() => dismissToast(item.id), 5000)); } }, [dismissToast]); useEffect(() => () => { for (const timer of toastTimers.current.values()) window.clearTimeout(timer); }, []); async function notificationAction(action: "read" | "read_all" | "clear", id?: string) { try { const result = await call<{ notifications: NextNotificationState }>("taskchef_app_notifications", { action, ...(id ? { id } : {}) }); receiveNotifications(result.notifications); if (action === "clear") { for (const item of toasts) dismissToast(item.id); } } catch (cause) { setError(String(cause)); throw cause; } } async function actionError(operation: "open" | "done" | "move" | "copy" | "settings", cause: unknown, task?: Task) { try { const result = await call<{ notifications: NextNotificationState }>("taskchef_app_notifications", { action: "error", operation, error: String(cause).slice(0, 1000), ...(task ? { taskId: task.id } : {}) }); receiveNotifications(result.notifications); } catch (notificationError) { setError(`${String(cause)}. ${String(notificationError)}`); } } const [project, setProject] = useState(""); const [search, setSearch] = useState(""); const [date, setDate] = useState("all"); const [status, setStatus] = useState(""); // Keep the list implementation for later, but expose only Board. Ignore old saved List choices. const [view, setView] = useState<"board" | "list">("board"); const [completedLimit, setCompletedLimit] = useState(5); const [archivedLimit, setArchivedLimit] = useState(5); const [now, setNow] = useState(() => Date.now()); const selectedRef = useRef(null); const detailPending = useRef(false); function setDetailPending(value: boolean) { detailPending.current = value; setDetailLoading(value); } const selectionVersion = useRef(0); const notificationSelection = useRef(false); const detailRequestVersion = useRef(0); const refreshVersion = useRef(0); const revisionRef = useRef(null); const displayModeRef = useRef(displayMode); const previousDisplayMode = useRef(displayMode); displayModeRef.current = displayMode; const visibleTaskIds = useRef([]); const visibilityRefreshTimer = useRef(null); const refresh = useCallback(async (force = false) => { const version = ++refreshVersion.current; const initialSelection = selectionVersion.current; const data = await call<({ snapshot: DashboardSnapshot & { revision: number; scan: ScanStats; projects?: Project[] }; unchanged?: never; scan?: never; settings: VisibilitySettings; settingsUrl?: string | null; notifications?: NextNotificationState; github?: GitHubAuth } | { unchanged: true; revision: number; scan: ScanStats; snapshot?: never; settings: VisibilitySettings; settingsUrl?: string | null; notifications?: NextNotificationState; github?: GitHubAuth }) & { version?: string }>( "taskchef_app_snapshot", { project, date, visibleTaskIds: visibleTaskIds.current, ...(revisionRef.current === null ? {} : { revision: revisionRef.current }), ...(force ? { force: true } : {}) }, ); if (version !== refreshVersion.current) return; if (data.version) setTaskchefVersion(data.version); setLoading(false); receiveNotifications(data.notifications); if (data.github) setGitHubAuth(data.github); setVisibility(data.settings); if (data.snapshot) { revisionRef.current = data.snapshot.revision; setTasks(data.snapshot.healthy === false ? [] : data.snapshot.tasks); setRegisteredProjects(data.snapshot.healthy === false ? [] : data.snapshot.projects ?? []); setScan(data.snapshot.scan); setError(data.snapshot.healthy === false ? data.snapshot.scan.error?.startsWith("TaskChef") ? data.snapshot.scan.error : "TaskChef cannot read the Codex databases. Check that state_5.sqlite and thread_history_1.sqlite are available and compatible, then refresh." : null); if (data.snapshot.healthy === false) { selectedRef.current = null; selectionVersion.current += 1; setSelected(null); setOpened(false); setDetailError(null); return; } } else { setScan(data.scan); setError(null); } setNow(Date.now()); const selectedTask = selectedRef.current; if (!selectedTask || initialSelection !== selectionVersion.current) return; const selection = selectionVersion.current; const clearRemovedTask = () => { selectionVersion.current += 1; selectedRef.current = null; setSelected(null); setOpened(false); setDetailError(null); }; if (data.snapshot && data.snapshot.healthy !== false && !data.snapshot.tasks.some((task) => task.id === selectedTask.id)) { clearRemovedTask(); return; } if (detailPending.current && !force) return; const detailRequest = ++detailRequestVersion.current; setDetailPending(true); try { const detail = await call<{ task: Task }>("taskchef_app_task", { taskId: selectedTask.id, ...(force ? { refreshGithub: true } : {}) }); if (selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current || selectedRef.current?.id !== detail.task.id) return; selectedRef.current = detail.task; setSelected(detail.task); setDetailError(null); } catch (cause) { if (selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current) return; if (cause instanceof Error && cause.message === "Task not found.") clearRemovedTask(); else setDetailError(String(cause)); } finally { if (selection === selectionVersion.current && detailRequest === detailRequestVersion.current) setDetailPending(false); } }, [receiveNotifications, project, date]); const onVisibleTasksChange = useCallback((ids: string[]) => { if (JSON.stringify(ids) === JSON.stringify(visibleTaskIds.current)) return; visibleTaskIds.current = ids; if (visibilityRefreshTimer.current !== null) window.clearTimeout(visibilityRefreshTimer.current); visibilityRefreshTimer.current = null; if (ids.length === 0) return; visibilityRefreshTimer.current = window.setTimeout(() => { if (document.visibilityState === "visible") void refresh().catch(cause => setError(String(cause))); }, 250); }, [refresh]); useEffect(() => () => { if (visibilityRefreshTimer.current !== null) window.clearTimeout(visibilityRefreshTimer.current); }, []); const githubRequest = useCallback(async (action: "status" | "start" | "poll" | "disconnect") => (await call<{ github: GitHubAuth }>("taskchef_app_github", { action })).github, []); const githubOpenLink = useCallback((url: string) => bridge.openLink({ url }), []); const githubRefresh = useCallback(() => refresh(true), [refresh]); const githubOpenSettings = useCallback(() => call("taskchef_app_open_settings"), []); useEffect(() => { logLifecycle("mounted"); const onVisibility = () => { const next = document.visibilityState; logLifecycle("visibilitychange"); if (next === "visible" && displayModeRef.current !== "inline") void refresh().catch((cause) => { setTasks([]); setError(String(cause)); }); }; const onPageHide = () => { logLifecycle("pagehide"); }; document.addEventListener("visibilitychange", onVisibility); window.addEventListener("pagehide", onPageHide); bridge.onhostcontextchanged = (context) => { const next = context.displayMode ?? bridge.getHostContext?.()?.displayMode ?? "unknown"; logLifecycle("host context changed", next); setDisplayMode(next); }; bridge.onteardown = () => { logLifecycle("host teardown"); return {}; }; void connected.then(() => setDisplayMode(bridge.getHostContext?.()?.displayMode ?? "unknown")); void refresh().catch((cause) => { setTasks([]); setError(String(cause)); }); return () => { logLifecycle("unmounted"); document.removeEventListener("visibilitychange", onVisibility); window.removeEventListener("pagehide", onPageHide); }; }, [refresh]); useEffect(() => { if (previousDisplayMode.current === "inline" && displayMode !== "inline" && document.visibilityState === "visible") { void refresh().catch((cause) => { setTasks([]); setError(String(cause)); }); } previousDisplayMode.current = displayMode; if (displayMode === "inline") return; const timer = window.setInterval(() => { if (document.visibilityState === "visible") void refresh().catch((cause) => { setTasks([]); setError(String(cause)); }); }, 5000); return () => window.clearInterval(timer); }, [displayMode, refresh]); const eligibleTasks = useMemo(() => tasks.filter((task) => eligibleForView(task, showCli, showArchived, showExec)), [tasks, showCli, showArchived, showExec]); useEffect(() => { setProject(""); setCompletedLimit(5); setArchivedLimit(5); if (!showArchived) setStatus((current) => current === "archived" ? "" : current); }, [showExec, showCli, showArchived]); const lanes = NEXT_LANES.filter((lane) => lane.status !== "archived" || showArchived); useEffect(() => { if (!selected || notificationSelection.current || eligibleForView(selected, showCli, showArchived, showExec)) return; selectionVersion.current += 1; selectedRef.current = null; setSelected(null); setOpened(false); setDetailError(null); }, [selected, showCli, showArchived, showExec]); const projects = useMemo(() => { const items = registeredProjects.length ? registeredProjects : [...new Map(eligibleTasks .filter((task) => task.project.id !== "projectless") .map((task) => [task.project.id || task.project.path || task.project.name, task.project])).values()]; const recent = new Map(); for (const task of eligibleTasks) { const key = task.project.id || task.project.path || task.project.name; const timestamp = Date.parse(task.updatedAt); if (Number.isFinite(timestamp)) recent.set(key, Math.max(recent.get(key) ?? 0, timestamp)); } return [ { label: "All projects", value: "" }, ...items.map((item) => ({ label: items.some((other) => other !== item && other.name === item.name) ? `${item.name} (${item.path})` : item.name, value: item.id || item.path || item.name, path: item.path, updatedAt: recent.get(item.id || item.path || item.name) })), { label: "No project", value: "projectless" }, ]; }, [eligibleTasks, registeredProjects]); useEffect(() => { if (project && !projects.some((item) => item.value === project)) setProject(""); }, [project, projects]); const projectTasks = useMemo(() => eligibleTasks.filter((task) => !project || (task.project.id || task.project.path || task.project.name) === project), [eligibleTasks, project]); const boardTasks: Task[] = useMemo(() => { const terms = search.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); return filterTasks(projectTasks, { date, now }).filter((task: Task) => { const content = [ task.title, task.promptExcerpt ?? "", task.replyExcerpt ?? "", task.project.name, ...(task.project.githubRepos ?? []), ...(task.pullRequests ?? []).flatMap((pr) => [pr.title ?? "", pr.url.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)/)?.[1] ?? ""]), ].join("\n").toLocaleLowerCase(); return terms.every((term) => content.includes(term)); }); }, [projectTasks, date, now, search]); const visible = useMemo(() => boardTasks.filter((task) => !status || (task.status ?? "unverified") === status), [boardTasks, status]); const statusOptions = [{ label: `All ${boardTasks.length}`, value: "" }, ...lanes.map(({ label, status: value }) => ({ label: `${label} ${boardTasks.filter((task) => task.status === value).length}`, value: value ?? "unverified", }))]; function changeView(value: string) { if (value !== "board" && value !== "list") return; setView(value); try { window.localStorage.setItem(VIEW_KEY, value); } catch { /* Keep the selection in memory. */ } } async function select(task: Task, fromNotification = false) { notificationSelection.current = fromNotification; const selection = ++selectionVersion.current; const detailRequest = ++detailRequestVersion.current; selectedRef.current = task; setSelected(task); setOpened(true); setDetailPending(true); setDetailError(null); try { const result = await call<{ task: Task }>("taskchef_app_task", { taskId: task.id }); if (selection !== selectionVersion.current || detailRequest !== detailRequestVersion.current) return; selectedRef.current = result.task; setSelected(result.task); } catch (cause) { if (selection === selectionVersion.current && detailRequest === detailRequestVersion.current) setDetailError(String(cause)); } finally { if (selection === selectionVersion.current && detailRequest === detailRequestVersion.current) setDetailPending(false); } } async function openChat(task: Task) { setBusy(true); try { await call("taskchef_app_open_chat", { taskId: task.id }); } catch (cause) { await actionError("open", cause, task); } finally { setBusy(false); } } async function markDone(task: Task) { if (!task.turnId) return; setBusy(true); try { const result = await call<{ task: Task; notifications?: NextNotificationState; github?: GitHubAuth }>("taskchef_app_set_done", { taskId: task.id, expectedTurnId: task.turnId, done: true }); if (selectedRef.current?.id === task.id) { selectedRef.current = result.task; setSelected(result.task); } receiveNotifications(result.notifications); await refresh().catch((cause) => { setTasks([]); setError(String(cause)); }); } catch (cause) { await actionError("done", cause, task); } finally { setBusy(false); } } async function moveTask(task: Task, destination: Task["status"]) { if (busy) return; if (destination === "archived") { setArchiveHelp(task); return; } if (!["needs_input", "interrupted"].includes(task.status ?? "")) return; if (destination === "completed") { if (task.scheduled) { await actionError("move", new Error("This chat has an active schedule. Move it to Scheduled instead of Done."), task); return; } if (task.pullRequests?.some(pr => pr.state !== "merged")) { await actionError("move", new Error("Chats with an unmerged or unconfirmed PR cannot move to Done. Merge the PR first."), task); return; } await markDone(task); return; } if (destination !== "scheduled" || !task.turnId) return; setBusy(true); try { await call("taskchef_app_set_scheduled", { taskId: task.id, expectedTurnId: task.turnId }); await refresh(); } catch (cause) { await actionError("move", cause, task); } finally { setBusy(false); } } function closeDetail() { if (busy) return; selectionVersion.current += 1; selectedRef.current = null; setOpened(false); setSelected(null); setDetailError(null); } return
TaskChef {displayMode !== "inline" && { setProject(value); setCompletedLimit(5); }} value={project} />