Initial implementation of vault-os

Complete implementation across all 13 phases:

- vault-core: types, YAML frontmatter parsing, entity classification,
  filesystem ops, config, prompt composition, validation, search
- vault-watch: filesystem watcher with daemon write filtering, event
  classification
- vault-scheduler: cron engine, process executor, task runner with
  retry logic and concurrency limiting
- vault-api: Axum REST API (15 route modules), WebSocket with broadcast,
  AI assistant proxy, validation, templates
- Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror
  editor, dynamic view system, AI chat sidebar
- Nix flake with dev shell and NixOS module
- Graceful shutdown, inotify overflow recovery, tracing instrumentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Harald Hoyer 2026-03-03 01:21:17 +01:00
commit f820a72b04
123 changed files with 18288 additions and 0 deletions

24
dashboard/.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
dashboard/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 [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## 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,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
dashboard/index.html Normal file
View file

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

4235
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
dashboard/package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.2",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.16",
"@hello-pangea/dnd": "^18.0.1",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.90.21",
"codemirror": "^6.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

47
dashboard/src/App.tsx Normal file
View file

@ -0,0 +1,47 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { TasksPage } from './pages/TasksPage';
import { AgentsPage } from './pages/AgentsPage';
import { CronsPage } from './pages/CronsPage';
import { AgentQueuePage } from './pages/AgentQueuePage';
import { KnowledgePage } from './pages/KnowledgePage';
import { EditorPage } from './pages/EditorPage';
import { ViewPage } from './pages/ViewPage';
import { CommandPalette } from './components/CommandPalette';
import { useWebSocket } from './hooks/useWebSocket';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 5000, retry: 1 },
},
});
function AppInner() {
useWebSocket();
return (
<BrowserRouter>
<CommandPalette />
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<TasksPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/crons" element={<CronsPage />} />
<Route path="/queue" element={<AgentQueuePage />} />
<Route path="/knowledge" element={<KnowledgePage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/view/*" element={<ViewPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AppInner />
</QueryClientProvider>
);
}

152
dashboard/src/api/client.ts Normal file
View file

@ -0,0 +1,152 @@
import type {
Agent,
AgentTask,
CronJob,
HumanTask,
KnowledgeNote,
Skill,
TreeNode,
VaultStats,
HealthStatus,
ViewPageDef,
ViewDetail,
NotificationItem,
} from './types';
const BASE = '/api';
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || res.statusText);
}
return res.json();
}
// Agents
export const listAgents = () => fetchJson<Agent[]>(`${BASE}/agents`);
export const getAgent = (name: string) => fetchJson<Agent>(`${BASE}/agents/${name}`);
export const triggerAgent = (name: string, context?: string) =>
fetchJson<{ status: string }>(`${BASE}/agents/${name}/trigger`, {
method: 'POST',
body: JSON.stringify({ context }),
});
// Skills
export const listSkills = () => fetchJson<Skill[]>(`${BASE}/skills`);
export const getSkill = (name: string) => fetchJson<Skill>(`${BASE}/skills/${name}`);
export const skillUsedBy = (name: string) => fetchJson<string[]>(`${BASE}/skills/${name}/used-by`);
// Crons
export const listCrons = () => fetchJson<CronJob[]>(`${BASE}/crons`);
export const triggerCron = (name: string) =>
fetchJson<{ status: string }>(`${BASE}/crons/${name}/trigger`, { method: 'POST' });
export const pauseCron = (name: string) =>
fetchJson<{ status: string }>(`${BASE}/crons/${name}/pause`, { method: 'POST' });
export const resumeCron = (name: string) =>
fetchJson<{ status: string }>(`${BASE}/crons/${name}/resume`, { method: 'POST' });
// Human Tasks
export const listHumanTasks = () => fetchJson<HumanTask[]>(`${BASE}/todos/harald`);
export const listHumanTasksByStatus = (status: string) =>
fetchJson<HumanTask[]>(`${BASE}/todos/harald/${status}`);
export const createHumanTask = (task: {
title: string;
priority?: string;
labels?: string[];
body?: string;
}) =>
fetchJson<{ status: string; path: string }>(`${BASE}/todos/harald`, {
method: 'POST',
body: JSON.stringify(task),
});
export const moveHumanTask = (status: string, id: string, to: string) =>
fetchJson<{ status: string }>(`${BASE}/todos/harald/${status}/${id}/move`, {
method: 'PATCH',
body: JSON.stringify({ to }),
});
export const deleteHumanTask = (status: string, id: string) =>
fetchJson<{ status: string }>(`${BASE}/todos/harald/${status}/${id}`, { method: 'DELETE' });
// Agent Tasks
export const listAgentTasks = () => fetchJson<AgentTask[]>(`${BASE}/todos/agent`);
export const getAgentTask = (id: string) => fetchJson<AgentTask>(`${BASE}/todos/agent/${id}`);
export const createAgentTask = (task: {
title: string;
agent: string;
priority?: string;
body?: string;
}) =>
fetchJson<{ status: string; path: string }>(`${BASE}/todos/agent`, {
method: 'POST',
body: JSON.stringify(task),
});
// Knowledge
export const listKnowledge = (q?: string, tag?: string) => {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (tag) params.set('tag', tag);
const qs = params.toString();
return fetchJson<KnowledgeNote[]>(`${BASE}/knowledge${qs ? `?${qs}` : ''}`);
};
export const getKnowledge = (path: string) =>
fetchJson<{ path: string; frontmatter: unknown; body: string; html: string }>(
`${BASE}/knowledge/${path}`,
);
// Files
export const readFile = (path: string) =>
fetchJson<{ path: string; frontmatter: unknown; body: string }>(`${BASE}/files/${path}`);
export const writeFile = (path: string, data: { frontmatter?: unknown; body?: string; raw?: string }) =>
fetchJson<{ status: string }>(`${BASE}/files/${path}`, {
method: 'PUT',
body: JSON.stringify(data),
});
export const patchFile = (path: string, updates: Record<string, unknown>) =>
fetchJson<{ status: string }>(`${BASE}/files/${path}`, {
method: 'PATCH',
body: JSON.stringify(updates),
});
export const deleteFile = (path: string) =>
fetchJson<{ status: string }>(`${BASE}/files/${path}`, { method: 'DELETE' });
// Tree
export const getTree = () => fetchJson<TreeNode>(`${BASE}/tree`);
// Suggest
export const suggestAgents = () => fetchJson<string[]>(`${BASE}/suggest/agents`);
export const suggestSkills = () => fetchJson<string[]>(`${BASE}/suggest/skills`);
export const suggestTags = () => fetchJson<string[]>(`${BASE}/suggest/tags`);
export const suggestFiles = (q?: string) =>
fetchJson<string[]>(`${BASE}/suggest/files${q ? `?q=${encodeURIComponent(q)}` : ''}`);
export const suggestModels = () => fetchJson<string[]>(`${BASE}/suggest/models`);
export const suggestMcpServers = () => fetchJson<string[]>(`${BASE}/suggest/mcp-servers`);
// Stats
export const getStats = () => fetchJson<VaultStats>(`${BASE}/stats`);
export const getActivity = () =>
fetchJson<{ path: string; kind: string; modified: string; name: string }[]>(`${BASE}/activity`);
export const getHealth = () => fetchJson<HealthStatus>(`${BASE}/health`);
// Views
export const listViewPages = () => fetchJson<ViewPageDef[]>(`${BASE}/views/pages`);
export const listViewWidgets = () => fetchJson<ViewPageDef[]>(`${BASE}/views/widgets`);
export const listViewLayouts = () => fetchJson<ViewPageDef[]>(`${BASE}/views/layouts`);
export const getView = (path: string) => fetchJson<ViewDetail>(`${BASE}/views/${path}`);
export const putView = (path: string, data: { frontmatter?: unknown; body?: string; raw?: string }) =>
fetchJson<{ status: string }>(`${BASE}/views/${path}`, {
method: 'PUT',
body: JSON.stringify(data),
});
export const deleteView = (path: string) =>
fetchJson<{ status: string }>(`${BASE}/views/${path}`, { method: 'DELETE' });
// Notifications
export const listNotifications = () => fetchJson<NotificationItem[]>(`${BASE}/notifications`);
export const dismissNotification = (id: string) =>
fetchJson<{ status: string }>(`${BASE}/notifications/${id}`, { method: 'DELETE' });

161
dashboard/src/api/types.ts Normal file
View file

@ -0,0 +1,161 @@
export type Priority = 'urgent' | 'high' | 'medium' | 'low';
export type TaskStatus = 'urgent' | 'open' | 'in-progress' | 'done';
export type AgentTaskStatus = 'queued' | 'running' | 'done' | 'failed';
export type RunStatus = 'success' | 'failure' | 'timeout';
export interface Agent {
name: string;
executable: string;
model?: string;
escalate_to?: string;
mcp_servers: string[];
skills: string[];
timeout: number;
max_retries: number;
env: Record<string, string>;
body?: string;
}
export interface Skill {
name: string;
description: string;
version?: number;
requires_mcp: string[];
inputs: string[];
outputs: string[];
body?: string;
}
export interface CronJob {
name: string;
title: string;
schedule: string;
agent: string;
enabled: boolean;
status: 'active' | 'paused';
last_run?: string;
last_status?: RunStatus;
next_run?: string;
run_count: number;
}
export interface HumanTask {
id: string;
title: string;
priority: Priority;
status: TaskStatus;
source?: string;
repo?: string;
labels: string[];
created: string;
due?: string;
body: string;
}
export interface AgentTask {
id: string;
title: string;
agent: string;
priority: Priority;
type?: string;
status: AgentTaskStatus;
created: string;
started?: string;
completed?: string;
retry: number;
max_retries: number;
input?: unknown;
output?: unknown;
error?: string;
body: string;
}
export interface KnowledgeNote {
path: string;
title: string;
tags: string[];
}
export interface TreeNode {
name: string;
path: string;
type: 'file' | 'directory';
children?: TreeNode[];
}
export interface VaultStats {
agents: number;
skills: number;
crons_scheduled: number;
human_tasks: Record<TaskStatus, number>;
agent_tasks: Record<AgentTaskStatus, number>;
knowledge_notes: number;
total_tasks_executed: number;
total_cron_fires: number;
}
export interface WsEvent {
type: string;
area: string;
path: string;
data?: Record<string, unknown>;
}
export interface HealthStatus {
status: string;
version: string;
uptime_secs: number;
agents: number;
crons_scheduled: number;
total_tasks_executed: number;
}
// View system types
export interface ViewPageDef {
name: string;
type: string;
title?: string;
icon?: string;
route?: string;
position?: number;
layout?: string;
component?: string;
description?: string;
}
export interface WidgetInstanceDef {
widget: string;
props?: Record<string, unknown>;
}
export interface ViewRegions {
[region: string]: WidgetInstanceDef[];
}
export interface ViewDetail {
path: string;
frontmatter: {
type: string;
title?: string;
icon?: string;
route?: string;
position?: number;
layout?: string;
regions?: ViewRegions;
name?: string;
description?: string;
component?: string;
} | null;
body: string;
}
export interface NotificationItem {
id: string;
title: string;
message?: string;
level?: string;
source?: string;
created?: string;
expires?: string;
}

80
dashboard/src/api/ws.ts Normal file
View file

@ -0,0 +1,80 @@
import type { WsEvent } from './types';
type Listener = (event: WsEvent) => void;
export class VaultWebSocket {
private ws: WebSocket | null = null;
private listeners: Map<string, Set<Listener>> = new Map();
private globalListeners: Set<Listener> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private url: string;
constructor(url?: string) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
this.url = url || `${proto}//${window.location.host}/ws`;
}
connect() {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.ws = new WebSocket(this.url);
this.ws.onmessage = (msg) => {
try {
const event: WsEvent = JSON.parse(msg.data);
this.globalListeners.forEach((fn) => fn(event));
this.listeners.get(event.type)?.forEach((fn) => fn(event));
} catch {
// ignore malformed messages
}
};
this.ws.onclose = () => {
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.ws?.close();
};
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.ws?.close();
this.ws = null;
}
/** Listen to a specific event type */
on(type: string, fn: Listener): () => void {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set());
}
this.listeners.get(type)!.add(fn);
return () => this.listeners.get(type)?.delete(fn);
}
/** Listen to all events */
onAny(fn: Listener): () => void {
this.globalListeners.add(fn);
return () => this.globalListeners.delete(fn);
}
send(action: Record<string, unknown>) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(action));
}
}
private scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, 3000);
}
}
export const vaultWs = new VaultWebSocket();

