diff options
Diffstat (limited to 'admin-panel/src/client/components')
| -rw-r--r-- | admin-panel/src/client/components/agent-log.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/board.ts | 123 | ||||
| -rw-r--r-- | admin-panel/src/client/components/diff-viewer.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/pipeline-controls.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/project-form.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/task-card.ts | 40 | ||||
| -rw-r--r-- | admin-panel/src/client/components/task-modal.ts | 271 |
7 files changed, 442 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">×</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; +} |
