Add task detail view, pipeline visualization, approve/reject workflow

API (web/api.py) — 5 new endpoints:
  GET  /api/tasks/{id}/pipeline — agent_logs as pipeline steps
  GET  /api/tasks/{id}/full — task + steps + related decisions
  POST /api/tasks/{id}/approve — mark done, optionally add decision
  POST /api/tasks/{id}/reject — return to pending with reason
  POST /api/tasks/{id}/run — launch pipeline in background (202)

Frontend:
  TaskDetail (/task/:id) — full task page with:
    - Pipeline graph: role cards with icons, arrows, status colors
    - Click step → expand output (pre-formatted, JSON detected)
    - Action bar: Approve (with optional decision), Reject, Run Pipeline
    - Polling for live pipeline updates
  Dashboard: review_tasks badge ("awaiting review" in yellow)
  ProjectView: task rows are now clickable links to /task/:id

Runner: output_summary no longer truncated (full output for GUI).
Models: get_project_summary includes review_tasks count.

13 new API tests, 105 total, all passing. Frontend builds clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
johnfrum1234 2026-03-15 14:32:29 +02:00
parent fabae74c19
commit 38c252fc1b
9 changed files with 550 additions and 7 deletions

View file

@ -78,7 +78,7 @@ def run_agent(
agent_role=role, agent_role=role,
action="execute", action="execute",
input_summary=f"task={task_id}, model={model}", input_summary=f"task={task_id}, model={model}",
output_summary=output_text[:500] if output_text else None, output_summary=output_text or None,
tokens_used=result.get("tokens_used"), tokens_used=result.get("tokens_used"),
model=model, model=model,
cost_usd=result.get("cost_usd"), cost_usd=result.get("cost_usd"),

View file

@ -416,7 +416,8 @@ def get_project_summary(conn: sqlite3.Connection) -> list[dict]:
COUNT(t.id) AS total_tasks, COUNT(t.id) AS total_tasks,
SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS done_tasks, SUM(CASE WHEN t.status = 'done' THEN 1 ELSE 0 END) AS done_tasks,
SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) AS active_tasks, SUM(CASE WHEN t.status = 'in_progress' THEN 1 ELSE 0 END) AS active_tasks,
SUM(CASE WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked_tasks SUM(CASE WHEN t.status = 'blocked' THEN 1 ELSE 0 END) AS blocked_tasks,
SUM(CASE WHEN t.status = 'review' THEN 1 ELSE 0 END) AS review_tasks
FROM projects p FROM projects p
LEFT JOIN tasks t ON t.project_id = p.id LEFT JOIN tasks t ON t.project_id = p.id
GROUP BY p.id GROUP BY p.id

147
tests/test_api.py Normal file
View file

@ -0,0 +1,147 @@
"""Tests for web/api.py — new task endpoints (pipeline, approve, reject, full)."""
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
# Patch DB_PATH before importing app
import web.api as api_module
@pytest.fixture
def client(tmp_path):
db_path = tmp_path / "test.db"
api_module.DB_PATH = db_path
from web.api import app
c = TestClient(app)
# Seed data
c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
c.post("/api/tasks", json={"project_id": "p1", "title": "Fix bug"})
return c
def test_get_task(client):
r = client.get("/api/tasks/P1-001")
assert r.status_code == 200
assert r.json()["title"] == "Fix bug"
def test_get_task_not_found(client):
r = client.get("/api/tasks/NOPE")
assert r.status_code == 404
def test_task_pipeline_empty(client):
r = client.get("/api/tasks/P1-001/pipeline")
assert r.status_code == 200
assert r.json() == []
def test_task_pipeline_with_logs(client):
# Insert agent logs directly
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.log_agent_run(conn, "p1", "debugger", "execute",
task_id="P1-001", output_summary="Found bug",
tokens_used=1000, duration_seconds=5, success=True)
models.log_agent_run(conn, "p1", "tester", "execute",
task_id="P1-001", output_summary="Tests pass",
tokens_used=500, duration_seconds=3, success=True)
conn.close()
r = client.get("/api/tasks/P1-001/pipeline")
assert r.status_code == 200
steps = r.json()
assert len(steps) == 2
assert steps[0]["agent_role"] == "debugger"
assert steps[0]["output_summary"] == "Found bug"
assert steps[1]["agent_role"] == "tester"
def test_task_full(client):
r = client.get("/api/tasks/P1-001/full")
assert r.status_code == 200
data = r.json()
assert data["id"] == "P1-001"
assert "pipeline_steps" in data
assert "related_decisions" in data
def test_task_full_not_found(client):
r = client.get("/api/tasks/NOPE/full")
assert r.status_code == 404
def test_approve_task(client):
# First set task to review
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="review")
conn.close()
r = client.post("/api/tasks/P1-001/approve", json={})
assert r.status_code == 200
assert r.json()["status"] == "done"
# Verify task is done
r = client.get("/api/tasks/P1-001")
assert r.json()["status"] == "done"
def test_approve_with_decision(client):
r = client.post("/api/tasks/P1-001/approve", json={
"decision_title": "Use AbortController",
"decision_description": "Fix race condition with AbortController",
"decision_type": "decision",
})
assert r.status_code == 200
assert r.json()["decision"] is not None
assert r.json()["decision"]["title"] == "Use AbortController"
def test_approve_not_found(client):
r = client.post("/api/tasks/NOPE/approve", json={})
assert r.status_code == 404
def test_reject_task(client):
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="review")
conn.close()
r = client.post("/api/tasks/P1-001/reject", json={
"reason": "Didn't fix the root cause"
})
assert r.status_code == 200
assert r.json()["status"] == "pending"
# Verify task is pending with review reason
r = client.get("/api/tasks/P1-001")
data = r.json()
assert data["status"] == "pending"
assert data["review"]["rejected"] == "Didn't fix the root cause"
def test_reject_not_found(client):
r = client.post("/api/tasks/NOPE/reject", json={"reason": "bad"})
assert r.status_code == 404
def test_task_pipeline_not_found(client):
r = client.get("/api/tasks/NOPE/pipeline")
assert r.status_code == 404
def test_project_summary_includes_review(client):
from core.db import init_db
from core import models
conn = init_db(api_module.DB_PATH)
models.update_task(conn, "P1-001", status="review")
conn.close()
r = client.get("/api/projects")
projects = r.json()
assert projects[0]["review_tasks"] == 1

