kin/web/frontend/src/views/TaskDetail.vue
2026-03-19 17:06:24 +02:00

814 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
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'
import LiveConsole from '../components/LiveConsole.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
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)
const followupLoading = ref(false)
// Reject modal
const showReject = ref(false)
const rejectReason = ref('')
// Revise modal
const showRevise = ref(false)
const reviseComment = ref('')
const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
if (!selectedStep.value) return null
return parseAgentOutput(selectedStep.value.output_summary)
})
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
const autoMode = ref(false)
function loadMode(t_val: typeof task.value) {
if (!t_val) return
if (t_val.execution_mode) {
autoMode.value = t_val.execution_mode === 'auto_complete'
} else if (t_val.status === 'review') {
autoMode.value = false
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t_val.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)
if (task.value.status === 'in_progress' && !polling.value) {
startPolling()
}
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',
revising: 'orange',
}
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
}
}
interface ParsedAgentOutput {
verdict: string | null
details: string | null
raw: string
}
function parseAgentOutput(text: string | null): ParsedAgentOutput {
if (!text) return { verdict: null, details: null, raw: '' }
const verdictMatch = text.match(/##\s*Verdict\s*\n([\s\S]*?)(?=##\s*Details|$)/m)
const detailsJsonMatch = text.match(/##\s*Details[\s\S]*?```json\n([\s\S]*?)```/)
const verdict = verdictMatch ? verdictMatch[1].trim() : null
let details: string | null = null
if (detailsJsonMatch) {
try {
details = JSON.stringify(JSON.parse(detailsJsonMatch[1].trim()), null, 2)
} catch {
details = detailsJsonMatch[1].trim()
}
}
return { verdict, details, raw: 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 runFollowup() {
if (!task.value) return
followupLoading.value = true
followupResults.value = []
try {
const res = await api.followupTask(props.id)
if (res.created?.length) {
followupResults.value = res.created.map(ft => ({ id: ft.id, title: ft.title }))
}
await load()
} catch (e: any) {
error.value = e.message
} finally {
followupLoading.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 {
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 = t('taskDetail.pipeline_already_running')
} 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(t('taskDetail.mark_resolved_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">{{ t('taskDetail.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">{{ t('taskDetail.requires_manual') }}</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">{{ t('taskDetail.autopilot_failed') }}</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">{{ t('taskDetail.dangerously_skipped') }}</span>
<p class="text-xs text-red-300/70 mt-0.5">{{ t('taskDetail.dangerously_skipped_hint') }}</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">{{ t('taskDetail.acceptance_criteria') }}</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">
{{ t('taskDetail.pipeline') }}
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">{{ t('taskDetail.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">
{{ t('taskDetail.no_pipeline') }}
</div>
<!-- Live Console -->
<LiveConsole
v-if="task.pipeline_id"
:pipeline-id="task.pipeline_id"
:pipeline-status="task.status"
class="mb-6"
/>
<!-- Selected step output -->
<div v-if="selectedStep && parsedSelectedOutput" 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">
<!-- New format: verdict + collapsible details -->
<template v-if="parsedSelectedOutput.verdict !== null">
<div class="p-4">
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
<details v-if="parsedSelectedOutput.details !== null" class="mt-3">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">{{ t('taskDetail.more_details') }}</summary>
<pre class="mt-2 text-xs text-gray-500 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{{ parsedSelectedOutput.details }}</pre>
</details>
</div>
</template>
<!-- Fallback: old format -->
<template v-else>
<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>
</template>
</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">{{ t('taskDetail.attachments') }}</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>
{{ t('taskDetail.autopilot_active') }}
</div>
<button v-if="task.status === 'blocked' || (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">
{{ t('taskDetail.approve_task') }}
</button>
<button v-if="task.status === 'blocked' || (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">
{{ t('taskDetail.revise_task') }}
</button>
<button v-if="task.status === 'blocked' || ((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">
{{ t('taskDetail.reject_task') }}
</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">
{{ t('taskDetail.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) ? t('taskDetail.pipeline_running') : t('taskDetail.run_pipeline') }}
</button>
<button v-if="task.status === 'blocked'"
@click="runFollowup"
:disabled="followupLoading"
class="px-4 py-2 text-sm bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900 disabled:opacity-50">
<span v-if="followupLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ followupLoading ? t('taskDetail.generating_followup') : t('taskDetail.create_followup') }}
</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 ? t('taskDetail.resolving') : t('taskDetail.resolve_manually') }}
</button>
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
@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 ? t('taskDetail.deploying') : t('taskDetail.deploy') }}
</button>
</div>
<!-- Followup results block -->
<div v-if="followupResults.length && !showApprove" class="mt-3 p-3 border border-purple-800 bg-purple-950/30 rounded">
<p class="text-sm text-purple-400 mb-2">Создано {{ followupResults.length }} follow-up задач:</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="followupResults = []"
class="mt-2 w-full py-1.5 bg-gray-800 text-gray-400 border border-gray-700 rounded text-xs hover:bg-gray-700">
{{ t('common.close') }}
</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">{{ t('taskDetail.terminal_login_hint') }}</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">{{ t('taskDetail.login_after_hint') }}</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.overall_success !== false && 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.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
</span>
<span class="text-gray-500">{{ 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">x</button>
</div>
<!-- Structured steps -->
<div v-if="deployResult.results?.length" class="space-y-1 mt-1">
<details v-for="step in deployResult.results" :key="step.step" class="border border-gray-700 rounded">
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
<span class="text-gray-300 text-[11px]">{{ step.step }}</span>
<span class="text-gray-600 text-[10px] ml-auto">exit {{ step.exit_code }}</span>
</summary>
<div class="px-2 pb-2">
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-32 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-32 overflow-y-auto text-[10px] mt-1">{{ step.stderr }}</pre>
</div>
</details>
</div>
<!-- Legacy output -->
<template v-else>
<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>
</template>
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
</div>
</div>
</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="t('taskDetail.decision_title_placeholder')"
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="t('taskDetail.description_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" 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="t('taskDetail.send_to_revision')" @close="showRevise = false">
<form @submit.prevent="revise" class="space-y-3">
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
<textarea v-model="reviseComment" :placeholder="t('taskDetail.revise_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">
{{ t('taskDetail.send_to_revision') }}
</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">{{ t('taskDetail.title_label') }}</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">{{ t('taskDetail.brief_label') }}</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">{{ t('taskDetail.priority_label') }}</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">{{ t('taskDetail.acceptance_criteria_label') }}</label>
<textarea v-model="editForm.acceptanceCriteria" rows="3"
:placeholder="t('taskDetail.acceptance_criteria_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 ? t('common.saving') : t('common.save') }}
</button>
</form>
</Modal>
</div>
</template>