feat(board): dynamic agent profiles from OGraph events (#33)

- Remove hardcoded AGENT_NAME_MAP, load profiles from agent_profile_updated events
- OGraphClient.loadAgentProfiles() fetches agent objects and replays profile events (LWW)
- Hardcoded fallback map retained for graceful degradation
- TaskDialog accepts dynamic agents list as prop
- App.tsx loads profiles in parallel with tasks on startup
This commit is contained in:
小糯 🐱 2026-04-13 16:50:55 +08:00
parent cdd735e018
commit 6f73544e96
36 changed files with 7888 additions and 0 deletions

24
packages/board/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
packages/board/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,11 @@
# OGraph Task Board — Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Build a Task Kanban Board Web UI for managing OGraph tasks — view, create, assign, and track task status across agents (小糯、小橘、小墨、NEKO、KUMA).
**Architecture:** Single-page React app with Vite + shadcn/ui + Tailwind. Talks directly to OGraph API. Kanban columns by status. Deployed as static files, can be served from anywhere.
**Tech Stack:** React 19, Vite, TypeScript, shadcn/ui (Radix primitives), Tailwind CSS 4, @dnd-kit for drag-and-drop.
---

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
packages/board/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>board</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5566
packages/board/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
{
"name": "@uncaged/ograph-board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4",
"wrangler": "^4.81.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

203
packages/board/src/App.tsx Normal file
View File

@ -0,0 +1,203 @@
import { useState, useEffect, useCallback } from "react"
import api, { isConfigured, resetClient } from "@/api"
import type { Task, TaskStatus, CreateTaskInput, Agent } from "@/types"
import { getAgents } from "@/types"
import { Header } from "@/components/Header"
import { KanbanBoard } from "@/components/KanbanBoard"
import { TaskDialog } from "@/components/TaskDialog"
import { SettingsDialog } from "@/components/SettingsDialog"
function App() {
const [tasks, setTasks] = useState<Task[]>([])
const [agents, setAgents] = useState<Agent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [connected, setConnected] = useState(false)
// Dialog state
const [dialogOpen, setDialogOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [defaultStatus, setDefaultStatus] = useState<TaskStatus>("backlog")
// Settings dialog
const [settingsOpen, setSettingsOpen] = useState(false)
const loadTasks = useCallback(async () => {
if (!isConfigured()) {
setLoading(false)
setConnected(false)
setTasks([])
setAgents([])
return
}
setLoading(true)
setError(null)
try {
// Load agent profiles and tasks in parallel
const [data] = await Promise.all([
api.getTasks(),
api.loadAgentProfiles(),
])
setTasks(data)
setAgents(getAgents())
setConnected(true)
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load tasks"
setError(msg)
setConnected(false)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadTasks()
}, [loadTasks])
function handleNewTask() {
setEditingTask(null)
setDefaultStatus("backlog")
setDialogOpen(true)
}
function handleAddTaskInColumn(status: TaskStatus) {
setEditingTask(null)
setDefaultStatus(status)
setDialogOpen(true)
}
function handleTaskClick(task: Task) {
setEditingTask(task)
setDialogOpen(true)
}
async function handleSave(data: CreateTaskInput & { id?: number; status?: TaskStatus }) {
setError(null)
try {
if (data.id) {
await api.updateTask(data.id, {
title: data.title,
description: data.description ?? "",
priority: data.priority,
status: data.status,
assigneeId: data.assigneeId ?? null,
})
// Re-fetch all tasks after mutation to get accurate state
await loadTasks()
} else {
await api.createTask(data)
await loadTasks()
}
} catch (err) {
setError(err instanceof Error ? err.message : "Operation failed")
}
}
async function handleDelete(id: number) {
setError(null)
try {
await api.deleteTask(id)
await loadTasks()
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed")
}
}
async function handleMoveTask(taskId: number, newStatus: TaskStatus) {
const task = tasks.find((t) => t.id === taskId)
if (!task || task.status === newStatus) return
// Optimistic update
setTasks((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus, updatedAt: Date.now() } : t))
)
try {
await api.moveTask(taskId, newStatus)
} catch {
// Revert on error — re-fetch
loadTasks()
}
}
function handleSettingsSaved() {
resetClient()
loadTasks()
}
// Show settings dialog on first visit if not configured
useEffect(() => {
if (!isConfigured() && !loading) {
setSettingsOpen(true)
}
}, [loading])
if (loading) {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center">
<div className="text-zinc-500 text-sm">Loading tasks from OGraph...</div>
</div>
)
}
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
<Header
onNewTask={handleNewTask}
onOpenSettings={() => setSettingsOpen(true)}
taskCount={tasks.length}
connected={connected}
/>
{error && (
<div className="mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
{!connected && !error && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-zinc-500 text-sm">No API connection configured.</p>
<button
onClick={() => setSettingsOpen(true)}
className="text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2"
>
Open Settings to connect to OGraph
</button>
</div>
</div>
)}
{connected && (
<main className="flex-1 overflow-hidden">
<KanbanBoard
tasks={tasks}
onTaskClick={handleTaskClick}
onAddTask={handleAddTaskInColumn}
onMoveTask={handleMoveTask}
/>
</main>
)}
<TaskDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
task={editingTask}
defaultStatus={defaultStatus}
agents={agents}
onSave={handleSave}
onDelete={handleDelete}
/>
<SettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
onSaved={handleSettingsSaved}
/>
</div>
)
}
export default App

View File

@ -0,0 +1,70 @@
import type { Task, TaskStatus, CreateTaskInput } from "@/types"
import { OGraphClient, createClientFromStorage } from "./ograph-client"
export interface TaskAPI {
getTasks(): Promise<Task[]>
createTask(data: CreateTaskInput): Promise<Task>
updateTask(id: number, data: Partial<Task>): Promise<Task>
deleteTask(id: number): Promise<void>
moveTask(id: number, status: TaskStatus): Promise<Task>
loadAgentProfiles(): Promise<Record<number, { name: string; emoji: string }>>
}
let _client: OGraphClient | null = null
/** Get or create the OGraph client from localStorage config */
export function getClient(): OGraphClient | null {
if (!_client) {
_client = createClientFromStorage()
}
return _client
}
/** Reset the cached client (call after settings change) */
export function resetClient(): void {
_client = null
}
/** Check if the API is configured */
export function isConfigured(): boolean {
return getClient() !== null
}
/** Create a TaskAPI backed by the real OGraph client */
function createAPI(): TaskAPI {
return {
async getTasks() {
const client = getClient()
if (!client) throw new Error("OGraph API not configured. Please set up your API token in Settings.")
return client.getTasks()
},
async createTask(data: CreateTaskInput) {
const client = getClient()
if (!client) throw new Error("OGraph API not configured.")
return client.createTask(data)
},
async updateTask(id: number, data: Partial<Task>) {
const client = getClient()
if (!client) throw new Error("OGraph API not configured.")
return client.updateTask(id, data)
},
async deleteTask(id: number) {
const client = getClient()
if (!client) throw new Error("OGraph API not configured.")
return client.deleteTask(id)
},
async moveTask(id: number, status: TaskStatus) {
const client = getClient()
if (!client) throw new Error("OGraph API not configured.")
return client.moveTask(id, status)
},
async loadAgentProfiles() {
const client = getClient()
if (!client) throw new Error("OGraph API not configured.")
return client.loadAgentProfiles()
},
}
}
const api = createAPI()
export default api

View File

@ -0,0 +1,155 @@
import type { Task, TaskStatus, CreateTaskInput } from "@/types"
let nextId = 11
const now = Date.now()
const hour = 3600_000
const day = 86400_000
const mockTasks: Task[] = [
{
id: 1,
title: "OGraph Channel Plugin P0",
description: "Implement the core channel plugin for OGraph event routing. This is critical for multi-agent communication.",
status: "in_progress",
priority: "p0",
assigneeId: 1,
createdAt: now - 7 * day,
updatedAt: now - 2 * hour,
},
{
id: 2,
title: "Implement AgentClient interface",
description: "Design and implement the AgentClient TypeScript interface for communicating with OGraph backend.",
status: "in_review",
priority: "p1",
assigneeId: 4,
createdAt: now - 5 * day,
updatedAt: now - 6 * hour,
},
{
id: 3,
title: "Task Board Web UI",
description: "Build a Kanban-style task board using React + Tailwind with shadcn/ui components.",
status: "in_progress",
priority: "p1",
assigneeId: 2,
createdAt: now - 3 * day,
updatedAt: now - 1 * hour,
},
{
id: 4,
title: "Setup CI/CD Pipeline",
description: "Configure GitHub Actions for automated testing and deployment of OGraph packages.",
status: "todo",
priority: "p2",
assigneeId: 3,
createdAt: now - 4 * day,
updatedAt: now - 1 * day,
},
{
id: 5,
title: "Write projection definitions",
description: "Define OGraph projections for task_summary, agent_workload, and board_stats.",
status: "backlog",
priority: "p2",
assigneeId: null,
createdAt: now - 2 * day,
updatedAt: now - 2 * day,
},
{
id: 6,
title: "Add event replay mechanism",
description: "Implement event replay for reconstructing state from event history.",
status: "backlog",
priority: "p1",
assigneeId: null,
createdAt: now - 6 * day,
updatedAt: now - 6 * day,
},
{
id: 7,
title: "Design agent emoji avatars",
description: "Create a consistent emoji avatar system for the 5 known agents.",
status: "done",
priority: "p3",
assigneeId: 5,
createdAt: now - 8 * day,
updatedAt: now - 3 * day,
},
{
id: 8,
title: "Cloudflare Worker deployment",
description: "Deploy OGraph API to Cloudflare Workers with D1 database binding.",
status: "done",
priority: "p0",
assigneeId: 1,
createdAt: now - 10 * day,
updatedAt: now - 5 * day,
},
{
id: 9,
title: "Schema validation middleware",
description: "Add Zod-based schema validation for all event payloads.",
status: "todo",
priority: "p2",
assigneeId: 4,
createdAt: now - 1 * day,
updatedAt: now - 1 * day,
},
{
id: 10,
title: "Multi-tenant support",
description: "Implement workspace-level isolation for multi-tenant OGraph deployments.",
status: "backlog",
priority: "p3",
assigneeId: null,
createdAt: now - 1 * day,
updatedAt: now - 1 * day,
},
]
function delay(ms: number = 80): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export async function getTasks(): Promise<Task[]> {
await delay()
return [...mockTasks]
}
export async function createTask(data: CreateTaskInput): Promise<Task> {
await delay()
const task: Task = {
id: nextId++,
title: data.title,
description: data.description ?? "",
status: data.status ?? "backlog",
priority: data.priority,
assigneeId: data.assigneeId ?? null,
createdAt: Date.now(),
updatedAt: Date.now(),
}
mockTasks.push(task)
return { ...task }
}
export async function updateTask(id: number, updates: Partial<Task>): Promise<Task> {
await delay()
const idx = mockTasks.findIndex((t) => t.id === id)
if (idx === -1) throw new Error(`Task ${id} not found`)
const task = mockTasks[idx]
Object.assign(task, updates, { updatedAt: Date.now() })
return { ...task }
}
export async function deleteTask(id: number): Promise<void> {
await delay()
const idx = mockTasks.findIndex((t) => t.id === id)
if (idx === -1) throw new Error(`Task ${id} not found`)
mockTasks.splice(idx, 1)
}
export async function moveTask(id: number, status: TaskStatus): Promise<Task> {
return updateTask(id, { status })
}

View File

@ -0,0 +1,412 @@
import type { Task, TaskStatus, TaskPriority, CreateTaskInput } from "@/types"
import type { TaskAPI } from "./index"
/** Agent name+emoji map — populated dynamically from OGraph agent_profile_updated events */
export let AGENT_NAME_MAP: Record<number, { name: string; emoji: string }> = {}
/** Hardcoded fallback — used only if OGraph profile fetch fails */
const FALLBACK_AGENT_MAP: Record<number, { name: string; emoji: string }> = {
2: { name: "小墨", emoji: "🖊️" },
3: { name: "小橘", emoji: "🍊" },
5: { name: "KUMA", emoji: "🐻" },
}
// ---- helpers ----
interface OGraphObject {
id: number
type: string
}
interface OGraphPayload {
subject?: { ref: number } | number
creator?: { ref: number } | number
assignee?: { ref: number } | number
participant?: { ref: number } | number
author?: { ref: number } | number
title?: string
priority?: string
description?: string
status?: string
content?: string
}
interface OGraphEvent {
id: number
type_name: string
type_hash: string
payload: OGraphPayload
created_at: number
}
function refId(val: { ref: number } | number | undefined): number | undefined {
if (val === undefined || val === null) return undefined
if (typeof val === "number") return val
return val.ref
}
function extractSubjectId(payload: OGraphPayload): number | undefined {
return refId(payload.subject)
}
// ---- client ----
export class OGraphClient implements TaskAPI {
private endpoint: string
private token: string
constructor(endpoint: string, token: string) {
this.endpoint = endpoint.replace(/\/+$/, "")
this.token = token
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const url = `${this.endpoint}${path}`
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (this.token) {
headers["Authorization"] = `Bearer ${this.token}`
}
const res = await fetch(url, {
...init,
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
})
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`OGraph API error ${res.status}: ${body}`)
}
return res.json() as Promise<T>
}
// ---- public API (implements TaskAPI) ----
async getTasks(): Promise<Task[]> {
// 1. Get all task objects
const objectsRes = await this.request<{ objects: OGraphObject[] }>("/objects?type=task")
const objects = objectsRes.objects ?? []
if (objects.length === 0) return []
// 2. Fetch all events (with a high limit) and group by subject
const eventsRes = await this.request<{ events: OGraphEvent[] }>("/events?limit=500")
const allEvents = eventsRes.events ?? []
// Group events by subject ref (task id)
const eventsByTask = new Map<number, OGraphEvent[]>()
for (const obj of objects) {
eventsByTask.set(obj.id, [])
}
for (const evt of allEvents) {
const subjectId = extractSubjectId(evt.payload)
if (subjectId !== undefined && eventsByTask.has(subjectId)) {
eventsByTask.get(subjectId)!.push(evt)
}
}
// 3. For each task, also fetch events by ref to catch anything missing
// (the bulk fetch above may not include all events if there are many)
// Skip this for efficiency — 500 events should be enough for now.
// 4. Replay events to build task state
const tasks: Task[] = []
for (const obj of objects) {
const events = eventsByTask.get(obj.id) ?? []
const task = this.replayEvents(obj.id, events)
if (task) tasks.push(task)
}
return tasks
}
async createTask(data: CreateTaskInput): Promise<Task> {
// 1. Create the task object
const obj = await this.request<OGraphObject>("/objects", {
method: "POST",
body: JSON.stringify({ type: "task" }),
})
// 2. Emit task_created event
await this.request<OGraphEvent>("/events", {
method: "POST",
body: JSON.stringify({
type: "task_created",
payload: {
subject: { ref: obj.id },
title: data.title,
priority: data.priority ?? "p2",
...(data.description ? { description: data.description } : {}),
},
}),
})
// 3. If assignee specified, emit task_assigned
if (data.assigneeId) {
await this.request<OGraphEvent>("/events", {
method: "POST",
body: JSON.stringify({
type: "task_assigned",
payload: {
subject: { ref: obj.id },
assignee: { ref: data.assigneeId },
},
}),
})
}
// 4. If status is not default (backlog), emit task_status_changed
const status = data.status ?? "backlog"
if (status !== "backlog") {
await this.request<OGraphEvent>("/events", {
method: "POST",
body: JSON.stringify({
type: "task_status_changed",
payload: {
subject: { ref: obj.id },
status,
},
}),
})
}
// Return reconstructed task
return {
id: obj.id,
title: data.title,
description: data.description ?? "",
status,
priority: (data.priority ?? "p2") as TaskPriority,
assigneeId: data.assigneeId ?? null,
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
async updateTask(id: number, data: Partial<Task>): Promise<Task> {
// Emit events for each changed field
// We don't know what the old values are, so we emit events for everything provided
const promises: Promise<unknown>[] = []
if (data.status !== undefined) {
promises.push(
this.request("/events", {
method: "POST",
body: JSON.stringify({
type: "task_status_changed",
payload: {
subject: { ref: id },
status: data.status,
},
}),
})
)
}
if (data.assigneeId !== undefined) {
promises.push(
this.request("/events", {
method: "POST",
body: JSON.stringify({
type: "task_assigned",
payload: {
subject: { ref: id },
assignee: data.assigneeId ? { ref: data.assigneeId } : null,
},
}),
})
)
}
// Note: OGraph doesn't have a "task_title_changed" or "task_priority_changed" event type
// For title/priority/description changes, we would need new event types.
// For now, we emit task_created again with updated info (not ideal but works for MVP)
// Actually, let's just handle status and assignee which have proper event types.
await Promise.all(promises)
// Re-fetch this task's events to rebuild state
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${id}&limit=200`)
const events = eventsRes.events ?? []
const task = this.replayEvents(id, events)
if (!task) throw new Error(`Failed to rebuild task ${id} after update`)
return task
}
async deleteTask(_id: number): Promise<void> {
// OGraph is event-sourced and doesn't support deletion.
// We could mark it as "archived" via a status change.
// For now, move to "done" as a soft-delete.
await this.request("/events", {
method: "POST",
body: JSON.stringify({
type: "task_status_changed",
payload: {
subject: { ref: _id },
status: "done",
},
}),
})
}
async moveTask(id: number, status: TaskStatus): Promise<Task> {
await this.request("/events", {
method: "POST",
body: JSON.stringify({
type: "task_status_changed",
payload: {
subject: { ref: id },
status,
},
}),
})
// Re-fetch this task's events to rebuild state
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${id}&limit=200`)
const events = eventsRes.events ?? []
const task = this.replayEvents(id, events)
if (!task) throw new Error(`Failed to rebuild task ${id} after move`)
return task
}
async getAgentIds(): Promise<number[]> {
const res = await this.request<{ objects: OGraphObject[] }>("/objects?type=agent")
return (res.objects ?? []).map((o) => o.id)
}
/**
* Load agent profiles from OGraph agent_profile_updated events.
* For each agent object, fetches its event stream and picks the latest
* agent_profile_updated event (LWW). Falls back to FALLBACK_AGENT_MAP.
*/
async loadAgentProfiles(): Promise<Record<number, { name: string; emoji: string }>> {
const map: Record<number, { name: string; emoji: string }> = { ...FALLBACK_AGENT_MAP }
try {
// 1. Get all agent objects
const agentIds = await this.getAgentIds()
if (agentIds.length === 0) return map
// 2. For each agent, fetch events and find latest agent_profile_updated
for (const agentId of agentIds) {
try {
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${agentId}&limit=200`)
const events = eventsRes.events ?? []
const profileEvents = events.filter(e => e.type_name === "agent_profile_updated")
if (profileEvents.length > 0) {
// Sort by id (chronological) and take the latest
const latest = profileEvents.sort((a, b) => a.id - b.id).at(-1)!
const p = latest.payload as { name?: string; emoji?: string }
if (p.name) {
map[agentId] = { name: p.name, emoji: p.emoji ?? "👤" }
}
}
} catch {
// Failed to fetch events for this agent, keep fallback
}
}
} catch {
// Failed to fetch agent objects, return fallback map
}
// Update the global map
AGENT_NAME_MAP = map
return map
}
// ---- event replay ----
private replayEvents(taskId: number, events: OGraphEvent[]): Task | null {
// Sort events by id (chronological)
const sorted = [...events].sort((a, b) => a.id - b.id)
let title = `Task #${taskId}`
let description = ""
let priority: TaskPriority = "p2"
let status: TaskStatus = "backlog"
let assigneeId: number | null = null
let createdAt = Date.now()
let updatedAt = Date.now()
let hasCreatedEvent = false
for (const evt of sorted) {
const ts = evt.created_at
switch (evt.type_name) {
case "task_created":
hasCreatedEvent = true
title = evt.payload.title ?? title
priority = (evt.payload.priority as TaskPriority) ?? priority
description = evt.payload.description ?? description
createdAt = ts
updatedAt = ts
break
case "task_assigned":
assigneeId = refId(evt.payload.assignee) ?? null
updatedAt = ts
break
case "assigned":
// legacy format
assigneeId = refId(evt.payload.participant) ?? null
updatedAt = ts
break
case "task_status_changed":
status = (evt.payload.status as TaskStatus) ?? status
updatedAt = ts
break
case "task_commented":
case "commented":
// Comments don't change task state, but update timestamp
updatedAt = ts
break
}
}
// If no task_created event was found, it might still be a valid task object
// (created but with no events yet). We still return it.
if (!hasCreatedEvent && sorted.length === 0) {
// Task object exists but has no events — still show it
}
return {
id: taskId,
title,
description,
status,
priority,
assigneeId,
createdAt,
updatedAt,
}
}
}
// ---- localStorage config ----
const STORAGE_KEY_ENDPOINT = "ograph_endpoint"
const STORAGE_KEY_TOKEN = "ograph_token"
export function getStoredConfig(): { endpoint: string; token: string } | null {
const endpoint = localStorage.getItem(STORAGE_KEY_ENDPOINT)
const token = localStorage.getItem(STORAGE_KEY_TOKEN)
if (!endpoint || !token) return null
return { endpoint, token }
}
export function setStoredConfig(endpoint: string, token: string): void {
localStorage.setItem(STORAGE_KEY_ENDPOINT, endpoint)
localStorage.setItem(STORAGE_KEY_TOKEN, token)
}
export function clearStoredConfig(): void {
localStorage.removeItem(STORAGE_KEY_ENDPOINT)
localStorage.removeItem(STORAGE_KEY_TOKEN)
}
export function createClientFromStorage(): OGraphClient | null {
const config = getStoredConfig()
if (!config) return null
return new OGraphClient(config.endpoint, config.token)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,48 @@
import { Plus, LayoutDashboard, Settings } from "lucide-react"
import { Button } from "@/components/ui/button"
interface HeaderProps {
onNewTask: () => void
onOpenSettings: () => void
taskCount: number
connected: boolean
}
export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) {
return (
<header className="border-b border-zinc-800 bg-zinc-950 px-6 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-zinc-800 text-zinc-300">
<LayoutDashboard className="h-4 w-4" />
</div>
<div>
<h1 className="text-lg font-semibold text-zinc-100 leading-tight">OGraph Task Board</h1>
<p className="text-xs text-zinc-500">
{connected
? `${taskCount} task${taskCount !== 1 ? "s" : ""}`
: "Not connected — configure in Settings"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={onOpenSettings}
variant="ghost"
size="icon"
className="text-zinc-500 hover:text-zinc-200"
title="Settings"
>
<Settings className="h-4 w-4" />
</Button>
<Button onClick={onNewTask} size="sm" className="gap-1.5" disabled={!connected}>
<Plus className="h-4 w-4" />
New Task
</Button>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,37 @@
import { ALL_STATUSES, type Task, type TaskStatus } from "@/types"
import { KanbanColumn } from "@/components/KanbanColumn"
interface KanbanBoardProps {
tasks: Task[]
onTaskClick: (task: Task) => void
onAddTask: (status: TaskStatus) => void
onMoveTask: (taskId: number, status: TaskStatus) => void
}
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) {
return (
<div className="flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
{ALL_STATUSES.map((status) => {
const columnTasks = tasks
.filter((t) => t.status === status)
.sort((a, b) => {
// Sort by priority first (p0 first), then by updatedAt descending
const prioOrder = a.priority.localeCompare(b.priority)
if (prioOrder !== 0) return prioOrder
return b.updatedAt - a.updatedAt
})
return (
<KanbanColumn
key={status}
status={status}
tasks={columnTasks}
onTaskClick={onTaskClick}
onAddTask={onAddTask}
onDrop={onMoveTask}
/>
)
})}
</div>
)
}

View File

@ -0,0 +1,95 @@
import { Plus } from "lucide-react"
import { STATUS_CONFIG, type Task, type TaskStatus } from "@/types"
import { TaskCard } from "@/components/TaskCard"
import { Button } from "@/components/ui/button"
interface KanbanColumnProps {
status: TaskStatus
tasks: Task[]
onTaskClick: (task: Task) => void
onAddTask: (status: TaskStatus) => void
onDrop: (taskId: number, status: TaskStatus) => void
}
const borderColors: Record<string, string> = {
zinc: "border-t-zinc-500",
blue: "border-t-blue-500",
amber: "border-t-amber-500",
purple: "border-t-purple-500",
emerald: "border-t-emerald-500",
}
const countBgColors: Record<string, string> = {
zinc: "bg-zinc-700/50 text-zinc-300",
blue: "bg-blue-500/15 text-blue-400",
amber: "bg-amber-500/15 text-amber-400",
purple: "bg-purple-500/15 text-purple-400",
emerald: "bg-emerald-500/15 text-emerald-400",
}
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }: KanbanColumnProps) {
const config = STATUS_CONFIG[status]
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10)
if (!isNaN(taskId)) {
onDrop(taskId, status)
}
}
return (
<div
className="flex flex-col min-w-[272px] w-[272px] shrink-0"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Column header */}
<div className={`rounded-t-lg border-t-2 ${borderColors[config.color]} bg-zinc-900/50 border-x border-b border-zinc-800 px-3 py-2.5`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-200">{config.label}</span>
<span className={`text-xs rounded-full px-1.5 py-0.5 font-medium ${countBgColors[config.color]}`}>
{tasks.length}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover:text-zinc-200"
onClick={() => onAddTask(status)}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Task list */}
<div className="flex-1 overflow-y-auto space-y-2 py-2 min-h-[120px] max-h-[calc(100vh-200px)]">
{tasks.map((task) => (
<div
key={task.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", String(task.id))
e.dataTransfer.effectAllowed = "move"
}}
>
<TaskCard task={task} onClick={onTaskClick} />
</div>
))}
{tasks.length === 0 && (
<div className="flex items-center justify-center h-20 text-xs text-zinc-600 border border-dashed border-zinc-800 rounded-lg mx-1">
No tasks
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,147 @@
import { useState, useEffect } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { getStoredConfig, setStoredConfig } from "@/api/ograph-client"
import { resetClient } from "@/api"
interface SettingsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSaved: () => void
}
export function SettingsDialog({ open, onOpenChange, onSaved }: SettingsDialogProps) {
const [endpoint, setEndpoint] = useState("https://ograph.shazhou.workers.dev")
const [token, setToken] = useState("")
const [testing, setTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
useEffect(() => {
if (open) {
const stored = getStoredConfig()
if (stored) {
setEndpoint(stored.endpoint)
setToken(stored.token)
}
setError(null)
setSuccess(false)
}
}, [open])
async function handleTest() {
setTesting(true)
setError(null)
setSuccess(false)
try {
const url = endpoint.replace(/\/+$/, "")
const res = await fetch(`${url}/objects?type=task`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
}
const data = await res.json()
if (!Array.isArray(data)) {
throw new Error("Unexpected response format")
}
setSuccess(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Connection failed")
} finally {
setTesting(false)
}
}
function handleSave() {
if (!endpoint.trim() || !token.trim()) {
setError("Both endpoint and token are required")
return
}
setStoredConfig(endpoint.trim(), token.trim())
resetClient()
onSaved()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>OGraph Settings</DialogTitle>
<DialogDescription className="text-zinc-500">
Configure your OGraph API endpoint and authentication token.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="settings-endpoint" className="text-sm font-medium text-zinc-300">
API Endpoint
</label>
<Input
id="settings-endpoint"
placeholder="https://ograph.shazhou.workers.dev"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<label htmlFor="settings-token" className="text-sm font-medium text-zinc-300">
API Token
</label>
<Input
id="settings-token"
type="password"
placeholder="Enter your API token..."
value={token}
onChange={(e) => setToken(e.target.value)}
/>
</div>
{error && (
<div className="rounded-md bg-red-500/10 border border-red-500/30 px-3 py-2 text-sm text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/30 px-3 py-2 text-sm text-emerald-400">
Connection successful! Found tasks in OGraph.
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={testing || !endpoint.trim() || !token.trim()}
>
{testing ? "Testing..." : "Test Connection"}
</Button>
<Button
type="button"
onClick={handleSave}
disabled={!endpoint.trim() || !token.trim()}
>
Save & Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,55 @@
import { PRIORITY_CONFIG, resolveAgent, type Task } from "@/types"
import { Badge } from "@/components/ui/badge"
interface TaskCardProps {
task: Task
onClick: (task: Task) => void
}
const priorityClasses: Record<string, string> = {
red: "bg-red-500/15 text-red-400 border-red-500/30",
orange: "bg-orange-500/15 text-orange-400 border-orange-500/30",
yellow: "bg-amber-500/15 text-amber-400 border-amber-500/30",
green: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
}
export function TaskCard({ task, onClick }: TaskCardProps) {
const priorityCfg = PRIORITY_CONFIG[task.priority]
const assignee = task.assigneeId ? resolveAgent(task.assigneeId) : null
return (
<button
type="button"
onClick={() => onClick(task)}
className="w-full text-left rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-700 hover:bg-zinc-800/80 transition-all duration-150 cursor-pointer group focus:outline-none focus:ring-1 focus:ring-zinc-600"
>
<div className="flex items-start justify-between gap-2 mb-2">
<span className="text-sm font-medium text-zinc-100 leading-snug line-clamp-2 group-hover:text-white">
{task.title}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 font-semibold ${priorityClasses[priorityCfg.color] ?? ""}`}
>
{priorityCfg.label}
</Badge>
{assignee && (
<div className="flex items-center gap-1 text-xs text-zinc-400" title={assignee.name}>
<span className="text-sm">{assignee.emoji}</span>
<span className="hidden sm:inline">{assignee.name}</span>
</div>
)}
</div>
{task.description && (
<p className="mt-2 text-xs text-zinc-500 line-clamp-2 leading-relaxed">
{task.description}
</p>
)}
</button>
)
}

View File

@ -0,0 +1,199 @@
import { useState, useEffect } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ALL_STATUSES,
ALL_PRIORITIES,
STATUS_CONFIG,
PRIORITY_CONFIG,
type Agent,
type Task,
type TaskStatus,
type TaskPriority,
type CreateTaskInput,
} from "@/types"
interface TaskDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
task: Task | null // null = creating new task
defaultStatus?: TaskStatus
agents: Agent[]
onSave: (data: CreateTaskInput & { id?: number; status?: TaskStatus }) => void
onDelete?: (id: number) => void
}
export function TaskDialog({ open, onOpenChange, task, defaultStatus, agents, onSave, onDelete }: TaskDialogProps) {
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [priority, setPriority] = useState<TaskPriority>("p2")
const [status, setStatus] = useState<TaskStatus>(defaultStatus ?? "backlog")
const [assigneeId, setAssigneeId] = useState<string>("unassigned")
useEffect(() => {
if (task) {
setTitle(task.title)
setDescription(task.description)
setPriority(task.priority)
setStatus(task.status)
setAssigneeId(task.assigneeId ? String(task.assigneeId) : "unassigned")
} else {
setTitle("")
setDescription("")
setPriority("p2")
setStatus(defaultStatus ?? "backlog")
setAssigneeId("unassigned")
}
}, [task, defaultStatus, open])
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim()) return
onSave({
id: task?.id,
title: title.trim(),
description: description.trim(),
priority,
status,
assigneeId: assigneeId === "unassigned" ? null : Number(assigneeId),
})
onOpenChange(false)
}
const isEditing = task !== null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{isEditing ? "Edit Task" : "New Task"}</DialogTitle>
<DialogDescription className="text-zinc-500">
{isEditing ? "Update the task details below." : "Fill in the details to create a new task."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div className="space-y-1.5">
<label htmlFor="task-title" className="text-sm font-medium text-zinc-300">
Title
</label>
<Input
id="task-title"
placeholder="Task title..."
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<label htmlFor="task-desc" className="text-sm font-medium text-zinc-300">
Description
</label>
<Textarea
id="task-desc"
placeholder="Add a description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
{/* Priority & Status row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-zinc-300">Priority</label>
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_PRIORITIES.map((p) => (
<SelectItem key={p} value={p}>
{PRIORITY_CONFIG[p].icon} {PRIORITY_CONFIG[p].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-zinc-300">Status</label>
<Select value={status} onValueChange={(v) => setStatus(v as TaskStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_CONFIG[s].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Assignee */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-zinc-300">Assignee</label>
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={String(agent.id)}>
{agent.emoji} {agent.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter className="gap-2">
{isEditing && onDelete && (
<Button
type="button"
variant="destructive"
className="mr-auto"
onClick={() => {
onDelete(task.id)
onOpenChange(false)
}}
>
Delete
</Button>
)}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!title.trim()}>
{isEditing ? "Save Changes" : "Create Task"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow",
secondary:
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-border bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-zinc-800 bg-zinc-900 text-zinc-50 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 py-1 text-sm text-zinc-100 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-zinc-100 placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,157 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-zinc-800 bg-transparent px-3 py-2 text-sm text-zinc-100 shadow-sm ring-offset-background placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-zinc-800 bg-zinc-900 text-zinc-50 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm text-zinc-100 outline-none focus:bg-zinc-800 focus:text-zinc-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-zinc-800 bg-transparent px-3 py-2 text-sm text-zinc-100 shadow-sm placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,35 @@
@import "tailwindcss";
@theme {
--color-background: var(--color-zinc-950);
--color-foreground: var(--color-zinc-50);
--color-muted: var(--color-zinc-800);
--color-muted-foreground: var(--color-zinc-400);
--color-border: var(--color-zinc-800);
--color-input: var(--color-zinc-800);
--color-ring: var(--color-zinc-700);
--color-primary: var(--color-zinc-50);
--color-primary-foreground: var(--color-zinc-900);
--color-secondary: var(--color-zinc-800);
--color-secondary-foreground: var(--color-zinc-50);
--color-destructive: var(--color-red-500);
--color-destructive-foreground: var(--color-zinc-50);
--color-accent: var(--color-zinc-800);
--color-accent-foreground: var(--color-zinc-50);
--color-card: var(--color-zinc-900);
--color-card-foreground: var(--color-zinc-50);
--color-popover: var(--color-zinc-900);
--color-popover-foreground: var(--color-zinc-50);
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
html {
color-scheme: dark;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
}

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,70 @@
import { AGENT_NAME_MAP } from "@/api/ograph-client"
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done"
export type TaskPriority = "p0" | "p1" | "p2" | "p3"
export interface Agent {
id: number
name: string
emoji: string
}
export interface Task {
id: number
title: string
description: string
status: TaskStatus
priority: TaskPriority
assigneeId: number | null
createdAt: number
updatedAt: number
}
export interface CreateTaskInput {
title: string
description?: string
status?: TaskStatus
priority: TaskPriority
assigneeId?: number | null
}
export interface TaskEvent {
id: number
type: string
payload: Record<string, unknown>
createdAt: number
}
/** Resolve an agent by ID using the dynamically-loaded name map */
export function resolveAgent(id: number): Agent {
const known = AGENT_NAME_MAP[id]
if (known) return { id, name: known.name, emoji: known.emoji }
return { id, name: `Agent #${id}`, emoji: "👤" }
}
/** Get all known agents from the dynamically-loaded map */
export function getAgents(): Agent[] {
return Object.entries(AGENT_NAME_MAP).map(([idStr, info]) => ({
id: Number(idStr),
name: info.name,
emoji: info.emoji,
}))
}
export const STATUS_CONFIG: Record<TaskStatus, { label: string; color: string }> = {
backlog: { label: "Backlog", color: "zinc" },
todo: { label: "To Do", color: "blue" },
in_progress: { label: "In Progress", color: "amber" },
in_review: { label: "In Review", color: "purple" },
done: { label: "Done", color: "emerald" },
}
export const PRIORITY_CONFIG: Record<TaskPriority, { label: string; color: string; icon: string }> = {
p0: { label: "P0 Critical", color: "red", icon: "🔴" },
p1: { label: "P1 High", color: "orange", icon: "🟠" },
p2: { label: "P2 Medium", color: "yellow", icon: "🟡" },
p3: { label: "P3 Low", color: "green", icon: "🟢" },
}
export const ALL_STATUSES: TaskStatus[] = ["backlog", "todo", "in_progress", "in_review", "done"]
export const ALL_PRIORITIES: TaskPriority[] = ["p0", "p1", "p2", "p3"]

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"ignoreDeprecations": "6.0"
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

View File

@ -0,0 +1,3 @@
name = "og-tasks"
compatibility_date = "2024-09-23"
assets = { directory = "dist" }