kin/web/frontend/src/views/TaskDetail.vue

702 lines
31 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
import AttachmentUploader from '../components/AttachmentUploader.vue'
import AttachmentList from '../components/AttachmentList.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const task = ref<TaskFull | null>(null)
const loading = ref(true)
const error = ref('')
const claudeLoginError = ref(false)
const selectedStep = ref<PipelineStep | null>(null)
const polling = ref(false)
const pipelineStarting = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Approve modal
const showApprove = ref(false)
const approveForm = ref({ title: '', description: '', type: 'decision', createFollowups: true })
const approveLoading = ref(false)
const followupResults = ref<{ id: string; title: string }[]>([])
const pendingActions = ref<PendingAction[]>([])
const resolvingAction = ref(false)
// Reject modal
const showReject = ref(false)
const rejectReason = ref('')
// Revise modal
const showRevise = ref(false)
const reviseComment = ref('')
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
const autoMode = ref(false)
function loadMode(t: typeof task.value) {
if (!t) return
if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto_complete'
} else if (t.status === 'review') {
// Task is in review — always show Approve/Reject regardless of localStorage
autoMode.value = false
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto_complete'
}
}
async function toggleMode() {
if (!task.value) return
autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto_complete' : 'review')
try {
const updated = await api.patchTask(props.id, { execution_mode: autoMode.value ? 'auto_complete' : 'review' })
task.value = { ...task.value, ...updated }
} catch (e: any) {
error.value = e.message
}
}
async function load() {
try {
const prev = task.value
task.value = await api.taskFull(props.id)
loadMode(task.value)
// Auto-start polling if task is in_progress
if (task.value.status === 'in_progress' && !polling.value) {
startPolling()
}
// Stop polling when pipeline done
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
stopPolling()
}
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
function startPolling() {
if (polling.value) return
polling.value = true
pollTimer = setInterval(load, 3000)
}
function stopPolling() {
polling.value = false
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
}
onMounted(() => { load(); loadAttachments() })
onUnmounted(stopPolling)
function statusColor(s: string) {
const m: Record<string, string> = {
pending: 'gray', in_progress: 'blue', review: 'yellow',
done: 'green', blocked: 'red', decomposed: 'purple', cancelled: 'gray',
}
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}', followup_pm: '\u{1F4DD}',
}
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 {
const parsed = JSON.parse(text)
return JSON.stringify(parsed, null, 2)
} catch {
return text
}
}
async function approve() {
if (!task.value) return
approveLoading.value = true
followupResults.value = []
pendingActions.value = []
try {
const data: Record<string, unknown> = {
create_followups: approveForm.value.createFollowups,
}
if (approveForm.value.title) {
data.decision_title = approveForm.value.title
data.decision_description = approveForm.value.description
data.decision_type = approveForm.value.type
}
const res = await api.approveTask(props.id, data as any)
if (res.followup_tasks?.length) {
followupResults.value = res.followup_tasks.map(t => ({ id: t.id, title: t.title }))
}
if (res.pending_actions?.length) {
pendingActions.value = res.pending_actions
}
if (!res.followup_tasks?.length && !res.pending_actions?.length) {
showApprove.value = false
}
approveForm.value = { title: '', description: '', type: 'decision', createFollowups: true }
await load()
} catch (e: any) {
error.value = e.message
} finally {
approveLoading.value = false
}
}
async function resolveAction(action: PendingAction, choice: string) {
resolvingAction.value = true
try {
const res = await api.resolveAction(props.id, action, choice)
pendingActions.value = pendingActions.value.filter(a => a !== action)
if (choice === 'manual_task' && res.result && typeof res.result === 'object' && 'id' in res.result) {
followupResults.value.push({ id: (res.result as any).id, title: (res.result as any).title })
}
if (choice === 'rerun') {
await load()
}
} catch (e: any) {
error.value = e.message
} finally {
resolvingAction.value = false
}
}
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 revise() {
if (!task.value || !reviseComment.value) return
try {
await api.reviseTask(props.id, reviseComment.value)
showRevise.value = false
reviseComment.value = ''
await load()
} catch (e: any) {
error.value = e.message
}
}
async function runPipeline() {
claudeLoginError.value = false
pipelineStarting.value = true
try {
2026-03-16 23:34:22 +02:00
// Sync task execution_mode with current toggle state before running
const targetMode = autoMode.value ? 'auto_complete' : 'review'
if (task.value && task.value.execution_mode !== targetMode) {
const updated = await api.patchTask(props.id, { execution_mode: targetMode })
task.value = { ...task.value, ...updated }
}
await api.runTask(props.id)
startPolling()
await load()
} catch (e: any) {
if (e instanceof ApiError && e.code === 'claude_auth_required') {
claudeLoginError.value = true
} else if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
} else {
error.value = e.message
}
} finally {
pipelineStarting.value = false
}
}
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
const isRunning = computed(() => task.value?.status === 'in_progress')
const isManualEscalation = computed(() => task.value?.brief?.task_type === 'manual_escalation')
const resolvingManually = ref(false)
async function resolveManually() {
if (!task.value) return
if (!confirm('Пометить задачу как решённую вручную?')) return
resolvingManually.value = true
try {
const updated = await api.patchTask(props.id, { status: 'done' })
task.value = { ...task.value, ...updated }
} catch (e: any) {
error.value = e.message
} finally {
resolvingManually.value = false
}
}
function goBack() {
if (window.history.length > 1) {
router.back()
} else if (task.value) {
const backStatus = route.query.back_status as string | undefined
router.push({
path: `/project/${task.value.project_id}`,
query: backStatus ? { status: backStatus } : undefined,
})
}
}
const statusChanging = ref(false)
async function changeStatus(newStatus: string) {
if (!task.value || newStatus === task.value.status) return
statusChanging.value = true
try {
const updated = await api.patchTask(props.id, { status: newStatus })
task.value = { ...task.value, ...updated }
} catch (e: any) {
error.value = e.message
} finally {
statusChanging.value = false
}
}
// Deploy
const deploying = ref(false)
const deployResult = ref<DeployResult | null>(null)
async function runDeploy() {
if (!task.value) return
deploying.value = true
deployResult.value = null
try {
deployResult.value = await api.deployProject(task.value.project_id)
} catch (e: any) {
error.value = e.message
} finally {
deploying.value = false
}
}
// Attachments
const attachments = ref<Attachment[]>([])
async function loadAttachments() {
try {
attachments.value = await api.getAttachments(props.id)
} catch {}
}
// Edit modal (pending tasks only)
const showEdit = ref(false)
const editForm = ref({ title: '', briefText: '', priority: 5, acceptanceCriteria: '' })
const editLoading = ref(false)
const editError = ref('')
function getBriefText(brief: Record<string, unknown> | null): string {
if (!brief) return ''
if (typeof brief === 'string') return brief as string
if ('text' in brief) return String(brief.text)
return JSON.stringify(brief)
}
function openEdit() {
if (!task.value) return
editForm.value = {
title: task.value.title,
briefText: getBriefText(task.value.brief),
priority: task.value.priority,
acceptanceCriteria: task.value.acceptance_criteria ?? '',
}
editError.value = ''
showEdit.value = true
}
async function saveEdit() {
if (!task.value) return
editLoading.value = true
editError.value = ''
try {
const data: Parameters<typeof api.patchTask>[1] = {}
if (editForm.value.title !== task.value.title) data.title = editForm.value.title
if (editForm.value.priority !== task.value.priority) data.priority = editForm.value.priority
const origBriefText = getBriefText(task.value.brief)
if (editForm.value.briefText !== origBriefText) data.brief_text = editForm.value.briefText
const origAC = task.value.acceptance_criteria ?? ''
if (editForm.value.acceptanceCriteria !== origAC) data.acceptance_criteria = editForm.value.acceptanceCriteria
if (Object.keys(data).length === 0) { showEdit.value = false; return }
const updated = await api.patchTask(props.id, data)
task.value = { ...task.value, ...updated }
showEdit.value = false
} catch (e: any) {
editError.value = e.message
} finally {
editLoading.value = false
}
}
</script>
<template>
<div v-if="loading && !task" class="text-gray-500 text-sm">Loading...</div>
<div v-else-if="error && !task" 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">
<button @click="goBack" class="text-gray-600 hover:text-gray-400 text-sm cursor-pointer bg-transparent border-none p-0">
&larr; {{ task.project_id }}
</button>
</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 v-if="task.execution_mode === 'auto_complete'"
class="text-[10px] px-1.5 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded"
title="Auto mode: agents can write files">&#x1F513; auto</span>
<select
:value="task.status"
@change="changeStatus(($event.target as HTMLSelectElement).value)"
:disabled="statusChanging"
class="text-xs bg-gray-800 border border-gray-700 text-gray-300 rounded px-2 py-0.5 disabled:opacity-50"
>
<option value="pending">pending</option>
<option value="in_progress">in_progress</option>
<option value="review">review</option>
<option value="done">done</option>
<option value="blocked">blocked</option>
<option value="cancelled">cancelled</option>
</select>
<span v-if="isRunning" class="inline-block w-2 h-2 bg-blue-500 rounded-full animate-pulse"></span>
<span class="text-xs text-gray-600">pri {{ task.priority }}</span>
</div>
<!-- Manual escalation context banner -->
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold text-orange-400">&#9888; Требует ручного решения</span>
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
эскалация из
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
{{ task.parent_task_id }}
</router-link>
</span>
</div>
<p class="text-xs text-orange-300">{{ task.title }}</p>
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
</div>
<!-- Dangerous skip warning banner -->
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
<span class="text-red-400 text-base shrink-0">&#9888;</span>
<div>
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
</div>
</div>
<div v-if="task.brief && !isManualEscalation" class="text-xs text-gray-500 mb-1">
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
</div>
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
Blocked: {{ task.blocked_reason }}
</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 || isRunning" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-3">
Pipeline
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
</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">
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">&rarr;</div>
<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-if="!hasSteps && !isRunning" 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>
<!-- Attachments -->
<div class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
</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">
<div v-if="autoMode && (isRunning || task.status === 'review')"
class="flex items-center gap-1.5 px-3 py-1.5 bg-yellow-900/20 border border-yellow-800/50 rounded text-xs text-yellow-400">
<span class="inline-block w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></span>
Автопилот активен
</div>
<button v-if="task.status === 'review' && !autoMode"
@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' && !autoMode"
@click="showRevise = true"
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900">
&#x1F504; Revise
</button>
<button v-if="(task.status === 'review' || task.status === 'in_progress') && !autoMode"
@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' || task.status === 'review'"
@click="toggleMode"
class="px-3 py-2 text-sm border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Auto' : '&#x1F512; Review' }}
</button>
<button v-if="task.status === 'pending'"
@click="openEdit"
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
&#9998; Edit
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling || pipelineStarting"
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">
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '&#9654; Run Pipeline' }}
</button>
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
@click="resolveManually"
:disabled="resolvingManually"
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ resolvingManually ? 'Сохраняем...' : '&#10003; Решить вручную' }}
</button>
<button v-if="task.status === 'done' && task.project_deploy_command"
@click.stop="runDeploy"
:disabled="deploying"
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ deploying ? 'Deploying...' : '&#x1F680; Deploy' }}
</button>
</div>
<!-- Claude login error banner -->
<div v-if="claudeLoginError" class="mt-3 px-4 py-3 border border-yellow-700 bg-yellow-950/30 rounded">
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold text-yellow-300">&#9888; Claude CLI requires login</p>
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
</div>
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0"></button>
</div>
</div>
<!-- Deploy result inline block -->
<div v-if="deployResult" class="mx-0 mt-2 p-3 rounded border text-xs font-mono"
:class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }}
</span>
<span class="text-gray-500">exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs"></button>
</div>
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
</div>
<!-- Approve Modal -->
<Modal v-if="showApprove" title="Approve Task" @close="showApprove = false; followupResults = []; pendingActions = []">
<!-- Pending permission actions -->
<div v-if="pendingActions.length" class="space-y-3">
<p class="text-sm text-yellow-400">Permission issues need your decision:</p>
<div v-for="(action, i) in pendingActions" :key="i"
class="border border-yellow-900/50 rounded-lg p-3 space-y-2">
<p class="text-sm text-gray-300">{{ action.description }}</p>
<div class="flex gap-2">
<button @click="resolveAction(action, 'rerun')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
Rerun (skip permissions)
</button>
<button @click="resolveAction(action, 'manual_task')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700 disabled:opacity-50">
Create task
</button>
<button @click="resolveAction(action, 'skip')" :disabled="resolvingAction"
class="px-3 py-1 text-xs bg-gray-800 text-gray-500 border border-gray-700 rounded hover:bg-gray-700 disabled:opacity-50">
Skip
</button>
</div>
</div>
</div>
<!-- Follow-up results -->
<div v-if="followupResults.length && !pendingActions.length" class="space-y-3">
<p class="text-sm text-green-400">Task approved. Created {{ followupResults.length }} follow-up tasks:</p>
<div class="space-y-1">
<router-link v-for="f in followupResults" :key="f.id" :to="`/task/${f.id}`"
class="block px-3 py-2 border border-gray-800 rounded text-sm text-gray-300 hover:border-gray-600 no-underline">
<span class="text-gray-500">{{ f.id }}</span> {{ f.title }}
</router-link>
</div>
<button @click="showApprove = false; followupResults = []"
class="w-full py-2 bg-gray-800 text-gray-300 border border-gray-700 rounded text-sm hover:bg-gray-700">
Close
</button>
</div>
<!-- Approve form -->
<form v-if="!followupResults.length && !pendingActions.length" @submit.prevent="approve" class="space-y-3">
<label class="flex items-center gap-2 text-sm text-gray-300 cursor-pointer">
<input type="checkbox" v-model="approveForm.createFollowups"
class="rounded border-gray-600 bg-gray-800 text-blue-500" />
Create follow-up tasks from pipeline results
</label>
<p class="text-xs text-gray-500">Optionally record a decision:</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" :disabled="approveLoading"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
{{ approveLoading ? 'Processing...' : '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>
<!-- Revise Modal -->
<Modal v-if="showRevise" title="&#x1F504; Revise Task" @close="showRevise = false">
<form @submit.prevent="revise" class="space-y-3">
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
<textarea v-model="reviseComment" placeholder="Что доработать / уточнить..." rows="4" 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-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900">
&#x1F504; Отправить на доработку
</button>
</form>
</Modal>
<!-- Edit Modal (pending tasks only) -->
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
<form @submit.prevent="saveEdit" class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Title</label>
<input v-model="editForm.title" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Brief</label>
<textarea v-model="editForm.briefText" rows="4" placeholder="Task 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"></textarea>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Priority (110)</label>
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
<textarea v-model="editForm.acceptanceCriteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
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>
</div>
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
<button type="submit" :disabled="editLoading"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
{{ editLoading ? 'Saving...' : 'Save' }}
</button>
</form>
</Modal>
</div>
</template>