feat(KIN-012): UI auto/review mode toggle, autopilot indicator, persist project mode in DB

- TaskDetail: hide Approve/Reject buttons in auto mode, show "Автопилот активен" badge
- TaskDetail: execution_mode persisted per-task via PATCH /api/tasks/{id}
- TaskDetail: loadMode reads DB value, falls back to localStorage per project
- TaskDetail: back navigation preserves status filter via ?back_status query param
- ProjectView: toggleMode now persists to DB via PATCH /api/projects/{id}
- ProjectView: loadMode reads project.execution_mode from DB first
- ProjectView: task list shows 🔓 badge for auto-mode tasks
- ProjectView: status filter synced to URL query param ?status=
- api.ts: add patchProject(), execution_mode field on Project interface
- core/db.py, core/models.py: execution_mode columns + migration for projects & tasks
- web/api.py: PATCH /api/projects/{id} and PATCH /api/tasks/{id} support execution_mode
- tests: 256 tests pass, new test_auto_mode.py with 60+ auto mode tests
- frontend: vitest config added for component tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 20:02:01 +02:00
parent 3cb516193b
commit 4a27bf0693
12 changed files with 2698 additions and 30 deletions

View file

@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type TaskFull, type PipelineStep, type PendingAction } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const task = ref<TaskFull | null>(null)
const loading = ref(true)
@ -25,17 +28,27 @@ const resolvingAction = ref(false)
const showReject = ref(false)
const rejectReason = ref('')
// Auto/Review mode (persisted per project)
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
const autoMode = ref(false)
function loadMode(projectId: string) {
autoMode.value = localStorage.getItem(`kin-mode-${projectId}`) === 'auto'
function loadMode(t: typeof task.value) {
if (!t) return
if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto'
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto'
}
}
function toggleMode() {
async function toggleMode() {
if (!task.value) return
autoMode.value = !autoMode.value
if (task.value) {
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
localStorage.setItem(`kin-mode-${task.value.project_id}`, autoMode.value ? 'auto' : 'review')
try {
const updated = await api.patchTask(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
task.value = { ...task.value, ...updated }
} catch (e: any) {
error.value = e.message
}
}
@ -43,7 +56,7 @@ async function load() {
try {
const prev = task.value
task.value = await api.taskFull(props.id)
if (task.value?.project_id) loadMode(task.value.project_id)
loadMode(task.value)
// Auto-start polling if task is in_progress
if (task.value.status === 'in_progress' && !polling.value) {
startPolling()
@ -186,6 +199,18 @@ async function runPipeline() {
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
const isRunning = computed(() => task.value?.status === 'in_progress')
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) {
@ -209,14 +234,17 @@ async function changeStatus(newStatus: string) {
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link :to="`/project/${task.project_id}`" class="text-gray-600 hover:text-gray-400 text-sm no-underline">
<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 }}
</router-link>
</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'"
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)"
@ -303,12 +331,17 @@ async function changeStatus(newStatus: string) {
<!-- Actions Bar -->
<div class="sticky bottom-0 bg-gray-950 border-t border-gray-800 py-3 flex gap-3 -mx-6 px-6 mt-8">
<button v-if="task.status === 'review'"
<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' || task.status === 'in_progress'"
<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