kin: KIN-075 Расширить канбан-вид до ширины экрана, сейчас он ограничен центром. + добавить кнопки Тас Аудит Автокомит Авто в канбан вид

This commit is contained in:
Gros Frumos 2026-03-16 10:28:06 +02:00
parent 9764d1b414
commit 394301c7a7
4 changed files with 205 additions and 6 deletions

View file

@ -14,7 +14,7 @@ import EscalationBanner from './components/EscalationBanner.vue'
<span class="text-xs text-gray-600">multi-agent orchestrator</span> <span class="text-xs text-gray-600">multi-agent orchestrator</span>
</nav> </nav>
</header> </header>
<main class="max-w-6xl mx-auto px-6 py-6"> <main class="px-6 py-6">
<router-view /> <router-view />
</main> </main>
</div> </div>

View file

@ -0,0 +1,55 @@
/**
* KIN-075: Тест полной ширины экрана
* Проверяет что App.vue не ограничивает ширину контента нет max-w-* на <main>
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import App from '../App.vue'
vi.mock('../components/EscalationBanner.vue', () => ({
default: { template: '<div />' },
}))
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>home</div>' } },
{ path: '/settings', component: { template: '<div>settings</div>' } },
],
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('KIN-075: App.vue — полная ширина экрана', () => {
it('<main> не содержит класс max-w-* — контент не ограничен по ширине', async () => {
const router = makeRouter()
await router.push('/')
const wrapper = mount(App, { global: { plugins: [router] } })
await flushPromises()
const main = wrapper.find('main')
expect(main.exists(), '<main> должен существовать в App.vue').toBe(true)
expect(
main.classes().some(c => c.startsWith('max-w-')),
'<main> не должен иметь ограничивающий класс max-w-*',
).toBe(false)
})
it('<main> не содержит класс max-w-6xl (регрессия KIN-075)', async () => {
const router = makeRouter()
await router.push('/')
const wrapper = mount(App, { global: { plugins: [router] } })
await flushPromises()
const main = wrapper.find('main')
expect(main.classes()).not.toContain('max-w-6xl')
})
})

View file

@ -561,3 +561,88 @@ describe('KIN-UI-001: регрессии — другие вкладки не с
expect(wrapper.text()).toContain('No modules') expect(wrapper.text()).toContain('No modules')
}) })
}) })
// ─────────────────────────────────────────────────────────────
// KIN-075: кнопки управления в канбан-виде
// ─────────────────────────────────────────────────────────────
describe('KIN-075: канбан — кнопки управления', () => {
it('Кнопка переключения режима (Авто/Review) присутствует в канбан-виде', async () => {
const wrapper = await mountOnKanban()
// MOCK_PROJECT.execution_mode = 'review' → autoMode=false → title = 'Review mode: agents read-only'
const modeBtn = wrapper.find('button[title*="mode:"]')
expect(modeBtn.exists()).toBe(true)
})
it('Кнопка Автокомит присутствует в канбан-виде', async () => {
const wrapper = await mountOnKanban()
// MOCK_PROJECT.autocommit_enabled = 0 → autocommit=false → title = 'Autocommit: off'
const btn = wrapper.find('button[title*="Autocommit:"]')
expect(btn.exists()).toBe(true)
expect(btn.text()).toMatch(/Автокомит/)
})
it('Кнопка Аудит присутствует в канбан-виде', async () => {
const wrapper = await mountOnKanban()
const btn = wrapper.find('button[title="Check which pending tasks are already done"]')
expect(btn.exists()).toBe(true)
expect(btn.text()).toContain('Аудит')
})
it('Кнопка "+ Тас" присутствует в канбан-виде', async () => {
const wrapper = await mountOnKanban()
const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Тас')
expect(tasBtn?.exists()).toBe(true)
})
it('Клик на кнопку режима вызывает api.patchProject с execution_mode', async () => {
vi.mocked(api.patchProject).mockResolvedValue(undefined as any)
const wrapper = await mountOnKanban()
const modeBtn = wrapper.find('button[title*="mode:"]')
await modeBtn.trigger('click')
await flushPromises()
expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith(
'KIN',
expect.objectContaining({ execution_mode: expect.any(String) }),
)
})
it('Клик на кнопку Автокомит вызывает api.patchProject с autocommit_enabled', async () => {
vi.mocked(api.patchProject).mockResolvedValue(undefined as any)
const wrapper = await mountOnKanban()
const btn = wrapper.find('button[title*="Autocommit:"]')
await btn.trigger('click')
await flushPromises()
expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith(
'KIN',
expect.objectContaining({ autocommit_enabled: expect.anything() }),
)
})
it('Клик на кнопку Аудит вызывает api.auditProject', async () => {
vi.mocked(api.auditProject).mockResolvedValue({
success: true, already_done: [], still_pending: [], unclear: [],
} as any)
const wrapper = await mountOnKanban()
const auditBtn = wrapper.find('button[title="Check which pending tasks are already done"]')
await auditBtn.trigger('click')
await flushPromises()
expect(vi.mocked(api.auditProject)).toHaveBeenCalledWith('KIN')
})
it('Клик на "+ Тас" открывает модальное окно Add Task', async () => {
const wrapper = await mountOnKanban()
const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Тас')!
await tasBtn.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Add Task')
})
})

View file

@ -149,6 +149,7 @@ function initStatusFilter(): string[] {
const selectedStatuses = ref<string[]>(initStatusFilter()) const selectedStatuses = ref<string[]>(initStatusFilter())
const selectedCategory = ref('') const selectedCategory = ref('')
const taskSearch = ref('')
function toggleStatus(s: string) { function toggleStatus(s: string) {
const idx = selectedStatuses.value.indexOf(s) const idx = selectedStatuses.value.indexOf(s)
@ -271,6 +272,10 @@ watch(selectedStatuses, (val) => {
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } }) router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
}, { deep: true }) }, { deep: true })
watch(() => props.id, () => {
taskSearch.value = ''
})
onMounted(async () => { onMounted(async () => {
await load() await load()
loadMode() loadMode()
@ -289,9 +294,19 @@ const taskCategories = computed(() => {
return Array.from(cats).sort() return Array.from(cats).sort()
}) })
const filteredTasks = computed(() => { const searchFilteredTasks = computed(() => {
if (!project.value) return [] if (!project.value) return []
let tasks = project.value.tasks const q = taskSearch.value.trim().toLowerCase()
if (!q) return project.value.tasks
return project.value.tasks.filter(t => {
if (t.title.toLowerCase().includes(q)) return true
if (t.brief && JSON.stringify(t.brief).toLowerCase().includes(q)) return true
return false
})
})
const filteredTasks = computed(() => {
let tasks = searchFilteredTasks.value
if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status)) if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status))
if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value) if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value)
return tasks return tasks
@ -401,10 +416,9 @@ const dragOverStatus = ref<string | null>(null)
let kanbanPollTimer: ReturnType<typeof setInterval> | null = null let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
const kanbanTasksByStatus = computed(() => { const kanbanTasksByStatus = computed(() => {
if (!project.value) return {} as Record<string, Task[]>
const result: Record<string, Task[]> = {} const result: Record<string, Task[]> = {}
for (const col of KANBAN_COLUMNS) result[col.status] = [] for (const col of KANBAN_COLUMNS) result[col.status] = []
for (const t of project.value.tasks) { for (const t of searchFilteredTasks.value) {
if (result[t.status]) result[t.status].push(t) if (result[t.status]) result[t.status].push(t)
} }
return result return result
@ -604,6 +618,13 @@ async function addDecision() {
<Badge :text="cat" :color="CATEGORY_COLORS[cat] || 'gray'" /> <Badge :text="cat" :color="CATEGORY_COLORS[cat] || 'gray'" />
</button> </button>
</div> </div>
<!-- Search -->
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск по задачам..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-56 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
</div>
</div> </div>
<!-- Manual escalation tasks --> <!-- Manual escalation tasks -->
<div v-if="manualEscalationTasks.length" class="mb-4"> <div v-if="manualEscalationTasks.length" class="mb-4">
@ -824,7 +845,44 @@ async function addDecision() {
</div> </div>
<!-- Kanban Tab --> <!-- Kanban Tab -->
<div v-if="activeTab === 'kanban'" class="overflow-x-auto pb-4"> <div v-if="activeTab === 'kanban'" class="pb-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск..."
class="bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300 placeholder-gray-600 w-48 focus:border-gray-500 outline-none" />
<button v-if="taskSearch" @click="taskSearch = ''"
class="text-gray-600 hover:text-red-400 text-xs px-1"></button>
</div>
<div class="flex gap-2">
<button @click="toggleMode"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autoMode
? 'bg-yellow-900/30 text-yellow-400 border-yellow-800 hover:bg-yellow-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoMode ? 'Auto mode: agents can write files' : 'Review mode: agents read-only'">
{{ autoMode ? '&#x1F513; Авто' : '&#x1F512; Review' }}
</button>
<button @click="toggleAutocommit"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="autocommit
? 'bg-green-900/30 text-green-400 border-green-800 hover:bg-green-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autocommit ? 'Autocommit: on — git commit after pipeline' : 'Autocommit: off'">
{{ autocommit ? '&#x2713; Автокомит' : 'Автокомит' }}
</button>
<button @click="runAudit" :disabled="auditLoading"
class="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 border border-purple-800 rounded hover:bg-purple-900/50 disabled:opacity-50"
title="Check which pending tasks are already done">
<span v-if="auditLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ auditLoading ? 'Auditing...' : 'Аудит' }}
</button>
<button @click="showAddTask = true"
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Тас
</button>
</div>
</div>
<div class="overflow-x-auto">
<div class="flex gap-3" style="min-width: max-content"> <div class="flex gap-3" style="min-width: max-content">
<div v-for="col in KANBAN_COLUMNS" :key="col.status" class="w-64 flex flex-col gap-2"> <div v-for="col in KANBAN_COLUMNS" :key="col.status" class="w-64 flex flex-col gap-2">
<!-- Column header --> <!-- Column header -->
@ -860,6 +918,7 @@ async function addDecision() {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- Add Task Modal --> <!-- Add Task Modal -->