kin: KIN-108-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 07:57:15 +02:00
parent 8b409fd7db
commit 353416ead1
16 changed files with 799 additions and 212 deletions

View file

@ -1,6 +1,7 @@
<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'
@ -11,6 +12,7 @@ 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)
@ -45,15 +47,14 @@ const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
// 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
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.project_id}`) === 'auto_complete'
autoMode.value = localStorage.getItem(`kin-mode-${t_val.project_id}`) === 'auto_complete'
}
}
@ -74,11 +75,9 @@ async function load() {
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()
}
@ -241,7 +240,6 @@ async function runPipeline() {
claudeLoginError.value = false
pipelineStarting.value = true
try {
// 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 })
@ -254,7 +252,7 @@ async function runPipeline() {
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 уже запущен'
error.value = t('taskDetail.pipeline_already_running')
} else {
error.value = e.message
}
@ -271,7 +269,7 @@ const resolvingManually = ref(false)
async function resolveManually() {
if (!task.value) return
if (!confirm('Пометить задачу как решённую вручную?')) return
if (!confirm(t('taskDetail.mark_resolved_confirm'))) return
resolvingManually.value = true
try {
const updated = await api.patchTask(props.id, { status: 'done' })
@ -386,7 +384,7 @@ async function saveEdit() {
</script>
<template>
<div v-if="loading && !task" class="text-gray-500 text-sm">Loading...</div>
<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 -->
@ -422,7 +420,7 @@ async function saveEdit() {
<!-- 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 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">
@ -432,15 +430,15 @@ async function saveEdit() {
</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>
<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">--dangerously-skip-permissions использовался в этой задаче</span>
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
<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>
@ -448,7 +446,7 @@ async function saveEdit() {
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>
<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">
@ -462,8 +460,8 @@ async function saveEdit() {
<!-- 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>
{{ 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">
@ -493,7 +491,7 @@ async function saveEdit() {
<!-- No pipeline -->
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
No pipeline steps yet.
{{ t('taskDetail.no_pipeline') }}
</div>
<!-- Live Console -->
@ -516,7 +514,7 @@ async function saveEdit() {
<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">&darr; подробнее</summary>
<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>
@ -542,7 +540,7 @@ async function saveEdit() {
<!-- Attachments -->
<div class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
<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>
@ -552,22 +550,22 @@ async function saveEdit() {
<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">
&#10003; Approve
{{ 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">
&#x1F504; Revise
{{ 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">
&#10007; Reject
{{ t('taskDetail.reject_task') }}
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
@click="toggleMode"
@ -581,28 +579,28 @@ async function saveEdit() {
<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
{{ 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) ? 'Pipeline running...' : '&#9654; Run Pipeline' }}
{{ (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 ? 'Сохраняем...' : '&#10003; Решить вручную' }}
{{ 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 ? 'Deploying...' : '&#x1F680; Deploy' }}
{{ deploying ? t('taskDetail.deploying') : t('taskDetail.deploy') }}
</button>
</div>
@ -611,9 +609,9 @@ async function saveEdit() {
<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>
<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">После входа повторите запуск pipeline.</p>
<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>
@ -624,7 +622,7 @@ async function saveEdit() {
: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 ? 'Deploy succeeded' : 'Deploy failed' }}
{{ 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>
@ -650,7 +648,7 @@ async function saveEdit() {
</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">Зависимые проекты:</p>
<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>
@ -704,9 +702,9 @@ async function saveEdit() {
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)"
<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="Description"
<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">
@ -728,14 +726,14 @@ async function saveEdit() {
</Modal>
<!-- Revise Modal -->
<Modal v-if="showRevise" title="&#x1F504; Revise Task" @close="showRevise = false">
<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="Что доработать / уточнить..." rows="4" required
<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">
&#x1F504; Отправить на доработку
{{ t('taskDetail.send_to_revision') }}
</button>
</form>
</Modal>
@ -744,30 +742,30 @@ async function saveEdit() {
<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>
<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">Brief</label>
<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">Priority (110)</label>
<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">Критерии приёмки</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.acceptance_criteria_label') }}</label>
<textarea v-model="editForm.acceptanceCriteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
: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 ? 'Saving...' : 'Save' }}
{{ editLoading ? t('common.saving') : t('common.save') }}
</button>
</form>
</Modal>