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

+

Realtime agent fleet status and throughput health.

+
+ + {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) => ( + + ))} + + + +
+
+ +
+

Prompt to Task

+