Compare commits

..

No commits in common. "d7f7193ad7feb7aba34df9861db0d312e8696b5b" and "477fc68cd3002bffb30cdc627fcd300f9611c26c" have entirely different histories.

7 changed files with 63 additions and 455 deletions

View file

@ -10,7 +10,6 @@ All functions are defensive: never raise, always log warnings on error.
import logging import logging
import shutil import shutil
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
_logger = logging.getLogger("kin.worktree") _logger = logging.getLogger("kin.worktree")
@ -60,15 +59,12 @@ def create_worktree(project_path: str, task_id: str, step_name: str = "step") ->
return None return None
def merge_worktree(worktree_path: str, project_path: str, max_retries: int = 0, retry_delay_s: int = 15) -> dict: def merge_worktree(worktree_path: str, project_path: str) -> dict:
"""Merge the worktree branch back into current HEAD of project_path. """Merge the worktree branch back into current HEAD of project_path.
Branch name is derived from the worktree directory name. Branch name is derived from the worktree directory name.
On conflict: aborts merge and returns success=False with conflict list. On conflict: aborts merge and returns success=False with conflict list.
max_retries: number of retry attempts after the first failure (default 0 = no retry).
retry_delay_s: seconds to wait between retry attempts.
Returns {success: bool, conflicts: list[str], merged_files: list[str]} Returns {success: bool, conflicts: list[str], merged_files: list[str]}
""" """
git = _git(project_path) git = _git(project_path)
@ -92,7 +88,6 @@ def merge_worktree(worktree_path: str, project_path: str, max_retries: int = 0,
) )
commit_had_changes = commit_result.returncode == 0 commit_had_changes = commit_result.returncode == 0
for attempt in range(max_retries + 1):
merge_result = subprocess.run( merge_result = subprocess.run(
[git, "merge", "--no-ff", branch_name], [git, "merge", "--no-ff", branch_name],
cwd=project_path, cwd=project_path,
@ -135,15 +130,6 @@ def merge_worktree(worktree_path: str, project_path: str, max_retries: int = 0,
capture_output=True, capture_output=True,
timeout=10, timeout=10,
) )
if attempt < max_retries:
_logger.warning(
"KIN-139: merge conflict in %s (attempt %d/%d), retrying in %ds",
branch_name, attempt + 1, max_retries + 1, retry_delay_s,
)
time.sleep(retry_delay_s)
continue
_logger.warning("Merge conflict in worktree %s: %s", branch_name, conflicts) _logger.warning("Merge conflict in worktree %s: %s", branch_name, conflicts)
return {"success": False, "conflicts": conflicts, "merged_files": []} return {"success": False, "conflicts": conflicts, "merged_files": []}

View file

@ -1,255 +0,0 @@
"""KIN-145 — Regression tests for merge_worktree retry/sleep blocking fix.
Root cause: agents/runner.py was calling merge_worktree(worktree_path, p_path,
max_retries=3, retry_delay_s=15). Git conflicts are deterministic and cannot
be resolved by retrying. Each retry causes time.sleep(15) 3 retries = 45s
blocking sleep. The pipeline thread blocks, the parent web-server times out,
_check_parent_alive catches ESRCH all active pipelines marked as failed
mass failure across all projects.
Fix: Removed max_retries=3, retry_delay_s=15 kwargs from runner.py:2076.
merge_worktree now uses default max_retries=0 conflict returns immediately.
Tests verify:
A. merge_worktree with default max_retries=0 does NOT call time.sleep on conflict
B. merge_worktree with default args returns {success: False} immediately on conflict
C. merge_worktree with explicit max_retries=N DOES sleep N times (feature intact)
D. Old kwargs (max_retries=3, retry_delay_s=15) would produce 45s total blocking
E. runner.py source code does NOT pass max_retries or retry_delay_s (static guard)
"""
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _ok_run():
m = MagicMock()
m.returncode = 0
m.stdout = ""
m.stderr = ""
return m
def _fail_run(stderr="CONFLICT (content): merge conflict in src/app.py"):
m = MagicMock()
m.returncode = 1
m.stdout = ""
m.stderr = stderr
return m
def _conflict_diff():
m = MagicMock()
m.returncode = 0
m.stdout = "src/conflict.py\n"
m.stderr = ""
return m
def _side_effects_for_n_conflict_attempts(n_attempts: int) -> list:
"""Build subprocess.run side_effects for n conflict attempts.
Structure: git-add, git-commit, then per-attempt: merge-fail + diff + abort.
"""
effects = [_ok_run(), _ok_run()] # git add -A, git commit
for _ in range(n_attempts):
effects.extend([_fail_run(), _conflict_diff(), _ok_run()]) # merge-fail, diff, abort
return effects
# ─────────────────────────────────────────────────────────────────────────────
# A & B. Default max_retries=0: no sleep, immediate failure return
# ─────────────────────────────────────────────────────────────────────────────
class TestMergeWorktreeNoRetryDefault:
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_merge_worktree_default_args_does_not_sleep_on_conflict(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145 fix A: merge_worktree с аргументами по умолчанию НЕ вызывает sleep при конфликте."""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-dev"
worktree_path.mkdir(parents=True)
mock_run.side_effect = _side_effects_for_n_conflict_attempts(1)
merge_worktree(str(worktree_path), str(tmp_path))
mock_sleep.assert_not_called()
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_merge_worktree_default_args_returns_failure_immediately_on_conflict(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145 fix B: merge_worktree без retry немедленно возвращает success=False при конфликте."""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-dev"
worktree_path.mkdir(parents=True)
mock_run.side_effect = _side_effects_for_n_conflict_attempts(1)
result = merge_worktree(str(worktree_path), str(tmp_path))
assert result["success"] is False
assert "src/conflict.py" in result["conflicts"]
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_merge_worktree_explicit_max_retries_0_no_sleep(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145: explicit max_retries=0 тоже не вызывает sleep при конфликте."""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-b-dev"
worktree_path.mkdir(parents=True)
mock_run.side_effect = _side_effects_for_n_conflict_attempts(1)
merge_worktree(str(worktree_path), str(tmp_path), max_retries=0)
mock_sleep.assert_not_called()
# ─────────────────────────────────────────────────────────────────────────────
# C. Retry feature still works when explicitly requested
# ─────────────────────────────────────────────────────────────────────────────
class TestMergeWorktreeRetryBehaviorIntact:
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_merge_worktree_max_retries_2_sleeps_twice(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145 fix C: retry-функциональность не сломана — max_retries=2 вызывает sleep 2 раза."""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-retry-dev"
worktree_path.mkdir(parents=True)
# max_retries=2 → 3 total attempts (attempt 0,1,2)
# sleep happens after attempt 0 and 1 (not after final attempt 2)
mock_run.side_effect = _side_effects_for_n_conflict_attempts(3)
merge_worktree(str(worktree_path), str(tmp_path), max_retries=2, retry_delay_s=5)
assert mock_sleep.call_count == 2
for c in mock_sleep.call_args_list:
assert c.args[0] == 5
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_merge_worktree_retry_with_eventual_success_no_failure(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145: retry завершается успехом если второй attempt проходит."""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-retry-ok"
worktree_path.mkdir(parents=True)
diff_ok = MagicMock()
diff_ok.returncode = 0
diff_ok.stdout = "src/fixed.py\n"
diff_ok.stderr = ""
mock_run.side_effect = [
_ok_run(), # git add -A
_ok_run(), # git commit
_fail_run(), # merge attempt 0: fail
_conflict_diff(), # git diff --diff-filter=U
_ok_run(), # git merge --abort
_ok_run(), # merge attempt 1: success
diff_ok, # git diff HEAD~1 HEAD --name-only
]
result = merge_worktree(str(worktree_path), str(tmp_path), max_retries=1, retry_delay_s=1)
assert result["success"] is True
assert mock_sleep.call_count == 1
# ─────────────────────────────────────────────────────────────────────────────
# D. Old buggy kwargs would have caused 45s total blocking sleep
# ─────────────────────────────────────────────────────────────────────────────
class TestOldBuggyBehaviorDocumented:
@patch("core.worktree.time.sleep")
@patch("subprocess.run")
def test_old_kwargs_max_retries_3_delay_15_sleeps_45s_total(
self, mock_run, mock_sleep, tmp_path
):
"""KIN-145 doc D: старые kwargs max_retries=3, retry_delay_s=15 = 45s суммарного blocking.
Этот тест документирует удалённое поведение.
Если этот тест когда-либо упадёт (sleep_count != 3), значит поведение изменилось.
"""
from core.worktree import merge_worktree
worktree_path = tmp_path / ".kin_worktrees" / "T-145-old-dev"
worktree_path.mkdir(parents=True)
# max_retries=3 → 4 total attempts → 3 sleeps
mock_run.side_effect = _side_effects_for_n_conflict_attempts(4)
merge_worktree(str(worktree_path), str(tmp_path), max_retries=3, retry_delay_s=15)
assert mock_sleep.call_count == 3
total_sleep_s = sum(c.args[0] for c in mock_sleep.call_args_list)
assert total_sleep_s == 45
# ─────────────────────────────────────────────────────────────────────────────
# E. Static guard: runner.py must not pass retry kwargs to merge_worktree
# ─────────────────────────────────────────────────────────────────────────────
class TestRunnerCallSiteStaticGuard:
def test_runner_merge_worktree_call_has_no_max_retries_kwarg(self):
"""KIN-145 guard E: runner.py НЕ передаёт max_retries в merge_worktree."""
runner_path = Path(__file__).parent.parent / "agents" / "runner.py"
source = runner_path.read_text()
calls = re.findall(r"merge_worktree\([^)]*\)", source, re.DOTALL)
assert calls, "merge_worktree не найден в agents/runner.py — проверь путь"
for call_src in calls:
assert "max_retries" not in call_src, (
f"runner.py вызывает merge_worktree с max_retries: {call_src!r}\n"
"Это баг KIN-145 — retry kwargs вызывают 45s blocking sleep!"
)
def test_runner_merge_worktree_call_has_no_retry_delay_kwarg(self):
"""KIN-145 guard E: runner.py НЕ передаёт retry_delay_s в merge_worktree."""
runner_path = Path(__file__).parent.parent / "agents" / "runner.py"
source = runner_path.read_text()
calls = re.findall(r"merge_worktree\([^)]*\)", source, re.DOTALL)
assert calls, "merge_worktree не найден в agents/runner.py — проверь путь"
for call_src in calls:
assert "retry_delay_s" not in call_src, (
f"runner.py вызывает merge_worktree с retry_delay_s: {call_src!r}\n"
"Это баг KIN-145 — retry kwargs вызывают 45s blocking sleep!"
)
def test_runner_merge_worktree_call_uses_exactly_two_positional_args(self):
"""KIN-145 guard E: вызов merge_worktree в runner.py имеет ровно 2 аргумента."""
runner_path = Path(__file__).parent.parent / "agents" / "runner.py"
source = runner_path.read_text()
calls = re.findall(r"merge_worktree\(([^)]*)\)", source, re.DOTALL)
assert calls, "merge_worktree не найден в agents/runner.py"
for call_args in calls:
args = [a.strip() for a in call_args.split(",") if a.strip()]
assert len(args) == 2, (
f"runner.py вызывает merge_worktree с {len(args)} аргументами: {call_args!r}\n"
f"Ожидается ровно 2 (worktree_path, p_path). "
f"Лишние kwargs — источник бага KIN-145!"
)

View file

@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api, ApiError, type EscalationNotification, type Task } from '../api' import { api, type EscalationNotification, type Task } from '../api'
const { t, locale } = useI18n() const { t, locale } = useI18n()
const router = useRouter() const router = useRouter()
@ -186,28 +186,6 @@ function cancelRevise() {
reviseComment.value = '' reviseComment.value = ''
} }
const restartingTaskId = ref<string | null>(null)
const restartError = ref<string | null>(null)
async function restartTask(taskId: string) {
if (!confirm(t('escalation.restart_confirm', { task_id: taskId }))) return
restartingTaskId.value = taskId
restartError.value = null
try {
await api.runTask(taskId)
dismiss(taskId)
} catch (e: any) {
if (e instanceof ApiError && e.code === 'task_already_running') {
restartError.value = t('escalation.restart_already_running')
} else {
restartError.value = e.message
}
setTimeout(() => { restartError.value = null }, 4000)
} finally {
restartingTaskId.value = null
}
}
onMounted(async () => { onMounted(async () => {
await load() await load()
pollTimer = setInterval(load, 10000) pollTimer = setInterval(load, 10000)
@ -304,29 +282,14 @@ onUnmounted(() => {
<p class="text-xs text-gray-300 leading-snug break-words">{{ n.reason }}</p> <p class="text-xs text-gray-300 leading-snug break-words">{{ n.reason }}</p>
<p class="text-xs text-gray-600 mt-1">{{ formatTime(n.blocked_at) }}</p> <p class="text-xs text-gray-600 mt-1">{{ formatTime(n.blocked_at) }}</p>
</div> </div>
<div class="flex flex-col gap-1 shrink-0">
<button
@click="restartTask(n.task_id)"
:disabled="restartingTaskId === n.task_id"
class="px-2 py-1 text-xs bg-blue-900/40 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 hover:text-blue-200 disabled:opacity-50 transition-colors"
>
<span v-if="restartingTaskId === n.task_id" class="inline-block w-2 h-2 border border-blue-400 border-t-transparent rounded-full animate-spin"></span>
<span v-else>{{ t('escalation.restart') }}</span>
</button>
<button <button
@click="dismiss(n.task_id)" @click="dismiss(n.task_id)"
class="px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200 transition-colors" class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
>{{ t('escalation.dismiss') }}</button> >{{ t('escalation.dismiss') }}</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Ошибка перезапуска -->
<div v-if="restartError" class="px-4 py-2 text-xs text-red-400 border-t border-gray-800 bg-red-950/20">
{{ restartError }}
</div>
<!-- Разделитель между секциями --> <!-- Разделитель между секциями -->
<div v-if="visible.length > 0 && visibleCompleted.length > 0" class="border-t border-gray-700"></div> <div v-if="visible.length > 0 && visibleCompleted.length > 0" class="border-t border-gray-700"></div>

View file

@ -243,10 +243,7 @@
"revise_comment_placeholder": "Revision comment...", "revise_comment_placeholder": "Revision comment...",
"revise_send": "Send", "revise_send": "Send",
"revise_cancel": "Cancel", "revise_cancel": "Cancel",
"revise_default_comment": "Sent for revision", "revise_default_comment": "Sent for revision"
"restart": "▶ Restart",
"restart_confirm": "Restart pipeline for task {task_id}?",
"restart_already_running": "Pipeline is already running"
}, },
"liveConsole": { "liveConsole": {
"hide_log": "▲ Скрыть лог", "hide_log": "▲ Скрыть лог",

View file

@ -243,10 +243,7 @@
"revise_comment_placeholder": "Комментарий к доработке...", "revise_comment_placeholder": "Комментарий к доработке...",
"revise_send": "Отправить", "revise_send": "Отправить",
"revise_cancel": "Отмена", "revise_cancel": "Отмена",
"revise_default_comment": "Отправлено на доработку", "revise_default_comment": "Отправлено на доработку"
"restart": "▶ Перезапустить",
"restart_confirm": "Перезапустить pipeline для задачи {task_id}?",
"restart_already_running": "Pipeline уже запущен"
}, },
"liveConsole": { "liveConsole": {
"hide_log": "▲ Скрыть лог", "hide_log": "▲ Скрыть лог",

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { api, type Project, type CostEntry } from '../api' import { api, type Project, type CostEntry } from '../api'
import Badge from '../components/Badge.vue' import Badge from '../components/Badge.vue'
@ -22,33 +22,6 @@ const filteredProjects = computed(() => {
) )
}) })
const SORT_OPTIONS = ['name', 'priority', 'active', 'cost'] as const
type SortOption = typeof SORT_OPTIONS[number]
const selectedSort = ref<SortOption>('name')
const showSortMenu = ref(false)
const sortedProjects = computed(() => {
const list = [...filteredProjects.value]
switch (selectedSort.value) {
case 'priority': return list.sort((a, b) => b.priority - a.priority)
case 'active': return list.sort((a, b) => b.active_tasks - a.active_tasks)
case 'cost': return list.sort((a, b) => (costMap.value[b.id] || 0) - (costMap.value[a.id] || 0))
default: return list.sort((a, b) => a.name.localeCompare(b.name))
}
})
const GROUP_KEYS = ['development', 'operations', 'research'] as const
const groupedProjects = computed(() => {
const groups: Record<string, Project[]> = { development: [], operations: [], research: [] }
for (const p of sortedProjects.value) {
const ptype = p.project_type || 'development'
const key = ptype in groups ? ptype : 'development'
groups[key].push(p)
}
return groups
})
// Add project modal // Add project modal
const showAdd = ref(false) const showAdd = ref(false)
const form = ref({ const form = ref({
@ -88,19 +61,14 @@ async function load() {
error.value = e.message error.value = e.message
} finally { } finally {
loading.value = false loading.value = false
checkAndPoll()
} }
} }
let dashPollTimer: ReturnType<typeof setInterval> | null = null let dashPollTimer: ReturnType<typeof setInterval> | null = null
onMounted(load) onMounted(async () => {
await load()
onUnmounted(() => { checkAndPoll()
if (dashPollTimer) {
clearInterval(dashPollTimer)
dashPollTimer = null
}
}) })
function checkAndPoll() { function checkAndPoll() {
@ -259,36 +227,17 @@ async function createNewProject() {
</div> </div>
</div> </div>
<div v-if="!loading && !error" class="mb-3 flex gap-2"> <div v-if="!loading && !error" class="mb-3">
<input v-model="projectSearch" <input v-model="projectSearch"
:placeholder="t('dashboard.search_placeholder')" :placeholder="t('dashboard.search_placeholder')"
class="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 placeholder-gray-600 focus:border-gray-500 outline-none" /> class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-300 placeholder-gray-600 focus:border-gray-500 outline-none" />
<div class="relative">
<div v-if="showSortMenu" class="fixed inset-0 z-[5]" @click="showSortMenu = false"></div>
<button @click="showSortMenu = !showSortMenu"
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 flex items-center gap-1 whitespace-nowrap">
{{ selectedSort }}
</button>
<div v-if="showSortMenu" class="absolute right-0 top-full mt-1 bg-gray-900 border border-gray-700 rounded shadow-lg z-10 min-w-[100px]">
<button v-for="opt in SORT_OPTIONS" :key="opt"
@click="selectedSort = opt; showSortMenu = false"
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-800"
:class="selectedSort === opt ? 'text-blue-400' : 'text-gray-400'">
{{ opt }}
</button>
</div>
</div>
</div> </div>
<p v-if="loading" class="text-gray-500 text-sm">{{ t('dashboard.loading') }}</p> <p v-if="loading" class="text-gray-500 text-sm">{{ t('dashboard.loading') }}</p>
<p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p> <p v-else-if="error" class="text-red-400 text-sm">{{ error }}</p>
<div v-else class="space-y-4"> <div v-else class="grid gap-3">
<template v-for="groupKey in GROUP_KEYS" :key="groupKey"> <div v-for="p in filteredProjects" :key="p.id">
<div v-if="groupedProjects[groupKey].length > 0">
<p class="text-xs text-gray-600 mb-2 text-center tracking-wider"> {{ groupKey }} ({{ groupedProjects[groupKey].length }}) </p>
<div class="grid gap-3">
<div v-for="p in groupedProjects[groupKey]" :key="p.id">
<!-- Inline delete confirmation --> <!-- Inline delete confirmation -->
<div v-if="confirmDeleteId === p.id" <div v-if="confirmDeleteId === p.id"
class="border border-red-800 rounded-lg p-4 bg-red-950/20"> class="border border-red-800 rounded-lg p-4 bg-red-950/20">
@ -356,9 +305,6 @@ async function createNewProject() {
</router-link> </router-link>
</div> </div>
</div> </div>
</div>
</template>
</div>
<!-- Add Project Modal --> <!-- Add Project Modal -->
<Modal v-if="showAdd" :title="t('dashboard.add_project_title')" @close="showAdd = false"> <Modal v-if="showAdd" :title="t('dashboard.add_project_title')" @close="showAdd = false">

View file

@ -175,8 +175,8 @@ function phaseStatusColor(s: string) {
} }
// Tab groups // Tab groups
const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions', 'modules', 'links'] as const const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions'] as const
const MORE_TABS = ['environments', 'settings'] as const const MORE_TABS = ['modules', 'environments', 'links', 'settings'] as const
function tabLabel(tab: string): string { function tabLabel(tab: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
@ -308,6 +308,8 @@ async function toggleWorktrees() {
} }
} }
const anyModeActive = computed(() => autoMode.value || autocommit.value || autoTest.value || worktrees.value)
// Settings form // Settings form
const settingsForm = ref({ const settingsForm = ref({
execution_mode: 'review', execution_mode: 'review',
@ -755,25 +757,6 @@ function taskDepth(task: Task): number {
const expandedTasks = ref(new Set<string>()) const expandedTasks = ref(new Set<string>())
// Computed: IDs of parent tasks with at least one non-done child
const autoExpandIds = computed(() => {
const ids = new Set<string>()
for (const [parentId, children] of childrenMap.value.entries()) {
if (children.some(c => c.status !== 'done')) {
ids.add(parentId)
}
}
return ids
})
// Auto-expand on first load; respect manual toggles after that
let _autoExpandInitialized = false
watch(autoExpandIds, (ids) => {
if (_autoExpandInitialized || ids.size === 0) return
expandedTasks.value = new Set(ids)
_autoExpandInitialized = true
}, { immediate: true })
function toggleExpand(taskId: string) { function toggleExpand(taskId: string) {
const next = new Set(expandedTasks.value) const next = new Set(expandedTasks.value)
if (next.has(taskId)) next.delete(taskId) if (next.has(taskId)) next.delete(taskId)
@ -1193,30 +1176,21 @@ async function addDecision() {
class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button> class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded"></button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<!-- Mode toggle (always visible) --> <!-- Mode dropdown -->
<button
:data-mode="autoMode ? 'auto' : 'review'"
@click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20 hover:bg-yellow-900/50' : 'text-gray-400 border-gray-700 bg-gray-800/50 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '🔓 Auto' : '🔒 Review' }}
</button>
<!-- Aux settings dropdown (Autocommit, AutoTest, Worktrees) -->
<div class="relative"> <div class="relative">
<div v-if="showModeMenu" class="fixed inset-0 z-[5]" @click="showModeMenu = false"></div> <div v-if="showModeMenu" class="fixed inset-0 z-10" @click="showModeMenu = false"></div>
<button <button
data-testid="mode-menu-trigger" data-testid="mode-menu-trigger"
:data-mode="autoMode ? 'auto' : 'review'" :data-mode="autoMode ? 'auto' : 'review'"
@click="showModeMenu = !showModeMenu" @click="showModeMenu = !showModeMenu"
class="px-2 py-1 text-xs border rounded relative z-10 transition-colors" class="px-2 py-1 text-xs border rounded relative z-20"
:class="(autocommit || autoTest || worktrees) ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'"> :class="anyModeActive ? 'text-yellow-400 border-yellow-800 bg-yellow-900/20' : 'text-gray-400 border-gray-700 bg-gray-800/50'">
Mode
</button> </button>
<div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-10 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1"> <div v-if="showModeMenu" class="absolute right-0 top-full mt-1 z-20 w-52 bg-gray-900 border border-gray-700 rounded shadow-lg py-1">
<button <button
data-testid="mode-toggle-auto" data-testid="mode-toggle-auto"
@click="toggleMode(); showModeMenu = false" @click="toggleMode"
class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800" class="w-full text-left px-3 py-1.5 text-xs flex items-center justify-between hover:bg-gray-800"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'"> :title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
<span>{{ autoMode ? '🔓 Auto' : '🔒 Review' }}</span> <span>{{ autoMode ? '🔓 Auto' : '🔒 Review' }}</span>