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, computed } from 'vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type ProjectDetail, type AuditResult } 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 project = ref<ProjectDetail | null>(null)
const loading = ref(true)
@ -12,7 +15,7 @@ const error = ref('')
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
// Filters
const taskStatusFilter = ref('')
const taskStatusFilter = ref((route.query.status as string) || '')
const decisionTypeFilter = ref('')
const decisionSearch = ref('')
@ -20,12 +23,22 @@ const decisionSearch = ref('')
const autoMode = ref(false)
function loadMode() {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
if (project.value?.execution_mode) {
autoMode.value = project.value.execution_mode === 'auto'
} else {
autoMode.value = localStorage.getItem(`kin-mode-${props.id}`) === 'auto'
}
}
function toggleMode() {
async function toggleMode() {
autoMode.value = !autoMode.value
localStorage.setItem(`kin-mode-${props.id}`, autoMode.value ? 'auto' : 'review')
try {
await api.patchProject(props.id, { execution_mode: autoMode.value ? 'auto' : 'review' })
if (project.value) project.value = { ...project.value, execution_mode: autoMode.value ? 'auto' : 'review' }
} catch (e: any) {
error.value = e.message
}
}
// Audit
@ -85,6 +98,10 @@ async function load() {
}
}
watch(taskStatusFilter, (val) => {
router.replace({ query: { ...route.query, status: val || undefined } })
})
onMounted(() => { load(); loadMode() })
const filteredTasks = computed(() => {
@ -267,12 +284,15 @@ async function addDecision() {
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="`/task/${t.id}`"
:to="{ path: `/task/${t.id}`, query: taskStatusFilter ? { back_status: taskStatusFilter } : undefined }"
class="flex items-center justify-between px-3 py-2 border border-gray-800 rounded text-sm hover:border-gray-600 no-underline block transition-colors">
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<span class="text-gray-300 truncate">{{ t.title }}</span>
<span v-if="t.execution_mode === 'auto'"
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
title="Auto mode">&#x1F513;</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">from {{ t.parent_task_id }}</span>
</div>
<div class="flex items-center gap-2 text-xs text-gray-600 shrink-0">

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