View file

@ -3,6 +3,7 @@ Kin Web API — FastAPI backend reading ~/.kin/kin.db via core.models.
Run: uvicorn web.api:app --reload --port 8420 Run: uvicorn web.api:app --reload --port 8420
""" """
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -11,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from core.db import init_db from core.db import init_db
@ -134,6 +136,111 @@ def create_task(body: TaskCreate):
return t return t
@app.get("/api/tasks/{task_id}/pipeline")
def get_task_pipeline(task_id: str):
"""Get agent_logs for a task (pipeline steps)."""
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
rows = conn.execute(
"""SELECT id, agent_role, action, output_summary, success,
duration_seconds, tokens_used, model, cost_usd, created_at
FROM agent_logs WHERE task_id = ? ORDER BY created_at""",
(task_id,),
).fetchall()
steps = [dict(r) for r in rows]
conn.close()
return steps
@app.get("/api/tasks/{task_id}/full")
def get_task_full(task_id: str):
"""Task + pipeline steps + related decisions."""
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
rows = conn.execute(
"""SELECT id, agent_role, action, output_summary, success,
duration_seconds, tokens_used, model, cost_usd, created_at
FROM agent_logs WHERE task_id = ? ORDER BY created_at""",
(task_id,),
).fetchall()
steps = [dict(r) for r in rows]
decisions = models.get_decisions(conn, t["project_id"])
# Filter to decisions linked to this task
task_decisions = [d for d in decisions if d.get("task_id") == task_id]
conn.close()
return {**t, "pipeline_steps": steps, "related_decisions": task_decisions}
class TaskApprove(BaseModel):
decision_title: str | None = None
decision_description: str | None = None
decision_type: str = "decision"
@app.post("/api/tasks/{task_id}/approve")
def approve_task(task_id: str, body: TaskApprove | None = None):
"""Approve a task: set status=done, optionally add a decision."""
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
models.update_task(conn, task_id, status="done")
decision = None
if body and body.decision_title:
decision = models.add_decision(
conn, t["project_id"], body.decision_type,
body.decision_title, body.decision_description or body.decision_title,
task_id=task_id,
)
conn.close()
return {"status": "done", "decision": decision}
class TaskReject(BaseModel):
reason: str
@app.post("/api/tasks/{task_id}/reject")
def reject_task(task_id: str, body: TaskReject):
"""Reject a task: set status=pending with reason in review field."""
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
models.update_task(conn, task_id, status="pending", review={"rejected": body.reason})
conn.close()
return {"status": "pending", "reason": body.reason}
@app.post("/api/tasks/{task_id}/run")
def run_task(task_id: str):
"""Launch pipeline for a task in background. Returns 202."""
conn = get_conn()
t = models.get_task(conn, task_id)
if not t:
conn.close()
raise HTTPException(404, f"Task '{task_id}' not found")
conn.close()
# Launch kin run in background subprocess
kin_root = Path(__file__).parent.parent
subprocess.Popen(
[sys.executable, "-m", "cli.main", "run", task_id, "--db",
str(DB_PATH)],
cwd=str(kin_root),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return JSONResponse({"status": "started", "task_id": task_id}, status_code=202)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Decisions # Decisions
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -28,6 +28,7 @@ export interface Project {
done_tasks: number done_tasks: number
active_tasks: number active_tasks: number
blocked_tasks: number blocked_tasks: number
review_tasks: number
} }
export interface ProjectDetail extends Project { export interface ProjectDetail extends Project {
@ -73,6 +74,24 @@ export interface Module {
dependencies: string[] | null dependencies: string[] | null
} }
export interface PipelineStep {
id: number
agent_role: string
action: string
output_summary: string | null
success: boolean | number
duration_seconds: number | null
tokens_used: number | null
model: string | null
cost_usd: number | null
created_at: string
}
export interface TaskFull extends Task {
pipeline_steps: PipelineStep[]
related_decisions: Decision[]
}
export interface CostEntry { export interface CostEntry {
project_id: string project_id: string
project_name: string project_name: string
@ -85,11 +104,20 @@ export interface CostEntry {
export const api = { export const api = {
projects: () => get<Project[]>('/projects'), projects: () => get<Project[]>('/projects'),
project: (id: string) => get<ProjectDetail>(`/projects/${id}`), project: (id: string) => get<ProjectDetail>(`/projects/${id}`),
task: (id: string) => get<Task>(`/tasks/${id}`),
taskFull: (id: string) => get<TaskFull>(`/tasks/${id}/full`),
taskPipeline: (id: string) => get<PipelineStep[]>(`/tasks/${id}/pipeline`),
cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`), cost: (days = 7) => get<CostEntry[]>(`/cost?days=${days}`),
createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) => createProject: (data: { id: string; name: string; path: string; tech_stack?: string[]; priority?: number }) =>
post<Project>('/projects', data), post<Project>('/projects', data),
createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) => createTask: (data: { project_id: string; title: string; priority?: number; route_type?: string }) =>
post<Task>('/tasks', data), post<Task>('/tasks', data),
approveTask: (id: string, data?: { decision_title?: string; decision_description?: string; decision_type?: string }) =>
post<{ status: string }>(`/tasks/${id}/approve`, data || {}),
rejectTask: (id: string, reason: string) =>
post<{ status: string }>(`/tasks/${id}/reject`, { reason }),
runTask: (id: string) =>
post<{ status: string }>(`/tasks/${id}/run`, {}),
bootstrap: (data: { path: string; id: string; name: string }) => bootstrap: (data: { path: string; id: string; name: string }) =>
post<{ project: Project }>('/bootstrap', data), post<{ project: Project }>('/bootstrap', data),
} }

View file

@ -4,12 +4,14 @@ import './style.css'
import App from './App.vue' import App from './App.vue'
import Dashboard from './views/Dashboard.vue' import Dashboard from './views/Dashboard.vue'
import ProjectView from './views/ProjectView.vue' import ProjectView from './views/ProjectView.vue'
import TaskDetail from './views/TaskDetail.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', component: Dashboard }, { path: '/', component: Dashboard },
{ path: '/project/:id', component: ProjectView, props: true }, { path: '/project/:id', component: ProjectView, props: true },
{ path: '/task/:id', component: TaskDetail, props: true },
], ],
}) })

View file

@ -116,10 +116,11 @@ async function runBootstrap() {
<div class="flex gap-4 text-xs"> <div class="flex gap-4 text-xs">
<span class="text-gray-500">{{ p.total_tasks }} tasks</span> <span class="text-gray-500">{{ p.total_tasks }} tasks</span>
<span v-if="p.active_tasks" class="text-blue-400">{{ p.active_tasks }} active</span> <span v-if="p.active_tasks" class="text-blue-400">{{ p.active_tasks }} active</span>
<span v-if="p.review_tasks" class="text-yellow-400">{{ p.review_tasks }} awaiting review</span>
<span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span> <span v-if="p.blocked_tasks" class="text-red-400">{{ p.blocked_tasks }} blocked</span>
<span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span> <span v-if="p.done_tasks" class="text-green-500">{{ p.done_tasks }} done</span>
<span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks > 0" class="text-gray-500"> <span v-if="p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) > 0" class="text-gray-500">
{{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks }} pending {{ p.total_tasks - p.done_tasks - p.active_tasks - p.blocked_tasks - (p.review_tasks || 0) }} pending
</span> </span>
</div> </div>
</router-link> </router-link>

View file

@ -190,8 +190,9 @@ async function addDecision() {
</div> </div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div> <div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1"> <div v-else class="space-y-1">
<div v-for="t in filteredTasks" :key="t.id" <router-link v-for="t in filteredTasks" :key="t.id"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-700"> :to="`/task/${t.id}`"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span> <span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" /> <Badge :text="t.status" :color="taskStatusColor(t.status)" />
@ -201,7 +202,7 @@ async function addDecision() {
<span v-if="t.assigned_role">{{ t.assigned_role }}</span> <span v-if="t.assigned_role">{{ t.assigned_role }}</span>
<span>pri {{ t.priority }}</span> <span>pri {{ t.priority }}</span>
</div> </div>
</div> </router-link>
</div> </div>
</div> </div>

View file

@ -0,0 +1,256 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { api, type TaskFull, type PipelineStep } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const task = ref<TaskFull | null>(null)
const loading = ref(true)
const error = ref('')
const selectedStep = ref<PipelineStep | null>(null)
const polling = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Approve modal
const showApprove = ref(false)
const approveForm = ref({ title: '', description: '', type: 'decision' })
// Reject modal
const showReject = ref(false)
const rejectReason = ref('')
async function load() {
try {
loading.value = true
task.value = await api.taskFull(props.id)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(load)
function statusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', in_progress: 'blue', review: 'yellow',
done: 'green', blocked: 'red', decomposed: 'purple',
}
return m[s] || 'gray'
}
const roleIcons: Record<string, string> = {
pm: '\u{1F9E0}', security: '\u{1F6E1}', debugger: '\u{1F50D}',
frontend_dev: '\u{1F4BB}', backend_dev: '\u{2699}', tester: '\u{2705}',
reviewer: '\u{1F4CB}', architect: '\u{1F3D7}',
}
function stepStatusClass(step: PipelineStep) {
if (step.success) return 'border-green-700 bg-green-950/30'
return 'border-red-700 bg-red-950/30'
}
function stepStatusIcon(step: PipelineStep) {
return step.success ? '\u2713' : '\u2717'
}
function stepStatusColor(step: PipelineStep) {
return step.success ? 'text-green-400' : 'text-red-400'
}
function formatOutput(text: string | null): string {
if (!text) return ''
// Try to detect and format JSON
try {
const parsed = JSON.parse(text)
return JSON.stringify(parsed, null, 2)
} catch {
return text
}
}
async function approve() {
if (!task.value) return
try {
const data = approveForm.value.title
? { decision_title: approveForm.value.title, decision_description: approveForm.value.description, decision_type: approveForm.value.type }
: undefined
await api.approveTask(props.id, data)
showApprove.value = false
approveForm.value = { title: '', description: '', type: 'decision' }
await load()
} catch (e: any) {
error.value = e.message
}
}
async function reject() {
if (!task.value || !rejectReason.value) return
try {
await api.rejectTask(props.id, rejectReason.value)
showReject.value = false
rejectReason.value = ''
await load()
} catch (e: any) {
error.value = e.message
}
}
async function runPipeline() {
try {
await api.runTask(props.id)
polling.value = true
pollTimer = setInterval(async () => {
await load()
if (task.value && !['in_progress'].includes(task.value.status)) {
stopPolling()
}
}, 3000)
} catch (e: any) {
error.value = e.message
}
}
function stopPolling() {
polling.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
</script>
<template>
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="task">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link :to="`/project/${task.project_id}`" class="text-gray-600 hover:text-gray-400 text-sm no-underline">
&larr; {{ task.project_id }}
</router-link>
</div>
<div class="flex items-center gap-3 mb-2">
<h1 class="text-xl font-bold text-gray-100">{{ task.id }}</h1>
<span class="text-gray-400">{{ task.title }}</span>
<Badge :text="task.status" :color="statusColor(task.status)" />
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
</div>
<div v-if="task.brief" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.assigned_role" class="text-xs text-gray-500">
Assigned: {{ task.assigned_role }}
</div>
</div>
<!-- Pipeline Graph -->
<div v-if="hasSteps" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-3">Pipeline</h2>
<div class="flex items-center gap-1 overflow-x-auto pb-2">
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
<!-- Arrow -->
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">&rarr;</div>
<!-- Step card -->
<button
@click="selectedStep = selectedStep?.id === step.id ? null : step"
class="border rounded-lg px-3 py-2 min-w-[120px] text-left transition-all shrink-0"
:class="[
stepStatusClass(step),
selectedStep?.id === step.id ? 'ring-1 ring-blue-500' : '',
]"
>
<div class="flex items-center gap-1.5 mb-1">
<span class="text-base">{{ roleIcons[step.agent_role] || '\u{1F916}' }}</span>
<span class="text-xs font-medium text-gray-300">{{ step.agent_role }}</span>
<span :class="stepStatusColor(step)" class="text-xs ml-auto">{{ stepStatusIcon(step) }}</span>
</div>
<div class="flex gap-2 text-[10px] text-gray-500">
<span v-if="step.duration_seconds">{{ step.duration_seconds }}s</span>
<span v-if="step.tokens_used">{{ step.tokens_used?.toLocaleString() }}tk</span>
<span v-if="step.cost_usd">${{ step.cost_usd?.toFixed(3) }}</span>
</div>
</button>
</template>
</div>
</div>
<!-- No pipeline -->
<div v-else class="mb-6 text-sm text-gray-600">
No pipeline steps yet.
</div>
<!-- Selected step output -->
<div v-if="selectedStep" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">
Output: {{ selectedStep.agent_role }}
<span class="text-xs text-gray-600 font-normal ml-2">{{ selectedStep.created_at }}</span>
</h2>
<div class="border border-gray-800 rounded-lg bg-gray-900/50 overflow-hidden">
<pre class="p-4 text-xs text-gray-300 overflow-x-auto whitespace-pre-wrap max-h-[600px] overflow-y-auto">{{ formatOutput(selectedStep.output_summary) }}</pre>
</div>
</div>
<!-- Related Decisions -->
<div v-if="task.related_decisions?.length" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">Related Decisions</h2>
<div class="space-y-1">
<div v-for="d in task.related_decisions" :key="d.id"
class="px-3 py-2 border border-gray-800 rounded text-xs">
<Badge :text="d.type" :color="d.type === 'gotcha' ? 'red' : 'blue'" />
<span class="text-gray-300 ml-2">{{ d.title }}</span>
</div>
</div>
</div>
<!-- Actions Bar -->
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
<button v-if="task.status === 'review'"
@click="showApprove = true"
class="px-4 py-2 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
&#10003; Approve
</button>
<button v-if="task.status === 'review' || task.status === 'in_progress'"
@click="showReject = true"
class="px-4 py-2 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
&#10007; Reject
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling"
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ polling ? 'Running...' : '&#9654; Run Pipeline' }}
</button>
</div>
<!-- Approve Modal -->
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false">
<form @submit.prevent="approve" class="space-y-3">
<p class="text-sm text-gray-400">Optionally record a decision from this task:</p>
<input v-model="approveForm.title" placeholder="Decision title (optional)"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y" rows="2"></textarea>
<button type="submit"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900">
Approve &amp; mark done
</button>
</form>
</Modal>
<!-- Reject Modal -->
<Modal v-if="showReject" title="Reject Task" @close="showReject = false">
<form @submit.prevent="reject" class="space-y-3">
<textarea v-model="rejectReason" placeholder="Why are you rejecting this?" rows="3" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
<button type="submit"
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900">
Reject &amp; return to pending
</button>
</form>
</Modal>
</div>
</template>