diff --git a/taskboard-electron/assets/agentforge-source.png b/taskboard-electron/assets/agentforge-source.png new file mode 100644 index 0000000..7c1e366 Binary files /dev/null and b/taskboard-electron/assets/agentforge-source.png differ diff --git a/taskboard-electron/assets/agentforge.icns b/taskboard-electron/assets/agentforge.icns new file mode 100644 index 0000000..cfdbae0 Binary files /dev/null and b/taskboard-electron/assets/agentforge.icns differ diff --git a/taskboard-electron/assets/agentforge.png b/taskboard-electron/assets/agentforge.png new file mode 100644 index 0000000..9973717 Binary files /dev/null and b/taskboard-electron/assets/agentforge.png differ diff --git a/taskboard-electron/bun.lock b/taskboard-electron/bun.lock index 4cf40a9..6e2fa79 100644 --- a/taskboard-electron/bun.lock +++ b/taskboard-electron/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", + "lucide-react": "^1.18.0", "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -883,6 +884,8 @@ "lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lucide-react": ["lucide-react@1.18.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA=="], + "macos-alias": ["macos-alias@0.2.12", "https://registry.npmmirror.com/macos-alias/-/macos-alias-0.2.12.tgz", { "dependencies": { "nan": "^2.4.0" }, "os": "darwin" }, "sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw=="], "make-fetch-happen": ["make-fetch-happen@10.2.1", "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", { "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^7.7.1", "minipass": "^3.1.6", "minipass-collect": "^1.0.2", "minipass-fetch": "^2.0.3", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.3", "promise-retry": "^2.0.1", "socks-proxy-agent": "^7.0.0", "ssri": "^9.0.0" } }, "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w=="], diff --git a/taskboard-electron/forge.config.js b/taskboard-electron/forge.config.js index 1b8813c..bb3de38 100644 --- a/taskboard-electron/forge.config.js +++ b/taskboard-electron/forge.config.js @@ -6,6 +6,7 @@ module.exports = { asar: true, name: "AgentForge", appBundleId: "com.agentforge.app", + icon: "./assets/agentforge", extraResource: [ "./resources/taskboard", "./resources/weixin-bridge", diff --git a/taskboard-electron/index.html b/taskboard-electron/index.html index 8221757..64156c1 100644 --- a/taskboard-electron/index.html +++ b/taskboard-electron/index.html @@ -3,6 +3,7 @@ AgentForge +
diff --git a/taskboard-electron/package.json b/taskboard-electron/package.json index c7a4cd9..652c82f 100644 --- a/taskboard-electron/package.json +++ b/taskboard-electron/package.json @@ -51,6 +51,7 @@ "dependencies": { "chokidar": "^5.0.0", "electron-squirrel-startup": "^1.0.1", + "lucide-react": "^1.18.0", "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/taskboard-electron/scripts/build.ts b/taskboard-electron/scripts/build.ts index 2224c10..3b4a741 100644 --- a/taskboard-electron/scripts/build.ts +++ b/taskboard-electron/scripts/build.ts @@ -57,6 +57,9 @@ export async function buildRenderer(): Promise { throw: false, }); if (!result.success) reportFailure("renderer", result); + await fs.cp(path.join(appRoot, "assets"), path.join(rendererOutDir, "assets"), { + recursive: true, + }); console.log("[build] renderer -> .bun/renderer/index.html"); } diff --git a/taskboard-electron/scripts/generate-icon.ts b/taskboard-electron/scripts/generate-icon.ts new file mode 100644 index 0000000..245de3e --- /dev/null +++ b/taskboard-electron/scripts/generate-icon.ts @@ -0,0 +1,131 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { PNG } = require("pngjs"); + +type Pixel = [number, number, number, number]; + +const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const assetsDir = path.join(appRoot, "assets"); +const sourcePath = path.join(assetsDir, "agentforge-source.png"); +const iconsetDir = path.join(assetsDir, "agentforge.iconset"); +const pngPath = path.join(assetsDir, "agentforge.png"); +const icnsPath = path.join(assetsDir, "agentforge.icns"); + +const iconSpecs = [ + ["icon_16x16.png", 16], + ["icon_16x16@2x.png", 32], + ["icon_32x32.png", 32], + ["icon_32x32@2x.png", 64], + ["icon_128x128.png", 128], + ["icon_128x128@2x.png", 256], + ["icon_256x256.png", 256], + ["icon_256x256@2x.png", 512], + ["icon_512x512.png", 512], + ["icon_512x512@2x.png", 1024], +] as const; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function smoothstep(edge0: number, edge1: number, x: number): number { + const t = clamp((x - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function sample(source: typeof PNG, x: number, y: number): Pixel { + const x0 = clamp(Math.floor(x), 0, source.width - 1); + const y0 = clamp(Math.floor(y), 0, source.height - 1); + const x1 = clamp(x0 + 1, 0, source.width - 1); + const y1 = clamp(y0 + 1, 0, source.height - 1); + const tx = x - x0; + const ty = y - y0; + + const read = (px: number, py: number): Pixel => { + const idx = (py * source.width + px) * 4; + return [ + source.data[idx], + source.data[idx + 1], + source.data[idx + 2], + source.data[idx + 3] ?? 255, + ]; + }; + + const c00 = read(x0, y0); + const c10 = read(x1, y0); + const c01 = read(x0, y1); + const c11 = read(x1, y1); + + return [0, 1, 2, 3].map((channel) => { + const top = c00[channel] * (1 - tx) + c10[channel] * tx; + const bottom = c01[channel] * (1 - tx) + c11[channel] * tx; + return Math.round(top * (1 - ty) + bottom * ty); + }) as Pixel; +} + +function appIconMask(x: number, y: number, size: number): number { + const inset = size * 0.055; + const radius = size * 0.215; + const half = (size - inset * 2) / 2; + const qx = Math.abs(x - size / 2) - (half - radius); + const qy = Math.abs(y - size / 2) - (half - radius); + const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)); + const inside = Math.min(Math.max(qx, qy), 0); + const distance = outside + inside - radius; + return 1 - smoothstep(-1.1, 1.1, distance); +} + +function renderIcon(source: typeof PNG, size: number): Buffer { + const output = new PNG({ width: size, height: size }); + const scale = Math.min(source.width, source.height); + const offsetX = (source.width - scale) / 2; + const offsetY = (source.height - scale) / 2; + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const srcX = offsetX + ((x + 0.5) / size) * scale - 0.5; + const srcY = offsetY + ((y + 0.5) / size) * scale - 0.5; + const [red, green, blue, alpha] = sample(source, srcX, srcY); + const mask = appIconMask(x + 0.5, y + 0.5, size); + const idx = (y * size + x) * 4; + + output.data[idx] = red; + output.data[idx + 1] = green; + output.data[idx + 2] = blue; + output.data[idx + 3] = Math.round(alpha * mask); + } + } + + return PNG.sync.write(output); +} + +await mkdir(assetsDir, { recursive: true }); +await rm(iconsetDir, { recursive: true, force: true }); +await mkdir(iconsetDir, { recursive: true }); + +const source = PNG.sync.read(Buffer.from(await Bun.file(sourcePath).arrayBuffer())); + +for (const [name, size] of iconSpecs) { + const png = renderIcon(source, size); + await writeFile(path.join(iconsetDir, name), png); + if (size === 1024) await writeFile(pngPath, png); +} + +const result = Bun.spawnSync(["iconutil", "-c", "icns", iconsetDir, "-o", icnsPath], { + stdout: "pipe", + stderr: "pipe", +}); + +if (!result.success) { + console.error(new TextDecoder().decode(result.stderr)); + process.exit(result.exitCode || 1); +} + +await rm(iconsetDir, { recursive: true, force: true }); +console.log( + `[icon] wrote ${path.relative(appRoot, pngPath)} and ${path.relative(appRoot, icnsPath)}`, +); diff --git a/taskboard-electron/src/main.ts b/taskboard-electron/src/main.ts index 5f396b4..e472b0c 100644 --- a/taskboard-electron/src/main.ts +++ b/taskboard-electron/src/main.ts @@ -246,6 +246,7 @@ const createWindow = () => { height: 800, minWidth: 900, minHeight: 600, + icon: path.join(app.getAppPath(), "assets", "agentforge.png"), titleBarStyle: "hiddenInset", webPreferences: { preload: path.join(__dirname, "preload.js"), diff --git a/taskboard-electron/src/renderer/App.tsx b/taskboard-electron/src/renderer/App.tsx index c286d1a..d306a8b 100644 --- a/taskboard-electron/src/renderer/App.tsx +++ b/taskboard-electron/src/renderer/App.tsx @@ -1,4 +1,19 @@ import { useState, useEffect, useCallback, useRef, type CSSProperties } from "react"; +import { + CheckCircle2, + HeartPulse, + Inbox, + KanbanSquare, + MonitorCog, + Moon, + Play, + Plus, + Search, + Settings, + Sparkles, + Sun, + type LucideIcon, +} from "lucide-react"; import QRCode from "qrcode"; import { formatDateTimeLocalInput, @@ -20,53 +35,92 @@ const API = "http://127.0.0.1:9712/api"; // ─── Theme ─── const THEMES: Record> = { dark: { - bg: "#0a0a0f", - surface: "#12121a", - surfaceHover: "#1a1a26", - border: "#1e1e2e", - borderActive: "#2d2d44", - text: "#e2e2ef", - textMuted: "#6b6b8a", - textDim: "#44445e", - accent: "#7c6aff", - accentGlow: "rgba(124, 106, 255, 0.15)", - green: "#34d399", - greenBg: "rgba(52, 211, 153, 0.08)", - orange: "#fbbf24", - orangeBg: "rgba(251, 191, 36, 0.08)", - red: "#f87171", - redBg: "rgba(248, 113, 113, 0.08)", - blue: "#60a5fa", - blueBg: "rgba(96, 165, 250, 0.08)", - cyan: "#22d3ee", - cyanBg: "rgba(34, 211, 238, 0.08)", + bg: "#0f1116", + surface: "rgba(28, 30, 36, 0.86)", + surfaceHover: "rgba(38, 41, 48, 0.92)", + panel: "rgba(22, 24, 29, 0.74)", + panelRaised: "rgba(34, 37, 44, 0.76)", + field: "rgba(12, 14, 18, 0.56)", + border: "rgba(235, 245, 255, 0.11)", + borderActive: "rgba(10, 132, 255, 0.46)", + text: "#f5f5f7", + textMuted: "#b3b8c2", + textDim: "#727783", + accent: "#0a84ff", + accentGlow: "rgba(10, 132, 255, 0.18)", + green: "#30d158", + greenBg: "rgba(48, 209, 88, 0.12)", + orange: "#ff9f0a", + orangeBg: "rgba(255, 159, 10, 0.13)", + red: "#ff453a", + redBg: "rgba(255, 69, 58, 0.13)", + blue: "#64d2ff", + blueBg: "rgba(100, 210, 255, 0.12)", + cyan: "#5e5ce6", + cyanBg: "rgba(94, 92, 230, 0.13)", + yellow: "#ffd60a", + headerBg: "rgba(20, 22, 27, 0.74)", + headerBorder: "rgba(235, 245, 255, 0.12)", + boardBg: + "radial-gradient(circle at 20% 0%, rgba(10,132,255,0.18), transparent 32%), radial-gradient(circle at 78% 12%, rgba(94,92,230,0.14), transparent 28%), linear-gradient(180deg, #12141a 0%, #0f1116 42%)", + columnBg: "rgba(24, 27, 33, 0.58)", + columnHeader: "#f5f5f7", + shadow: "0 24px 60px rgba(0, 0, 0, 0.38)", + shadowSoft: "0 10px 30px rgba(0, 0, 0, 0.24)", + brandStart: "#64d2ff", + brandEnd: "#5e5ce6", + brandInk: "#ffffff", }, light: { - bg: "#f5f5fa", - surface: "#ffffff", - surfaceHover: "#eeeef7", - border: "#dcdce8", - borderActive: "#b8b8d0", - text: "#1a1a2e", - textMuted: "#6b6b8a", - textDim: "#a0a0bc", - accent: "#5b4ecc", - accentGlow: "rgba(91, 78, 204, 0.12)", - green: "#059669", - greenBg: "rgba(5, 150, 105, 0.08)", - orange: "#d97706", - orangeBg: "rgba(217, 119, 6, 0.08)", - red: "#dc2626", - redBg: "rgba(220, 38, 38, 0.08)", - blue: "#2563eb", - blueBg: "rgba(37, 99, 235, 0.08)", - cyan: "#0891b2", - cyanBg: "rgba(8, 145, 178, 0.08)", + bg: "#f5f5f7", + surface: "rgba(255, 255, 255, 0.88)", + surfaceHover: "rgba(255, 255, 255, 0.96)", + panel: "rgba(255, 255, 255, 0.62)", + panelRaised: "rgba(255, 255, 255, 0.82)", + field: "rgba(244, 246, 250, 0.86)", + border: "rgba(60, 60, 67, 0.14)", + borderActive: "rgba(0, 122, 255, 0.36)", + text: "#1d1d1f", + textMuted: "#6e6e73", + textDim: "#9a9aa1", + accent: "#007aff", + accentGlow: "rgba(0, 122, 255, 0.13)", + green: "#34c759", + greenBg: "rgba(52, 199, 89, 0.1)", + orange: "#ff9500", + orangeBg: "rgba(255, 149, 0, 0.11)", + red: "#ff3b30", + redBg: "rgba(255, 59, 48, 0.1)", + blue: "#007aff", + blueBg: "rgba(0, 122, 255, 0.1)", + cyan: "#5856d6", + cyanBg: "rgba(88, 86, 214, 0.1)", + yellow: "#ffcc00", + headerBg: "rgba(245, 245, 247, 0.72)", + headerBorder: "rgba(60, 60, 67, 0.12)", + boardBg: + "radial-gradient(circle at 18% 0%, rgba(0,122,255,0.14), transparent 34%), radial-gradient(circle at 82% 8%, rgba(88,86,214,0.11), transparent 30%), linear-gradient(180deg, #fbfbfd 0%, #f5f5f7 44%)", + columnBg: "rgba(255, 255, 255, 0.58)", + columnHeader: "#1d1d1f", + shadow: "0 18px 44px rgba(31, 35, 45, 0.12)", + shadowSoft: "0 8px 24px rgba(31, 35, 45, 0.09)", + brandStart: "#00c7ff", + brandEnd: "#5856d6", + brandInk: "#ffffff", }, }; // Mutable module-level theme reference — updated before each App render let theme = THEMES.dark; +const APP_FONT_STACK = + "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"; +const DISPLAY_FONT_STACK = + "'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"; +const MONO_FONT_STACK = "'SF Mono', 'JetBrains Mono', ui-monospace, Menlo, monospace"; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} function getStatusConfig() { return { @@ -86,14 +140,35 @@ function getStatusConfig() { } const COLUMNS = [ - { key: "queued", label: "Queue", statuses: ["pending", "scheduled", "blocked"], icon: "⧖" }, - { key: "running", label: "Running", statuses: ["running"], icon: "▸" }, - { key: "done", label: "Done", statuses: ["completed", "failed", "cancelled"], icon: "◆" }, + { + key: "queued", + label: "Queue", + hint: "ready, delayed, or blocked", + statuses: ["pending", "scheduled", "blocked"], + icon: Inbox, + tone: "orange", + }, + { + key: "running", + label: "Running", + hint: "live agent sessions", + statuses: ["running"], + icon: Play, + tone: "blue", + }, + { + key: "done", + label: "Done", + hint: "completed, failed, cancelled", + statuses: ["completed", "failed", "cancelled"], + icon: CheckCircle2, + tone: "green", + }, ]; const AGENTS = { - claude: { label: "Claude Code", icon: "⌘", color: "#7c6aff" }, - codex: { label: "Codex CLI", icon: "◈", color: "#10a37f" }, + claude: { label: "Claude Code", icon: "C", color: "#ff9f0a" }, + codex: { label: "Codex CLI", icon: "X", color: "#00c7be" }, }; const DEFAULT_AGENT = "codex"; const DEFAULT_TIMEOUT_SECONDS = 12000; @@ -1020,39 +1095,304 @@ async function runWeixinAction(action) { function Tooltip({ text, children }) { const [visible, setVisible] = useState(false); + const [position, setPosition] = useState<{ + arrowLeft: number; + left: number; + top: number; + placement: "top" | "bottom"; + } | null>(null); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + + useEffect(() => { + if (!visible) return; + + const updatePosition = () => { + const trigger = triggerRef.current; + const tooltip = tooltipRef.current; + if (!trigger || !tooltip) return; + + const triggerRect = trigger.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const gap = 8; + const margin = 8; + const topCandidate = triggerRect.top - tooltipRect.height - gap; + const placement = topCandidate < margin ? "bottom" : "top"; + const top = placement === "top" ? topCandidate : triggerRect.bottom + gap; + const centeredLeft = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2; + const maxLeft = window.innerWidth - tooltipRect.width - margin; + const left = clamp(centeredLeft, margin, Math.max(margin, maxLeft)); + + setPosition({ + arrowLeft: clamp( + triggerRect.left + triggerRect.width / 2 - left, + 10, + tooltipRect.width - 10, + ), + left, + top, + placement, + }); + }; + + const frame = requestAnimationFrame(updatePosition); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [visible, text]); + return (
setVisible(true)} + onMouseEnter={() => { + setPosition(null); + setVisible(true); + }} onMouseLeave={() => setVisible(false)} + onFocus={() => { + setPosition(null); + setVisible(true); + }} + onBlur={() => setVisible(false)} > {children} {visible && (
{text} +
)}
); } +function BrandMark({ size = 40 }) { + return ( +
+ AgentForge + + ); +} + +function IconGlyph({ + icon: Icon, + size = 15, + strokeWidth = 2.35, + style, +}: { + icon: LucideIcon; + size?: number; + strokeWidth?: number; + style?: CSSProperties; +}) { + return ( +
); } @@ -1279,18 +1662,19 @@ function ActionBtn({ label, title, onClick, color }) { onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ - background: hovered ? `${color}22` : "transparent", - border: "none", + background: hovered ? `${color}22` : theme.field, + border: `1px solid ${hovered ? `${color}66` : theme.border}`, color: color, cursor: "pointer", - width: 24, - height: 24, + width: 26, + height: 26, borderRadius: 6, - fontSize: 14, + fontSize: 13, display: "flex", alignItems: "center", justifyContent: "center", - transition: "background 0.15s", + backdropFilter: "blur(18px)", + transition: "background 0.15s ease, border-color 0.15s ease", }} > {label} @@ -1299,43 +1683,71 @@ function ActionBtn({ label, title, onClick, color }) { } function Column({ col, tasks, onAction, onViewDetail }) { + const iconColor = theme[col.tone] || theme.accent; + const iconBackground = theme[`${col.tone}Bg`] || theme.field; + return ( -
+
- {col.icon} - - {col.label} - +
+ +
+
+ {col.label} +
+
{col.hint}
+
+
{tasks.length}
-
+
{tasks.map((t) => ( ))} @@ -1343,14 +1755,15 @@ function Column({ col, tasks, onAction, onViewDetail }) {
- No tasks + Clear
)}
@@ -5690,8 +6103,62 @@ export default function App() { : tasks; const runningCount = tasks.filter((t) => t.status === "running").length; - const scheduledCount = tasks.filter((t) => t.status === "scheduled").length; + const queueCount = tasks.filter((t) => + ["pending", "scheduled", "blocked"].includes(t.status), + ).length; + const doneCount = tasks.filter((t) => + ["completed", "failed", "cancelled"].includes(t.status), + ).length; const enabledHeartbeatCount = heartbeats.filter((h) => h.enabled).length; + const pausedHeartbeatCount = Math.max(heartbeats.length - enabledHeartbeatCount, 0); + const heartbeatIssueCount = heartbeats.filter((h) => h.last_error).length; + const enabledSkillCount = skills.filter((s) => s.enabled).length; + const pausedSkillCount = Math.max(skills.length - enabledSkillCount, 0); + const skillPatternCount = (skillData.patterns || []).filter( + (p) => p.recurrence_count >= 2, + ).length; + const activeSummary = { + tasks: { + label: `${runningCount} running / ${queueCount} queued`, + tone: runningCount > 0 ? theme.blue : theme.green, + background: runningCount > 0 ? theme.blueBg : theme.greenBg, + metrics: [ + { label: "Total", value: tasks.length }, + { label: "Queue", value: queueCount, tone: theme.orange }, + { label: "Running", value: runningCount, tone: theme.blue }, + { label: "Done", value: doneCount, tone: theme.green }, + ], + }, + heartbeats: { + label: + heartbeatIssueCount > 0 + ? `${enabledHeartbeatCount} enabled / ${heartbeatIssueCount} issues` + : `${enabledHeartbeatCount} enabled / ${pausedHeartbeatCount} paused`, + tone: heartbeatIssueCount > 0 ? theme.orange : theme.cyan, + background: heartbeatIssueCount > 0 ? theme.orangeBg : theme.cyanBg, + metrics: [ + { label: "Total", value: heartbeats.length }, + { label: "Enabled", value: enabledHeartbeatCount, tone: theme.green }, + { label: "Paused", value: pausedHeartbeatCount, tone: theme.textMuted }, + { + label: "Issues", + value: heartbeatIssueCount, + tone: heartbeatIssueCount ? theme.orange : theme.green, + }, + ], + }, + skills: { + label: `${enabledSkillCount} enabled / ${skillPatternCount} patterns`, + tone: theme.accent, + background: theme.accentGlow, + metrics: [ + { label: "Installed", value: skills.length }, + { label: "Enabled", value: enabledSkillCount, tone: theme.green }, + { label: "Paused", value: pausedSkillCount, tone: theme.textMuted }, + { label: "Patterns", value: skillPatternCount, tone: theme.accent }, + ], + }, + }[activeView]; if (backendError) { return ( @@ -5753,11 +6220,13 @@ export default function App() { style={{ minHeight: "100vh", background: theme.bg, + backgroundImage: theme.boardBg, + backgroundSize: "100% 100%, 100% 100%, 100% 100%", color: theme.text, - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", + fontFamily: APP_FONT_STACK, }} > - + {/* API error toast */} {apiError && (
-
+
+
+ +
+
+ AgentForge +
+
+ Agent orchestration board +
+
+
+
- ⌘ +
-
+ +
- AgentForge -
-
- {connected ? ( - ● Connected - ) : ( - ● Disconnected — run `bun taskboard.ts` - )} - {connected && - ` · ${runningCount} running · ${scheduledCount} scheduled · ${enabledHeartbeatCount} heartbeats`} + {[ + { key: "tasks", label: "Tasks", icon: KanbanSquare }, + { key: "heartbeats", label: "Heartbeats", icon: HeartPulse }, + { key: "skills", label: "Skills", icon: Sparkles }, + ].map((tab) => ( + + ))}
-
-
-
-
- {[ - { key: "tasks", label: "Tasks" }, - { key: "heartbeats", label: "Heartbeats" }, - { key: "skills", label: "Skills" }, - ].map((tab) => ( + {activeView !== "skills" && ( +
+
+ )} + + {(() => { + const cycle = { system: "light", light: "dark", dark: "system" }; + const icons = { system: MonitorCog, light: Sun, dark: Moon }; + const labels = { system: "System theme", light: "Light mode", dark: "Dark mode" }; + const ThemeIcon = icons[colorMode]; + return ( + setColorMode(cycle[colorMode])} + active={colorMode !== "system"} + > + + + ); + })()} + + setShowSettings(true)}> + + + + {activeView !== "skills" && ( - ))} + )}
- setFilter(e.target.value)} - style={{ - padding: "8px 14px", - borderRadius: 8, - border: `1px solid ${theme.border}`, - background: theme.surface, - color: theme.text, - fontSize: 12, - outline: "none", - width: 180, - }} - /> - {/* Color mode toggle */} - {(() => { - const cycle = { system: "light", light: "dark", dark: "system" }; - const icons = { system: "⊙", light: "☀", dark: "☾" }; - const labels = { system: "System theme", light: "Light mode", dark: "Dark mode" }; - return ( - - - - ); - })()} - - - - {activeView !== "skills" && ( - - )} +
+ +
+ {activeSummary.metrics.map((metric) => ( + + ))}
{activeView === "tasks" ? (
- {COLUMNS.map((col) => ( - col.statuses.includes(t.status))} - onAction={handleAction} - onViewDetail={setDetail} - /> - ))} +
+ {COLUMNS.map((col) => ( + col.statuses.includes(t.status))} + onAction={handleAction} + onViewDetail={setDetail} + /> + ))} +
) : activeView === "heartbeats" ? ( -
+
No heartbeats yet @@ -6207,6 +6703,40 @@ export default function App() { // CSS动画定义 const styles = ` + html, body, #root { + min-height: 100%; + margin: 0; + } + + body { + overflow-x: hidden; + } + + button, input, textarea, select { + font: inherit; + } + + ::selection { + background: ${theme.accentGlow}; + color: ${theme.text}; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: ${theme.borderActive}; + border: 3px solid transparent; + border-radius: 8px; + background-clip: padding-box; + } + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; }