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:
parent
3cb516193b
commit
4a27bf0693
12 changed files with 2698 additions and 30 deletions
|
|
@ -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">🔓</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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
← {{ 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">🔓 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">
|
||||
✓ 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">
|
||||
✗ Reject
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue