summaryrefslogtreecommitdiff
path: root/admin-panel/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'admin-panel/src/client')
-rw-r--r--admin-panel/src/client/components/agent-log.ts2
-rw-r--r--admin-panel/src/client/components/board.ts123
-rw-r--r--admin-panel/src/client/components/diff-viewer.ts2
-rw-r--r--admin-panel/src/client/components/pipeline-controls.ts2
-rw-r--r--admin-panel/src/client/components/project-form.ts2
-rw-r--r--admin-panel/src/client/components/task-card.ts40
-rw-r--r--admin-panel/src/client/components/task-modal.ts271
-rw-r--r--admin-panel/src/client/index.html22
-rw-r--r--admin-panel/src/client/lib/api.ts155
-rw-r--r--admin-panel/src/client/lib/markdown.ts39
-rw-r--r--admin-panel/src/client/lib/ws.ts79
-rw-r--r--admin-panel/src/client/main.ts63
-rw-r--r--admin-panel/src/client/pages/dashboard.ts150
-rw-r--r--admin-panel/src/client/pages/project.ts167
-rw-r--r--admin-panel/src/client/pages/settings.ts59
-rw-r--r--admin-panel/src/client/styles/main.css724
16 files changed, 1900 insertions, 0 deletions
diff --git a/admin-panel/src/client/components/agent-log.ts b/admin-panel/src/client/components/agent-log.ts
new file mode 100644
index 0000000..5938426
--- /dev/null
+++ b/admin-panel/src/client/components/agent-log.ts
@@ -0,0 +1,2 @@
+// Agent log is inlined in task-modal.ts — this file exists for Phase 3 expansion
+export {};
diff --git a/admin-panel/src/client/components/board.ts b/admin-panel/src/client/components/board.ts
new file mode 100644
index 0000000..b611133
--- /dev/null
+++ b/admin-panel/src/client/components/board.ts
@@ -0,0 +1,123 @@
+import { api, type Task } from '../lib/api.js';
+import { renderTaskCard } from './task-card.js';
+import { showTaskModal } from './task-modal.js';
+
+const COLUMNS = [
+ { id: 'todo', label: 'Req' },
+ { id: 'plan', label: 'Plan' },
+ { id: 'plan_review', label: 'Plan Review' },
+ { id: 'impl', label: 'Impl' },
+ { id: 'impl_review', label: 'Impl Review' },
+ { id: 'test', label: 'Test' },
+ { id: 'done', label: 'Done' },
+];
+
+export function renderBoard(container: HTMLElement, projectName: string, tasks: Task[]): void {
+ const tasksByStatus = new Map<string, Task[]>();
+ for (const col of COLUMNS) {
+ tasksByStatus.set(col.id, []);
+ }
+ for (const task of tasks) {
+ const list = tasksByStatus.get(task.status);
+ if (list) list.push(task);
+ else tasksByStatus.get('todo')!.push(task); // fallback
+ }
+
+ container.innerHTML = `<div class="board-container">${COLUMNS.map(col => {
+ const colTasks = tasksByStatus.get(col.id)!;
+ return `
+ <div class="board-column" data-status="${col.id}">
+ <div class="column-header">
+ <h3>${col.label}</h3>
+ <span class="column-count">${colTasks.length}</span>
+ </div>
+ <div class="column-body" data-status="${col.id}">
+ ${colTasks.map(task => renderTaskCard(task)).join('')}
+ </div>
+ </div>
+ `;
+ }).join('')}</div>`;
+
+ // Setup drag-drop
+ setupDragDrop(container, projectName);
+
+ // Setup card click
+ container.querySelectorAll('.task-card').forEach(card => {
+ card.addEventListener('click', (e) => {
+ // Don't open modal if dragging
+ if ((card as HTMLElement).classList.contains('dragging')) return;
+ const taskId = parseInt((card as HTMLElement).dataset.taskId!);
+ const task = tasks.find(t => t.id === taskId);
+ if (task) showTaskModal(projectName, task);
+ });
+ });
+}
+
+function setupDragDrop(container: HTMLElement, projectName: string): void {
+ const cards = container.querySelectorAll('.task-card');
+ const columnBodies = container.querySelectorAll('.column-body');
+
+ let draggedCard: HTMLElement | null = null;
+
+ cards.forEach(card => {
+ const el = card as HTMLElement;
+ el.draggable = true;
+
+ el.addEventListener('dragstart', (e) => {
+ draggedCard = el;
+ el.classList.add('dragging');
+ (e as DragEvent).dataTransfer!.effectAllowed = 'move';
+ (e as DragEvent).dataTransfer!.setData('text/plain', el.dataset.taskId!);
+ });
+
+ el.addEventListener('dragend', () => {
+ el.classList.remove('dragging');
+ draggedCard = null;
+ columnBodies.forEach(cb => cb.classList.remove('drag-over'));
+ });
+ });
+
+ columnBodies.forEach(body => {
+ body.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ (e as DragEvent).dataTransfer!.dropEffect = 'move';
+ body.classList.add('drag-over');
+ });
+
+ body.addEventListener('dragleave', () => {
+ body.classList.remove('drag-over');
+ });
+
+ body.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ body.classList.remove('drag-over');
+
+ if (!draggedCard) return;
+
+ const taskId = parseInt(draggedCard.dataset.taskId!);
+ const newStatus = (body as HTMLElement).dataset.status!;
+
+ // Calculate rank based on drop position
+ const existingCards = Array.from(body.querySelectorAll('.task-card:not(.dragging)'));
+ let rank: number;
+
+ if (existingCards.length === 0) {
+ rank = 1;
+ } else {
+ // Drop at end by default
+ const lastCard = existingCards[existingCards.length - 1] as HTMLElement;
+ const lastRank = parseFloat(lastCard.dataset.rank || '0');
+ rank = lastRank + 1;
+ }
+
+ // Optimistically move card
+ body.appendChild(draggedCard);
+
+ try {
+ await api.reorderTask(projectName, taskId, rank, newStatus);
+ } catch (err) {
+ console.error('Reorder failed:', err);
+ }
+ });
+ });
+}
diff --git a/admin-panel/src/client/components/diff-viewer.ts b/admin-panel/src/client/components/diff-viewer.ts
new file mode 100644
index 0000000..b746b75
--- /dev/null
+++ b/admin-panel/src/client/components/diff-viewer.ts
@@ -0,0 +1,2 @@
+// Diff viewer is inlined in task-modal.ts — this file exists for Phase 4 expansion
+export {};
diff --git a/admin-panel/src/client/components/pipeline-controls.ts b/admin-panel/src/client/components/pipeline-controls.ts
new file mode 100644
index 0000000..2b3e9a5
--- /dev/null
+++ b/admin-panel/src/client/components/pipeline-controls.ts
@@ -0,0 +1,2 @@
+// Pipeline controls are inlined in task-modal.ts — this file exists for Phase 3 expansion
+export {};
diff --git a/admin-panel/src/client/components/project-form.ts b/admin-panel/src/client/components/project-form.ts
new file mode 100644
index 0000000..d69f194
--- /dev/null
+++ b/admin-panel/src/client/components/project-form.ts
@@ -0,0 +1,2 @@
+// Project form is inlined in dashboard.ts — this file exists for future extraction if needed
+export {};
diff --git a/admin-panel/src/client/components/task-card.ts b/admin-panel/src/client/components/task-card.ts
new file mode 100644
index 0000000..a63e507
--- /dev/null
+++ b/admin-panel/src/client/components/task-card.ts
@@ -0,0 +1,40 @@
+import type { Task } from '../lib/api.js';
+
+export function renderTaskCard(task: Task): string {
+ const tags = parseTags(task.tags);
+ const priorityClass = `badge-${task.priority}`;
+ const isActive = task.current_agent !== null;
+
+ return `
+ <div class="task-card ${isActive ? 'pipeline-active' : ''}"
+ data-task-id="${task.id}"
+ data-rank="${task.rank}"
+ draggable="true">
+ <div class="task-card-header">
+ <span class="task-id">#${task.id}</span>
+ ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''}
+ </div>
+ <div class="task-title">${escapeHtml(task.title)}</div>
+ <div class="task-meta">
+ <span class="badge ${priorityClass}">${task.priority}</span>
+ <span class="badge badge-level">L${task.level}</span>
+ ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
+ </div>
+ </div>
+ `;
+}
+
+function parseTags(tagsStr: string): string[] {
+ try {
+ const parsed = JSON.parse(tagsStr);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/admin-panel/src/client/components/task-modal.ts b/admin-panel/src/client/components/task-modal.ts
new file mode 100644
index 0000000..725d921
--- /dev/null
+++ b/admin-panel/src/client/components/task-modal.ts
@@ -0,0 +1,271 @@
+import { api, type Task } from '../lib/api.js';
+import { renderMarkdown } from '../lib/markdown.js';
+
+const STATUS_ORDER = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done'];
+const STATUS_LABELS: Record<string, string> = {
+ todo: 'Req', plan: 'Plan', plan_review: 'Plan Review',
+ impl: 'Impl', impl_review: 'Impl Review', test: 'Test', done: 'Done'
+};
+
+const TABS = ['Requirements', 'Plan', 'Implementation', 'Reviews', 'Tests', 'Agent Log'];
+
+export function showTaskModal(projectName: string, task: Task): void {
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal task-modal">
+ <div class="modal-header">
+ <div class="top-row">
+ <span class="task-id" style="font-size:14px">#${task.id}</span>
+ <button class="btn-icon close-btn" style="font-size:20px">&times;</button>
+ </div>
+ <h2 style="width:100%">${escapeHtml(task.title)}</h2>
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
+ <span class="badge badge-${task.priority}">${task.priority}</span>
+ <span class="badge badge-level">L${task.level}</span>
+ <span class="badge" style="background:var(--border);color:var(--text-secondary)">
+ ${STATUS_LABELS[task.status] || task.status}
+ </span>
+ ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''}
+ ${parseTags(task.tags).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
+ </div>
+ </div>
+
+ ${renderLifecycleBar(task)}
+
+ <div class="tabs" id="task-tabs">
+ ${TABS.map((tab, i) => `
+ <button class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${tab}</button>
+ `).join('')}
+ </div>
+
+ <div class="tab-content" id="tab-content">
+ ${renderTabContent(0, task)}
+ </div>
+
+ <div class="modal-footer">
+ <div class="pipeline-controls">
+ <button class="btn btn-primary btn-sm" id="run-pipeline-btn" title="Run full pipeline">Run Pipeline</button>
+ <button class="btn btn-secondary btn-sm" id="step-pipeline-btn" title="Run next step">Step</button>
+ <button class="btn btn-danger btn-sm" id="stop-pipeline-btn" title="Stop pipeline">Stop</button>
+ </div>
+ <div style="flex:1"></div>
+ <button class="btn btn-danger btn-sm" id="delete-task-btn">Delete</button>
+ </div>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Tab switching
+ overlay.querySelectorAll('.tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+ overlay.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ tab.classList.add('active');
+ const tabIdx = parseInt((tab as HTMLElement).dataset.tab!);
+ overlay.querySelector('#tab-content')!.innerHTML = renderTabContent(tabIdx, task);
+ });
+ });
+
+ // Close
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ // Pipeline controls
+ overlay.querySelector('#run-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.runPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ overlay.querySelector('#step-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.stepPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ overlay.querySelector('#stop-pipeline-btn')?.addEventListener('click', async () => {
+ try {
+ await api.stopPipeline(projectName, task.id);
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+
+ // Delete
+ overlay.querySelector('#delete-task-btn')?.addEventListener('click', async () => {
+ if (!confirm(`Delete task #${task.id}?`)) return;
+ try {
+ await api.deleteTask(projectName, task.id);
+ overlay.remove();
+ } catch (err) {
+ alert((err as Error).message);
+ }
+ });
+}
+
+function renderLifecycleBar(task: Task): string {
+ const currentIdx = STATUS_ORDER.indexOf(task.status);
+ return `
+ <div class="lifecycle-bar">
+ ${STATUS_ORDER.map((s, i) => {
+ let cls = 'lifecycle-step';
+ if (i < currentIdx) cls += ' completed';
+ else if (i === currentIdx) cls += ' current';
+ return `<div class="${cls}" title="${STATUS_LABELS[s]}"></div>`;
+ }).join('')}
+ </div>
+ `;
+}
+
+function renderTabContent(tabIdx: number, task: Task): string {
+ switch (tabIdx) {
+ case 0: // Requirements
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.description
+ ? `<div>${renderMarkdown(task.description)}</div>`
+ : '<p>No description provided.</p>'
+ }
+ ${task.done_when ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Done When</h4>
+ <div>${renderMarkdown(task.done_when)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 1: // Plan
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.plan
+ ? `<div>${renderMarkdown(task.plan)}</div>`
+ : '<p>No plan generated yet. Run the pipeline to create one.</p>'
+ }
+ ${task.decision_log ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Decision Log</h4>
+ <div>${renderMarkdown(task.decision_log)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 2: // Implementation
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${task.implementation_notes
+ ? `<div>${renderMarkdown(task.implementation_notes)}</div>`
+ : '<p>No implementation notes yet.</p>'
+ }
+ ${task.branch_name ? `<p style="margin-top:12px">Branch: <code>${escapeHtml(task.branch_name)}</code></p>` : ''}
+ ${task.diff ? `
+ <h4 style="margin-top:16px;color:var(--text-primary)">Diff</h4>
+ <div class="diff-viewer">${renderDiff(task.diff)}</div>
+ ` : ''}
+ </div>
+ `;
+
+ case 3: // Reviews
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ <h4 style="color:var(--text-primary)">Plan Reviews (${task.plan_review_count})</h4>
+ ${renderJsonArray(task.plan_review_comments, 'No plan reviews yet.')}
+
+ <h4 style="margin-top:16px;color:var(--text-primary)">Code Reviews (${task.impl_review_count})</h4>
+ ${renderJsonArray(task.review_comments, 'No code reviews yet.')}
+
+ ${task.gerrit_change_number ? `
+ <p style="margin-top:16px">Gerrit Change: <a href="/gerrit/${task.gerrit_change_number}" style="color:var(--accent-glow)">#${task.gerrit_change_number}</a></p>
+ ` : ''}
+ </div>
+ `;
+
+ case 4: // Tests
+ return `
+ <div style="color:var(--text-secondary);font-size:13px">
+ ${renderJsonArray(task.test_results, 'No test results yet. Run the pipeline to generate tests.')}
+ </div>
+ `;
+
+ case 5: // Agent Log
+ return `
+ <div class="agent-log">
+ ${renderAgentLog(task.agent_log)}
+ </div>
+ `;
+
+ default:
+ return '';
+ }
+}
+
+function renderDiff(diff: string): string {
+ return diff.split('\n').map(line => {
+ let cls = 'diff-line';
+ if (line.startsWith('+')) cls += ' diff-add';
+ else if (line.startsWith('-')) cls += ' diff-del';
+ else if (line.startsWith('@@')) cls += ' diff-hunk';
+ return `<div class="${cls}">${escapeHtml(line)}</div>`;
+ }).join('');
+}
+
+function renderJsonArray(jsonStr: string, emptyMsg: string): string {
+ try {
+ const items = JSON.parse(jsonStr);
+ if (!Array.isArray(items) || items.length === 0) return `<p>${emptyMsg}</p>`;
+ return items.map((item: unknown) => {
+ if (typeof item === 'string') return `<div class="log-entry"><div class="log-message">${escapeHtml(item)}</div></div>`;
+ if (typeof item === 'object' && item !== null) {
+ const obj = item as Record<string, unknown>;
+ return `<div class="log-entry">
+ ${obj.agent ? `<div class="log-agent">${escapeHtml(String(obj.agent))}</div>` : ''}
+ <div class="log-message">${escapeHtml(String(obj.message || obj.comment || JSON.stringify(obj)))}</div>
+ </div>`;
+ }
+ return '';
+ }).join('');
+ } catch {
+ return `<p>${emptyMsg}</p>`;
+ }
+}
+
+function renderAgentLog(logStr: string): string {
+ try {
+ const entries = JSON.parse(logStr);
+ if (!Array.isArray(entries) || entries.length === 0) {
+ return '<div class="empty-state"><p>No agent activity yet.</p></div>';
+ }
+ return entries.map((entry: Record<string, unknown>) => `
+ <div class="log-entry">
+ <span class="log-time">${entry.timestamp ? new Date(String(entry.timestamp)).toLocaleTimeString() : ''}</span>
+ <span class="log-agent">${escapeHtml(String(entry.agent || ''))}</span>
+ <span class="log-message">${escapeHtml(String(entry.message || ''))}</span>
+ </div>
+ `).join('');
+ } catch {
+ return '<div class="empty-state"><p>No agent activity yet.</p></div>';
+ }
+}
+
+function parseTags(tagsStr: string): string[] {
+ try {
+ const parsed = JSON.parse(tagsStr);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/admin-panel/src/client/index.html b/admin-panel/src/client/index.html
new file mode 100644
index 0000000..58af369
--- /dev/null
+++ b/admin-panel/src/client/index.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Bastion Admin</title>
+ <link rel="stylesheet" href="./styles/main.css" />
+</head>
+<body>
+ <div id="app">
+ <nav id="navbar">
+ <a href="/" class="nav-brand">BASTION</a>
+ <div class="nav-links">
+ <a href="/" data-route="/">Dashboard</a>
+ <a href="/settings" data-route="/settings">Settings</a>
+ </div>
+ </nav>
+ <main id="main-content"></main>
+ </div>
+ <script type="module" src="./main.ts"></script>
+</body>
+</html>
diff --git a/admin-panel/src/client/lib/api.ts b/admin-panel/src/client/lib/api.ts
new file mode 100644
index 0000000..d82693f
--- /dev/null
+++ b/admin-panel/src/client/lib/api.ts
@@ -0,0 +1,155 @@
+const BASE = '/api';
+
+async function request<T>(path: string, options?: RequestInit): Promise<T> {
+ const res = await fetch(`${BASE}${path}`, {
+ headers: { 'Content-Type': 'application/json' },
+ ...options,
+ });
+
+ if (res.status === 204) return undefined as T;
+
+ const data = await res.json();
+ if (!res.ok) {
+ throw new Error(data.error || `Request failed: ${res.status}`);
+ }
+ return data as T;
+}
+
+export const api = {
+ // Projects
+ getProjects: () => request<Project[]>('/projects'),
+ createProject: (data: CreateProject) =>
+ request<Project>('/projects', { method: 'POST', body: JSON.stringify(data) }),
+ getProject: (name: string) => request<Project>(`/projects/${name}`),
+ updateProject: (name: string, data: Partial<Project>) =>
+ request<Project>(`/projects/${name}`, { method: 'PATCH', body: JSON.stringify(data) }),
+ deleteProject: (name: string, deleteRepo = false) =>
+ request<void>(`/projects/${name}?delete_repo=${deleteRepo}`, { method: 'DELETE' }),
+
+ // Tasks
+ getTasks: (project: string) => request<Task[]>(`/projects/${project}/tasks`),
+ createTask: (project: string, data: CreateTask) =>
+ request<Task>(`/projects/${project}/tasks`, { method: 'POST', body: JSON.stringify(data) }),
+ getTask: (project: string, id: number) => request<Task>(`/projects/${project}/tasks/${id}`),
+ updateTask: (project: string, id: number, data: Partial<Task>) =>
+ request<Task>(`/projects/${project}/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
+ deleteTask: (project: string, id: number) =>
+ request<void>(`/projects/${project}/tasks/${id}`, { method: 'DELETE' }),
+ reorderTask: (project: string, id: number, rank: number, status?: string) =>
+ request<Task>(`/projects/${project}/tasks/${id}/reorder`, {
+ method: 'PATCH',
+ body: JSON.stringify({ rank, status }),
+ }),
+
+ // Pipeline
+ runPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/run/${taskId}`, { method: 'POST' }),
+ stepPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/step/${taskId}`, { method: 'POST' }),
+ stopPipeline: (project: string, taskId: number) =>
+ request<void>(`/projects/${project}/pipeline/stop/${taskId}`, { method: 'POST' }),
+ getPipelineStatus: (project: string, taskId: number) =>
+ request<PipelineStatus>(`/projects/${project}/pipeline/status/${taskId}`),
+
+ // Dashboard
+ getDashboardStats: () => request<DashboardStats>('/dashboard/stats'),
+ getDashboardActivity: () => request<ActivityEntry[]>('/dashboard/activity'),
+
+ // Settings
+ getSettings: () => request<Record<string, string>>('/settings'),
+ updateSettings: (data: Record<string, string>) =>
+ request<void>('/settings', { method: 'PATCH', body: JSON.stringify(data) }),
+};
+
+// Types
+export interface Project {
+ id: number;
+ name: string;
+ display_name: string;
+ description: string | null;
+ repo_name: string;
+ repo_path: string;
+ default_branch: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateProject {
+ name: string;
+ display_name: string;
+ description?: string;
+}
+
+export interface Task {
+ id: number;
+ title: string;
+ status: string;
+ priority: string;
+ level: number;
+ description: string | null;
+ plan: string | null;
+ decision_log: string | null;
+ done_when: string | null;
+ implementation_notes: string | null;
+ tags: string;
+ plan_review_comments: string;
+ review_comments: string;
+ test_results: string;
+ agent_log: string;
+ current_agent: string | null;
+ plan_review_count: number;
+ impl_review_count: number;
+ branch_name: string | null;
+ gerrit_change_id: string | null;
+ gerrit_change_number: number | null;
+ diff: string | null;
+ rank: number;
+ created_at: string;
+ started_at: string | null;
+ planned_at: string | null;
+ reviewed_at: string | null;
+ tested_at: string | null;
+ completed_at: string | null;
+}
+
+export interface CreateTask {
+ title: string;
+ description?: string;
+ priority?: string;
+ level?: number;
+ tags?: string[];
+}
+
+export interface PipelineStatus {
+ running: boolean;
+ currentAgent: string | null;
+ jobs: PipelineJob[];
+}
+
+export interface PipelineJob {
+ id: number;
+ task_id: number;
+ agent: string;
+ status: string;
+ model: string;
+ started_at: string | null;
+ completed_at: string | null;
+ error: string | null;
+ tokens_used: number;
+}
+
+export interface DashboardStats {
+ totalProjects: number;
+ totalTasks: number;
+ tasksByStatus: Record<string, number>;
+ completedToday: number;
+}
+
+export interface ActivityEntry {
+ project: string;
+ taskId: number;
+ taskTitle: string;
+ agent: string;
+ action: string;
+ timestamp: string;
+}
diff --git a/admin-panel/src/client/lib/markdown.ts b/admin-panel/src/client/lib/markdown.ts
new file mode 100644
index 0000000..c89fa77
--- /dev/null
+++ b/admin-panel/src/client/lib/markdown.ts
@@ -0,0 +1,39 @@
+// Minimal markdown renderer — handles the basics without pulling in a library
+export function renderMarkdown(text: string): string {
+ if (!text) return '';
+
+ let html = escapeHtml(text);
+
+ // Code blocks
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>');
+
+ // Inline code
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
+
+ // Headers
+ html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
+ html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
+ html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
+
+ // Bold and italic
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+
+ // Lists
+ html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
+ html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
+
+ // Line breaks
+ html = html.replace(/\n\n/g, '</p><p>');
+ html = `<p>${html}</p>`;
+ html = html.replace(/<p><\/p>/g, '');
+
+ return html;
+}
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
diff --git a/admin-panel/src/client/lib/ws.ts b/admin-panel/src/client/lib/ws.ts
new file mode 100644
index 0000000..14e0cec
--- /dev/null
+++ b/admin-panel/src/client/lib/ws.ts
@@ -0,0 +1,79 @@
+type EventHandler = (data: unknown) => void;
+
+class WsClient {
+ private ws: WebSocket | null = null;
+ private handlers = new Map<string, Set<EventHandler>>();
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ private subscribedProjects = new Set<string>();
+
+ connect(): void {
+ if (this.ws?.readyState === WebSocket.OPEN) return;
+
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ this.ws = new WebSocket(`${protocol}//${location.host}/ws`);
+
+ this.ws.onopen = () => {
+ // Re-subscribe to projects
+ for (const project of this.subscribedProjects) {
+ this.send({ type: 'subscribe', project });
+ }
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ const handlers = this.handlers.get(data.type);
+ if (handlers) {
+ for (const handler of handlers) {
+ handler(data);
+ }
+ }
+ // Also fire wildcard handlers
+ const wildcardHandlers = this.handlers.get('*');
+ if (wildcardHandlers) {
+ for (const handler of wildcardHandlers) {
+ handler(data);
+ }
+ }
+ } catch {
+ // ignore
+ }
+ };
+
+ this.ws.onclose = () => {
+ this.reconnectTimer = setTimeout(() => this.connect(), 3000);
+ };
+ }
+
+ disconnect(): void {
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.ws?.close();
+ this.ws = null;
+ }
+
+ subscribe(project: string): void {
+ this.subscribedProjects.add(project);
+ this.send({ type: 'subscribe', project });
+ }
+
+ unsubscribe(project: string): void {
+ this.subscribedProjects.delete(project);
+ this.send({ type: 'unsubscribe', project });
+ }
+
+ on(event: string, handler: EventHandler): () => void {
+ if (!this.handlers.has(event)) {
+ this.handlers.set(event, new Set());
+ }
+ this.handlers.get(event)!.add(handler);
+ return () => this.handlers.get(event)?.delete(handler);
+ }
+
+ private send(data: unknown): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(data));
+ }
+ }
+}
+
+export const wsClient = new WsClient();
diff --git a/admin-panel/src/client/main.ts b/admin-panel/src/client/main.ts
new file mode 100644
index 0000000..87f25db
--- /dev/null
+++ b/admin-panel/src/client/main.ts
@@ -0,0 +1,63 @@
+import { renderDashboard } from './pages/dashboard.js';
+import { renderProjectPage } from './pages/project.js';
+import { renderSettings } from './pages/settings.js';
+import { wsClient } from './lib/ws.js';
+
+const mainContent = document.getElementById('main-content')!;
+
+// Simple client-side router
+type Route = {
+ pattern: RegExp;
+ handler: (container: HTMLElement, params: string[]) => Promise<void>;
+};
+
+const routes: Route[] = [
+ { pattern: /^\/$/, handler: (c) => renderDashboard(c) },
+ { pattern: /^\/project\/([a-z0-9-]+)$/, handler: (c, p) => renderProjectPage(c, p[1]) },
+ { pattern: /^\/settings$/, handler: (c) => renderSettings(c) },
+];
+
+function navigate(path: string): void {
+ for (const route of routes) {
+ const match = path.match(route.pattern);
+ if (match) {
+ updateNavActive(path);
+ route.handler(mainContent, Array.from(match));
+ return;
+ }
+ }
+ // 404
+ mainContent.innerHTML = `
+ <div class="empty-state">
+ <h3>Page not found</h3>
+ <p><a href="/" data-route="/" style="color:var(--accent-glow)">Back to Dashboard</a></p>
+ </div>
+ `;
+}
+
+function updateNavActive(path: string): void {
+ document.querySelectorAll('.nav-links a').forEach(a => {
+ const route = (a as HTMLElement).dataset.route;
+ a.classList.toggle('active', route === path || (route === '/' && path.startsWith('/project/')));
+ });
+}
+
+// Intercept link clicks for SPA navigation
+document.addEventListener('click', (e) => {
+ const link = (e.target as HTMLElement).closest('[data-route]');
+ if (link) {
+ e.preventDefault();
+ const path = (link as HTMLElement).dataset.route!;
+ history.pushState(null, '', path);
+ navigate(path);
+ }
+});
+
+// Handle browser back/forward
+window.addEventListener('popstate', () => {
+ navigate(location.pathname);
+});
+
+// Init
+wsClient.connect();
+navigate(location.pathname);
diff --git a/admin-panel/src/client/pages/dashboard.ts b/admin-panel/src/client/pages/dashboard.ts
new file mode 100644
index 0000000..ded6d3b
--- /dev/null
+++ b/admin-panel/src/client/pages/dashboard.ts
@@ -0,0 +1,150 @@
+import { api, type Project } from '../lib/api.js';
+
+export async function renderDashboard(container: HTMLElement): Promise<void> {
+ container.innerHTML = `
+ <div class="dashboard-header">
+ <h1>Projects</h1>
+ <button class="btn btn-primary" id="new-project-btn">+ New Project</button>
+ </div>
+ <div class="project-grid" id="project-grid">
+ <div class="empty-state">
+ <h3>Loading...</h3>
+ </div>
+ </div>
+ `;
+
+ const grid = container.querySelector('#project-grid')!;
+ const newBtn = container.querySelector('#new-project-btn')!;
+
+ // Load projects
+ try {
+ const projects = await api.getProjects();
+ renderProjects(grid, projects);
+ } catch (err) {
+ grid.innerHTML = `<div class="empty-state"><h3>Failed to load projects</h3><p>${(err as Error).message}</p></div>`;
+ }
+
+ newBtn.addEventListener('click', () => showCreateDialog(grid));
+}
+
+function renderProjects(grid: Element, projects: Project[]): void {
+ if (projects.length === 0) {
+ grid.innerHTML = `
+ <div class="empty-state">
+ <h3>No projects yet</h3>
+ <p>Create your first project to get started.</p>
+ </div>
+ `;
+ return;
+ }
+
+ grid.innerHTML = projects.map(p => `
+ <a href="/project/${p.name}" class="project-card" data-route="/project/${p.name}">
+ <h3>${escapeHtml(p.display_name)}</h3>
+ <div class="project-name">${escapeHtml(p.name)}</div>
+ ${p.description ? `<div class="project-desc">${escapeHtml(p.description)}</div>` : ''}
+ <div class="project-stats">
+ <span class="stat"><span class="stat-dot todo"></span> ${escapeHtml(p.repo_name)}</span>
+ <span class="stat">${timeAgo(p.created_at)}</span>
+ </div>
+ </a>
+ `).join('');
+}
+
+function showCreateDialog(grid: Element): void {
+ // Remove existing modal if present
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal">
+ <div class="modal-header">
+ <h2>New Project</h2>
+ <button class="btn-icon close-btn">&times;</button>
+ </div>
+ <form id="create-project-form">
+ <div class="form-group">
+ <label for="proj-name">Project Name</label>
+ <input type="text" id="proj-name" placeholder="my-project" required pattern="[a-z0-9-]+" />
+ </div>
+ <div class="form-group">
+ <label for="proj-display">Display Name</label>
+ <input type="text" id="proj-display" placeholder="My Project" required />
+ </div>
+ <div class="form-group">
+ <label for="proj-desc">Description</label>
+ <textarea id="proj-desc" placeholder="What is this project about?"></textarea>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary close-btn">Cancel</button>
+ <button type="submit" class="btn btn-primary">Create Project</button>
+ </div>
+ </form>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ // Auto-generate name from display name
+ const displayInput = overlay.querySelector('#proj-display') as HTMLInputElement;
+ const nameInput = overlay.querySelector('#proj-name') as HTMLInputElement;
+ displayInput.addEventListener('input', () => {
+ nameInput.value = displayInput.value
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-');
+ });
+
+ // Close handlers
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ // Submit
+ const form = overlay.querySelector('#create-project-form') as HTMLFormElement;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Creating...';
+
+ try {
+ await api.createProject({
+ name: nameInput.value,
+ display_name: displayInput.value,
+ description: (overlay.querySelector('#proj-desc') as HTMLTextAreaElement).value || undefined,
+ });
+ overlay.remove();
+ // Reload projects
+ const projects = await api.getProjects();
+ renderProjects(grid, projects);
+ } catch (err) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Project';
+ alert((err as Error).message);
+ }
+ });
+
+ displayInput.focus();
+}
+
+function escapeHtml(text: string): string {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+function timeAgo(dateStr: string): string {
+ const date = new Date(dateStr + 'Z');
+ const now = new Date();
+ const diff = Math.floor((now.getTime() - date.getTime()) / 1000);
+
+ if (diff < 60) return 'just now';
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
+ return `${Math.floor(diff / 86400)}d ago`;
+}
diff --git a/admin-panel/src/client/pages/project.ts b/admin-panel/src/client/pages/project.ts
new file mode 100644
index 0000000..5e01d64
--- /dev/null
+++ b/admin-panel/src/client/pages/project.ts
@@ -0,0 +1,167 @@
+import { api, type Task, type Project } from '../lib/api.js';
+import { wsClient } from '../lib/ws.js';
+import { renderBoard } from '../components/board.js';
+
+let currentProject: string | null = null;
+let cleanupWs: (() => void) | null = null;
+
+export async function renderProjectPage(container: HTMLElement, projectName: string): Promise<void> {
+ // Cleanup previous subscriptions
+ if (currentProject && currentProject !== projectName) {
+ wsClient.unsubscribe(currentProject);
+ }
+ if (cleanupWs) cleanupWs();
+
+ currentProject = projectName;
+
+ container.innerHTML = `
+ <div class="board-header">
+ <div style="display:flex;align-items:center;gap:12px">
+ <a href="/" data-route="/" class="btn btn-secondary btn-sm">&larr; Back</a>
+ <h1 id="project-title">Loading...</h1>
+ </div>
+ <button class="btn btn-primary" id="new-task-btn">+ New Task</button>
+ </div>
+ <div id="board-root"></div>
+ `;
+
+ const boardRoot = container.querySelector('#board-root') as HTMLElement;
+ const titleEl = container.querySelector('#project-title')!;
+ const newTaskBtn = container.querySelector('#new-task-btn')!;
+
+ try {
+ const [project, tasks] = await Promise.all([
+ api.getProject(projectName),
+ api.getTasks(projectName),
+ ]);
+
+ titleEl.textContent = project.display_name;
+ renderBoard(boardRoot, projectName, tasks);
+
+ // WebSocket subscription
+ wsClient.subscribe(projectName);
+ const unsub1 = wsClient.on('task:created', (data: unknown) => {
+ const event = data as { project: string; task: Task };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+ const unsub2 = wsClient.on('task:updated', (data: unknown) => {
+ const event = data as { project: string; task: Task };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+ const unsub3 = wsClient.on('task:deleted', (data: unknown) => {
+ const event = data as { project: string };
+ if (event.project === projectName) {
+ refreshBoard(boardRoot, projectName);
+ }
+ });
+
+ cleanupWs = () => { unsub1(); unsub2(); unsub3(); };
+
+ } catch (err) {
+ boardRoot.innerHTML = `<div class="empty-state"><h3>Failed to load project</h3><p>${(err as Error).message}</p></div>`;
+ }
+
+ newTaskBtn.addEventListener('click', () => showCreateTaskDialog(projectName, boardRoot));
+}
+
+async function refreshBoard(boardRoot: HTMLElement, projectName: string): Promise<void> {
+ try {
+ const tasks = await api.getTasks(projectName);
+ renderBoard(boardRoot, projectName, tasks);
+ } catch {
+ // silently fail on refresh
+ }
+}
+
+function showCreateTaskDialog(projectName: string, boardRoot: HTMLElement): void {
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal">
+ <div class="modal-header">
+ <h2>New Task</h2>
+ <button class="btn-icon close-btn">&times;</button>
+ </div>
+ <form id="create-task-form">
+ <div class="form-group">
+ <label for="task-title">Title</label>
+ <input type="text" id="task-title" placeholder="What needs to be done?" required />
+ </div>
+ <div class="form-group">
+ <label for="task-desc">Description</label>
+ <textarea id="task-desc" rows="4" placeholder="Detailed requirements..."></textarea>
+ </div>
+ <div style="display:flex;gap:12px">
+ <div class="form-group" style="flex:1">
+ <label for="task-priority">Priority</label>
+ <select id="task-priority">
+ <option value="low">Low</option>
+ <option value="medium" selected>Medium</option>
+ <option value="high">High</option>
+ </select>
+ </div>
+ <div class="form-group" style="flex:1">
+ <label for="task-level">Level</label>
+ <select id="task-level">
+ <option value="1">L1 — Quick</option>
+ <option value="2">L2 — Standard</option>
+ <option value="3" selected>L3 — Full</option>
+ </select>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="task-tags">Tags (comma-separated)</label>
+ <input type="text" id="task-tags" placeholder="frontend, bug-fix" />
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary close-btn">Cancel</button>
+ <button type="submit" class="btn btn-primary">Create Task</button>
+ </div>
+ </form>
+ </div>
+ `;
+
+ document.body.appendChild(overlay);
+
+ overlay.querySelectorAll('.close-btn').forEach(btn => {
+ btn.addEventListener('click', () => overlay.remove());
+ });
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) overlay.remove();
+ });
+
+ const form = overlay.querySelector('#create-task-form') as HTMLFormElement;
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Creating...';
+
+ const tagsStr = (overlay.querySelector('#task-tags') as HTMLInputElement).value;
+ const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
+
+ try {
+ await api.createTask(projectName, {
+ title: (overlay.querySelector('#task-title') as HTMLInputElement).value,
+ description: (overlay.querySelector('#task-desc') as HTMLTextAreaElement).value || undefined,
+ priority: (overlay.querySelector('#task-priority') as HTMLSelectElement).value,
+ level: parseInt((overlay.querySelector('#task-level') as HTMLSelectElement).value),
+ tags,
+ });
+ overlay.remove();
+ await refreshBoard(boardRoot, projectName);
+ } catch (err) {
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Create Task';
+ alert((err as Error).message);
+ }
+ });
+
+ (overlay.querySelector('#task-title') as HTMLInputElement).focus();
+}
diff --git a/admin-panel/src/client/pages/settings.ts b/admin-panel/src/client/pages/settings.ts
new file mode 100644
index 0000000..e78c69f
--- /dev/null
+++ b/admin-panel/src/client/pages/settings.ts
@@ -0,0 +1,59 @@
+export async function renderSettings(container: HTMLElement): Promise<void> {
+ container.innerHTML = `
+ <div class="settings-container">
+ <h1 style="margin-bottom:24px">Settings</h1>
+
+ <div class="settings-section">
+ <h2>API Configuration</h2>
+ <div class="form-group">
+ <label for="api-key">Anthropic API Key</label>
+ <input type="password" id="api-key" placeholder="sk-ant-..." style="width:100%" />
+ <small style="color:var(--text-secondary);margin-top:4px;display:block">
+ Configured via ANTHROPIC_API_KEY environment variable.
+ The API key is set in the container's environment, not stored in the database.
+ </small>
+ </div>
+ </div>
+
+ <div class="settings-section">
+ <h2>Agent Models</h2>
+ <p style="color:var(--text-secondary);margin-bottom:16px;font-size:13px">
+ Configure which Claude model each agent uses.
+ </p>
+ <div class="form-group">
+ <label>Planner (Plan generation)</label>
+ <select disabled><option>Claude Opus</option></select>
+ </div>
+ <div class="form-group">
+ <label>Critic (Plan review)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Builder (Implementation)</label>
+ <select disabled><option>Claude Opus</option></select>
+ </div>
+ <div class="form-group">
+ <label>Shield (Test writing)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Inspector (Code review)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <div class="form-group">
+ <label>Ranger (Test runner)</label>
+ <select disabled><option>Claude Sonnet</option></select>
+ </div>
+ <small style="color:var(--text-secondary)">Model configuration will be available when the AI pipeline is enabled.</small>
+ </div>
+
+ <div class="settings-section">
+ <h2>Git / SSH</h2>
+ <p style="color:var(--text-secondary);font-size:13px">
+ SSH key for git-server and Gerrit access is mounted at <code>/app/.ssh/</code> in the container.
+ Ensure the public key is added to <code>/var/git_ssh_keys/</code> on the host.
+ </p>
+ </div>
+ </div>
+ `;
+}
diff --git a/admin-panel/src/client/styles/main.css b/admin-panel/src/client/styles/main.css
new file mode 100644
index 0000000..d2fe35b
--- /dev/null
+++ b/admin-panel/src/client/styles/main.css
@@ -0,0 +1,724 @@
+/* Cyberpunk Dark Theme */
+:root {
+ --bg-deep: #0A0D1F;
+ --bg-card: #1A1A2E;
+ --accent: #7B2FBE;
+ --accent-glow: #A855F7;
+ --alert: #FF2D78;
+ --text-secondary: #8B9BB4;
+ --text-primary: #E8ECF4;
+ --bg-input: #12152a;
+ --border: #2a2a4a;
+ --success: #22c55e;
+ --warning: #eab308;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ background: var(--bg-deep);
+ color: var(--text-primary);
+ font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+#app {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* Navbar */
+#navbar {
+ display: flex;
+ align-items: center;
+ padding: 0 24px;
+ height: 52px;
+ background: var(--bg-card);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.nav-brand {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: 3px;
+ color: var(--accent-glow);
+ text-decoration: none;
+ margin-right: 32px;
+}
+
+.nav-links {
+ display: flex;
+ gap: 4px;
+}
+
+.nav-links a {
+ color: var(--text-secondary);
+ text-decoration: none;
+ padding: 6px 14px;
+ border-radius: 6px;
+ font-size: 13px;
+ transition: all 0.15s;
+}
+
+.nav-links a:hover,
+.nav-links a.active {
+ color: var(--text-primary);
+ background: rgba(123, 47, 190, 0.15);
+}
+
+.nav-links a.active {
+ color: var(--accent-glow);
+}
+
+/* Main content */
+#main-content {
+ flex: 1;
+ overflow: auto;
+ padding: 24px;
+}
+
+/* Scrollbar */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--bg-deep);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-secondary);
+}
+
+/* Buttons */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-glow);
+ box-shadow: 0 0 20px rgba(168, 85, 247, 0.3);
+}
+
+.btn-secondary {
+ background: var(--border);
+ color: var(--text-primary);
+}
+
+.btn-secondary:hover {
+ background: var(--text-secondary);
+}
+
+.btn-danger {
+ background: transparent;
+ color: var(--alert);
+ border: 1px solid var(--alert);
+}
+
+.btn-danger:hover {
+ background: var(--alert);
+ color: white;
+}
+
+.btn-sm {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+
+.btn-icon {
+ padding: 6px;
+ background: transparent;
+ color: var(--text-secondary);
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.btn-icon:hover {
+ color: var(--text-primary);
+ background: var(--border);
+}
+
+/* Form elements */
+input, textarea, select {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ color: var(--text-primary);
+ padding: 8px 12px;
+ border-radius: 6px;
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+input:focus, textarea:focus, select:focus {
+ border-color: var(--accent);
+}
+
+textarea {
+ resize: vertical;
+ min-height: 80px;
+}
+
+label {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--text-secondary);
+ margin-bottom: 4px;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group input,
+.form-group textarea,
+.form-group select {
+ width: 100%;
+}
+
+/* Cards */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+}
+
+/* Modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ animation: fadeIn 0.15s;
+}
+
+.modal {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ width: 90%;
+ max-width: 540px;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 24px;
+ animation: slideUp 0.2s;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 20px;
+}
+
+.modal-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 20px;
+ padding-top: 16px;
+ border-top: 1px solid var(--border);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+/* Dashboard */
+.dashboard-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+}
+
+.dashboard-header h1 {
+ font-size: 24px;
+ font-weight: 600;
+}
+
+.project-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 16px;
+}
+
+.project-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 20px;
+ cursor: pointer;
+ transition: all 0.15s;
+ text-decoration: none;
+ color: inherit;
+ display: block;
+}
+
+.project-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 20px rgba(123, 47, 190, 0.15);
+ transform: translateY(-1px);
+}
+
+.project-card h3 {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+
+.project-card .project-name {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-bottom: 8px;
+ font-family: monospace;
+}
+
+.project-card .project-desc {
+ color: var(--text-secondary);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+
+.project-card .project-stats {
+ display: flex;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.project-card .stat {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.stat-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.stat-dot.todo { background: var(--text-secondary); }
+.stat-dot.active { background: var(--accent-glow); }
+.stat-dot.done { background: var(--success); }
+
+/* Kanban Board */
+.board-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.board-header h1 {
+ font-size: 20px;
+ font-weight: 600;
+}
+
+.board-container {
+ display: flex;
+ gap: 12px;
+ overflow-x: auto;
+ height: calc(100vh - 140px);
+ padding-bottom: 16px;
+}
+
+.board-column {
+ flex: 0 0 260px;
+ display: flex;
+ flex-direction: column;
+ background: rgba(26, 26, 46, 0.3);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.column-header {
+ padding: 12px 14px;
+ background: var(--bg-card);
+ border-bottom: 2px solid var(--accent-glow);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+}
+
+.column-header h3 {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+}
+
+.column-count {
+ font-size: 11px;
+ color: var(--text-secondary);
+ background: var(--border);
+ padding: 1px 8px;
+ border-radius: 10px;
+}
+
+.column-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.column-body.drag-over {
+ background: rgba(123, 47, 190, 0.08);
+}
+
+/* Task Cards */
+.task-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent);
+ border-radius: 6px;
+ padding: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+ user-select: none;
+}
+
+.task-card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
+}
+
+.task-card.dragging {
+ opacity: 0.5;
+ transform: rotate(2deg);
+}
+
+.task-card.pipeline-active {
+ animation: pulseGlow 2s infinite;
+}
+
+@keyframes pulseGlow {
+ 0%, 100% { box-shadow: 0 0 5px rgba(168, 85, 247, 0.2); }
+ 50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.5); }
+}
+
+.task-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.task-id {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-family: monospace;
+}
+
+.task-title {
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ margin-bottom: 8px;
+}
+
+.task-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.badge {
+ font-size: 10px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.badge-high { background: var(--alert); color: white; }
+.badge-medium { background: var(--accent); color: white; }
+.badge-low { background: var(--border); color: var(--text-secondary); }
+
+.badge-level {
+ background: var(--border);
+ color: var(--text-secondary);
+}
+
+.badge-agent {
+ background: rgba(168, 85, 247, 0.2);
+ color: var(--accent-glow);
+}
+
+.tag {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 3px;
+ background: var(--border);
+ color: var(--text-secondary);
+}
+
+/* Task Detail Modal */
+.task-modal {
+ max-width: 800px;
+}
+
+.task-modal .modal-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.task-modal .modal-header .top-row {
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.task-modal .tabs {
+ display: flex;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 16px;
+}
+
+.task-modal .tab {
+ padding: 8px 16px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.15s;
+ background: none;
+ border-top: none;
+ border-left: none;
+ border-right: none;
+}
+
+.task-modal .tab:hover {
+ color: var(--text-primary);
+}
+
+.task-modal .tab.active {
+ color: var(--accent-glow);
+ border-bottom-color: var(--accent-glow);
+}
+
+.tab-content {
+ min-height: 200px;
+}
+
+.lifecycle-bar {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 16px;
+}
+
+.lifecycle-step {
+ flex: 1;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--border);
+}
+
+.lifecycle-step.completed {
+ background: var(--accent-glow);
+}
+
+.lifecycle-step.current {
+ background: var(--accent-glow);
+ animation: pulseGlow 2s infinite;
+}
+
+/* Agent Log */
+.agent-log {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.log-entry {
+ display: flex;
+ gap: 12px;
+ padding: 12px;
+ background: var(--bg-deep);
+ border-radius: 6px;
+ font-size: 13px;
+}
+
+.log-entry .log-time {
+ color: var(--text-secondary);
+ font-family: monospace;
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.log-entry .log-agent {
+ color: var(--accent-glow);
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.log-entry .log-message {
+ color: var(--text-secondary);
+}
+
+/* Diff Viewer */
+.diff-viewer {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 12px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: auto;
+ max-height: 500px;
+}
+
+.diff-line {
+ padding: 1px 12px;
+ white-space: pre;
+}
+
+.diff-add {
+ background: rgba(34, 197, 94, 0.1);
+ color: #4ade80;
+}
+
+.diff-del {
+ background: rgba(255, 45, 120, 0.1);
+ color: #ff6b9d;
+}
+
+.diff-hunk {
+ color: var(--accent-glow);
+ background: rgba(168, 85, 247, 0.05);
+}
+
+/* Pipeline Controls */
+.pipeline-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+/* Settings Page */
+.settings-container {
+ max-width: 600px;
+}
+
+.settings-section {
+ margin-bottom: 32px;
+}
+
+.settings-section h2 {
+ font-size: 16px;
+ margin-bottom: 16px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+/* Empty state */
+.empty-state {
+ text-align: center;
+ padding: 48px 24px;
+ color: var(--text-secondary);
+}
+
+.empty-state h3 {
+ font-size: 16px;
+ margin-bottom: 8px;
+ color: var(--text-primary);
+}
+
+/* Toast notifications */
+.toast-container {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 2000;
+}
+
+.toast {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 12px 16px;
+ font-size: 13px;
+ animation: slideUp 0.2s;
+ max-width: 360px;
+}
+
+.toast.error {
+ border-color: var(--alert);
+}
+
+.toast.success {
+ border-color: var(--success);
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .board-container {
+ gap: 8px;
+ }
+
+ .board-column {
+ flex: 0 0 220px;
+ }
+
+ .project-grid {
+ grid-template-columns: 1fr;
+ }
+}