773 lines
34 KiB
Vue
773 lines
34 KiB
Vue
<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)
|
||
|
||
// 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',
|
||
}
|
||
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 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">
|
||
← {{ 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">🔓 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">⚠</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">→</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 === '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 === '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 === '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 ? '🔓 Auto' : '🔒 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="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>
|
||
|
||
<!-- 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">⚠ 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 & 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>
|
||
|
||
<!-- 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>
|