View file

@ -0,0 +1,39 @@
import { useActivity } from '../hooks/useApi';
export function ActivityFeed() {
const { data: activity, isLoading } = useActivity();
if (isLoading) return <div className="p-4 text-sm text-text-muted">Loading...</div>;
if (!activity?.length) return <div className="p-4 text-sm text-text-muted">No recent activity</div>;
return (
<div className="space-y-1">
{activity.slice(0, 20).map((item, i) => (
<div key={i} className="flex items-center gap-2 px-4 py-1.5 text-xs">
<span className={`h-1.5 w-1.5 rounded-full ${kindColor(item.kind)}`} />
<span className="flex-1 truncate text-text-secondary">{item.name}</span>
<span className="text-text-muted">{timeAgo(item.modified)}</span>
</div>
))}
</div>
);
}
function kindColor(kind: string): string {
switch (kind) {
case 'human_task': return 'bg-accent';
case 'agent_task': return 'bg-warning';
case 'knowledge': return 'bg-success';
default: return 'bg-text-muted';
}
}
function timeAgo(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return 'now';
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}

View file

@ -0,0 +1,42 @@
import type { Agent } from '../api/types';
interface Props {
agent: Agent;
onTrigger: (name: string) => void;
}
export function AgentCard({ agent, onTrigger }: Props) {
return (
<div className="rounded-lg border border-border bg-surface-raised p-4 transition-colors hover:border-border-hover">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-text-primary">{agent.name}</h3>
<button
onClick={() => onTrigger(agent.name)}
className="rounded bg-accent/15 px-2 py-0.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
Trigger
</button>
</div>
<div className="mb-2 text-xs text-text-secondary">
<span className="mr-3">{agent.executable}</span>
{agent.model && <span className="text-text-muted">{agent.model}</span>}
</div>
{agent.skills.length > 0 && (
<div className="flex flex-wrap gap-1">
{agent.skills.map((s) => (
<span key={s} className="rounded bg-surface-overlay px-1.5 py-0.5 text-xs text-text-secondary">
{s}
</span>
))}
</div>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-text-muted">
<span>timeout: {agent.timeout}s</span>
{agent.max_retries > 0 && <span>retries: {agent.max_retries}</span>}
</div>
</div>
);
}

View file

@ -0,0 +1,94 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
interface Command {
id: string;
label: string;
action: () => void;
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const commands: Command[] = [
{ id: 'new-task', label: 'New Human Task', action: () => navigate('/editor?new=todos/harald/open') },
{ id: 'new-agent-task', label: 'New Agent Task', action: () => navigate('/editor?new=todos/agent/queued') },
{ id: 'new-agent', label: 'New Agent', action: () => navigate('/editor?new=agents') },
{ id: 'new-skill', label: 'New Skill', action: () => navigate('/editor?new=skills') },
{ id: 'new-cron', label: 'New Cron Job', action: () => navigate('/editor?new=crons/active') },
{ id: 'new-note', label: 'New Knowledge Note', action: () => navigate('/editor?new=knowledge') },
{ id: 'nav-tasks', label: 'Go to Tasks', action: () => navigate('/') },
{ id: 'nav-agents', label: 'Go to Agents', action: () => navigate('/agents') },
{ id: 'nav-crons', label: 'Go to Crons', action: () => navigate('/crons') },
{ id: 'nav-queue', label: 'Go to Agent Queue', action: () => navigate('/queue') },
{ id: 'nav-knowledge', label: 'Go to Knowledge', action: () => navigate('/knowledge') },
{ id: 'nav-editor', label: 'Open Editor', action: () => navigate('/editor') },
];
const filtered = query
? commands.filter((c) => c.label.toLowerCase().includes(query.toLowerCase()))
: commands;
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setOpen((prev) => !prev);
setQuery('');
}
if (e.key === 'Escape') setOpen(false);
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]" onClick={() => setOpen(false)}>
<div className="fixed inset-0 bg-black/50" />
<div
className="relative w-full max-w-md rounded-lg border border-border bg-surface-raised shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<input
ref={inputRef}
className="w-full rounded-t-lg border-b border-border bg-transparent px-4 py-3 text-sm text-text-primary outline-none"
placeholder="Type a command..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filtered.length > 0) {
filtered[0].action();
setOpen(false);
}
}}
/>
<div className="max-h-64 overflow-auto py-1">
{filtered.map((cmd) => (
<button
key={cmd.id}
className="block w-full px-4 py-2 text-left text-sm text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text-primary"
onClick={() => {
cmd.action();
setOpen(false);
}}
>
{cmd.label}
</button>
))}
{filtered.length === 0 && (
<div className="px-4 py-2 text-sm text-text-muted">No matching commands</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
import type { CronJob } from '../api/types';
import { StatusBadge } from './StatusBadge';
interface Props {
cron: CronJob;
onTrigger: (name: string) => void;
onToggle: (name: string, active: boolean) => void;
}
export function CronRow({ cron, onTrigger, onToggle }: Props) {
const isActive = cron.status === 'active';
return (
<div className="flex items-center gap-4 border-b border-border px-4 py-3 last:border-b-0">
<div className="flex-1">
<div className="text-sm font-medium text-text-primary">{cron.title}</div>
<div className="mt-0.5 flex items-center gap-3 text-xs text-text-secondary">
<code className="rounded bg-surface-overlay px-1.5 py-0.5">{cron.schedule}</code>
<span>agent: {cron.agent}</span>
<span>runs: {cron.run_count}</span>
</div>
</div>
<div className="flex items-center gap-3">
{cron.last_status && <StatusBadge value={cron.last_status} />}
<StatusBadge value={cron.status} />
<button
onClick={() => onToggle(cron.name, isActive)}
className="rounded px-2 py-1 text-xs text-text-secondary transition-colors hover:bg-surface-overlay"
>
{isActive ? 'Pause' : 'Resume'}
</button>
<button
onClick={() => onTrigger(cron.name)}
className="rounded bg-accent/15 px-2 py-1 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
Fire
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,89 @@
import { useState } from 'react';
import type { TreeNode } from '../api/types';
interface Props {
tree: TreeNode;
selectedPath?: string;
onSelect: (path: string) => void;
onCreateFile?: (dir: string) => void;
}
export function FileTree({ tree, selectedPath, onSelect, onCreateFile }: Props) {
return (
<div className="text-sm">
{tree.children?.map((node) => (
<TreeItem
key={node.path}
node={node}
depth={0}
selectedPath={selectedPath}
onSelect={onSelect}
onCreateFile={onCreateFile}
/>
))}
</div>
);
}
function TreeItem({
node,
depth,
selectedPath,
onSelect,
onCreateFile,
}: {
node: TreeNode;
depth: number;
selectedPath?: string;
onSelect: (path: string) => void;
onCreateFile?: (dir: string) => void;
}) {
const [expanded, setExpanded] = useState(depth < 1);
const isDir = node.type === 'directory';
const isSelected = node.path === selectedPath;
const pad = `${depth * 12 + 8}px`;
if (isDir) {
return (
<div>
<div
className="group flex cursor-pointer items-center py-0.5 pr-2 text-text-secondary hover:bg-surface-overlay"
style={{ paddingLeft: pad }}
onClick={() => setExpanded(!expanded)}
onContextMenu={(e) => {
e.preventDefault();
onCreateFile?.(node.path);
}}
>
<span className="mr-1 text-xs text-text-muted">{expanded ? '\u25BE' : '\u25B8'}</span>
<span className="truncate">{node.name}</span>
</div>
{expanded &&
node.children?.map((child) => (
<TreeItem
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onSelect={onSelect}
onCreateFile={onCreateFile}
/>
))}
</div>
);
}
return (
<div
className={`cursor-pointer truncate py-0.5 pr-2 transition-colors ${
isSelected
? 'bg-accent/15 text-accent'
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
}`}
style={{ paddingLeft: `${depth * 12 + 20}px` }}
onClick={() => onSelect(node.path)}
>
{node.name}
</div>
);
}

View file

@ -0,0 +1,77 @@
import {
DragDropContext,
Droppable,
Draggable,
type DropResult,
} from '@hello-pangea/dnd';
import type { HumanTask, TaskStatus } from '../api/types';
import { TaskCard } from './TaskCard';
const COLUMNS: { id: TaskStatus; label: string }[] = [
{ id: 'urgent', label: 'Urgent' },
{ id: 'open', label: 'Open' },
{ id: 'in-progress', label: 'In Progress' },
{ id: 'done', label: 'Done' },
];
interface Props {
tasks: HumanTask[];
onMove: (id: string, fromStatus: string, toStatus: string) => void;
}
export function Kanban({ tasks, onMove }: Props) {
const byStatus = (status: TaskStatus) => tasks.filter((t) => t.status === status);
const handleDragEnd = (result: DropResult) => {
if (!result.destination) return;
const fromStatus = result.source.droppableId;
const toStatus = result.destination.droppableId;
if (fromStatus === toStatus) return;
onMove(result.draggableId, fromStatus, toStatus);
};
return (
<DragDropContext onDragEnd={handleDragEnd}>
<div className="flex gap-4 p-4">
{COLUMNS.map((col) => {
const items = byStatus(col.id);
return (
<div key={col.id} className="flex w-72 shrink-0 flex-col">
<div className="mb-2 flex items-center justify-between px-1">
<h3 className="text-sm font-semibold text-text-secondary">{col.label}</h3>
<span className="text-xs text-text-muted">{items.length}</span>
</div>
<Droppable droppableId={col.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex min-h-[200px] flex-col gap-2 rounded-lg border border-border p-2 transition-colors ${
snapshot.isDraggingOver ? 'border-accent/40 bg-accent/5' : 'bg-surface'
}`}
>
{items.map((task, idx) => (
<Draggable key={task.id} draggableId={task.id} index={idx}>
{(prov) => (
<div
ref={prov.innerRef}
{...prov.draggableProps}
{...prov.dragHandleProps}
>
<TaskCard task={task} />
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
})}
</div>
</DragDropContext>
);
}

View file

@ -0,0 +1,13 @@
import { Outlet } from 'react-router-dom';
import { NavigationSidebar } from './NavigationSidebar';
export function Layout() {
return (
<div className="flex h-screen">
<NavigationSidebar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,96 @@
import { NavLink } from 'react-router-dom';
import { useHealth } from '../hooks/useApi';
import { useViewPages } from '../views/ViewRenderer';
/** Static built-in navigation entries */
const builtinNav = [
{ to: '/', label: 'Tasks', icon: '/' },
{ to: '/agents', label: 'Agents' },
{ to: '/crons', label: 'Crons' },
{ to: '/queue', label: 'Queue' },
{ to: '/knowledge', label: 'Knowledge' },
{ to: '/editor', label: 'Editor' },
];
export function NavigationSidebar() {
const { data: health } = useHealth();
const { data: viewPages } = useViewPages();
// Build dynamic nav entries from view pages, sorted by position
const dynamicNav = (viewPages || [])
.filter((p) => p.route && p.title)
.sort((a, b) => (a.position ?? 100) - (b.position ?? 100))
.map((p) => ({
to: p.route!.startsWith('/') ? p.route! : `/${p.route}`,
label: p.title || p.name,
icon: p.icon,
}));
return (
<aside className="flex w-52 shrink-0 flex-col border-r border-border bg-surface-raised">
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<span className="text-lg font-bold text-accent">vault:os</span>
</div>
<nav className="flex-1 overflow-auto px-2 py-3">
<div className="space-y-0.5">
{builtinNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`block rounded-md px-3 py-1.5 text-sm transition-colors ${
isActive
? 'bg-accent/15 text-accent font-medium'
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
}`
}
>
{item.label}
</NavLink>
))}
</div>
{dynamicNav.length > 0 && (
<>
<div className="my-3 border-t border-border" />
<div className="mb-1 px-3 text-[10px] font-semibold uppercase tracking-wider text-text-muted">
Views
</div>
<div className="space-y-0.5">
{dynamicNav.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`block rounded-md px-3 py-1.5 text-sm transition-colors ${
isActive
? 'bg-accent/15 text-accent font-medium'
: 'text-text-secondary hover:bg-surface-overlay hover:text-text-primary'
}`
}
>
{item.icon && <span className="mr-1.5">{item.icon}</span>}
{item.label}
</NavLink>
))}
</div>
</>
)}
</nav>
<div className="border-t border-border px-4 py-3 text-xs text-text-muted">
{health ? (
<>
<div>v{health.version}</div>
<div>{health.agents} agents</div>
<div>{health.crons_scheduled} crons</div>
</>
) : (
<div>connecting...</div>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,49 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listNotifications, dismissNotification } from '../api/client';
const LEVEL_STYLES: Record<string, string> = {
info: 'bg-accent/10 border-accent/30 text-accent',
warning: 'bg-warning/10 border-warning/30 text-warning',
error: 'bg-danger/10 border-danger/30 text-danger',
success: 'bg-success/10 border-success/30 text-success',
};
export function NotificationBanner() {
const queryClient = useQueryClient();
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: listNotifications,
refetchInterval: 30000,
});
const dismiss = useMutation({
mutationFn: dismissNotification,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
});
if (!notifications?.length) return null;
return (
<div className="space-y-2 px-6 pt-4">
{notifications.map((n) => (
<div
key={n.id}
className={`flex items-start gap-3 rounded-md border px-3 py-2 text-sm ${
LEVEL_STYLES[n.level || 'info'] || LEVEL_STYLES.info
}`}
>
<div className="flex-1">
<div className="font-medium">{n.title}</div>
{n.message && <div className="mt-0.5 text-xs opacity-80">{n.message}</div>}
</div>
<button
onClick={() => dismiss.mutate(n.id)}
className="shrink-0 text-xs opacity-60 hover:opacity-100"
>
dismiss
</button>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,26 @@
const styles: Record<string, string> = {
urgent: 'bg-urgent/20 text-urgent',
high: 'bg-danger/20 text-danger',
medium: 'bg-warning/20 text-warning',
low: 'bg-text-muted/20 text-text-secondary',
open: 'bg-accent/20 text-accent',
'in-progress': 'bg-warning/20 text-warning',
done: 'bg-success/20 text-success',
queued: 'bg-text-muted/20 text-text-secondary',
running: 'bg-accent/20 text-accent',
failed: 'bg-danger/20 text-danger',
active: 'bg-success/20 text-success',
paused: 'bg-text-muted/20 text-text-secondary',
success: 'bg-success/20 text-success',
failure: 'bg-danger/20 text-danger',
timeout: 'bg-warning/20 text-warning',
};
export function StatusBadge({ value }: { value: string }) {
const cls = styles[value] || 'bg-surface-overlay text-text-secondary';
return (
<span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${cls}`}>
{value}
</span>
);
}

View file

@ -0,0 +1,35 @@
import type { HumanTask } from '../api/types';
import { StatusBadge } from './StatusBadge';
export function TaskCard({ task }: { task: HumanTask }) {
const age = timeAgo(task.created);
return (
<div className="rounded-lg border border-border bg-surface-raised p-3 transition-colors hover:border-border-hover">
<div className="mb-1.5 text-sm font-medium text-text-primary">{task.title}</div>
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge value={task.priority} />
{task.labels.map((l) => (
<span key={l} className="rounded bg-surface-overlay px-1.5 py-0.5 text-xs text-text-secondary">
{l}
</span>
))}
</div>
<div className="mt-2 flex items-center justify-between text-xs text-text-muted">
<span>{age}</span>
{task.source && <span>via {task.source}</span>}
</div>
</div>
);
}
function timeAgo(iso: string): string {
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.floor(ms / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}

View file

@ -0,0 +1,185 @@
import { useState, useRef, useEffect } from 'react';
import { ModelSelector } from './ModelSelector';
import { DiffView } from './DiffView';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface Props {
filePath?: string;
onClose: () => void;
}
/** Extract unified diff blocks from markdown-formatted assistant response */
function extractDiffs(content: string): string[] {
const diffs: string[] = [];
const regex = /```(?:diff)?\n([\s\S]*?)```/g;
let match;
while ((match = regex.exec(content)) !== null) {
const block = match[1].trim();
if (block.includes('@@') || block.startsWith('---') || block.startsWith('diff ')) {
diffs.push(block);
}
}
return diffs;
}
export function ChatSidebar({ filePath, onClose }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [model, setModel] = useState('local/qwen3');
const [loading, setLoading] = useState(false);
const [applyingDiff, setApplyingDiff] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
}, [messages]);
const sendMessage = async () => {
const text = input.trim();
if (!text || loading) return;
const userMsg: Message = { role: 'user', content: text };
const newMessages = [...messages, userMsg];
setMessages(newMessages);
setInput('');
setLoading(true);
try {
const res = await fetch('/api/assistant/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: newMessages.map((m) => ({ role: m.role, content: m.content })),
model,
file_path: filePath,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Request failed' }));
setMessages([...newMessages, { role: 'assistant', content: `Error: ${err.error}` }]);
return;
}
const data = await res.json();
setMessages([...newMessages, { role: 'assistant', content: data.message.content }]);
} catch (e) {
setMessages([
...newMessages,
{ role: 'assistant', content: `Error: ${e instanceof Error ? e.message : 'Unknown error'}` },
]);
} finally {
setLoading(false);
}
};
const applyDiff = async (diff: string) => {
if (!filePath) return;
setApplyingDiff(diff);
try {
const res = await fetch('/api/assistant/apply-diff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath, diff }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Apply failed' }));
alert(`Failed to apply diff: ${err.error}`);
}
} finally {
setApplyingDiff(null);
}
};
const removeDiff = (diff: string) => {
// Remove the diff from the last assistant message display (user chose to reject)
void diff;
};
return (
<div className="flex h-full w-80 shrink-0 flex-col border-l border-border bg-surface-raised">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-sm font-medium text-text-primary">Assistant</span>
<div className="flex items-center gap-2">
<ModelSelector value={model} onChange={setModel} />
<button onClick={onClose} className="text-text-muted hover:text-text-primary">
&times;
</button>
</div>
</div>
{/* Messages */}
<div ref={scrollRef} className="flex-1 space-y-3 overflow-auto p-3">
{messages.length === 0 && (
<div className="text-xs text-text-muted">
Ask about the current file or request edits. The assistant will suggest diffs you can
apply directly.
</div>
)}
{messages.map((msg, i) => (
<div key={i}>
<div
className={`rounded-md px-3 py-2 text-sm ${
msg.role === 'user'
? 'ml-4 bg-accent/10 text-text-primary'
: 'mr-4 bg-surface-overlay text-text-secondary'
}`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
{/* Render extractable diffs as apply/reject widgets */}
{msg.role === 'assistant' &&
extractDiffs(msg.content).map((diff, di) => (
<div key={di} className="mt-2">
<DiffView
diff={diff}
onApply={() => applyDiff(diff)}
onReject={() => removeDiff(diff)}
applying={applyingDiff === diff}
/>
</div>
))}
</div>
))}
{loading && (
<div className="mr-4 rounded-md bg-surface-overlay px-3 py-2 text-sm text-text-muted">
Thinking...
</div>
)}
</div>
{/* Input */}
<div className="border-t border-border p-3">
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="Ask about this file..."
className="flex-1 rounded border border-border bg-surface-base px-2 py-1.5 text-sm text-text-primary outline-none placeholder:text-text-muted focus:border-accent"
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim()}
className="rounded bg-accent px-3 py-1.5 text-sm text-white hover:bg-accent/80 disabled:opacity-50"
>
Send
</button>
</div>
{filePath && (
<div className="mt-1.5 truncate text-[10px] text-text-muted">Context: {filePath}</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
interface Props {
diff: string;
onApply: () => void;
onReject: () => void;
applying?: boolean;
}
export function DiffView({ diff, onApply, onReject, applying }: Props) {
const lines = diff.split('\n');
return (
<div className="rounded border border-border bg-surface-overlay">
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
<span className="text-xs font-medium text-text-secondary">Suggested Changes</span>
<div className="flex gap-1.5">
<button
onClick={onReject}
disabled={applying}
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-danger/10 hover:text-danger"
>
Reject
</button>
<button
onClick={onApply}
disabled={applying}
className="rounded bg-accent px-2 py-0.5 text-xs text-white hover:bg-accent/80 disabled:opacity-50"
>
{applying ? 'Applying...' : 'Apply'}
</button>
</div>
</div>
<pre className="overflow-auto p-2 text-xs leading-relaxed">
{lines.map((line, i) => {
let cls = 'text-text-secondary';
if (line.startsWith('+') && !line.startsWith('+++')) {
cls = 'text-success bg-success/10';
} else if (line.startsWith('-') && !line.startsWith('---')) {
cls = 'text-danger bg-danger/10';
} else if (line.startsWith('@@')) {
cls = 'text-accent';
}
return (
<div key={i} className={cls}>
{line}
</div>
);
})}
</pre>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
async function fetchModels() {
const res = await fetch('/api/assistant/models');
if (!res.ok) throw new Error('Failed to fetch models');
return res.json() as Promise<{ id: string; name: string }[]>;
}
interface Props {
value: string;
onChange: (model: string) => void;
}
export function ModelSelector({ value, onChange }: Props) {
const { data: models } = useQuery({ queryKey: ['assistant-models'], queryFn: fetchModels });
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded border border-border bg-surface-raised px-2 py-1 text-xs text-text-secondary outline-none focus:border-accent"
>
{(models || []).map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
{!models?.length && <option value={value}>{value}</option>}
</select>
);
}

View file

@ -0,0 +1,254 @@
import { useState, useEffect } from 'react';
import { MarkdownEditor } from './MarkdownEditor';
import { MarkdownPreview, renderMarkdown } from './MarkdownPreview';
import { AgentForm } from '../forms/AgentForm';
import { CronForm } from '../forms/CronForm';
import { HumanTaskForm } from '../forms/HumanTaskForm';
import { AgentTaskForm } from '../forms/AgentTaskForm';
import { SkillForm } from '../forms/SkillForm';
import { KnowledgeForm } from '../forms/KnowledgeForm';
import { readFile, writeFile } from '../../api/client';
type ViewMode = 'edit' | 'preview' | 'split';
type FmMode = 'form' | 'yaml';
interface Props {
path: string;
onSaved?: () => void;
onToggleAssistant?: () => void;
}
export function FileEditor({ path, onSaved, onToggleAssistant }: Props) {
const [frontmatter, setFrontmatter] = useState<Record<string, unknown>>({});
const [body, setBody] = useState('');
const [rawYaml, setRawYaml] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('edit');
const [fmMode, setFmMode] = useState<FmMode>('form');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const entityType = detectEntityType(path);
// Load file
useEffect(() => {
if (!path) return;
setError(undefined);
readFile(path)
.then((data) => {
const fm = (data.frontmatter as Record<string, unknown>) || {};
setFrontmatter(fm);
setBody(data.body || '');
setRawYaml(toYaml(fm));
setDirty(false);
})
.catch((e) => setError(e.message));
}, [path]);
const handleFmChange = (values: Record<string, unknown>) => {
setFrontmatter(values);
setRawYaml(toYaml(values));
setDirty(true);
};
const handleYamlChange = (yaml: string) => {
setRawYaml(yaml);
try {
// We don't parse YAML client-side; just track the raw value
setDirty(true);
} catch {
// ignore parse errors during editing
}
};
const handleBodyChange = (value: string) => {
setBody(value);
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
setError(undefined);
try {
if (fmMode === 'yaml') {
// Send raw content
const raw = rawYaml.trim()
? `---\n${rawYaml.trimEnd()}\n---\n${body}`
: body;
await writeFile(path, { raw });
} else {
await writeFile(path, { frontmatter, body });
}
setDirty(false);
onSaved?.();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Save failed');
} finally {
setSaving(false);
}
};
// Ctrl+S / Cmd+S
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (dirty) handleSave();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-2">
<span className="truncate text-sm text-text-secondary">{path}</span>
{dirty && <span className="text-xs text-warning">unsaved</span>}
</div>
<div className="flex items-center gap-2">
{/* View mode toggle */}
{(['edit', 'split', 'preview'] as ViewMode[]).map((m) => (
<button
key={m}
onClick={() => setViewMode(m)}
className={`rounded px-2 py-1 text-xs transition-colors ${
viewMode === m
? 'bg-accent/15 text-accent'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{m}
</button>
))}
<span className="mx-1 text-border">|</span>
{/* Frontmatter mode toggle */}
<button
onClick={() => setFmMode(fmMode === 'form' ? 'yaml' : 'form')}
className="rounded px-2 py-1 text-xs text-text-muted hover:text-text-secondary"
>
{fmMode === 'form' ? 'YAML' : 'Form'}
</button>
{onToggleAssistant && (
<button
onClick={onToggleAssistant}
className="rounded px-2 py-1 text-xs text-text-muted hover:text-accent"
title="Toggle AI Assistant"
>
AI
</button>
)}
<button
onClick={handleSave}
disabled={!dirty || saving}
className="rounded bg-accent px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-hover disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{error && (
<div className="border-b border-danger/30 bg-danger/10 px-4 py-2 text-xs text-danger">{error}</div>
)}
<div className="flex min-h-0 flex-1">
{/* Frontmatter panel */}
<div className="w-72 shrink-0 overflow-auto border-r border-border">
{fmMode === 'form' ? (
renderEntityForm(entityType, frontmatter, handleFmChange)
) : (
<div className="h-full">
<MarkdownEditor value={rawYaml} onChange={handleYamlChange} placeholder="YAML frontmatter..." />
</div>
)}
</div>
{/* Body editor / preview */}
<div className="flex min-w-0 flex-1">
{(viewMode === 'edit' || viewMode === 'split') && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} min-w-0`}>
<MarkdownEditor value={body} onChange={handleBodyChange} placeholder="Write markdown..." />
</div>
)}
{(viewMode === 'preview' || viewMode === 'split') && (
<div
className={`${viewMode === 'split' ? 'w-1/2 border-l border-border' : 'w-full'} overflow-auto`}
>
<MarkdownPreview html={renderMarkdown(body)} />
</div>
)}
</div>
</div>
</div>
);
}
type EntityType = 'agent' | 'skill' | 'cron' | 'human-task' | 'agent-task' | 'knowledge' | 'generic';
function detectEntityType(path: string): EntityType {
if (path.startsWith('agents/')) return 'agent';
if (path.startsWith('skills/')) return 'skill';
if (path.startsWith('crons/')) return 'cron';
if (path.startsWith('todos/harald/')) return 'human-task';
if (path.startsWith('todos/agent/')) return 'agent-task';
if (path.startsWith('knowledge/')) return 'knowledge';
return 'generic';
}
function renderEntityForm(
type: EntityType,
values: Record<string, unknown>,
onChange: (v: Record<string, unknown>) => void,
) {
switch (type) {
case 'agent':
return <AgentForm values={values} onChange={onChange} />;
case 'skill':
return <SkillForm values={values} onChange={onChange} />;
case 'cron':
return <CronForm values={values} onChange={onChange} />;
case 'human-task':
return <HumanTaskForm values={values} onChange={onChange} />;
case 'agent-task':
return <AgentTaskForm values={values} onChange={onChange} />;
case 'knowledge':
return <KnowledgeForm values={values} onChange={onChange} />;
default:
return (
<div className="p-4 text-xs text-text-muted">
No structured form for this file type. Switch to YAML mode.
</div>
);
}
}
function toYaml(obj: Record<string, unknown>): string {
// Simple YAML serialization for the form -> YAML toggle
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'string' ? item : JSON.stringify(item)}`);
}
}
} else if (typeof value === 'object') {
lines.push(`${key}: ${JSON.stringify(value)}`);
} else if (typeof value === 'string' && value.includes('\n')) {
lines.push(`${key}: |`);
for (const line of value.split('\n')) {
lines.push(` ${line}`);
}
} else {
lines.push(`${key}: ${value}`);
}
}
return lines.join('\n') + '\n';
}

View file

@ -0,0 +1,190 @@
import { useState } from 'react';
interface Props {
fields: FieldDef[];
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export interface FieldDef {
name: string;
label: string;
type: 'text' | 'textarea' | 'number' | 'select' | 'tags' | 'datetime' | 'checkbox' | 'json';
options?: string[];
placeholder?: string;
required?: boolean;
}
export function FrontmatterForm({ fields, values, onChange }: Props) {
const update = (name: string, value: unknown) => {
onChange({ ...values, [name]: value });
};
return (
<div className="space-y-3 p-4">
{fields.map((field) => (
<div key={field.name}>
<label className="mb-1 block text-xs font-medium text-text-secondary">
{field.label}
{field.required && <span className="text-danger"> *</span>}
</label>
<FieldInput field={field} value={values[field.name]} onChange={(v) => update(field.name, v)} />
</div>
))}
</div>
);
}
function FieldInput({
field,
value,
onChange,
}: {
field: FieldDef;
value: unknown;
onChange: (v: unknown) => void;
}) {
const cls =
'w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent';
switch (field.type) {
case 'text':
return (
<input
className={cls}
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
/>
);
case 'textarea':
return (
<textarea
className={`${cls} min-h-[60px] resize-y`}
value={(value as string) || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={field.placeholder}
/>
);
case 'number':
return (
<input
type="number"
className={cls}
value={(value as number) ?? ''}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
placeholder={field.placeholder}
/>
);
case 'select':
return (
<select className={cls} value={(value as string) || ''} onChange={(e) => onChange(e.target.value)}>
<option value=""></option>
{field.options?.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
case 'tags':
return <TagsInput value={(value as string[]) || []} onChange={onChange} />;
case 'datetime':
return (
<input
type="datetime-local"
className={cls}
value={toDatetimeLocal((value as string) || '')}
onChange={(e) => onChange(e.target.value ? new Date(e.target.value).toISOString() : undefined)}
/>
);
case 'checkbox':
return (
<input
type="checkbox"
className="h-4 w-4 rounded border-border"
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
/>
);
case 'json':
return (
<textarea
className={`${cls} min-h-[60px] resize-y font-mono text-xs`}
value={typeof value === 'string' ? value : JSON.stringify(value, null, 2) || ''}
onChange={(e) => {
try {
onChange(JSON.parse(e.target.value));
} catch {
onChange(e.target.value);
}
}}
placeholder={field.placeholder || '{}'}
/>
);
default:
return null;
}
}
function TagsInput({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
const [input, setInput] = useState('');
const add = () => {
const tag = input.trim();
if (tag && !value.includes(tag)) {
onChange([...value, tag]);
}
setInput('');
};
return (
<div>
<div className="mb-1 flex flex-wrap gap-1">
{value.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded bg-surface-overlay px-2 py-0.5 text-xs text-text-secondary"
>
{tag}
<button
onClick={() => onChange(value.filter((t) => t !== tag))}
className="text-text-muted hover:text-danger"
>
x
</button>
</span>
))}
</div>
<input
className="w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
add();
}
}}
placeholder="Add tag..."
/>
</div>
);
}
function toDatetimeLocal(iso: string): string {
if (!iso) return '';
try {
return new Date(iso).toISOString().slice(0, 16);
} catch {
return '';
}
}

View file

@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder as phPlugin } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import {
autocompletion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import { suggestFiles } from '../../api/client';
interface Props {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
/** Wiki-link `[[...]]` async autocompletion source. */
async function wikiLinkCompletion(ctx: CompletionContext): Promise<CompletionResult | null> {
const before = ctx.matchBefore(/\[\[[^\]]*$/);
if (!before) return null;
const query = before.text.slice(2); // strip [[
const files = await suggestFiles(query).catch(() => [] as string[]);
return {
from: before.from + 2,
filter: false,
options: files.map((f) => ({
label: f,
apply: f + ']]',
})),
};
}
export function MarkdownEditor({ value, onChange, placeholder }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
keymap.of([...defaultKeymap, ...historyKeymap]),
history(),
markdown(),
oneDark,
autocompletion({ override: [wikiLinkCompletion] }),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
'&': { height: '100%', fontSize: '14px' },
'.cm-scroller': { overflow: 'auto' },
'.cm-content': { fontFamily: "'JetBrains Mono', 'Fira Code', monospace" },
}),
...(placeholder ? [phPlugin(placeholder)] : []),
],
});
const view = new EditorView({ state, parent: containerRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// Only create editor once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync external value changes (e.g., file load) without re-creating editor
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
view.dispatch({
changes: { from: 0, to: current.length, insert: value },
});
}
}, [value]);
return <div ref={containerRef} className="h-full" />;
}

View file

@ -0,0 +1,27 @@
interface Props {
html: string;
}
export function MarkdownPreview({ html }: Props) {
return (
<article
className="prose prose-invert max-w-none p-4"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
/** Render markdown to HTML client-side (basic). */
export function renderMarkdown(md: string): string {
// Simple client-side rendering for preview — real rendering done server-side
return md
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[\[(.+?)\]\]/g, '<a href="#" class="text-accent">$1</a>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/\n/g, '<br/>');
}

View file

@ -0,0 +1,21 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'name', label: 'Name', type: 'text', required: true, placeholder: 'my-agent' },
{ name: 'executable', label: 'Executable', type: 'select', required: true, options: ['claude-code', 'ollama', 'custom'] },
{ name: 'model', label: 'Model', type: 'text', placeholder: 'sonnet' },
{ name: 'escalate_to', label: 'Escalate To', type: 'text', placeholder: 'opus' },
{ name: 'skills', label: 'Skills', type: 'tags' },
{ name: 'mcp_servers', label: 'MCP Servers', type: 'tags' },
{ name: 'timeout', label: 'Timeout (seconds)', type: 'number', placeholder: '600' },
{ name: 'max_retries', label: 'Max Retries', type: 'number', placeholder: '0' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function AgentForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,19 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'title', label: 'Title', type: 'text', required: true },
{ name: 'agent', label: 'Agent', type: 'text', required: true },
{ name: 'priority', label: 'Priority', type: 'select', options: ['urgent', 'high', 'medium', 'low'] },
{ name: 'type', label: 'Task Type', type: 'text', placeholder: 'review' },
{ name: 'max_retries', label: 'Max Retries', type: 'number', placeholder: '0' },
{ name: 'input', label: 'Input (JSON)', type: 'json' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function AgentTaskForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,17 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'title', label: 'Title', type: 'text', required: true, placeholder: 'Daily code review' },
{ name: 'schedule', label: 'Schedule (cron)', type: 'text', required: true, placeholder: '0 9 * * *' },
{ name: 'agent', label: 'Agent', type: 'text', required: true, placeholder: 'reviewer' },
{ name: 'enabled', label: 'Enabled', type: 'checkbox' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function CronForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,19 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'title', label: 'Title', type: 'text', required: true },
{ name: 'priority', label: 'Priority', type: 'select', options: ['urgent', 'high', 'medium', 'low'] },
{ name: 'labels', label: 'Labels', type: 'tags' },
{ name: 'repo', label: 'Repository', type: 'text', placeholder: 'owner/repo' },
{ name: 'due', label: 'Due Date', type: 'datetime' },
{ name: 'source', label: 'Source', type: 'text', placeholder: 'self' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function HumanTaskForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,17 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'title', label: 'Title', type: 'text' },
{ name: 'tags', label: 'Tags', type: 'tags' },
{ name: 'source', label: 'Source', type: 'text', placeholder: 'agent-name or self' },
{ name: 'related', label: 'Related Files', type: 'tags' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function KnowledgeForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,19 @@
import { FrontmatterForm, type FieldDef } from '../editor/FrontmatterForm';
const fields: FieldDef[] = [
{ name: 'name', label: 'Name', type: 'text', required: true, placeholder: 'read-vault' },
{ name: 'description', label: 'Description', type: 'textarea', required: true },
{ name: 'version', label: 'Version', type: 'number', placeholder: '1' },
{ name: 'requires_mcp', label: 'Requires MCP', type: 'tags' },
{ name: 'inputs', label: 'Inputs', type: 'tags' },
{ name: 'outputs', label: 'Outputs', type: 'tags' },
];
interface Props {
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
}
export function SkillForm({ values, onChange }: Props) {
return <FrontmatterForm fields={fields} values={values} onChange={onChange} />;
}

View file

@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as api from '../api/client';
export function useAgents() {
return useQuery({ queryKey: ['agents'], queryFn: api.listAgents });
}
export function useAgent(name: string) {
return useQuery({ queryKey: ['agents', name], queryFn: () => api.getAgent(name), enabled: !!name });
}
export function useSkills() {
return useQuery({ queryKey: ['skills'], queryFn: api.listSkills });
}
export function useCrons() {
return useQuery({ queryKey: ['crons'], queryFn: api.listCrons });
}
export function useHumanTasks() {
return useQuery({ queryKey: ['humanTasks'], queryFn: api.listHumanTasks });
}
export function useAgentTasks() {
return useQuery({ queryKey: ['agentTasks'], queryFn: api.listAgentTasks });
}
export function useKnowledge(q?: string, tag?: string) {
return useQuery({
queryKey: ['knowledge', q, tag],
queryFn: () => api.listKnowledge(q, tag),
});
}
export function useStats() {
return useQuery({ queryKey: ['stats'], queryFn: api.getStats, refetchInterval: 30000 });
}
export function useActivity() {
return useQuery({ queryKey: ['activity'], queryFn: api.getActivity });
}
export function useHealth() {
return useQuery({ queryKey: ['health'], queryFn: api.getHealth, refetchInterval: 15000 });
}
export function useMoveHumanTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ status, id, to }: { status: string; id: string; to: string }) =>
api.moveHumanTask(status, id, to),
onSuccess: () => qc.invalidateQueries({ queryKey: ['humanTasks'] }),
});
}
export function useCreateHumanTask() {
const qc = useQueryClient();
return useMutation({
mutationFn: api.createHumanTask,
onSuccess: () => qc.invalidateQueries({ queryKey: ['humanTasks'] }),
});
}
export function useTriggerAgent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ name, context }: { name: string; context?: string }) =>
api.triggerAgent(name, context),
onSuccess: () => qc.invalidateQueries({ queryKey: ['agentTasks'] }),
});
}
export function useTriggerCron() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.triggerCron(name),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['crons'] });
qc.invalidateQueries({ queryKey: ['agentTasks'] });
},
});
}
export function usePauseCron() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.pauseCron(name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['crons'] }),
});
}
export function useResumeCron() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => api.resumeCron(name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['crons'] }),
});
}

View file

@ -0,0 +1,47 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { vaultWs } from '../api/ws';
import type { WsEvent } from '../api/types';
/** Connect/disconnect the global WebSocket with the component lifecycle. */
export function useWebSocket() {
const queryClient = useQueryClient();
useEffect(() => {
vaultWs.connect();
const unsub = vaultWs.onAny((event: WsEvent) => {
// Invalidate relevant queries based on event area
if (event.type.startsWith('agent_task'))
queryClient.invalidateQueries({ queryKey: ['agentTasks'] });
if (event.type.startsWith('human_task'))
queryClient.invalidateQueries({ queryKey: ['humanTasks'] });
if (event.type.startsWith('agent_') && !event.type.startsWith('agent_task'))
queryClient.invalidateQueries({ queryKey: ['agents'] });
if (event.type.startsWith('skill_'))
queryClient.invalidateQueries({ queryKey: ['skills'] });
if (event.type.startsWith('cron_'))
queryClient.invalidateQueries({ queryKey: ['crons'] });
if (event.type.startsWith('knowledge_'))
queryClient.invalidateQueries({ queryKey: ['knowledge'] });
// Always invalidate stats/activity on any change
queryClient.invalidateQueries({ queryKey: ['stats'] });
queryClient.invalidateQueries({ queryKey: ['activity'] });
});
return () => {
unsub();
vaultWs.disconnect();
};
}, [queryClient]);
}
/** Subscribe to a specific event type. */
export function useVaultEvent(type: string, handler: (event: WsEvent) => void) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
return vaultWs.on(type, (e) => handlerRef.current(e));
}, [type]);
}

24
dashboard/src/index.css Normal file
View file

@ -0,0 +1,24 @@
@import "tailwindcss";
@theme {
--color-surface: #0f1117;
--color-surface-raised: #1a1d27;
--color-surface-overlay: #252833;
--color-border: #2e3241;
--color-border-hover: #3d4254;
--color-text-primary: #e1e4ed;
--color-text-secondary: #8b90a0;
--color-text-muted: #5c6070;
--color-accent: #6c8cff;
--color-accent-hover: #8aa3ff;
--color-success: #4ade80;
--color-warning: #fbbf24;
--color-danger: #f87171;
--color-urgent: #ef4444;
}
body {
@apply bg-surface text-text-primary;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}

10
dashboard/src/main.tsx Normal file
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 { useAgentTasks } from '../hooks/useApi';
import { StatusBadge } from '../components/StatusBadge';
import type { AgentTask, AgentTaskStatus } from '../api/types';
const COLUMNS: { id: AgentTaskStatus; label: string }[] = [
{ id: 'queued', label: 'Queued' },
{ id: 'running', label: 'Running' },
{ id: 'done', label: 'Done' },
{ id: 'failed', label: 'Failed' },
];
export function AgentQueuePage() {
const { data: tasks, isLoading } = useAgentTasks();
const byStatus = (s: AgentTaskStatus) => (tasks || []).filter((t) => t.status === s);
return (
<div className="p-6">
<h1 className="mb-4 text-lg font-semibold">Agent Queue</h1>
{isLoading ? (
<div className="text-text-muted">Loading...</div>
) : (
<div className="flex gap-4">
{COLUMNS.map((col) => {
const items = byStatus(col.id);
return (
<div key={col.id} className="w-72 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<h3 className="text-sm font-semibold text-text-secondary">{col.label}</h3>
<span className="text-xs text-text-muted">{items.length}</span>
</div>
<div className="space-y-2 rounded-lg border border-border bg-surface p-2">
{items.length === 0 ? (
<div className="py-4 text-center text-xs text-text-muted">Empty</div>
) : (
items.map((task) => <AgentTaskCard key={task.id} task={task} />)
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function AgentTaskCard({ task }: { task: AgentTask }) {
return (
<div className="rounded-lg border border-border bg-surface-raised p-3">
<div className="mb-1 text-sm font-medium text-text-primary">{task.title}</div>
<div className="flex items-center gap-2 text-xs text-text-secondary">
<span>{task.agent}</span>
<StatusBadge value={task.priority} />
{task.type && <span className="text-text-muted">{task.type}</span>}
</div>
{task.error && (
<div className="mt-2 truncate rounded bg-danger/10 px-2 py-1 text-xs text-danger">
{task.error}
</div>
)}
{task.started && (
<div className="mt-1 text-xs text-text-muted">
started: {new Date(task.started).toLocaleTimeString()}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,29 @@
import { AgentCard } from '../components/AgentCard';
import { useAgents, useTriggerAgent } from '../hooks/useApi';
export function AgentsPage() {
const { data: agents, isLoading } = useAgents();
const triggerMut = useTriggerAgent();
return (
<div className="p-6">
<h1 className="mb-4 text-lg font-semibold">Agents</h1>
{isLoading ? (
<div className="text-text-muted">Loading agents...</div>
) : !agents?.length ? (
<div className="text-text-muted">No agents defined. Create markdown files in agents/</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{agents.map((agent) => (
<AgentCard
key={agent.name}
agent={agent}
onTrigger={(name) => triggerMut.mutate({ name })}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,40 @@
import { CronRow } from '../components/CronRow';
import { useCrons, useTriggerCron, usePauseCron, useResumeCron } from '../hooks/useApi';
export function CronsPage() {
const { data: crons, isLoading } = useCrons();
const triggerMut = useTriggerCron();
const pauseMut = usePauseCron();
const resumeMut = useResumeCron();
const handleToggle = (name: string, isActive: boolean) => {
if (isActive) {
pauseMut.mutate(name);
} else {
resumeMut.mutate(name);
}
};
return (
<div className="p-6">
<h1 className="mb-4 text-lg font-semibold">Cron Jobs</h1>
{isLoading ? (
<div className="text-text-muted">Loading crons...</div>
) : !crons?.length ? (
<div className="text-text-muted">No cron jobs defined. Create markdown files in crons/active/</div>
) : (
<div className="rounded-lg border border-border bg-surface-raised">
{crons.map((cron) => (
<CronRow
key={cron.name}
cron={cron}
onTrigger={(name) => triggerMut.mutate(name)}
onToggle={handleToggle}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileTree } from '../components/FileTree';
import { FileEditor } from '../components/editor/FileEditor';
import { ChatSidebar } from '../components/assistant/ChatSidebar';
import { getTree, writeFile } from '../api/client';
import { slugify } from '../utils';
export function EditorPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedPath, setSelectedPath] = useState(searchParams.get('file') || '');
const [showAssistant, setShowAssistant] = useState(false);
const { data: tree, refetch: refetchTree } = useQuery({
queryKey: ['tree'],
queryFn: getTree,
});
// Handle ?new=path for creating new files
useEffect(() => {
const newDir = searchParams.get('new');
if (newDir) {
const name = prompt('File name (without .md):');
if (name) {
const slug = slugify(name);
const path = `${newDir}/${slug}.md`;
writeFile(path, { frontmatter: { title: name }, body: '' }).then(() => {
setSelectedPath(path);
setSearchParams({});
refetchTree();
});
} else {
setSearchParams({});
}
}
}, [searchParams, setSearchParams, refetchTree]);
const handleSelect = (path: string) => {
if (path.endsWith('.md')) {
setSelectedPath(path);
}
};
const handleCreateFile = (dir: string) => {
const name = prompt('New file name (without .md):');
if (name) {
const slug = slugify(name);
const path = `${dir}/${slug}.md`;
writeFile(path, { frontmatter: {}, body: '' }).then(() => {
setSelectedPath(path);
refetchTree();
});
}
};
return (
<div className="flex h-full">
{/* File tree sidebar */}
<div className="w-56 shrink-0 overflow-auto border-r border-border bg-surface-raised py-2">
{tree ? (
<FileTree
tree={tree}
selectedPath={selectedPath}
onSelect={handleSelect}
onCreateFile={handleCreateFile}
/>
) : (
<div className="p-4 text-xs text-text-muted">Loading...</div>
)}
</div>
{/* Editor */}
<div className="min-w-0 flex-1">
{selectedPath ? (
<FileEditor
path={selectedPath}
onSaved={() => refetchTree()}
onToggleAssistant={() => setShowAssistant((v) => !v)}
/>
) : (
<div className="flex h-full items-center justify-center text-text-muted">
Select a file or press Cmd+K to create one
</div>
)}
</div>
{/* AI Assistant sidebar */}
{showAssistant && (
<ChatSidebar filePath={selectedPath} onClose={() => setShowAssistant(false)} />
)}
</div>
);
}

View file

@ -0,0 +1,96 @@
import { useState } from 'react';
import { useKnowledge } from '../hooks/useApi';
import { getKnowledge } from '../api/client';
export function KnowledgePage() {
const [search, setSearch] = useState('');
const [selectedTag, setSelectedTag] = useState<string>();
const [selectedNote, setSelectedNote] = useState<{
path: string;
html: string;
body: string;
frontmatter: unknown;
} | null>(null);
const { data: notes, isLoading } = useKnowledge(search || undefined, selectedTag);
const allTags = [...new Set((notes || []).flatMap((n) => n.tags))].sort();
const openNote = async (path: string) => {
const data = await getKnowledge(path.replace(/^knowledge\//, ''));
setSelectedNote(data);
};
return (
<div className="flex h-full">
<div className="w-80 shrink-0 border-r border-border">
<div className="border-b border-border p-4">
<h1 className="mb-3 text-lg font-semibold">Knowledge</h1>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-full rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
/>
{allTags.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setSelectedTag(selectedTag === tag ? undefined : tag)}
className={`rounded px-2 py-0.5 text-xs transition-colors ${
selectedTag === tag
? 'bg-accent/20 text-accent'
: 'bg-surface-overlay text-text-secondary hover:text-text-primary'
}`}
>
{tag}
</button>
))}
</div>
)}
</div>
<div className="overflow-auto">
{isLoading ? (
<div className="p-4 text-sm text-text-muted">Loading...</div>
) : !notes?.length ? (
<div className="p-4 text-sm text-text-muted">No notes found</div>
) : (
notes.map((note) => (
<button
key={note.path}
onClick={() => openNote(note.path)}
className={`block w-full border-b border-border px-4 py-2.5 text-left transition-colors hover:bg-surface-overlay ${
selectedNote?.path === note.path.replace(/^knowledge\//, '')
? 'bg-surface-overlay'
: ''
}`}
>
<div className="text-sm font-medium text-text-primary">{note.title}</div>
{note.tags.length > 0 && (
<div className="mt-0.5 flex gap-1">
{note.tags.map((t) => (
<span key={t} className="text-xs text-text-muted">#{t}</span>
))}
</div>
)}
</button>
))
)}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{selectedNote ? (
<article
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: selectedNote.html }}
/>
) : (
<div className="text-text-muted">Select a note to view</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
import { Kanban } from '../components/Kanban';
import { ActivityFeed } from '../components/ActivityFeed';
import { useHumanTasks, useMoveHumanTask, useCreateHumanTask, useStats } from '../hooks/useApi';
import { StatusBadge } from '../components/StatusBadge';
import { useState } from 'react';
export function TasksPage() {
const { data: tasks, isLoading } = useHumanTasks();
const { data: stats } = useStats();
const moveMut = useMoveHumanTask();
const createMut = useCreateHumanTask();
const [showCreate, setShowCreate] = useState(false);
const [newTitle, setNewTitle] = useState('');
const handleMove = (id: string, fromStatus: string, toStatus: string) => {
moveMut.mutate({ status: fromStatus, id, to: toStatus });
};
const handleCreate = () => {
if (!newTitle.trim()) return;
createMut.mutate({ title: newTitle.trim() }, {
onSuccess: () => { setNewTitle(''); setShowCreate(false); },
});
};
return (
<div className="flex h-full">
<div className="flex-1 overflow-auto">
<div className="flex items-center justify-between border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Tasks</h1>
<button
onClick={() => setShowCreate(!showCreate)}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent-hover"
>
New Task
</button>
</div>
{showCreate && (
<div className="flex items-center gap-2 border-b border-border px-6 py-2">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
placeholder="Task title..."
className="flex-1 rounded border border-border bg-surface px-3 py-1.5 text-sm text-text-primary outline-none focus:border-accent"
autoFocus
/>
<button onClick={handleCreate} className="rounded bg-accent px-3 py-1.5 text-xs text-white">
Create
</button>
</div>
)}
{isLoading ? (
<div className="p-6 text-text-muted">Loading tasks...</div>
) : (
<Kanban tasks={tasks || []} onMove={handleMove} />
)}
</div>
<aside className="w-64 shrink-0 border-l border-border bg-surface-raised">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-text-secondary">Overview</h2>
{stats && (
<div className="mt-2 space-y-1">
{Object.entries(stats.human_tasks).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<StatusBadge value={k} />
<span className="text-text-muted">{v}</span>
</div>
))}
</div>
)}
</div>
<div className="px-4 py-3">
<h2 className="mb-2 text-sm font-semibold text-text-secondary">Recent Activity</h2>
<ActivityFeed />
</div>
</aside>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { useParams } from 'react-router-dom';
import { ViewRenderer } from '../views/ViewRenderer';
import { NotificationBanner } from '../components/NotificationBanner';
export function ViewPage() {
const { '*': viewPath } = useParams();
if (!viewPath) {
return <div className="p-6 text-sm text-text-muted">No view specified</div>;
}
return (
<>
<NotificationBanner />
<ViewRenderer viewPath={`pages/${viewPath}`} />
</>
);
}

6
dashboard/src/utils.ts Normal file
View file

@ -0,0 +1,6 @@
export function slugify(s: string): string {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

View file

@ -0,0 +1,52 @@
import type { ViewRegions, WidgetInstanceDef } from '../api/types';
import { WidgetRenderer } from './WidgetRenderer';
interface Props {
layout?: string;
regions?: ViewRegions;
}
function renderWidgets(widgets: WidgetInstanceDef[]) {
return widgets.map((w, i) => <WidgetRenderer key={`${w.widget}-${i}`} instance={w} />);
}
/** Single-column: all widgets stacked in "main" region */
function SingleColumn({ regions }: { regions: ViewRegions }) {
const main = regions['main'] || [];
return <div className="space-y-4">{renderWidgets(main)}</div>;
}
/** Two-column: "left" and "right" regions side by side */
function TwoColumn({ regions }: { regions: ViewRegions }) {
const left = regions['left'] || regions['main'] || [];
const right = regions['right'] || regions['sidebar'] || [];
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-4">{renderWidgets(left)}</div>
<div className="space-y-4">{renderWidgets(right)}</div>
</div>
);
}
/** Dashboard grid: renders all regions as a responsive grid */
function DashboardGrid({ regions }: { regions: ViewRegions }) {
const all = Object.values(regions).flat();
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{renderWidgets(all)}
</div>
);
}
export function LayoutRenderer({ layout = 'single', regions = {} }: Props) {
switch (layout) {
case 'two-column':
return <TwoColumn regions={regions} />;
case 'dashboard':
case 'grid':
return <DashboardGrid regions={regions} />;
case 'single':
default:
return <SingleColumn regions={regions} />;
}
}

View file

@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { getView, listViewPages } from '../api/client';
import { LayoutRenderer } from './LayoutRenderer';
interface Props {
/** The path of the view page definition (relative to views/) */
viewPath: string;
}
export function ViewRenderer({ viewPath }: Props) {
const { data: view, isLoading, error } = useQuery({
queryKey: ['view', viewPath],
queryFn: () => getView(viewPath),
});
if (isLoading) {
return <div className="p-6 text-sm text-text-muted">Loading view...</div>;
}
if (error || !view?.frontmatter) {
return (
<div className="p-6 text-sm text-text-muted">
Failed to load view: {viewPath}
</div>
);
}
const { title, layout, regions } = view.frontmatter;
return (
<div className="p-6">
{title && <h1 className="mb-4 text-xl font-bold text-text-primary">{title}</h1>}
<LayoutRenderer layout={layout} regions={regions} />
</div>
);
}
/** Hook to get the list of dynamic view pages for navigation */
export function useViewPages() {
return useQuery({
queryKey: ['viewPages'],
queryFn: listViewPages,
staleTime: 30000,
});
}

View file

@ -0,0 +1,46 @@
import type { WidgetInstanceDef } from '../api/types';
import { KanbanWidget } from '../widgets/KanbanWidget';
import { ActivityFeedWidget } from '../widgets/ActivityFeedWidget';
import { AgentStatusBar } from '../widgets/AgentStatusBar';
import { StatsWidget } from '../widgets/StatsWidget';
import { CronScheduleWidget } from '../widgets/CronScheduleWidget';
import { FileListWidget } from '../widgets/FileListWidget';
import { MarkdownViewerWidget } from '../widgets/MarkdownViewerWidget';
import { TableWidget } from '../widgets/TableWidget';
import { TagCloudWidget } from '../widgets/TagCloudWidget';
import { FileTreeWidget } from '../widgets/FileTreeWidget';
const WIDGET_MAP: Record<string, React.ComponentType<Record<string, unknown>>> = {
Kanban: KanbanWidget as React.ComponentType<Record<string, unknown>>,
ActivityFeed: ActivityFeedWidget as React.ComponentType<Record<string, unknown>>,
AgentStatusBar: AgentStatusBar as React.ComponentType<Record<string, unknown>>,
Stats: StatsWidget as React.ComponentType<Record<string, unknown>>,
CronSchedule: CronScheduleWidget as React.ComponentType<Record<string, unknown>>,
FileList: FileListWidget as React.ComponentType<Record<string, unknown>>,
MarkdownViewer: MarkdownViewerWidget as React.ComponentType<Record<string, unknown>>,
Table: TableWidget as React.ComponentType<Record<string, unknown>>,
TagCloud: TagCloudWidget as React.ComponentType<Record<string, unknown>>,
FileTree: FileTreeWidget as React.ComponentType<Record<string, unknown>>,
};
interface Props {
instance: WidgetInstanceDef;
}
export function WidgetRenderer({ instance }: Props) {
const Component = WIDGET_MAP[instance.widget];
if (!Component) {
return (
<div className="rounded-md border border-border bg-surface-raised p-3 text-xs text-text-muted">
Unknown widget: {instance.widget}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-raised">
<Component {...(instance.props || {})} />
</div>
);
}

View file

@ -0,0 +1,9 @@
import { ActivityFeed } from '../components/ActivityFeed';
interface Props {
limit?: number;
}
export function ActivityFeedWidget({}: Props) {
return <ActivityFeed />;
}

View file

@ -0,0 +1,36 @@
import { useAgents, useAgentTasks } from '../hooks/useApi';
export function AgentStatusBar() {
const { data: agents } = useAgents();
const { data: tasks } = useAgentTasks();
const running = (tasks || []).filter((t) => t.status === 'running');
return (
<div className="flex flex-wrap gap-2 p-3">
{(agents || []).map((agent) => {
const active = running.filter((t) => t.agent === agent.name);
const isActive = active.length > 0;
return (
<div
key={agent.name}
className={`flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs ${
isActive
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-surface-raised text-text-secondary'
}`}
>
<span
className={`h-2 w-2 rounded-full ${isActive ? 'bg-accent animate-pulse' : 'bg-text-muted'}`}
/>
<span className="font-medium">{agent.name}</span>
{isActive && <span className="text-text-muted">{active.length} running</span>}
</div>
);
})}
{(!agents || agents.length === 0) && (
<span className="text-xs text-text-muted">No agents defined</span>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
import { CronRow } from '../components/CronRow';
import { useCrons, useTriggerCron, usePauseCron, useResumeCron } from '../hooks/useApi';
export function CronScheduleWidget() {
const { data: crons } = useCrons();
const triggerMut = useTriggerCron();
const pauseMut = usePauseCron();
const resumeMut = useResumeCron();
if (!crons?.length) return <div className="p-3 text-xs text-text-muted">No crons</div>;
return (
<div className="rounded-md border border-border">
{crons.map((cron) => (
<CronRow
key={cron.name}
cron={cron}
onTrigger={(name) => triggerMut.mutate(name)}
onToggle={(name, active) => (active ? pauseMut : resumeMut).mutate(name)}
/>
))}
</div>
);
}

View file

@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { suggestFiles } from '../api/client';
import { useNavigate } from 'react-router-dom';
interface Props {
source?: string;
filter?: string;
}
export function FileListWidget({ source, filter }: Props) {
const { data: files } = useQuery({
queryKey: ['suggestFiles', filter],
queryFn: () => suggestFiles(filter || source),
});
const navigate = useNavigate();
return (
<div className="max-h-80 overflow-auto">
{(files || []).map((f) => (
<button
key={f}
onClick={() => navigate(`/editor?file=${encodeURIComponent(f)}`)}
className="block w-full truncate border-b border-border px-3 py-1.5 text-left text-xs text-text-secondary transition-colors hover:bg-surface-overlay hover:text-text-primary last:border-b-0"
>
{f}
</button>
))}
{(!files || files.length === 0) && (
<div className="p-3 text-xs text-text-muted">No files</div>
)}
</div>
);
}

View file

@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { getTree } from '../api/client';
import { FileTree } from '../components/FileTree';
import { useNavigate } from 'react-router-dom';
export function FileTreeWidget() {
const { data: tree } = useQuery({ queryKey: ['tree'], queryFn: getTree });
const navigate = useNavigate();
if (!tree) return <div className="p-3 text-xs text-text-muted">Loading...</div>;
return (
<div className="max-h-80 overflow-auto py-1">
<FileTree
tree={tree}
onSelect={(path) => navigate(`/editor?file=${encodeURIComponent(path)}`)}
/>
</div>
);
}

View file

@ -0,0 +1,18 @@
import { Kanban } from '../components/Kanban';
import { useHumanTasks, useMoveHumanTask } from '../hooks/useApi';
interface Props {
source?: string;
draggable?: boolean;
}
export function KanbanWidget(_props: Props) {
const { data: tasks } = useHumanTasks();
const moveMut = useMoveHumanTask();
const handleMove = (id: string, from: string, to: string) => {
moveMut.mutate({ status: from, id, to });
};
return <Kanban tasks={tasks || []} onMove={handleMove} />;
}

View file

@ -0,0 +1,21 @@
interface Props {
content?: string;
}
export function MarkdownViewerWidget({ content }: Props) {
if (!content) return <div className="p-3 text-xs text-text-muted">No content</div>;
// Basic markdown-to-html for widget context
const html = content
.replace(/^### (.+)$/gm, '<h3 class="text-sm font-semibold mt-2 mb-1">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-base font-semibold mt-3 mb-1">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-lg font-bold mt-3 mb-1">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code class="bg-surface-overlay px-1 rounded text-xs">$1</code>')
.replace(/\n/g, '<br/>');
return (
<div className="p-3 text-sm text-text-secondary" dangerouslySetInnerHTML={{ __html: html }} />
);
}

View file

@ -0,0 +1,35 @@
import { useStats } from '../hooks/useApi';
interface Props {
keys?: string[];
}
export function StatsWidget({ keys }: Props) {
const { data: stats, isLoading } = useStats();
if (isLoading || !stats) return <div className="p-3 text-xs text-text-muted">Loading...</div>;
const entries: [string, number][] = [
['Agents', stats.agents],
['Skills', stats.skills],
['Crons', stats.crons_scheduled],
['Knowledge', stats.knowledge_notes],
['Tasks Executed', stats.total_tasks_executed],
['Cron Fires', stats.total_cron_fires],
];
const filtered = keys
? entries.filter(([k]) => keys.some((f) => k.toLowerCase().includes(f.toLowerCase())))
: entries;
return (
<div className="grid grid-cols-2 gap-3 p-3">
{filtered.map(([label, value]) => (
<div key={label} className="rounded-md border border-border bg-surface p-3">
<div className="text-2xl font-bold text-text-primary">{value}</div>
<div className="text-xs text-text-secondary">{label}</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,37 @@
interface Props {
columns?: { key: string; label: string }[];
data?: Record<string, unknown>[];
}
export function TableWidget({ columns, data }: Props) {
if (!data?.length || !columns?.length) {
return <div className="p-3 text-xs text-text-muted">No data</div>;
}
return (
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th key={col.key} className="px-3 py-2 text-xs font-medium text-text-secondary">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i} className="border-b border-border last:border-b-0 hover:bg-surface-overlay">
{columns.map((col) => (
<td key={col.key} className="px-3 py-1.5 text-text-primary">
{String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { suggestTags } from '../api/client';
export function TagCloudWidget() {
const { data: tags } = useQuery({ queryKey: ['tags'], queryFn: suggestTags });
if (!tags?.length) return <div className="p-3 text-xs text-text-muted">No tags</div>;
return (
<div className="flex flex-wrap gap-1.5 p-3">
{tags.map((tag) => (
<span
key={tag}
className="rounded-full bg-surface-overlay px-2.5 py-0.5 text-xs text-text-secondary transition-colors hover:bg-accent/15 hover:text-accent"
>
{tag}
</span>
))}
</div>
);
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "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 */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
dashboard/tsconfig.json Normal file
View file

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

View file

@ -0,0 +1,26 @@
{
"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 */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

13
dashboard/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://127.0.0.1:8080',
'/ws': { target: 'ws://127.0.0.1:8080', ws: true },
},
},
})