diff --git a/AGENTS.md b/AGENTS.md
index 918b726c..34b1a228 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,7 @@ pnpm-lock.yaml - Project file
pnpm-workspace.yaml - Project file
PRD.md - Project file
REFACTOR_TASK.md - Project file
+client/AGENTS.md - Client module map
server/AGENTS.md - Server module map
tsconfig.json - Project file
web/AGENTS.md - Web module map
diff --git a/client/AGENTS.md b/client/AGENTS.md
new file mode 100644
index 00000000..b7054efb
--- /dev/null
+++ b/client/AGENTS.md
@@ -0,0 +1,32 @@
+# client/
+> L2 | 父级: /AGENTS.md
+
+成员清单
+package.json: Client package manifest with React/Vite/Tailwind dependencies and scripts.
+vite.config.ts: Vite config with React plugin, Tailwind plugin, alias, and backend proxies.
+tailwind.config.ts: Tailwind theme + dark mode class strategy.
+tsconfig.json: TypeScript compiler settings for client source and config files.
+index.html: App HTML shell with Inter font preload and root mount node.
+postcss.config.cjs: PostCSS autoprefixer compatibility hook.
+src/: Client runtime source tree for app shell, pages, stores, hooks, and styles.
+
+目录结构
+src/
+├── AGENTS.md
+├── App.tsx
+├── main.tsx
+├── types.ts
+├── api/
+├── components/
+├── hooks/
+├── lib/
+├── pages/
+├── stores/
+└── styles/
+
+法则
+- Keep task and agent state in Zustand stores; do not duplicate domain state in page-local caches.
+- Route all backend calls through `src/api/client.ts`.
+- Realtime updates must flow through `useWebSocket` into stores.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 00000000..b526e8a1
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+ AgentCal Client
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
index 426476d1..798a5de5 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,5 +1,30 @@
{
"name": "@agentcal/client",
"version": "0.1.0",
- "private": true
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.30.0",
+ "zustand": "^4.5.7",
+ "recharts": "^2.15.4"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.12",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.7.0",
+ "autoprefixer": "^10.4.21",
+ "postcss": "^8.5.3",
+ "tailwindcss": "^4.1.12",
+ "typescript": "^5.9.2",
+ "vite": "^5.4.19"
+ }
}
diff --git a/client/postcss.config.cjs b/client/postcss.config.cjs
new file mode 100644
index 00000000..a47ef4f9
--- /dev/null
+++ b/client/postcss.config.cjs
@@ -0,0 +1,5 @@
+module.exports = {
+ plugins: {
+ autoprefixer: {},
+ },
+};
diff --git a/client/src/AGENTS.md b/client/src/AGENTS.md
new file mode 100644
index 00000000..12e6b9c7
--- /dev/null
+++ b/client/src/AGENTS.md
@@ -0,0 +1,23 @@
+# src/
+> L2 | 父级: /client/AGENTS.md
+
+成员清单
+main.tsx: Browser entrypoint and root render bootstrap.
+App.tsx: Route registration and websocket bootstrap.
+types.ts: Shared domain types aligned to backend schemas.
+api/client.ts: Typed REST wrappers for tasks/agents/calendar/system.
+stores/taskStore.ts: Task state, calendar state, selected task, and live logs.
+stores/agentStore.ts: Agent list state and upsert helpers.
+hooks/useWebSocket.ts: Reconnecting websocket event dispatcher.
+hooks/useCalendar.ts: Calendar date/view navigation helpers.
+components/: Reusable layout and UI primitives.
+pages/: Route-level feature pages.
+lib/status.ts: Task status label/color presentation helpers.
+styles/globals.css: Tailwind import and global visual tokens.
+
+法则
+- `types.ts` is the only shared type source for app modules.
+- Keep page components thin; move reusable behavior to hooks/components/stores.
+- Keep visual semantics centralized in `lib/status.ts`.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 00000000..6802793e
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,32 @@
+/**
+ * [INPUT]: Depends on react-router route tree, layout shell, pages, and websocket lifecycle hook.
+ * [OUTPUT]: Registers application routes and global realtime subscription bootstrap.
+ * [POS]: root client composition entry wiring navigation and page surfaces.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { Navigate, Route, Routes } from "react-router-dom";
+import { Layout } from "@/components/Layout";
+import { useWebSocket } from "@/hooks/useWebSocket";
+import { AgentsPage } from "@/pages/AgentsPage";
+import { CalendarPage } from "@/pages/CalendarPage";
+import { SettingsPage } from "@/pages/SettingsPage";
+import { StatsPage } from "@/pages/StatsPage";
+import { TaskDetailPage } from "@/pages/TaskDetailPage";
+
+export default function App() {
+ useWebSocket();
+
+ return (
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+ );
+}
diff --git a/client/src/api/AGENTS.md b/client/src/api/AGENTS.md
new file mode 100644
index 00000000..faef9621
--- /dev/null
+++ b/client/src/api/AGENTS.md
@@ -0,0 +1,11 @@
+# api/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+client.ts: REST client boundary with typed request helpers and endpoint wrappers.
+
+法则
+- Add new endpoints here first, then consume from stores/pages.
+- Keep all request errors normalized with consistent Error messages.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
new file mode 100644
index 00000000..4417f039
--- /dev/null
+++ b/client/src/api/client.ts
@@ -0,0 +1,200 @@
+/**
+ * [INPUT]: Depends on browser fetch and shared domain types from client/src/types.
+ * [OUTPUT]: Exposes typed wrappers for agents/tasks/calendar/system REST endpoints.
+ * [POS]: client network boundary that normalizes request/response handling for stores and pages.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import type {
+ Agent,
+ CalendarDailyResponse,
+ CalendarMonthlyResponse,
+ CalendarWeeklyResponse,
+ PromptTaskFromPromptResponse,
+ QueueStatus,
+ SystemConfig,
+ SystemStats,
+ SystemStatus,
+ Task,
+} from "@/types";
+
+const API_BASE = "/api";
+
+async function request(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${API_BASE}${path}`, {
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ ...init,
+ });
+
+ if (!response.ok) {
+ const payload = await response.text();
+ throw new Error(`API ${response.status}: ${payload || response.statusText}`);
+ }
+
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
+ return (await response.json()) as T;
+}
+
+function withQuery(path: string, query: Record): string {
+ const params = new URLSearchParams();
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined && value !== "") {
+ params.set(key, value);
+ }
+ }
+ const suffix = params.toString();
+ return suffix.length > 0 ? `${path}?${suffix}` : path;
+}
+
+export interface CreateTaskPayload {
+ title: string;
+ description?: string;
+ status?: Task["status"];
+ priority?: Task["priority"];
+ agent_type?: Task["agent_type"];
+ agent_id?: string | null;
+ scheduled_at?: string | null;
+ estimated_duration_min?: number;
+ depends_on?: string[];
+}
+
+export type UpdateTaskPayload = Partial>;
+
+export async function fetchAgents(projectId?: string): Promise {
+ return request(withQuery("/agents", { project_id: projectId }));
+}
+
+export async function fetchAgent(id: string): Promise {
+ return request(`/agents/${id}`);
+}
+
+export async function createAgent(payload: Partial): Promise {
+ return request("/agents", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function updateAgent(id: string, payload: Partial): Promise {
+ return request(`/agents/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function deleteAgent(id: string): Promise {
+ await request(`/agents/${id}`, { method: "DELETE" });
+}
+
+export async function fetchTasks(params?: Record): Promise {
+ return request(withQuery("/tasks", params ?? {}));
+}
+
+export async function fetchTask(id: string): Promise {
+ return request(`/tasks/${id}`);
+}
+
+export async function createTask(payload: CreateTaskPayload): Promise {
+ return request("/tasks", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function updateTask(id: string, payload: UpdateTaskPayload): Promise {
+ return request(`/tasks/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function deleteTask(id: string): Promise {
+ await request(`/tasks/${id}`, { method: "DELETE" });
+}
+
+export async function spawnTask(id: string): Promise {
+ const response = await request<{ task: Task }>(`/tasks/${id}/spawn`, {
+ method: "POST",
+ });
+ return response.task;
+}
+
+export async function redirectTask(id: string, message: string): Promise<{ ok: boolean }> {
+ return request<{ ok: boolean }>(`/tasks/${id}/redirect`, {
+ method: "POST",
+ body: JSON.stringify({ message }),
+ });
+}
+
+export async function killTask(id: string): Promise {
+ const response = await request<{ task: Task }>(`/tasks/${id}/kill`, {
+ method: "POST",
+ });
+ return response.task;
+}
+
+export async function retryTask(id: string): Promise {
+ return request(`/tasks/${id}/retry`, {
+ method: "POST",
+ });
+}
+
+export async function parseTaskFromPrompt(prompt: string): Promise {
+ return request("/tasks/from-prompt", {
+ method: "POST",
+ body: JSON.stringify({ prompt, dry_run: true }),
+ });
+}
+
+export async function createTaskFromPrompt(prompt: string): Promise {
+ return request("/tasks/from-prompt", {
+ method: "POST",
+ body: JSON.stringify({ prompt, dry_run: false }),
+ });
+}
+
+export async function fetchCalendarDay(date: string): Promise {
+ return request(withQuery("/calendar/daily", { date }));
+}
+
+export async function fetchCalendarWeek(date: string): Promise {
+ return request(withQuery("/calendar/weekly", { date }));
+}
+
+export async function fetchCalendarMonth(date: string): Promise {
+ return request(withQuery("/calendar/monthly", { date }));
+}
+
+export async function fetchSystemStats(): Promise {
+ return request("/system/stats");
+}
+
+export async function fetchSystemStatus(): Promise {
+ return request("/system/status");
+}
+
+export async function fetchSystemQueue(): Promise {
+ return request("/system/queue");
+}
+
+export async function fetchSystemConfig(): Promise {
+ return request("/system/config");
+}
+
+export async function updateSystemConfig(payload: Partial): Promise {
+ return request("/system/config", {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function triggerSystemSync(): Promise<{ synced_at: string }> {
+ return request<{ synced_at: string }>("/system/sync", {
+ method: "POST",
+ });
+}
diff --git a/client/src/components/AGENTS.md b/client/src/components/AGENTS.md
new file mode 100644
index 00000000..11673e35
--- /dev/null
+++ b/client/src/components/AGENTS.md
@@ -0,0 +1,16 @@
+# components/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+Layout.tsx: Shell wrapper with sidebar and main outlet.
+Sidebar.tsx: Collapsible navigation with route links and theme switch.
+ThemeToggle.tsx: Theme toggle control.
+CalendarGrid.tsx: Custom day/week/month scheduling grid.
+TaskCard.tsx: Status-colored task block renderer.
+LogViewer.tsx: Auto-scrolling live log panel.
+
+法则
+- Keep components presentational; domain mutations should come from stores/pages.
+- Preserve 8px radius and subtle shadow for Notion-like consistency.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/components/CalendarGrid.tsx b/client/src/components/CalendarGrid.tsx
new file mode 100644
index 00000000..2245c873
--- /dev/null
+++ b/client/src/components/CalendarGrid.tsx
@@ -0,0 +1,207 @@
+/**
+ * [INPUT]: Depends on current calendar view/date, task list, and task card renderer.
+ * [OUTPUT]: Renders self-built day/week/month calendar grids with colored time blocks.
+ * [POS]: core scheduling visualization surface for CalendarPage.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import type { Task, CalendarView } from "@/types";
+import { TaskCard } from "@/components/TaskCard";
+import { getMonthGrid, getWeekDays } from "@/hooks/useCalendar";
+
+interface CalendarGridProps {
+ view: CalendarView;
+ currentDate: Date;
+ tasks: Task[];
+ onTaskClick: (task: Task) => void;
+}
+
+const HOURS = Array.from({ length: 24 }, (_, hour) => hour);
+const MINIMUM_BLOCK_HEIGHT_PERCENT = 3;
+
+function getTaskStart(task: Task): Date {
+ const source = task.scheduled_at ?? task.started_at ?? task.created_at;
+ return new Date(source);
+}
+
+function getTaskDurationMinutes(task: Task): number {
+ return Math.max(task.actual_duration_min ?? task.estimated_duration_min ?? 30, 15);
+}
+
+function isSameDay(left: Date, right: Date): boolean {
+ return (
+ left.getFullYear() === right.getFullYear() &&
+ left.getMonth() === right.getMonth() &&
+ left.getDate() === right.getDate()
+ );
+}
+
+function minutesFromStartOfDay(date: Date): number {
+ return date.getHours() * 60 + date.getMinutes();
+}
+
+function groupByDay(tasks: Task[], day: Date): Task[] {
+ return tasks
+ .filter((task) => isSameDay(getTaskStart(task), day))
+ .sort((a, b) => getTaskStart(a).getTime() - getTaskStart(b).getTime());
+}
+
+function dayLabel(date: Date): string {
+ return date.toLocaleDateString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+function DayColumn({ day, tasks, onTaskClick }: { day: Date; tasks: Task[]; onTaskClick: (task: Task) => void }) {
+ return (
+
+
+ {dayLabel(day)}
+
+
+ {HOURS.map((hour) => (
+
+ ))}
+ {tasks.map((task, index) => {
+ const start = getTaskStart(task);
+ const startPercent = (minutesFromStartOfDay(start) / 1440) * 100;
+ const blockHeightPercent = Math.max((getTaskDurationMinutes(task) / 1440) * 100, MINIMUM_BLOCK_HEIGHT_PERCENT);
+ const leftOffset = (index % 2) * 4;
+ const width = index % 2 === 0 ? 96 : 92;
+
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function WeekView({ currentDate, tasks, onTaskClick }: { currentDate: Date; tasks: Task[]; onTaskClick: (task: Task) => void }) {
+ const days = getWeekDays(currentDate);
+ return (
+
+
+
+
+ Time
+
+
+ {HOURS.map((hour) => (
+
+ {String(hour).padStart(2, "0")}:00
+
+ ))}
+
+
+ {days.map((day) => (
+
+ ))}
+
+
+ );
+}
+
+function DayView({ currentDate, tasks, onTaskClick }: { currentDate: Date; tasks: Task[]; onTaskClick: (task: Task) => void }) {
+ const dayTasks = groupByDay(tasks, currentDate);
+ return (
+
+
+
+ {HOURS.map((hour) => (
+
+ {String(hour).padStart(2, "0")}:00
+
+ ))}
+
+
+
+
+ );
+}
+
+function MonthView({ currentDate, tasks, onTaskClick }: { currentDate: Date; tasks: Task[]; onTaskClick: (task: Task) => void }) {
+ const days = getMonthGrid(currentDate);
+ const now = new Date();
+
+ return (
+
+
+ {["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((weekday) => (
+
+ {weekday}
+
+ ))}
+
+
+ {days.map((day) => {
+ const dayTasks = groupByDay(tasks, day);
+ const inMonth = day.getMonth() === currentDate.getMonth();
+ const isToday = isSameDay(now, day);
+ return (
+
+
+
+ {day.getDate()}
+
+ {dayTasks.length}
+
+
+ {dayTasks.slice(0, 3).map((task) => (
+
+ ))}
+ {dayTasks.length > 3 ?
+{dayTasks.length - 3} more
: null}
+
+
+ );
+ })}
+
+
+ );
+}
+
+export function CalendarGrid({ view, currentDate, tasks, onTaskClick }: CalendarGridProps) {
+ if (view === "day") {
+ return ;
+ }
+
+ if (view === "month") {
+ return ;
+ }
+
+ return ;
+}
diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx
new file mode 100644
index 00000000..f075a52b
--- /dev/null
+++ b/client/src/components/Layout.tsx
@@ -0,0 +1,53 @@
+/**
+ * [INPUT]: Depends on sidebar component, local UI preferences, and router outlet composition.
+ * [OUTPUT]: Renders app shell with collapsible navigation and dark-mode class management.
+ * [POS]: top-level layout wrapper for every routed page in the client application.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { Outlet } from "react-router-dom";
+import { useEffect, useMemo, useState } from "react";
+import { Sidebar } from "@/components/Sidebar";
+
+function getStoredBoolean(key: string, fallback: boolean): boolean {
+ try {
+ const value = window.localStorage.getItem(key);
+ if (value === null) return fallback;
+ return value === "true";
+ } catch {
+ return fallback;
+ }
+}
+
+export function Layout() {
+ const [collapsed, setCollapsed] = useState(() => getStoredBoolean("agentcal.sidebar.collapsed", false));
+ const [darkMode, setDarkMode] = useState(() => {
+ const fromStorage = getStoredBoolean("agentcal.theme.dark", false);
+ if (fromStorage) return true;
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
+ });
+
+ useEffect(() => {
+ document.documentElement.classList.toggle("dark", darkMode);
+ window.localStorage.setItem("agentcal.theme.dark", String(darkMode));
+ }, [darkMode]);
+
+ useEffect(() => {
+ window.localStorage.setItem("agentcal.sidebar.collapsed", String(collapsed));
+ }, [collapsed]);
+
+ const mainOffset = useMemo(() => (collapsed ? "ml-20" : "ml-64"), [collapsed]);
+
+ return (
+
+ setCollapsed((value) => !value)}
+ onToggleTheme={() => setDarkMode((value) => !value)}
+ />
+
+
+
+
+ );
+}
diff --git a/client/src/components/LogViewer.tsx b/client/src/components/LogViewer.tsx
new file mode 100644
index 00000000..a85a1f78
--- /dev/null
+++ b/client/src/components/LogViewer.tsx
@@ -0,0 +1,35 @@
+/**
+ * [INPUT]: Depends on incremental log lines from task store and optional loading state.
+ * [OUTPUT]: Renders auto-scrolling terminal-like log panel.
+ * [POS]: task detail live execution stream viewer.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect, useRef } from "react";
+
+interface LogViewerProps {
+ lines: string[];
+ emptyText?: string;
+}
+
+export function LogViewer({ lines, emptyText = "No log output yet." }: LogViewerProps) {
+ const endRef = useRef(null);
+
+ useEffect(() => {
+ endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
+ }, [lines]);
+
+ return (
+
+ {lines.length === 0 ? (
+
{emptyText}
+ ) : (
+ lines.map((line, index) => (
+
+ {line}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx
new file mode 100644
index 00000000..55ffde7e
--- /dev/null
+++ b/client/src/components/Sidebar.tsx
@@ -0,0 +1,77 @@
+/**
+ * [INPUT]: Depends on router navigation, shell collapse state, and theme toggle actions.
+ * [OUTPUT]: Renders collapsible left navigation for Calendar, Agents, Stats, and Settings.
+ * [POS]: primary navigation rail for the AgentCal frontend shell.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { NavLink } from "react-router-dom";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+const navItems = [
+ { to: "/calendar", label: "Calendar", icon: "🗓" },
+ { to: "/agents", label: "Agents", icon: "🤖" },
+ { to: "/stats", label: "Stats", icon: "📊" },
+ { to: "/settings", label: "Settings", icon: "⚙" },
+];
+
+interface SidebarProps {
+ collapsed: boolean;
+ darkMode: boolean;
+ onToggleCollapsed: () => void;
+ onToggleTheme: () => void;
+}
+
+export function Sidebar({
+ collapsed,
+ darkMode,
+ onToggleCollapsed,
+ onToggleTheme,
+}: SidebarProps) {
+ return (
+
+ );
+}
diff --git a/client/src/components/TaskCard.tsx b/client/src/components/TaskCard.tsx
new file mode 100644
index 00000000..8091c2ae
--- /dev/null
+++ b/client/src/components/TaskCard.tsx
@@ -0,0 +1,43 @@
+/**
+ * [INPUT]: Depends on task entity data and status color semantics from status utilities.
+ * [OUTPUT]: Renders clickable task block with status color coding and timing metadata.
+ * [POS]: reusable calendar/task tile used in day/week/month presentations.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import type { CSSProperties } from "react";
+import type { Task } from "@/types";
+import { taskStatusSurface } from "@/lib/status";
+
+interface TaskCardProps {
+ task: Task;
+ compact?: boolean;
+ style?: CSSProperties;
+ onClick?: (task: Task) => void;
+}
+
+function formatTime(value: string | null): string {
+ if (!value) return "No time";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "No time";
+ return date.toLocaleTimeString(undefined, {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function TaskCard({ task, compact = false, style, onClick }: TaskCardProps) {
+ return (
+
+ );
+}
diff --git a/client/src/components/ThemeToggle.tsx b/client/src/components/ThemeToggle.tsx
new file mode 100644
index 00000000..857e2e6c
--- /dev/null
+++ b/client/src/components/ThemeToggle.tsx
@@ -0,0 +1,24 @@
+/**
+ * [INPUT]: Depends on current dark-mode state and toggle callback from layout shell.
+ * [OUTPUT]: Renders light/dark mode toggle button.
+ * [POS]: shared layout control for Tailwind class-based theme switching.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+interface ThemeToggleProps {
+ darkMode: boolean;
+ onToggle: () => void;
+}
+
+export function ThemeToggle({ darkMode, onToggle }: ThemeToggleProps) {
+ return (
+
+ );
+}
diff --git a/client/src/hooks/AGENTS.md b/client/src/hooks/AGENTS.md
new file mode 100644
index 00000000..6a79343b
--- /dev/null
+++ b/client/src/hooks/AGENTS.md
@@ -0,0 +1,12 @@
+# hooks/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+useCalendar.ts: View/date navigation and calendar query helpers.
+useWebSocket.ts: Reconnecting websocket client dispatching task/agent/log events.
+
+法则
+- Hooks must stay deterministic and side-effect scoped.
+- Realtime hook should only mutate stores through exposed actions.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/hooks/useCalendar.ts b/client/src/hooks/useCalendar.ts
new file mode 100644
index 00000000..cd357477
--- /dev/null
+++ b/client/src/hooks/useCalendar.ts
@@ -0,0 +1,154 @@
+/**
+ * [INPUT]: Depends on React state and calendar view semantics used by calendar page/components.
+ * [OUTPUT]: Exposes date navigation helpers, labels, and API query keys for day/week/month modes.
+ * [POS]: calendar behavior model shared by UI controls and task loading logic.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useMemo, useState } from "react";
+import type { CalendarView } from "@/types";
+
+function cloneDate(date: Date): Date {
+ return new Date(date.getTime());
+}
+
+function startOfDay(date: Date): Date {
+ const d = cloneDate(date);
+ d.setHours(0, 0, 0, 0);
+ return d;
+}
+
+function startOfWeek(date: Date): Date {
+ const d = startOfDay(date);
+ const day = d.getDay();
+ const delta = day === 0 ? -6 : 1 - day;
+ d.setDate(d.getDate() + delta);
+ return d;
+}
+
+function addDays(date: Date, days: number): Date {
+ const d = cloneDate(date);
+ d.setDate(d.getDate() + days);
+ return d;
+}
+
+function addWeeks(date: Date, weeks: number): Date {
+ return addDays(date, weeks * 7);
+}
+
+function addMonths(date: Date, months: number): Date {
+ const d = cloneDate(date);
+ d.setMonth(d.getMonth() + months);
+ return d;
+}
+
+function isoDate(date: Date): string {
+ return startOfDay(date).toISOString().slice(0, 10);
+}
+
+function isoMonth(date: Date): string {
+ return startOfDay(date).toISOString().slice(0, 7);
+}
+
+export interface UseCalendarResult {
+ view: CalendarView;
+ setView: (view: CalendarView) => void;
+ currentDate: Date;
+ setCurrentDate: (date: Date) => void;
+ goToday: () => void;
+ goPrevious: () => void;
+ goNext: () => void;
+ label: string;
+ queryDate: string;
+}
+
+export function useCalendar(initialView: CalendarView = "week"): UseCalendarResult {
+ const [view, setView] = useState(initialView);
+ const [currentDate, setCurrentDate] = useState(() => new Date());
+
+ const label = useMemo(() => {
+ const formatOptions: Intl.DateTimeFormatOptions = {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ };
+
+ if (view === "day") {
+ return currentDate.toLocaleDateString(undefined, {
+ weekday: "long",
+ ...formatOptions,
+ });
+ }
+
+ if (view === "month") {
+ return currentDate.toLocaleDateString(undefined, {
+ month: "long",
+ year: "numeric",
+ });
+ }
+
+ const start = startOfWeek(currentDate);
+ const end = addDays(start, 6);
+ return `${start.toLocaleDateString(undefined, formatOptions)} - ${end.toLocaleDateString(undefined, formatOptions)}`;
+ }, [currentDate, view]);
+
+ const queryDate = useMemo(() => {
+ if (view === "month") {
+ return isoMonth(currentDate);
+ }
+ if (view === "week") {
+ return isoDate(startOfWeek(currentDate));
+ }
+ return isoDate(currentDate);
+ }, [currentDate, view]);
+
+ const goToday = (): void => setCurrentDate(new Date());
+
+ const goPrevious = (): void => {
+ setCurrentDate((prev) => {
+ if (view === "day") return addDays(prev, -1);
+ if (view === "week") return addWeeks(prev, -1);
+ return addMonths(prev, -1);
+ });
+ };
+
+ const goNext = (): void => {
+ setCurrentDate((prev) => {
+ if (view === "day") return addDays(prev, 1);
+ if (view === "week") return addWeeks(prev, 1);
+ return addMonths(prev, 1);
+ });
+ };
+
+ return {
+ view,
+ setView,
+ currentDate,
+ setCurrentDate,
+ goToday,
+ goPrevious,
+ goNext,
+ label,
+ queryDate,
+ };
+}
+
+export function getWeekDays(baseDate: Date): Date[] {
+ const weekStart = startOfWeek(baseDate);
+ return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
+}
+
+export function getMonthGrid(baseDate: Date): Date[] {
+ const monthStart = new Date(baseDate.getFullYear(), baseDate.getMonth(), 1);
+ const monthEnd = new Date(baseDate.getFullYear(), baseDate.getMonth() + 1, 0);
+ const gridStart = startOfWeek(monthStart);
+ const grid: Date[] = [];
+
+ let cursor = gridStart;
+ while (cursor <= monthEnd || grid.length % 7 !== 0 || grid.length < 35) {
+ grid.push(cursor);
+ cursor = addDays(cursor, 1);
+ if (grid.length >= 42) break;
+ }
+
+ return grid;
+}
diff --git a/client/src/hooks/useWebSocket.ts b/client/src/hooks/useWebSocket.ts
new file mode 100644
index 00000000..c117c0e0
--- /dev/null
+++ b/client/src/hooks/useWebSocket.ts
@@ -0,0 +1,93 @@
+/**
+ * [INPUT]: Depends on browser WebSocket API and task/agent store mutation actions.
+ * [OUTPUT]: Maintains reconnecting ws subscription and dispatches realtime task/agent/log events.
+ * [POS]: client realtime bridge between backend websocket stream and Zustand state.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect } from "react";
+import { useAgentStore } from "@/stores/agentStore";
+import { useTaskStore } from "@/stores/taskStore";
+import type { Agent, Task, WebSocketEnvelope } from "@/types";
+
+function resolveWebSocketUrl(): string {
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ return `${protocol}//${window.location.host}/ws`;
+}
+
+export function useWebSocket(): void {
+ const upsertTask = useTaskStore((state) => state.upsertTask);
+ const appendLog = useTaskStore((state) => state.appendLog);
+ const upsertAgent = useAgentStore((state) => state.upsertAgent);
+
+ useEffect(() => {
+ let socket: WebSocket | null = null;
+ let timer: ReturnType | undefined;
+ let retry = 0;
+ let closedByUser = false;
+
+ const connect = () => {
+ socket = new WebSocket(resolveWebSocketUrl());
+
+ socket.onopen = () => {
+ retry = 0;
+ };
+
+ socket.onmessage = (event: MessageEvent) => {
+ try {
+ const payload = JSON.parse(event.data) as WebSocketEnvelope;
+ switch (payload.event) {
+ case "task:created":
+ case "task:updated":
+ case "task:completed":
+ case "task:failed": {
+ const task = (payload.data as { task?: Task }).task;
+ if (task) {
+ upsertTask(task);
+ }
+ break;
+ }
+ case "agent:status": {
+ const agent = (payload.data as { agent?: Agent }).agent;
+ if (agent) {
+ upsertAgent(agent);
+ }
+ break;
+ }
+ case "log:append": {
+ const data = payload.data as { task_id?: string; line?: string };
+ if (data.task_id && data.line) {
+ appendLog(data.task_id, data.line);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ } catch {
+ // Ignore malformed payloads from non-standard ws senders.
+ }
+ };
+
+ socket.onclose = () => {
+ if (closedByUser) {
+ return;
+ }
+ retry = retry + 1;
+ const delayMs = Math.min(1000 * 2 ** Math.min(retry, 4), 10000);
+ timer = setTimeout(connect, delayMs);
+ };
+
+ socket.onerror = () => {
+ socket?.close();
+ };
+ };
+
+ connect();
+
+ return () => {
+ closedByUser = true;
+ if (timer) clearTimeout(timer);
+ socket?.close();
+ };
+ }, [appendLog, upsertAgent, upsertTask]);
+}
diff --git a/client/src/lib/AGENTS.md b/client/src/lib/AGENTS.md
new file mode 100644
index 00000000..438b1632
--- /dev/null
+++ b/client/src/lib/AGENTS.md
@@ -0,0 +1,10 @@
+# lib/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+status.ts: Central task status labels, colors, and CI formatter helpers.
+
+法则
+- Keep all status visual mapping in one file to avoid divergent semantics.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/lib/status.ts b/client/src/lib/status.ts
new file mode 100644
index 00000000..eb64e68b
--- /dev/null
+++ b/client/src/lib/status.ts
@@ -0,0 +1,44 @@
+/**
+ * [INPUT]: Depends on task status enum values from shared domain types.
+ * [OUTPUT]: Exposes consistent status color, label, and badge helpers for UI rendering.
+ * [POS]: presentation utility layer used across calendar cards and task detail views.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import type { TaskStatus } from "@/types";
+
+export const taskStatusLabel: Record = {
+ blocked: "Blocked",
+ queued: "Queued",
+ running: "Running",
+ pr_open: "PR Open",
+ completed: "Completed",
+ failed: "Failed",
+ archived: "Archived",
+};
+
+export const taskStatusColor: Record = {
+ running: "bg-emerald-500",
+ queued: "bg-amber-400",
+ pr_open: "bg-sky-500",
+ completed: "bg-slate-400",
+ failed: "bg-red-500",
+ blocked: "bg-orange-500",
+ archived: "bg-slate-300",
+};
+
+export const taskStatusSurface: Record = {
+ running: "bg-emerald-50 border-emerald-200 text-emerald-900 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-200",
+ queued: "bg-amber-50 border-amber-200 text-amber-900 dark:bg-amber-900/30 dark:border-amber-800 dark:text-amber-200",
+ pr_open: "bg-sky-50 border-sky-200 text-sky-900 dark:bg-sky-900/30 dark:border-sky-800 dark:text-sky-200",
+ completed: "bg-slate-100 border-slate-200 text-slate-900 dark:bg-slate-800 dark:border-slate-700 dark:text-slate-200",
+ failed: "bg-red-50 border-red-200 text-red-900 dark:bg-red-900/30 dark:border-red-800 dark:text-red-200",
+ blocked: "bg-orange-50 border-orange-200 text-orange-900 dark:bg-orange-900/30 dark:border-orange-800 dark:text-orange-200",
+ archived: "bg-zinc-100 border-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:border-zinc-700 dark:text-zinc-300",
+};
+
+export function formatCiStatus(status: "pending" | "passing" | "failing" | null): string {
+ if (!status) return "-";
+ if (status === "passing") return "Passing";
+ if (status === "failing") return "Failing";
+ return "Pending";
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 00000000..41341bec
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,19 @@
+/**
+ * [INPUT]: Depends on ReactDOM root mounting, BrowserRouter, and global stylesheet import.
+ * [OUTPUT]: Boots the client application into #root.
+ * [POS]: runtime entrypoint for the AgentCal client bundle.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import App from "@/App";
+import "@/styles/globals.css";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+
+
+ ,
+);
diff --git a/client/src/pages/AGENTS.md b/client/src/pages/AGENTS.md
new file mode 100644
index 00000000..8f8165b2
--- /dev/null
+++ b/client/src/pages/AGENTS.md
@@ -0,0 +1,15 @@
+# pages/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+CalendarPage.tsx: Main planning surface with prompt-to-task and calendar views.
+AgentsPage.tsx: Agent fleet cards with status and stats.
+TaskDetailPage.tsx: Notion-like task deep view with live logs and control actions.
+StatsPage.tsx: Recharts analytics for throughput, success, and utilization.
+SettingsPage.tsx: Scheduler config and backend status page.
+
+法则
+- Page files orchestrate data load and composition; reusable UI lives in components.
+- Task actions (redirect/kill/retry/spawn) must handle optimistic errors clearly.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/pages/AgentsPage.tsx b/client/src/pages/AgentsPage.tsx
new file mode 100644
index 00000000..e87be872
--- /dev/null
+++ b/client/src/pages/AgentsPage.tsx
@@ -0,0 +1,105 @@
+/**
+ * [INPUT]: Depends on agent/task stores for status, current task linkage, and performance stats.
+ * [OUTPUT]: Renders agent status cards with live state, current work, and success metrics.
+ * [POS]: operational monitoring page for registered coding agents.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect } from "react";
+import { useAgentStore } from "@/stores/agentStore";
+import { useTaskStore } from "@/stores/taskStore";
+
+const statusClass: Record<"idle" | "busy" | "offline", string> = {
+ idle: "bg-emerald-500",
+ busy: "bg-amber-400",
+ offline: "bg-slate-400",
+};
+
+export function AgentsPage() {
+ const agents = useAgentStore((state) => state.agents);
+ const isLoading = useAgentStore((state) => state.isLoading);
+ const loadAgents = useAgentStore((state) => state.loadAgents);
+ const getTaskById = useTaskStore((state) => state.getTaskById);
+ const loadTasks = useTaskStore((state) => state.loadTasks);
+
+ useEffect(() => {
+ void loadAgents();
+ void loadTasks();
+ }, [loadAgents, loadTasks]);
+
+ if (isLoading) {
+ return Loading agents...
;
+ }
+
+ return (
+
+
+
+ {agents.length === 0 ? (
+
+ No agents registered.
+
+ ) : (
+
+ {agents.map((agent) => {
+ const currentTask = getTaskById(agent.current_task_id);
+ const totalCompletedOrFailed = agent.stats.success_count + agent.stats.fail_count;
+ const successRate = totalCompletedOrFailed === 0 ? 0 : (agent.stats.success_count / totalCompletedOrFailed) * 100;
+
+ return (
+
+
+
+
{agent.emoji ?? "🤖"} {agent.name}
+
{agent.type}
+
+
+
+ {agent.status}
+
+
+
+
+
+
{agent.stats.total_tasks}
+
Tasks
+
+
+
{agent.stats.success_count}
+
Success
+
+
+
{agent.stats.fail_count}
+
Failed
+
+
+
+
+
+
- Success rate
+ - {successRate.toFixed(1)}%
+
+
+
- Avg duration
+ - {agent.stats.avg_duration_min.toFixed(1)}m
+
+
+
- Current task
+ -
+ {currentTask ? currentTask.title : "No active task"}
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/CalendarPage.tsx b/client/src/pages/CalendarPage.tsx
new file mode 100644
index 00000000..72694fba
--- /dev/null
+++ b/client/src/pages/CalendarPage.tsx
@@ -0,0 +1,203 @@
+/**
+ * [INPUT]: Depends on calendar/task stores, calendar hook helpers, prompt-to-task api, and router navigation.
+ * [OUTPUT]: Renders day/week/month calendar workspace with prompt parsing preview and task creation flow.
+ * [POS]: primary planning page where users schedule work and open task details.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect, useMemo, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { CalendarGrid } from "@/components/CalendarGrid";
+import { useTaskStore } from "@/stores/taskStore";
+import { useCalendar } from "@/hooks/useCalendar";
+import { createTaskFromPrompt, parseTaskFromPrompt } from "@/api/client";
+import type { CalendarView, PromptParserMeta, PromptTaskDraft } from "@/types";
+
+const calendarViews: CalendarView[] = ["day", "week", "month"];
+
+export function CalendarPage() {
+ const navigate = useNavigate();
+ const calendarTasks = useTaskStore((state) => state.calendarTasks);
+ const loadCalendarTasks = useTaskStore((state) => state.loadCalendarTasks);
+ const upsertTask = useTaskStore((state) => state.upsertTask);
+ const setSelectedTaskId = useTaskStore((state) => state.setSelectedTaskId);
+
+ const { view, setView, currentDate, goPrevious, goNext, goToday, label, queryDate } = useCalendar("week");
+
+ const [prompt, setPrompt] = useState("");
+ const [draft, setDraft] = useState(null);
+ const [parserMeta, setParserMeta] = useState(null);
+ const [parseLoading, setParseLoading] = useState(false);
+ const [createLoading, setCreateLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ void loadCalendarTasks(view, queryDate);
+ }, [loadCalendarTasks, queryDate, view]);
+
+ const taskCount = useMemo(() => calendarTasks.length, [calendarTasks]);
+
+ async function handleParsePrompt(): Promise {
+ if (!prompt.trim()) return;
+
+ setParseLoading(true);
+ setError(null);
+ try {
+ const response = await parseTaskFromPrompt(prompt.trim());
+ setDraft(response.parsed);
+ setParserMeta(response.parser);
+ } catch (requestError) {
+ setError(requestError instanceof Error ? requestError.message : "Failed to parse prompt");
+ setDraft(null);
+ setParserMeta(null);
+ } finally {
+ setParseLoading(false);
+ }
+ }
+
+ async function handleCreateFromPrompt(): Promise {
+ if (!prompt.trim()) return;
+
+ setCreateLoading(true);
+ setError(null);
+ try {
+ const response = await createTaskFromPrompt(prompt.trim());
+ if (response.task) {
+ upsertTask(response.task);
+ }
+ setPrompt("");
+ setDraft(null);
+ setParserMeta(null);
+ await loadCalendarTasks(view, queryDate);
+ } catch (requestError) {
+ setError(requestError instanceof Error ? requestError.message : "Failed to create task");
+ } finally {
+ setCreateLoading(false);
+ }
+ }
+
+ function handleTaskClick(taskId: string): void {
+ setSelectedTaskId(taskId);
+ navigate(`/tasks/${taskId}`);
+ }
+
+ return (
+
+
+
+
+
Calendar
+
{label} · {taskCount} tasks
+
+
+ {calendarViews.map((candidateView) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
handleTaskClick(task.id)}
+ />
+
+ );
+}
diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx
new file mode 100644
index 00000000..5dda1668
--- /dev/null
+++ b/client/src/pages/SettingsPage.tsx
@@ -0,0 +1,116 @@
+/**
+ * [INPUT]: Depends on system config/status endpoints for scheduler and host diagnostics.
+ * [OUTPUT]: Renders settings controls for concurrent-agent limit plus runtime health snapshot.
+ * [POS]: operational preference page for client-side and backend scheduler settings.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect, useState } from "react";
+import {
+ fetchSystemConfig,
+ fetchSystemStatus,
+ updateSystemConfig,
+} from "@/api/client";
+import type { SystemStatus } from "@/types";
+
+export function SettingsPage() {
+ const [maxConcurrentAgents, setMaxConcurrentAgents] = useState(3);
+ const [status, setStatus] = useState(null);
+ const [message, setMessage] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ const load = async () => {
+ setLoading(true);
+ try {
+ const [config, systemStatus] = await Promise.all([fetchSystemConfig(), fetchSystemStatus()]);
+ setMaxConcurrentAgents(config.max_concurrent_agents);
+ setStatus(systemStatus);
+ } catch (requestError) {
+ setMessage(requestError instanceof Error ? requestError.message : "Failed to load settings");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ void load();
+ }, []);
+
+ async function saveSchedulerConfig(): Promise {
+ setSaving(true);
+ setMessage(null);
+ try {
+ const response = await updateSystemConfig({
+ max_concurrent_agents: maxConcurrentAgents,
+ });
+ setMaxConcurrentAgents(response.max_concurrent_agents);
+ setMessage("Saved");
+ } catch (requestError) {
+ setMessage(requestError instanceof Error ? requestError.message : "Failed to save");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+ Scheduler
+ {loading ? (
+ Loading configuration...
+ ) : (
+
+
+
setMaxConcurrentAgents(Math.max(1, Number(event.target.value || 1)))}
+ className="w-24 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
+ />
+
+ {message ?
{message}
: null}
+
+ )}
+
+
+
+ Backend Status
+ {status ? (
+
+
+ item.toFixed(2)).join(", ")} />
+
+
+
+
+
+ ) : (
+ Status unavailable.
+ )}
+
+
+ );
+}
+
+function StatRow({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/client/src/pages/StatsPage.tsx b/client/src/pages/StatsPage.tsx
new file mode 100644
index 00000000..ad9e0fd5
--- /dev/null
+++ b/client/src/pages/StatsPage.tsx
@@ -0,0 +1,181 @@
+/**
+ * [INPUT]: Depends on system stats API and Recharts visualization primitives.
+ * [OUTPUT]: Renders daily/weekly throughput, success ratio, and agent utilization charts.
+ * [POS]: analytics page summarizing execution quality and capacity trends.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Legend,
+ Line,
+ LineChart,
+ Pie,
+ PieChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import { fetchSystemStats } from "@/api/client";
+import type { SystemStats } from "@/types";
+
+const PIE_COLORS = ["#10b981", "#ef4444", "#94a3b8"];
+
+function weekKey(isoDate: string): string {
+ const date = new Date(`${isoDate}T00:00:00`);
+ if (Number.isNaN(date.getTime())) return isoDate;
+
+ const day = date.getDay();
+ const delta = day === 0 ? -6 : 1 - day;
+ date.setDate(date.getDate() + delta);
+ return date.toISOString().slice(0, 10);
+}
+
+export function StatsPage() {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const load = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await fetchSystemStats();
+ setStats(response);
+ } catch (requestError) {
+ setError(requestError instanceof Error ? requestError.message : "Failed to load stats");
+ setStats(null);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ void load();
+ }, []);
+
+ const weeklyTrend = useMemo(() => {
+ if (!stats) return [];
+ const totals = new Map();
+ for (const point of stats.completion_trend_30d) {
+ const key = weekKey(point.date);
+ totals.set(key, (totals.get(key) ?? 0) + point.count);
+ }
+
+ return Array.from(totals.entries())
+ .sort(([left], [right]) => (left > right ? 1 : -1))
+ .map(([weekStart, count]) => ({ weekStart, count }));
+ }, [stats]);
+
+ if (loading) {
+ return Loading stats...
;
+ }
+
+ if (error || !stats) {
+ return {error ?? "No stats available"}
;
+ }
+
+ const successRatePercent = (stats.totals.success_rate * 100).toFixed(1);
+ const pieData = [
+ { name: "Completed", value: stats.totals.completed_tasks },
+ { name: "Failed", value: stats.totals.failed_tasks },
+ {
+ name: "Other",
+ value: Math.max(stats.totals.total_tasks - stats.totals.completed_tasks - stats.totals.failed_tasks, 0),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ value.slice(5)} />
+
+
+
+
+
+
+
+
+
+
+
+ value.slice(5)} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {pieData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function MetricCard({ label, value, accent }: { label: string; value: number | string; accent?: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function ChartCard({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
diff --git a/client/src/pages/TaskDetailPage.tsx b/client/src/pages/TaskDetailPage.tsx
new file mode 100644
index 00000000..0c816ecc
--- /dev/null
+++ b/client/src/pages/TaskDetailPage.tsx
@@ -0,0 +1,236 @@
+/**
+ * [INPUT]: Depends on route task id, task/agent stores, log viewer component, and task action APIs.
+ * [OUTPUT]: Renders notion-like task detail page with metadata, live logs, and control actions.
+ * [POS]: deep task inspection and intervention surface for redirect/kill/retry operations.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { useEffect, useMemo, useState } from "react";
+import { Link, useParams } from "react-router-dom";
+import {
+ killTask,
+ redirectTask,
+ retryTask,
+ spawnTask,
+ updateTask,
+} from "@/api/client";
+import { LogViewer } from "@/components/LogViewer";
+import { formatCiStatus, taskStatusLabel, taskStatusSurface } from "@/lib/status";
+import { useAgentStore } from "@/stores/agentStore";
+import { useTaskStore } from "@/stores/taskStore";
+
+function formatDate(value: string | null): string {
+ if (!value) return "-";
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return "-";
+ return parsed.toLocaleString();
+}
+
+export function TaskDetailPage() {
+ const { taskId } = useParams<{ taskId: string }>();
+ const loadTaskById = useTaskStore((state) => state.loadTaskById);
+ const upsertTask = useTaskStore((state) => state.upsertTask);
+ const appendLog = useTaskStore((state) => state.appendLog);
+ const setSelectedTaskId = useTaskStore((state) => state.setSelectedTaskId);
+ const loadAgents = useAgentStore((state) => state.loadAgents);
+ const getAgentById = useAgentStore((state) => state.getAgentById);
+
+ const task = useTaskStore((state) => state.getTaskById(taskId ?? null));
+ const logs = useTaskStore((state) => (taskId ? state.logsByTaskId[taskId] ?? [] : []));
+
+ const [redirectMessage, setRedirectMessage] = useState("");
+ const [isActing, setIsActing] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!taskId) return;
+ setSelectedTaskId(taskId);
+ void loadTaskById(taskId);
+ void loadAgents();
+ }, [loadAgents, loadTaskById, setSelectedTaskId, taskId]);
+
+ const agent = useMemo(() => getAgentById(task?.agent_id ?? null), [getAgentById, task?.agent_id]);
+
+ if (!taskId) {
+ return Task id is missing.
;
+ }
+
+ if (!task) {
+ return Loading task details...
;
+ }
+
+ async function runAction(action: () => Promise): Promise {
+ setIsActing(true);
+ setError(null);
+ try {
+ await action();
+ } catch (requestError) {
+ setError(requestError instanceof Error ? requestError.message : "Task action failed");
+ } finally {
+ setIsActing(false);
+ }
+ }
+
+ return (
+
+
+
+
+ ← Back to Calendar
+
+
{task.title}
+
+
+ {taskStatusLabel[task.status]}
+
+
+
+
+
+
+
Agent
+
{agent ? `${agent.emoji ?? "🤖"} ${agent.name}` : "Unassigned"}
+
+
+
Priority
+
{task.priority}
+
+
+
Branch
+
{task.branch ?? "-"}
+
+
+
CI Status
+
{formatCiStatus(task.ci_status)}
+
+
+
Started
+
{formatDate(task.started_at)}
+
+
+
Completed
+
{formatDate(task.completed_at)}
+
+
+
+
+
+
+ Description
+
+ {task.description || "No description provided."}
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/stores/AGENTS.md b/client/src/stores/AGENTS.md
new file mode 100644
index 00000000..0fb55566
--- /dev/null
+++ b/client/src/stores/AGENTS.md
@@ -0,0 +1,12 @@
+# stores/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+taskStore.ts: Task/calendar state machine with websocket log accumulation.
+agentStore.ts: Agent state machine with load/upsert selectors.
+
+法则
+- Stores own domain state; page local state is only for transient UI controls.
+- Upsert helpers must preserve existing collections and selection coherence.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/stores/agentStore.ts b/client/src/stores/agentStore.ts
new file mode 100644
index 00000000..4eb90f71
--- /dev/null
+++ b/client/src/stores/agentStore.ts
@@ -0,0 +1,52 @@
+/**
+ * [INPUT]: Depends on Zustand state container and api client agent endpoints.
+ * [OUTPUT]: Exposes agent list state with load and realtime upsert actions.
+ * [POS]: client agent domain store consumed by agents page and task detail references.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { create } from "zustand";
+import type { Agent } from "@/types";
+import { fetchAgents } from "@/api/client";
+
+interface AgentStoreState {
+ agents: Agent[];
+ isLoading: boolean;
+ error: string | null;
+ loadAgents: () => Promise;
+ upsertAgent: (agent: Agent) => void;
+ getAgentById: (agentId: string | null) => Agent | null;
+}
+
+export const useAgentStore = create((set, get) => ({
+ agents: [],
+ isLoading: false,
+ error: null,
+ async loadAgents() {
+ set({ isLoading: true, error: null });
+ try {
+ const agents = await fetchAgents();
+ set({ agents, isLoading: false });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : "Failed to load agents",
+ });
+ }
+ },
+ upsertAgent(agent) {
+ set((state) => {
+ const index = state.agents.findIndex((candidate) => candidate.id === agent.id);
+ if (index === -1) {
+ return { agents: [agent, ...state.agents] };
+ }
+
+ const nextAgents = [...state.agents];
+ nextAgents[index] = agent;
+ return { agents: nextAgents };
+ });
+ },
+ getAgentById(agentId) {
+ if (!agentId) return null;
+ return get().agents.find((agent) => agent.id === agentId) ?? null;
+ },
+}));
diff --git a/client/src/stores/taskStore.ts b/client/src/stores/taskStore.ts
new file mode 100644
index 00000000..027321fb
--- /dev/null
+++ b/client/src/stores/taskStore.ts
@@ -0,0 +1,134 @@
+/**
+ * [INPUT]: Depends on Zustand state container and task/calendar endpoints from api client.
+ * [OUTPUT]: Exposes task collections, selected task state, and live log mutations.
+ * [POS]: client task source of truth coordinating calendar data and detail page data.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { create } from "zustand";
+import type { CalendarView, Task } from "@/types";
+import {
+ fetchCalendarDay,
+ fetchCalendarMonth,
+ fetchCalendarWeek,
+ fetchTask,
+ fetchTasks,
+} from "@/api/client";
+
+interface TaskStoreState {
+ tasks: Task[];
+ calendarTasks: Task[];
+ selectedTaskId: string | null;
+ logsByTaskId: Record;
+ isLoading: boolean;
+ error: string | null;
+ loadTasks: (params?: Record) => Promise;
+ loadCalendarTasks: (view: CalendarView, date: string) => Promise;
+ loadTaskById: (taskId: string) => Promise;
+ setSelectedTaskId: (taskId: string | null) => void;
+ upsertTask: (task: Task) => void;
+ removeTask: (taskId: string) => void;
+ appendLog: (taskId: string, line: string) => void;
+ clearLogs: (taskId: string) => void;
+ getTaskById: (taskId: string | null) => Task | null;
+}
+
+function upsert(tasks: Task[], task: Task): Task[] {
+ const index = tasks.findIndex((candidate) => candidate.id === task.id);
+ if (index === -1) return [task, ...tasks];
+ const next = [...tasks];
+ next[index] = task;
+ return next;
+}
+
+export const useTaskStore = create((set, get) => ({
+ tasks: [],
+ calendarTasks: [],
+ selectedTaskId: null,
+ logsByTaskId: {},
+ isLoading: false,
+ error: null,
+ async loadTasks(params) {
+ set({ isLoading: true, error: null });
+ try {
+ const tasks = await fetchTasks(params);
+ set({ tasks, isLoading: false });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : "Failed to load tasks",
+ });
+ }
+ },
+ async loadCalendarTasks(view, date) {
+ set({ isLoading: true, error: null });
+ try {
+ const response =
+ view === "day"
+ ? await fetchCalendarDay(date)
+ : view === "week"
+ ? await fetchCalendarWeek(date)
+ : await fetchCalendarMonth(date);
+ set({ calendarTasks: response.tasks, isLoading: false });
+ } catch (error) {
+ set({
+ isLoading: false,
+ error: error instanceof Error ? error.message : "Failed to load calendar tasks",
+ });
+ }
+ },
+ async loadTaskById(taskId) {
+ try {
+ const task = await fetchTask(taskId);
+ set((state) => ({
+ tasks: upsert(state.tasks, task),
+ calendarTasks: upsert(state.calendarTasks, task),
+ }));
+ return task;
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : "Failed to load task" });
+ return null;
+ }
+ },
+ setSelectedTaskId(taskId) {
+ set({ selectedTaskId: taskId });
+ },
+ upsertTask(task) {
+ set((state) => ({
+ tasks: upsert(state.tasks, task),
+ calendarTasks: upsert(state.calendarTasks, task),
+ }));
+ },
+ removeTask(taskId) {
+ set((state) => ({
+ tasks: state.tasks.filter((task) => task.id !== taskId),
+ calendarTasks: state.calendarTasks.filter((task) => task.id !== taskId),
+ selectedTaskId: state.selectedTaskId === taskId ? null : state.selectedTaskId,
+ }));
+ },
+ appendLog(taskId, line) {
+ set((state) => {
+ const current = state.logsByTaskId[taskId] ?? [];
+ return {
+ logsByTaskId: {
+ ...state.logsByTaskId,
+ [taskId]: [...current, line].slice(-1000),
+ },
+ };
+ });
+ },
+ clearLogs(taskId) {
+ set((state) => {
+ const next = { ...state.logsByTaskId };
+ delete next[taskId];
+ return { logsByTaskId: next };
+ });
+ },
+ getTaskById(taskId) {
+ if (!taskId) return null;
+ return (
+ get().tasks.find((task) => task.id === taskId) ??
+ get().calendarTasks.find((task) => task.id === taskId) ??
+ null
+ );
+ },
+}));
diff --git a/client/src/styles/AGENTS.md b/client/src/styles/AGENTS.md
new file mode 100644
index 00000000..41ffb900
--- /dev/null
+++ b/client/src/styles/AGENTS.md
@@ -0,0 +1,10 @@
+# styles/
+> L2 | 父级: /client/src/AGENTS.md
+
+成员清单
+globals.css: Tailwind entry import and global background/typography tokens.
+
+法则
+- Global CSS should define tokens and baseline only; component styling stays in class utilities.
+
+[PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
diff --git a/client/src/styles/globals.css b/client/src/styles/globals.css
new file mode 100644
index 00000000..c5526297
--- /dev/null
+++ b/client/src/styles/globals.css
@@ -0,0 +1,27 @@
+@import "tailwindcss";
+
+:root {
+ --radius: 8px;
+}
+
+html,
+body,
+#root {
+ height: 100%;
+}
+
+body {
+ background: radial-gradient(circle at top right, #f8fafc 0%, #f4f4f5 40%, #f8fafc 100%);
+ color: #0f172a;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+
+.dark body {
+ background: radial-gradient(circle at top right, #1f2937 0%, #0f172a 45%, #020617 100%);
+ color: #e5e7eb;
+}
+
+.panel {
+ border-radius: var(--radius);
+ box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);
+}
diff --git a/client/src/types.ts b/client/src/types.ts
new file mode 100644
index 00000000..6c00eba1
--- /dev/null
+++ b/client/src/types.ts
@@ -0,0 +1,193 @@
+/**
+ * [INPUT]: Depends on backend API schemas and websocket payload contracts.
+ * [OUTPUT]: Exposes shared frontend domain types for tasks, agents, calendar, and stats.
+ * [POS]: client single source of type truth consumed by stores, hooks, pages, and api layer.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+export type AgentType = "codex" | "claude";
+export type AgentStatus = "idle" | "busy" | "offline";
+export type TaskPriority = "low" | "medium" | "high" | "urgent";
+export type TaskStatus =
+ | "blocked"
+ | "queued"
+ | "running"
+ | "pr_open"
+ | "completed"
+ | "failed"
+ | "archived";
+export type CIStatus = "pending" | "passing" | "failing" | null;
+export type ReviewStatus = "pending" | "approved" | "rejected";
+
+export interface AgentStats {
+ total_tasks: number;
+ success_count: number;
+ fail_count: number;
+ avg_duration_min: number;
+}
+
+export interface Agent {
+ id: string;
+ project_id: string | null;
+ name: string;
+ type: AgentType;
+ status: AgentStatus;
+ current_task_id: string | null;
+ stats: AgentStats;
+ emoji?: string;
+ color?: string;
+ avatar_url?: string;
+ settings?: Record;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TaskReviews {
+ codex: ReviewStatus;
+ gemini: ReviewStatus;
+ claude: ReviewStatus;
+}
+
+export interface Task {
+ id: string;
+ project_id: string;
+ title: string;
+ description: string;
+ status: TaskStatus;
+ priority: TaskPriority;
+ agent_type: AgentType;
+ agent_id: string | null;
+ branch: string | null;
+ pr_url: string | null;
+ pr_number: number | null;
+ ci_status: CIStatus;
+ reviews: TaskReviews;
+ retry_count: number;
+ max_retries: number;
+ depends_on: string[];
+ blocked_by: string[];
+ scheduled_at: string | null;
+ started_at: string | null;
+ completed_at: string | null;
+ estimated_duration_min: number;
+ actual_duration_min: number | null;
+ tmux_session: string | null;
+ worktree_path: string | null;
+ log_path: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CalendarDailyResponse {
+ date: string;
+ tasks: Task[];
+}
+
+export interface CalendarWeeklyResponse {
+ week_start: string;
+ week_end: string;
+ tasks: Task[];
+}
+
+export interface CalendarMonthlyResponse {
+ month: string;
+ from: string;
+ to: string;
+ tasks: Task[];
+}
+
+export interface PromptTaskDraft {
+ title: string;
+ description: string;
+ priority: TaskPriority;
+ agent_type: AgentType;
+ scheduled_at: string | null;
+ depends_on: string[];
+}
+
+export interface PromptParserMeta {
+ provider: "openai" | "anthropic" | "fallback";
+ model: string | null;
+ fallback: boolean;
+ reason?: string;
+}
+
+export interface PromptTaskFromPromptResponse {
+ parsed: PromptTaskDraft;
+ parser: PromptParserMeta;
+ task?: Task;
+ dry_run: boolean;
+}
+
+export interface SystemStats {
+ generated_at: string;
+ totals: {
+ total_tasks: number;
+ completed_tasks: number;
+ failed_tasks: number;
+ avg_duration_min: number | null;
+ success_rate: number;
+ };
+ by_status: Array<{
+ status: TaskStatus;
+ count: number;
+ }>;
+ completion_trend_30d: Array<{
+ date: string;
+ count: number;
+ }>;
+ agent_utilization: Array<{
+ id: string;
+ name: string;
+ type: AgentType;
+ status: AgentStatus;
+ total_tasks: number;
+ success_count: number;
+ fail_count: number;
+ avg_duration_min: number;
+ running_tasks: number;
+ }>;
+}
+
+export interface SystemStatus {
+ timestamp: string;
+ cpu: {
+ cores: number;
+ load_avg: number[];
+ };
+ memory: {
+ total_mb: number;
+ free_mb: number;
+ used_mb: number;
+ usage_percent: number;
+ };
+ agents: {
+ active: number;
+ total: number;
+ };
+ tasks: {
+ running: number;
+ };
+ process: {
+ uptime_sec: number;
+ pid: number;
+ };
+}
+
+export interface QueueStatus {
+ generated_at: string;
+ max_concurrent_agents: number;
+ running_count: number;
+ available_slots: number;
+}
+
+export interface SystemConfig {
+ max_concurrent_agents: number;
+}
+
+export type CalendarView = "day" | "week" | "month";
+
+export interface WebSocketEnvelope {
+ event: string;
+ data: T;
+ timestamp: string;
+}
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/client/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts
new file mode 100644
index 00000000..18b2b746
--- /dev/null
+++ b/client/tailwind.config.ts
@@ -0,0 +1,31 @@
+/**
+ * [INPUT]: Depends on client source files and Tailwind dark mode class strategy.
+ * [OUTPUT]: Exposes Tailwind scan paths and theme extension for AgentCal UI tokens.
+ * [POS]: client design token and utility generation contract.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import type { Config } from "tailwindcss";
+
+export default {
+ darkMode: "class",
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ fontFamily: {
+ sans: ["Inter", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"],
+ },
+ borderRadius: {
+ DEFAULT: "8px",
+ },
+ boxShadow: {
+ card: "0 2px 8px rgba(15, 23, 42, 0.08)",
+ },
+ colors: {
+ surface: "#ffffff",
+ "surface-muted": "#f8fafc",
+ "surface-dark": "#0f172a",
+ "surface-dark-muted": "#111827",
+ },
+ },
+ },
+} satisfies Config;
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 00000000..c2e5f89a
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts", "tailwind.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 00000000..6f3da0c3
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,32 @@
+/**
+ * [INPUT]: Depends on Vite, React plugin, Tailwind plugin, and local backend proxy targets.
+ * [OUTPUT]: Exposes frontend dev/build config with /api and /ws proxy wiring.
+ * [POS]: client build/runtime gateway for local development and API bridging.
+ * [PROTOCOL]: 变更时更新此头部,然后检查 AGENTS.md
+ */
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import path from "node:path";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "src"),
+ },
+ },
+ server: {
+ port: 5174,
+ proxy: {
+ "/api": {
+ target: "http://localhost:3100",
+ changeOrigin: true,
+ },
+ "/ws": {
+ target: "ws://localhost:3100",
+ ws: true,
+ },
+ },
+ },
+});
diff --git a/package.json b/package.json
index a5a69fb3..03e28ef4 100644
--- a/package.json
+++ b/package.json
@@ -4,14 +4,19 @@
"private": true,
"scripts": {
"dev": "pnpm --filter @agentcal/server dev",
+ "dev:client": "pnpm --filter @agentcal/client dev",
+ "dev:all": "concurrently -k \"pnpm --filter @agentcal/server dev\" \"pnpm --filter @agentcal/client dev\"",
"build": "pnpm --filter @agentcal/server build",
"start": "pnpm --filter @agentcal/server start",
"typecheck": "pnpm --filter @agentcal/server typecheck"
},
+ "devDependencies": {
+ "concurrently": "^9.1.2"
+ },
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild"
]
}
-}
\ No newline at end of file
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c5a41134..5117bbac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,9 +6,57 @@ settings:
importers:
- .: {}
+ .:
+ devDependencies:
+ concurrently:
+ specifier: ^9.1.2
+ version: 9.2.1
- client: {}
+ client:
+ dependencies:
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ react-router-dom:
+ specifier: ^6.30.0
+ version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ recharts:
+ specifier: ^2.15.4
+ version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ zustand:
+ specifier: ^4.5.7
+ version: 4.5.7(@types/react@18.3.28)(react@18.3.1)
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.12
+ version: 4.2.1(vite@5.4.21(@types/node@22.19.11)(lightningcss@1.31.1))
+ '@types/react':
+ specifier: ^18.3.18
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: ^18.3.5
+ version: 18.3.7(@types/react@18.3.28)
+ '@vitejs/plugin-react':
+ specifier: ^4.7.0
+ version: 4.7.0(vite@5.4.21(@types/node@22.19.11)(lightningcss@1.31.1))
+ autoprefixer:
+ specifier: ^10.4.21
+ version: 10.4.27(postcss@8.5.6)
+ postcss:
+ specifier: ^8.5.3
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^4.1.12
+ version: 4.2.1
+ typescript:
+ specifier: ^5.9.2
+ version: 5.9.3
+ vite:
+ specifier: ^5.4.19
+ version: 5.4.21(@types/node@22.19.11)(lightningcss@1.31.1)
server:
dependencies:
@@ -845,9 +893,24 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@4.3.0:
+ resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
+ engines: {node: '>=8'}
+
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
+ autoprefixer@10.4.27:
+ resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==}
+ engines: {node: ^10 || ^12 || >=14}
+ hasBin: true
+ peerDependencies:
+ postcss: ^8.1.0
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -892,13 +955,33 @@ packages:
caniuse-lite@1.0.30001774:
resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==}
+ chalk@4.1.2:
+ resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
+ engines: {node: '>=10'}
+
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+ cliui@8.0.1:
+ resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
+ engines: {node: '>=12'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ color-convert@2.0.1:
+ resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
+ engines: {node: '>=7.0.0'}
+
+ color-name@1.1.4:
+ resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+
+ concurrently@9.2.1:
+ resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
+ engines: {node: '>=18'}
+ hasBin: true
+
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -1024,6 +1107,9 @@ packages:
electron-to-chromium@1.5.302:
resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==}
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
@@ -1094,6 +1180,9 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
+ fraction.js@5.3.4:
+ resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+
fresh@0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
@@ -1113,6 +1202,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
+ get-caller-file@2.0.5:
+ resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
+ engines: {node: 6.* || 8.* || >= 10.*}
+
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1134,6 +1227,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -1167,6 +1264,10 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -1364,6 +1465,9 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+ postcss-value-parser@4.2.0:
+ resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1458,6 +1562,10 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ require-directory@2.1.1:
+ resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
+ engines: {node: '>=0.10.0'}
+
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1466,6 +1574,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -1495,6 +1606,10 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ shell-quote@1.8.3:
+ resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
+ engines: {node: '>= 0.4'}
+
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -1525,13 +1640,29 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ supports-color@8.1.1:
+ resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
+ engines: {node: '>=10'}
+
tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
@@ -1553,6 +1684,13 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
@@ -1637,6 +1775,10 @@ packages:
terser:
optional: true
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -1652,9 +1794,21 @@ packages:
utf-8-validate:
optional: true
+ y18n@5.0.8:
+ resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
+ engines: {node: '>=10'}
+
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yargs-parser@21.1.1:
+ resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+ engines: {node: '>=12'}
+
+ yargs@17.7.2:
+ resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
+ engines: {node: '>=12'}
+
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -2236,8 +2390,23 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@4.3.0:
+ dependencies:
+ color-convert: 2.0.1
+
array-flatten@1.1.1: {}
+ autoprefixer@10.4.27(postcss@8.5.6):
+ dependencies:
+ browserslist: 4.28.1
+ caniuse-lite: 1.0.30001774
+ fraction.js: 5.3.4
+ picocolors: 1.1.1
+ postcss: 8.5.6
+ postcss-value-parser: 4.2.0
+
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.0: {}
@@ -2301,10 +2470,36 @@ snapshots:
caniuse-lite@1.0.30001774: {}
+ chalk@4.1.2:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
chownr@1.1.4: {}
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
clsx@2.1.1: {}
+ color-convert@2.0.1:
+ dependencies:
+ color-name: 1.1.4
+
+ color-name@1.1.4: {}
+
+ concurrently@9.2.1:
+ dependencies:
+ chalk: 4.1.2
+ rxjs: 7.8.2
+ shell-quote: 1.8.3
+ supports-color: 8.1.1
+ tree-kill: 1.2.2
+ yargs: 17.7.2
+
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -2401,6 +2596,8 @@ snapshots:
electron-to-chromium@1.5.302: {}
+ emoji-regex@8.0.0: {}
+
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
@@ -2539,6 +2736,8 @@ snapshots:
forwarded@0.2.0: {}
+ fraction.js@5.3.4: {}
+
fresh@0.5.2: {}
fs-constants@1.0.0: {}
@@ -2550,6 +2749,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
+ get-caller-file@2.0.5: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -2578,6 +2779,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ has-flag@4.0.0: {}
+
has-symbols@1.1.0: {}
hasown@2.0.2:
@@ -2606,6 +2809,8 @@ snapshots:
ipaddr.js@1.9.1: {}
+ is-fullwidth-code-point@3.0.0: {}
+
jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -2737,6 +2942,8 @@ snapshots:
picocolors@1.1.1: {}
+ postcss-value-parser@4.2.0: {}
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -2862,6 +3069,8 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
+ require-directory@2.1.1: {}
+
resolve-pkg-maps@1.0.0: {}
rollup@4.59.0:
@@ -2895,6 +3104,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
@@ -2936,6 +3149,8 @@ snapshots:
setprototypeof@1.2.0: {}
+ shell-quote@1.8.3: {}
+
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -2976,12 +3191,30 @@ snapshots:
statuses@2.0.2: {}
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
strip-json-comments@2.0.1: {}
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ supports-color@8.1.1:
+ dependencies:
+ has-flag: 4.0.0
+
tailwindcss@4.2.1: {}
tapable@2.3.0: {}
@@ -3005,6 +3238,10 @@ snapshots:
toidentifier@1.0.1: {}
+ tree-kill@1.2.2: {}
+
+ tslib@2.8.1: {}
+
tsx@4.21.0:
dependencies:
esbuild: 0.27.3
@@ -3072,12 +3309,32 @@ snapshots:
fsevents: 2.3.3
lightningcss: 1.31.1
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrappy@1.0.2: {}
ws@8.19.0: {}
+ y18n@5.0.8: {}
+
yallist@3.1.1: {}
+ yargs-parser@21.1.1: {}
+
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
zustand@4.5.7(@types/react@18.3.28)(react@18.3.1):
dependencies:
use-sync-external-store: 1.6.0(react@18.3.1)