Compare commits
5 commits
d5125793d0
...
993a8447d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993a8447d2 | ||
|
|
feb7ea71b1 | ||
|
|
e143a5a0da | ||
|
|
2e6755eee3 | ||
|
|
cacd8ef1d7 |
6 changed files with 386 additions and 5 deletions
297
web/frontend/src/__tests__/kin-ui-025-fixes.test.ts
Normal file
297
web/frontend/src/__tests__/kin-ui-025-fixes.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
/**
|
||||
* KIN-UI-025: Тесты трёх исправлений ревьюера
|
||||
*
|
||||
* Fix 1: Click-outside overlay — dropdown '+ New ▾' закрывается при клике вне меню
|
||||
* Fix 2: i18n — ключ dashboard.search_placeholder присутствует в обоих локалях без second arg
|
||||
* Fix 3: Stats-bar — статусы revising/cancelled/decomposed отображаются в счётчиках
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import enJson from '../locales/en.json'
|
||||
import ruJson from '../locales/ru.json'
|
||||
import ProjectView from '../views/ProjectView.vue'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
// ============================================================================
|
||||
// Fix 2: i18n — dashboard.search_placeholder
|
||||
// ============================================================================
|
||||
|
||||
describe('KIN-UI-025 Fix 2: dashboard.search_placeholder в en.json', () => {
|
||||
it('ключ dashboard.search_placeholder присутствует в en.json', () => {
|
||||
expect((enJson.dashboard as Record<string, unknown>).search_placeholder).toBeDefined()
|
||||
})
|
||||
|
||||
it('значение dashboard.search_placeholder в en.json корректно', () => {
|
||||
expect((enJson.dashboard as Record<string, unknown>).search_placeholder).toBe('Search projects...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KIN-UI-025 Fix 2: dashboard.search_placeholder в ru.json', () => {
|
||||
it('ключ dashboard.search_placeholder присутствует в ru.json', () => {
|
||||
expect((ruJson.dashboard as Record<string, unknown>).search_placeholder).toBeDefined()
|
||||
})
|
||||
|
||||
it('значение dashboard.search_placeholder в ru.json корректно', () => {
|
||||
expect((ruJson.dashboard as Record<string, unknown>).search_placeholder).toBe('Поиск проектов...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('KIN-UI-025 Fix 2: Dashboard.vue использует t() без второго аргумента', () => {
|
||||
it('вызов t("dashboard.search_placeholder") без второго аргумента (нет plural-хака)', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/Dashboard.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// Должен быть вызов без второго аргумента
|
||||
expect(source).toContain("t('dashboard.search_placeholder')")
|
||||
// Не должно быть второго аргумента типа строки (старый plural-хак)
|
||||
expect(source).not.toContain("t('dashboard.search_placeholder', '")
|
||||
expect(source).not.toContain('t("dashboard.search_placeholder", "')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Fix 1: Click-outside overlay в Dashboard.vue
|
||||
// ============================================================================
|
||||
|
||||
describe('KIN-UI-025 Fix 1: Click-outside overlay в Dashboard.vue', () => {
|
||||
it('шаблон содержит overlay div с fixed inset-0 и z-[5]', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/Dashboard.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
expect(source).toContain('fixed inset-0 z-[5]')
|
||||
})
|
||||
|
||||
it('overlay показывается только когда showNewMenu === true (v-if)', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/Dashboard.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// Overlay div должен быть внутри v-if="showNewMenu"
|
||||
const overlayLineMatch = source.match(/v-if="showNewMenu"[^>]*fixed inset-0/)
|
||||
expect(overlayLineMatch).not.toBeNull()
|
||||
})
|
||||
|
||||
it('overlay закрывает меню при клике: @click="showNewMenu = false"', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/Dashboard.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// Overlay должен иметь обработчик закрытия
|
||||
const lines = source.split('\n')
|
||||
const overlayLine = lines.find(l => l.includes('fixed inset-0 z-[5]'))
|
||||
expect(overlayLine).toBeDefined()
|
||||
expect(overlayLine).toContain('showNewMenu = false')
|
||||
})
|
||||
|
||||
it('dropdown имеет z-10 — выше overlay z-[5]', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/Dashboard.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// Меню должно иметь z-10
|
||||
expect(source).toContain('z-10')
|
||||
// Overlay должен иметь z-[5] (ниже меню)
|
||||
expect(source).toContain('z-[5]')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Fix 3: Stats-bar — revising/cancelled/decomposed в ProjectView
|
||||
// ============================================================================
|
||||
|
||||
vi.mock('../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api')>()
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
project: vi.fn(),
|
||||
projects: vi.fn(),
|
||||
getPhases: vi.fn(),
|
||||
environments: vi.fn(),
|
||||
projectLinks: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
syncObsidian: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { api } from '../api'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
|
||||
function makeTask(id: string, status: string) {
|
||||
return {
|
||||
id,
|
||||
project_id: 'proj-stats',
|
||||
title: `Task ${id}`,
|
||||
status,
|
||||
priority: 5,
|
||||
assigned_role: null,
|
||||
parent_task_id: null,
|
||||
brief: null,
|
||||
spec: null,
|
||||
execution_mode: null,
|
||||
blocked_reason: null,
|
||||
dangerously_skipped: null,
|
||||
category: null,
|
||||
acceptance_criteria: null,
|
||||
feedback: null,
|
||||
created_at: '2024-01-01T00:00:00',
|
||||
updated_at: '2024-01-01T00:00:00',
|
||||
completed_at: null,
|
||||
}
|
||||
}
|
||||
|
||||
const STATS_PROJECT = {
|
||||
id: 'proj-stats',
|
||||
name: 'Stats Test Project',
|
||||
path: '/projects/stats',
|
||||
status: 'active',
|
||||
priority: 5,
|
||||
tech_stack: ['python'],
|
||||
execution_mode: 'review',
|
||||
autocommit_enabled: 0,
|
||||
auto_test_enabled: 0,
|
||||
worktrees_enabled: 0,
|
||||
obsidian_vault_path: '',
|
||||
deploy_command: '',
|
||||
test_command: '',
|
||||
deploy_host: '',
|
||||
deploy_path: '',
|
||||
deploy_runtime: '',
|
||||
deploy_restart_cmd: '',
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: 6,
|
||||
done_tasks: 1,
|
||||
active_tasks: 1,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
project_type: 'development',
|
||||
ssh_host: '',
|
||||
ssh_user: '',
|
||||
ssh_key_path: '',
|
||||
ssh_proxy_jump: '',
|
||||
description: null,
|
||||
tasks: [
|
||||
makeTask('T1', 'done'),
|
||||
makeTask('T2', 'in_progress'),
|
||||
makeTask('T3', 'revising'),
|
||||
makeTask('T4', 'cancelled'),
|
||||
makeTask('T5', 'decomposed'),
|
||||
makeTask('T6', 'pending'),
|
||||
],
|
||||
modules: [],
|
||||
decisions: [],
|
||||
}
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/project/:id', component: ProjectView, props: true }],
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.project).mockResolvedValue(STATS_PROJECT as any)
|
||||
vi.mocked(api.projects).mockResolvedValue([])
|
||||
vi.mocked(api.getPhases).mockResolvedValue([])
|
||||
vi.mocked(api.environments).mockResolvedValue([])
|
||||
vi.mocked(api.projectLinks).mockResolvedValue([])
|
||||
vi.mocked(api.patchProject).mockResolvedValue(STATS_PROJECT as any)
|
||||
i18n.global.locale.value = 'en' as any
|
||||
})
|
||||
|
||||
describe('KIN-UI-025 Fix 3: Stats-бар — revising/cancelled/decomposed', () => {
|
||||
it('taskStats computed содержит поле revising', async () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// Проверяем что в taskStats есть фильтрация по revising
|
||||
expect(source).toContain("t.status === 'revising'")
|
||||
})
|
||||
|
||||
it('taskStats computed содержит поле cancelled', async () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
expect(source).toContain("t.status === 'cancelled'")
|
||||
})
|
||||
|
||||
it('taskStats computed содержит поле decomposed', async () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
expect(source).toContain("t.status === 'decomposed'")
|
||||
})
|
||||
|
||||
it('шаблон отображает span для revising со стилем text-orange-400', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
const lines = source.split('\n')
|
||||
const revisingSpan = lines.find(l => l.includes('taskStats.revising') && l.includes('text-orange-400'))
|
||||
expect(revisingSpan).toBeDefined()
|
||||
})
|
||||
|
||||
it('шаблон отображает span для cancelled', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
expect(source).toContain('taskStats.cancelled')
|
||||
})
|
||||
|
||||
it('шаблон отображает span для decomposed', () => {
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
expect(source).toContain('taskStats.decomposed')
|
||||
})
|
||||
|
||||
it('stats-бар рендерит "revising" для задачи со статусом revising', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/proj-stats')
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'proj-stats' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const statsText = wrapper.html()
|
||||
expect(statsText).toContain('revising')
|
||||
})
|
||||
|
||||
it('stats-бар рендерит "cancelled" для задачи со статусом cancelled', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/proj-stats')
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'proj-stats' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const statsText = wrapper.html()
|
||||
expect(statsText).toContain('cancelled')
|
||||
})
|
||||
|
||||
it('stats-бар рендерит "decomposed" для задачи со статусом decomposed', async () => {
|
||||
const router = makeRouter()
|
||||
await router.push('/project/proj-stats')
|
||||
const wrapper = mount(ProjectView, {
|
||||
props: { id: 'proj-stats' },
|
||||
global: { plugins: [router] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const statsText = wrapper.html()
|
||||
expect(statsText).toContain('decomposed')
|
||||
})
|
||||
|
||||
it('сумма всех статусов равна total (6 задач)', () => {
|
||||
// Статическая проверка: taskStats возвращает revising+cancelled+decomposed в sum
|
||||
const vuePath = path.resolve(__dirname, '../views/ProjectView.vue')
|
||||
const source = fs.readFileSync(vuePath, 'utf-8')
|
||||
// return объект должен включать все 8 статусов + total + pct
|
||||
expect(source).toContain('revising, cancelled, decomposed')
|
||||
})
|
||||
})
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
"proxy_jump_placeholder": "ProxyJump (optional, e.g. jumpt)",
|
||||
"path_required": "Path is required",
|
||||
"ssh_host_required": "SSH host is required for operations projects",
|
||||
"search_placeholder": "Search projects...",
|
||||
"bootstrap_path_placeholder": "Project path (e.g. ~/projects/vdolipoperek)",
|
||||
"roles": {
|
||||
"business_analyst": {
|
||||
|
|
@ -175,7 +176,9 @@
|
|||
"acceptance_criteria_label": "Acceptance criteria",
|
||||
"acceptance_criteria_placeholder": "What should the output be? What result counts as success?",
|
||||
"create_followup": "🔗 Create Follow-up",
|
||||
"generating_followup": "Generating..."
|
||||
"generating_followup": "Generating...",
|
||||
"review_required": "Review required:",
|
||||
"banner_auto_mode": "🔓 Auto mode"
|
||||
},
|
||||
"projectView": {
|
||||
"tasks_tab": "Tasks",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"proxy_jump_placeholder": "ProxyJump (опционально, например jumpt)",
|
||||
"path_required": "Путь обязателен",
|
||||
"ssh_host_required": "SSH хост обязателен для операционных проектов",
|
||||
"search_placeholder": "Поиск проектов...",
|
||||
"bootstrap_path_placeholder": "Путь к проекту (например ~/projects/vdolipoperek)",
|
||||
"roles": {
|
||||
"business_analyst": {
|
||||
|
|
@ -175,7 +176,9 @@
|
|||
"acceptance_criteria_label": "Критерии приёмки",
|
||||
"acceptance_criteria_placeholder": "Что должно быть на выходе? Какой результат считается успешным?",
|
||||
"create_followup": "🔗 Создать зависимости",
|
||||
"generating_followup": "Создаём..."
|
||||
"generating_followup": "Создаём...",
|
||||
"review_required": "Требует проверки:",
|
||||
"banner_auto_mode": "🔓 Авто режим"
|
||||
},
|
||||
"projectView": {
|
||||
"tasks_tab": "Задачи",
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ async function createNewProject() {
|
|||
<p class="text-sm text-gray-500" v-if="totalCost > 0">{{ t('dashboard.cost_this_week') }}: ${{ totalCost.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div v-if="showNewMenu" class="fixed inset-0 z-[5]" @click="showNewMenu = false"></div>
|
||||
<button @click="showNewMenu = !showNewMenu"
|
||||
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700 flex items-center gap-1">
|
||||
+ {{ t('dashboard.new_project') }} ▾
|
||||
|
|
@ -228,7 +229,7 @@ async function createNewProject() {
|
|||
|
||||
<div v-if="!loading && !error" class="mb-3">
|
||||
<input v-model="projectSearch"
|
||||
:placeholder="t('dashboard.search_placeholder', 'Search projects...')"
|
||||
:placeholder="t('dashboard.search_placeholder')"
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -655,7 +655,10 @@ const taskStats = computed(() => {
|
|||
const review = tasks.filter(t => t.status === 'review').length
|
||||
const blocked = tasks.filter(t => t.status === 'blocked').length
|
||||
const pending = tasks.filter(t => t.status === 'pending').length
|
||||
return { total, done, running, review, blocked, pending, pct: Math.round(done / total * 100) }
|
||||
const revising = tasks.filter(t => t.status === 'revising').length
|
||||
const cancelled = tasks.filter(t => t.status === 'cancelled').length
|
||||
const decomposed = tasks.filter(t => t.status === 'decomposed').length
|
||||
return { total, done, running, review, blocked, pending, revising, cancelled, decomposed, pct: Math.round(done / total * 100) }
|
||||
})
|
||||
|
||||
const searchFilteredTasks = computed(() => {
|
||||
|
|
@ -1107,6 +1110,9 @@ async function addDecision() {
|
|||
<span v-if="taskStats.review" class="text-yellow-400">⚠ {{ taskStats.review }} review</span>
|
||||
<span v-if="taskStats.blocked" class="text-red-400">✕ {{ taskStats.blocked }} blocked</span>
|
||||
<span v-if="taskStats.pending" class="text-gray-500">○ {{ taskStats.pending }} pending</span>
|
||||
<span v-if="taskStats.revising" class="text-orange-400">↻ {{ taskStats.revising }} revising</span>
|
||||
<span v-if="taskStats.cancelled" class="text-gray-500">— {{ taskStats.cancelled }} cancelled</span>
|
||||
<span v-if="taskStats.decomposed" class="text-gray-500">⊕ {{ taskStats.decomposed }} decomposed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mb-3">
|
||||
|
|
|
|||
|
|
@ -283,6 +283,24 @@ async function runPipeline() {
|
|||
const hasSteps = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 0)
|
||||
const isRunning = computed(() => task.value?.status === 'in_progress')
|
||||
const isManualEscalation = computed(() => task.value?.brief?.task_type === 'manual_escalation')
|
||||
const useVerticalPipeline = computed(() => (task.value?.pipeline_steps?.length ?? 0) > 5)
|
||||
|
||||
const expandedSteps = ref<Record<number, boolean>>({})
|
||||
function toggleStepExpand(id: number) {
|
||||
expandedSteps.value = { ...expandedSteps.value, [id]: !expandedSteps.value[id] }
|
||||
}
|
||||
|
||||
function verticalStepIcon(step: PipelineStep) {
|
||||
if (step.success === true || step.success === 1) return '\u2713'
|
||||
if (step.success === false || step.success === 0) return '\u2717'
|
||||
return '\u25CF'
|
||||
}
|
||||
|
||||
function verticalStepIconColor(step: PipelineStep) {
|
||||
if (step.success === true || step.success === 1) return 'text-green-400'
|
||||
if (step.success === false || step.success === 0) return 'text-red-400'
|
||||
return 'text-blue-400'
|
||||
}
|
||||
|
||||
const resolvingManually = ref(false)
|
||||
|
||||
|
|
@ -482,13 +500,36 @@ async function saveEdit() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review action-banner -->
|
||||
<div v-if="task.status === 'review' && !autoMode"
|
||||
class="mb-4 flex flex-wrap items-center gap-2 px-4 py-2 border border-yellow-800/60 bg-yellow-950/20 rounded-lg">
|
||||
<span class="text-xs font-semibold text-yellow-400 shrink-0">{{ t('taskDetail.review_required') }}</span>
|
||||
<button @click="showApprove = true"
|
||||
class="px-3 py-1.5 text-sm bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
{{ t('taskDetail.approve_task') }}
|
||||
</button>
|
||||
<button @click="showRevise = true"
|
||||
class="px-3 py-1.5 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900">
|
||||
{{ t('taskDetail.revise_task') }}
|
||||
</button>
|
||||
<button @click="showReject = true"
|
||||
class="px-3 py-1.5 text-sm bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
{{ t('taskDetail.reject_task') }}
|
||||
</button>
|
||||
<button @click="toggleMode"
|
||||
class="ml-auto px-3 py-1.5 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
|
||||
{{ t('taskDetail.banner_auto_mode') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Graph -->
|
||||
<div v-if="hasSteps || isRunning" class="mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-300 mb-3">
|
||||
{{ t('taskDetail.pipeline') }}
|
||||
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">{{ t('taskDetail.running') }}</span>
|
||||
</h2>
|
||||
<div class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
<!-- Horizontal pipeline (≤5 steps) -->
|
||||
<div v-if="!useVerticalPipeline" class="flex items-center gap-1 overflow-x-auto pb-2">
|
||||
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
|
||||
<div v-if="i > 0" class="text-gray-600 text-lg shrink-0 px-1">→</div>
|
||||
<button
|
||||
|
|
@ -512,6 +553,36 @@ async function saveEdit() {
|
|||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Vertical timeline (>5 steps) -->
|
||||
<div v-else class="space-y-1">
|
||||
<div v-for="step in task.pipeline_steps" :key="step.id"
|
||||
class="border border-gray-800 rounded-lg overflow-hidden">
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-800/30 transition-colors select-none"
|
||||
@click="toggleStepExpand(step.id)"
|
||||
>
|
||||
<span class="text-xs font-mono w-4 text-center shrink-0" :class="verticalStepIconColor(step)">{{ verticalStepIcon(step) }}</span>
|
||||
<span class="text-base shrink-0">{{ roleIcons[step.agent_role] || '\u{1F916}' }}</span>
|
||||
<span class="text-xs font-medium text-gray-300 flex-1">{{ step.agent_role }}</span>
|
||||
<span v-if="step.duration_seconds" class="text-[10px] text-gray-500">{{ step.duration_seconds }}s</span>
|
||||
<span v-if="step.cost_usd" class="text-[10px] text-gray-500">${{ step.cost_usd?.toFixed(3) }}</span>
|
||||
<span class="text-[10px] text-gray-600 ml-1">{{ expandedSteps[step.id] ? '▲' : '▼' }}</span>
|
||||
</div>
|
||||
<div v-if="expandedSteps[step.id]" class="border-t border-gray-800 bg-gray-900/50 p-3">
|
||||
<template v-if="parseAgentOutput(step.output_summary).verdict !== null">
|
||||
<p class="text-xs text-gray-300 whitespace-pre-wrap leading-relaxed">{{ parseAgentOutput(step.output_summary).verdict }}</p>
|
||||
<details v-if="parseAgentOutput(step.output_summary).details !== null" class="mt-2">
|
||||
<summary class="text-[10px] text-gray-500 cursor-pointer hover:text-gray-400 select-none">{{ t('taskDetail.more_details') }}</summary>
|
||||
<pre class="mt-1 text-[10px] text-gray-500 overflow-x-auto whitespace-pre-wrap max-h-[300px] overflow-y-auto">{{ parseAgentOutput(step.output_summary).details }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
<template v-else>
|
||||
<pre class="text-[10px] text-gray-300 overflow-x-auto whitespace-pre-wrap max-h-[300px] overflow-y-auto">{{ formatOutput(step.output_summary) }}</pre>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No pipeline -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue