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:
parent
fabae74c19
commit
38c252fc1b
9 changed files with 550 additions and 7 deletions
256
web/frontend/src/views/TaskDetail.vue
Normal file
256
web/frontend/src/views/TaskDetail.vue
Normal 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">
|
||||
← {{ 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">→</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">
|
||||
✓ 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">
|
||||
✗ 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...' : '▶ 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 & 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 & return to pending
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue