Merge branch 'KIN-108-frontend_dev'

This commit is contained in:
Gros Frumos 2026-03-18 07:57:15 +02:00
commit 03d49f42e6
16 changed files with 799 additions and 212 deletions

View file

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@ -331,6 +332,67 @@
}
}
},
"node_modules/@intlify/core-base": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.3.0",
"@intlify/message-compiler": "11.3.0",
"@intlify/shared": "11.3.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/shared": "11.3.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.3.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -3666,6 +3728,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/vue-i18n": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.3.0",
"@intlify/devtools-types": "11.3.0",
"@intlify/shared": "11.3.0",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"vue": "^3.5.30",
"vue-i18n": "^11.3.0",
"vue-router": "^4.6.4"
},
"devDependencies": {
@ -28,4 +29,4 @@
"vitest": "^4.1.0",
"vue-tsc": "^3.2.5"
}
}
}

View file

@ -1,5 +1,14 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import EscalationBanner from './components/EscalationBanner.vue'
const { t, locale } = useI18n()
function toggleLocale() {
const next = locale.value === 'ru' ? 'en' : 'ru'
locale.value = next
localStorage.setItem('kin-locale', next)
}
</script>
<template>
@ -10,8 +19,12 @@ import EscalationBanner from './components/EscalationBanner.vue'
</router-link>
<nav class="flex items-center gap-4">
<EscalationBanner />
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">Settings</router-link>
<span class="text-xs text-gray-600">multi-agent orchestrator</span>
<button
@click="toggleLocale"
class="text-xs text-gray-400 hover:text-gray-200 px-2 py-0.5 border border-gray-700 rounded hover:border-gray-500 transition-colors"
>{{ locale === 'ru' ? 'EN' : 'RU' }}</button>
<router-link to="/settings" class="text-xs text-gray-400 hover:text-gray-200 no-underline">{{ t('common.settings') }}</router-link>
<span class="text-xs text-gray-600">{{ t('common.subtitle') }}</span>
</nav>
</header>
<main class="px-6 py-6">

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Attachment } from '../api'
const { t } = useI18n()
const props = defineProps<{ attachments: Attachment[]; taskId: string }>()
const emit = defineEmits<{ deleted: [] }>()
@ -48,7 +51,7 @@ function formatSize(bytes: number): string {
@click="remove(att.id)"
:disabled="deletingId === att.id"
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-900/80 text-red-400 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50 flex items-center justify-center"
title="Удалить"
:title="t('attachments.delete_title')"
></button>
</div>
</div>

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '../api'
const { t } = useI18n()
const props = defineProps<{ taskId: string }>()
const emit = defineEmits<{ uploaded: [] }>()
@ -12,7 +15,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
async function upload(file: File) {
if (!file.type.startsWith('image/')) {
error.value = 'Поддерживаются только изображения'
error.value = t('attachments.images_only')
return
}
uploading.value = true
@ -52,10 +55,10 @@ function onDrop(event: DragEvent) {
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="onFileChange" />
<div v-if="uploading" class="flex items-center justify-center gap-2 text-xs text-blue-400">
<span class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></span>
Загрузка...
{{ t('attachments.uploading') }}
</div>
<div v-else class="text-xs text-gray-500">
Перетащите изображение или <span class="text-blue-400">нажмите для выбора</span>
{{ t('attachments.drop_hint') }} <span class="text-blue-400">{{ t('attachments.click_to_select') }}</span>
</div>
<p v-if="error" class="text-red-400 text-xs mt-1">{{ error }}</p>
</div>

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type EscalationNotification } from '../api'
const { t, locale } = useI18n()
const STORAGE_KEY = 'kin_dismissed_escalations'
const WATCHDOG_TOAST_KEY = 'kin_dismissed_watchdog_toasts'
@ -106,7 +109,7 @@ function dismissAll() {
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
return new Date(iso).toLocaleString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch {
return iso
}
@ -136,7 +139,7 @@ onUnmounted(() => {
>
<span class="shrink-0 text-sm">&#9888;</span>
<div class="flex-1 min-w-0">
<p class="text-xs leading-snug">Watchdog: задача <span class="font-mono font-semibold">{{ toast.task_id }}</span> заблокирована {{ toast.reason }}</p>
<p class="text-xs leading-snug">{{ t('escalation.watchdog_blocked', { task_id: toast.task_id, reason: toast.reason }) }}</p>
</div>
<button
@click="dismissWatchdogToast(toast.task_id)"
@ -153,7 +156,7 @@ onUnmounted(() => {
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
>
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
Эскалации
{{ t('escalation.escalations') }}
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
</button>
@ -163,12 +166,12 @@ onUnmounted(() => {
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-red-400">Эскалации требуется решение</span>
<span class="text-xs font-semibold text-red-400">{{ t('escalation.escalations_panel_title') }}</span>
<div class="flex items-center gap-2">
<button
@click="dismissAll"
class="text-xs text-gray-500 hover:text-gray-300"
>Принять все</button>
>{{ t('escalation.dismiss_all') }}</button>
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
</div>
@ -193,7 +196,7 @@ onUnmounted(() => {
<button
@click="dismiss(n.task_id)"
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"
>Принято</button>
>{{ t('escalation.dismiss') }}</button>
</div>
</div>
</div>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type PipelineLog } from '../api'
const props = defineProps<{
@ -7,6 +8,8 @@ const props = defineProps<{
pipelineStatus: string
}>()
const { t } = useI18n()
const visible = ref(false)
const logs = ref<PipelineLog[]>([])
const error = ref('')
@ -102,12 +105,12 @@ onUnmounted(() => {
@click="toggle"
class="text-xs text-gray-500 hover:text-gray-300 border border-gray-800 rounded px-3 py-1.5 bg-gray-900/50 hover:bg-gray-900 transition-colors"
>
{{ visible ? '▲ Скрыть лог' : '▼ Показать лог' }}
{{ visible ? t('liveConsole.hide_log') : t('liveConsole.show_log') }}
</button>
<div v-show="visible" class="mt-2 bg-gray-950 border border-gray-800 rounded-lg p-4 font-mono text-xs max-h-[400px] overflow-y-auto" ref="consoleEl" @scroll="onScroll">
<div v-if="!logs.length && !error" class="text-gray-600">Нет записей...</div>
<div v-if="error" class="text-red-400">Ошибка: {{ error }}</div>
<div v-if="!logs.length && !error" class="text-gray-600">{{ t('liveConsole.no_records') }}</div>
<div v-if="error" class="text-red-400">{{ t('liveConsole.error_prefix') }} {{ error }}</div>
<div v-for="log in logs" :key="log.id" class="mb-1">
<span class="text-gray-600">{{ log.ts }}</span>
<span :class="[levelClass(log.level), 'ml-2 font-semibold']">[{{ log.level }}]</span>

12
web/frontend/src/i18n.ts Normal file
View file

@ -0,0 +1,12 @@
import { createI18n } from 'vue-i18n'
import ru from './locales/ru.json'
import en from './locales/en.json'
const savedLocale = localStorage.getItem('kin-locale') || 'ru'
export const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'en',
messages: { ru, en },
})

View file

@ -0,0 +1,229 @@
{
"common": {
"settings": "Settings",
"subtitle": "multi-agent orchestrator",
"loading": "Loading...",
"saving": "Saving...",
"saved": "Saved",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"close": "Close",
"error": "Error",
"yes_delete": "Yes, delete",
"add": "Add",
"create": "Create"
},
"dashboard": {
"title": "Dashboard",
"cost_this_week": "Cost this week",
"bootstrap": "Bootstrap",
"new_project": "+ New Project",
"blank": "+ Blank",
"loading": "Loading...",
"delete_confirm": "Delete project \"{name}\"? This action is irreversible.",
"delete_project_title": "Confirm delete",
"cancel_delete_title": "Cancel delete",
"task_count": "{n} tasks",
"active_tasks": "{n} active",
"awaiting_review": "{n} awaiting review",
"blocked_tasks": "{n} blocked",
"done_tasks": "{n} done",
"pending_tasks": "{n} pending",
"add_project_title": "Add Project",
"project_type_label": "Project type:",
"create_btn": "Create",
"new_project_title": "New Project — Start Research",
"project_description_placeholder": "Project description (free text for agents)",
"research_stages": "Research stages (Architect is added automatically last):",
"architect_hint": "blueprint based on approved research",
"role_error": "Select at least one role",
"start_research": "Start Research",
"starting": "Starting...",
"bootstrap_title": "Bootstrap Project",
"bootstrap_btn": "Bootstrap",
"ssh_alias_hint": "Alias from ~/.ssh/config on the Kin server",
"path_placeholder": "Path (e.g. ~/projects/myproj)",
"name_placeholder": "Name",
"id_placeholder": "ID (e.g. vdol)",
"tech_stack_placeholder": "Tech stack (comma-separated)",
"priority_placeholder": "Priority (1-10)",
"ssh_host_placeholder": "SSH host (e.g. 192.168.1.1)",
"ssh_user_placeholder": "SSH user (e.g. root)",
"ssh_key_placeholder": "Key path (e.g. ~/.ssh/id_rsa)",
"proxy_jump_placeholder": "ProxyJump (optional, e.g. jumpt)",
"path_required": "Path is required",
"ssh_host_required": "SSH host is required for operations projects",
"bootstrap_path_placeholder": "Project path (e.g. ~/projects/vdolipoperek)",
"roles": {
"business_analyst": {
"label": "Business Analyst",
"hint": "business model, audience, monetization"
},
"market_researcher": {
"label": "Market Researcher",
"hint": "competitors, niche, strengths/weaknesses"
},
"legal_researcher": {
"label": "Legal Researcher",
"hint": "jurisdiction, licenses, KYC/AML, GDPR"
},
"tech_researcher": {
"label": "Tech Researcher",
"hint": "APIs, limitations, costs, alternatives"
},
"ux_designer": {
"label": "UX Designer",
"hint": "UX analysis, user journey, wireframes"
},
"marketer": {
"label": "Marketer",
"hint": "promotion strategy, SEO, conversion patterns"
},
"architect": {
"label": "Architect"
}
}
},
"chat": {
"back_to_project": "← Project",
"chat_label": "— chat",
"loading": "Loading...",
"server_unavailable": "Server unavailable. Check your connection.",
"empty_hint": "Describe a task or ask about the project status",
"input_placeholder": "Describe a task or question... (Enter — send, Shift+Enter — newline)",
"send": "Send",
"sending": "..."
},
"settings": {
"title": "Settings",
"obsidian_vault_path": "Obsidian Vault Path",
"test_command": "Test Command",
"test_command_hint": "Test run command, executed via shell in the project directory.",
"save_test": "Save Test",
"saving_test": "Saving…",
"deploy_config": "Deploy Config",
"server_host": "Server host",
"project_path_on_server": "Project path on server",
"runtime": "Runtime",
"select_runtime": "— select runtime —",
"restart_command": "Restart command (optional override)",
"fallback_command": "Fallback command (legacy, used when runtime not set)",
"save_deploy_config": "Save Deploy Config",
"saving_deploy": "Saving…",
"project_links": "Project Links",
"add_link": "+ Add Link",
"links_loading": "Loading...",
"no_links": "No links",
"select_project": "— select project —",
"auto_test": "Auto-test",
"auto_test_hint": "— run tests automatically after pipeline",
"worktrees": "Worktrees",
"worktrees_hint": "— agents run in isolated git worktrees",
"save_vault": "Save Vault",
"saving_vault": "Saving…",
"sync_obsidian": "Sync Obsidian",
"syncing": "Syncing…",
"saving_link": "Saving...",
"cancel_link": "Cancel",
"delete_link_confirm": "Delete link?",
"select_project_error": "Select a project"
},
"taskDetail": {
"pipeline_already_running": "Pipeline already running",
"mark_resolved_confirm": "Mark task as manually resolved?",
"requires_manual": "⚠ Requires manual resolution",
"acceptance_criteria": "Acceptance criteria",
"autopilot_failed": "Autopilot could not complete this automatically. Take action manually and click \"Resolve manually\".",
"dangerously_skipped": "--dangerously-skip-permissions was used in this task",
"dangerously_skipped_hint": "The agent executed commands bypassing permission checks. Review pipeline steps and changes made.",
"loading": "Loading...",
"pipeline": "Pipeline",
"running": "running...",
"no_pipeline": "No pipeline steps yet.",
"approve_task": "✓ Approve",
"revise_task": "🔄 Revise",
"reject_task": "✗ Reject",
"edit": "✒ Edit",
"run_pipeline": "▶ Run Pipeline",
"pipeline_running": "Pipeline running...",
"deploying": "Deploying...",
"deploy": "🚀 Deploy",
"deploy_succeeded": "Deploy succeeded",
"deploy_failed": "Deploy failed",
"resolve_manually": "✓ Resolve manually",
"resolving": "Saving...",
"send_to_revision": "🔄 Send for revision",
"revise_placeholder": "What to revise or clarify...",
"autopilot_active": "Autopilot active",
"attachments": "Attachments",
"more_details": "↓ more details",
"terminal_login_hint": "Open a terminal and run:",
"login_after_hint": "After login, retry the pipeline.",
"dependent_projects": "Dependent projects:",
"decision_title_placeholder": "Decision title (optional)",
"description_placeholder": "Description",
"brief_label": "Brief",
"priority_label": "Priority (110)",
"title_label": "Title",
"acceptance_criteria_label": "Acceptance criteria",
"acceptance_criteria_placeholder": "What should the output be? What result counts as success?"
},
"projectView": {
"tasks_tab": "Tasks",
"phases_tab": "Phases",
"decisions_tab": "Decisions",
"modules_tab": "Modules",
"kanban_tab": "Kanban",
"links_tab": "Links",
"add_task": "+ Task",
"audit_backlog": "Audit backlog",
"back": "← back",
"deploy": "Deploy",
"kanban_pending": "Pending",
"kanban_in_progress": "In Progress",
"kanban_review": "Review",
"kanban_blocked": "Blocked",
"kanban_done": "Done",
"chat": "Chat",
"dependent_projects": "Dependent projects:",
"environments": "Environments",
"auto_test_label": "Auto-test",
"worktrees_on": "Worktrees: on",
"worktrees_off": "Worktrees: off",
"all_statuses": "All",
"search_placeholder": "Search tasks...",
"manual_escalations_warn": "⚠ Require manual resolution",
"comment_required": "Comment required",
"select_project": "Select project",
"delete_env_confirm": "Delete environment?",
"delete_link_confirm": "Delete link?",
"run_pipeline_confirm": "Run pipeline for {n} tasks?",
"pipeline_already_running": "Pipeline already running",
"no_tasks": "No tasks.",
"loading_phases": "Loading phases...",
"revise_modal_title": "Revise phase",
"reject_modal_title": "Reject phase",
"add_link_title": "Add link"
},
"escalation": {
"watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}",
"escalations": "Escalations",
"escalations_panel_title": "Escalations — action required",
"dismiss_all": "Dismiss all",
"dismiss": "Dismiss"
},
"liveConsole": {
"hide_log": "▲ Hide log",
"show_log": "▼ Show log",
"no_records": "No records...",
"error_prefix": "Error:"
},
"attachments": {
"images_only": "Only images are supported",
"uploading": "Uploading...",
"drop_hint": "Drop an image or",
"click_to_select": "click to select",
"delete_title": "Delete"
}
}

View file

@ -0,0 +1,229 @@
{
"common": {
"settings": "Настройки",
"subtitle": "мультиагентный оркестратор",
"loading": "Загрузка...",
"saving": "Сохраняем...",
"saved": "Сохранено",
"cancel": "Отмена",
"save": "Сохранить",
"delete": "Удалить",
"close": "Закрыть",
"error": "Ошибка",
"yes_delete": "Да, удалить",
"add": "Добавить",
"create": "Создать"
},
"dashboard": {
"title": "Dashboard",
"cost_this_week": "Расходы за неделю",
"bootstrap": "Bootstrap",
"new_project": "+ Новый проект",
"blank": "+ Пустой",
"loading": "Загрузка...",
"delete_confirm": "Удалить проект «{name}»? Это действие необратимо.",
"delete_project_title": "Подтвердить удаление",
"cancel_delete_title": "Отмена удаления",
"task_count": "{n} задач",
"active_tasks": "{n} активных",
"awaiting_review": "{n} ожидают проверки",
"blocked_tasks": "{n} заблокированы",
"done_tasks": "{n} выполнены",
"pending_tasks": "{n} ожидают",
"add_project_title": "Добавить проект",
"project_type_label": "Тип проекта:",
"create_btn": "Создать",
"new_project_title": "Новый проект — Запустить исследование",
"project_description_placeholder": "Описание проекта (свободный текст для агентов)",
"research_stages": "Этапы research (Architect добавляется автоматически последним):",
"architect_hint": "blueprint на основе одобренных исследований",
"role_error": "Выберите хотя бы одну роль",
"start_research": "Запустить исследование",
"starting": "Запускаем...",
"bootstrap_title": "Bootstrap проекта",
"bootstrap_btn": "Bootstrap",
"ssh_alias_hint": "Алиас из ~/.ssh/config на сервере Kin",
"path_placeholder": "Путь (например ~/projects/myproj)",
"name_placeholder": "Название",
"id_placeholder": "ID (например vdol)",
"tech_stack_placeholder": "Стек (через запятую)",
"priority_placeholder": "Приоритет (1-10)",
"ssh_host_placeholder": "SSH хост (например 192.168.1.1)",
"ssh_user_placeholder": "SSH пользователь (например root)",
"ssh_key_placeholder": "Путь к ключу (например ~/.ssh/id_rsa)",
"proxy_jump_placeholder": "ProxyJump (опционально, например jumpt)",
"path_required": "Путь обязателен",
"ssh_host_required": "SSH хост обязателен для операционных проектов",
"bootstrap_path_placeholder": "Путь к проекту (например ~/projects/vdolipoperek)",
"roles": {
"business_analyst": {
"label": "Бизнес-аналитик",
"hint": "бизнес-модель, аудитория, монетизация"
},
"market_researcher": {
"label": "Маркет-ресёрчер",
"hint": "конкуренты, ниша, сильные/слабые стороны"
},
"legal_researcher": {
"label": "Правовой аналитик",
"hint": "юрисдикция, лицензии, KYC/AML, GDPR"
},
"tech_researcher": {
"label": "Тех-ресёрчер",
"hint": "API, ограничения, стоимость, альтернативы"
},
"ux_designer": {
"label": "UX-дизайнер",
"hint": "анализ UX конкурентов, user journey, wireframes"
},
"marketer": {
"label": "Маркетолог",
"hint": "стратегия продвижения, SEO, conversion-паттерны"
},
"architect": {
"label": "Архитектор"
}
}
},
"chat": {
"back_to_project": "← Проект",
"chat_label": "— чат",
"loading": "Загрузка...",
"server_unavailable": "Сервер недоступен. Проверьте подключение.",
"empty_hint": "Опишите задачу или спросите о статусе проекта",
"input_placeholder": "Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)",
"send": "Отправить",
"sending": "..."
},
"settings": {
"title": "Настройки",
"obsidian_vault_path": "Путь к Obsidian Vault",
"test_command": "Команда тестирования",
"test_command_hint": "Команда запуска тестов, выполняется через shell в директории проекта.",
"save_test": "Сохранить тест",
"saving_test": "Сохраняем…",
"deploy_config": "Конфигурация деплоя",
"server_host": "Хост сервера",
"project_path_on_server": "Путь к проекту на сервере",
"runtime": "Runtime",
"select_runtime": "— выберите runtime —",
"restart_command": "Команда перезапуска (опциональный override)",
"fallback_command": "Fallback команда (legacy, используется если runtime не задан)",
"save_deploy_config": "Сохранить конфиг деплоя",
"saving_deploy": "Сохраняем…",
"project_links": "Связи проекта",
"add_link": "+ Добавить связь",
"links_loading": "Загрузка...",
"no_links": "Нет связей",
"select_project": "— выберите проект —",
"auto_test": "Автотест",
"auto_test_hint": "— запускать тесты автоматически после pipeline",
"worktrees": "Worktrees",
"worktrees_hint": "— агенты запускаются в изолированных git worktrees",
"save_vault": "Сохранить Vault",
"saving_vault": "Сохраняем…",
"sync_obsidian": "Синхронизировать Obsidian",
"syncing": "Синхронизируем…",
"saving_link": "Сохраняем...",
"cancel_link": "Отмена",
"delete_link_confirm": "Удалить связь?",
"select_project_error": "Выберите проект"
},
"taskDetail": {
"pipeline_already_running": "Pipeline уже запущен",
"mark_resolved_confirm": "Пометить задачу как решённую вручную?",
"requires_manual": "⚠ Требует ручного решения",
"acceptance_criteria": "Критерии приёмки",
"autopilot_failed": "Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».",
"dangerously_skipped": "--dangerously-skip-permissions использовался в этой задаче",
"dangerously_skipped_hint": "Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.",
"loading": "Загрузка...",
"pipeline": "Pipeline",
"running": "выполняется...",
"no_pipeline": "Нет шагов pipeline.",
"approve_task": "✓ Подтвердить",
"revise_task": "🔄 Доработать",
"reject_task": "✗ Отклонить",
"edit": "✒ Редактировать",
"run_pipeline": "▶ Запустить Pipeline",
"pipeline_running": "Pipeline выполняется...",
"deploying": "Деплоим...",
"deploy": "🚀 Деплой",
"deploy_succeeded": "Деплой успешен",
"deploy_failed": "Деплой не удался",
"resolve_manually": "✓ Решить вручную",
"resolving": "Сохраняем...",
"send_to_revision": "🔄 Отправить на доработку",
"revise_placeholder": "Что доработать / уточнить...",
"autopilot_active": "Автопилот активен",
"attachments": "Вложения",
"more_details": "↓ подробнее",
"terminal_login_hint": "Откройте терминал и выполните:",
"login_after_hint": "После входа повторите запуск pipeline.",
"dependent_projects": "Зависимые проекты:",
"decision_title_placeholder": "Заголовок решения (опционально)",
"description_placeholder": "Описание",
"brief_label": "Описание",
"priority_label": "Приоритет (110)",
"title_label": "Заголовок",
"acceptance_criteria_label": "Критерии приёмки",
"acceptance_criteria_placeholder": "Что должно быть на выходе? Какой результат считается успешным?"
},
"projectView": {
"tasks_tab": "Задачи",
"phases_tab": "Фазы",
"decisions_tab": "Решения",
"modules_tab": "Модули",
"kanban_tab": "Kanban",
"links_tab": "Связи",
"add_task": "+ Задача",
"audit_backlog": "Аудит бэклога",
"back": "← назад",
"deploy": "Деплой",
"kanban_pending": "Ожидает",
"kanban_in_progress": "В работе",
"kanban_review": "Проверка",
"kanban_blocked": "Заблокирован",
"kanban_done": "Выполнено",
"chat": "Чат",
"dependent_projects": "Зависимые проекты:",
"environments": "Среды",
"auto_test_label": "Автотест",
"worktrees_on": "Worktrees: вкл",
"worktrees_off": "Worktrees: выкл",
"all_statuses": "Все",
"search_placeholder": "Поиск по задачам...",
"manual_escalations_warn": "⚠ Требуют ручного решения",
"comment_required": "Комментарий обязателен",
"select_project": "Выберите проект",
"delete_env_confirm": "Удалить среду?",
"delete_link_confirm": "Удалить связь?",
"run_pipeline_confirm": "Запустить pipeline для {n} задач?",
"pipeline_already_running": "Pipeline уже запущен",
"no_tasks": "Нет задач.",
"loading_phases": "Загрузка фаз...",
"revise_modal_title": "Доработать фазу",
"reject_modal_title": "Отклонить фазу",
"add_link_title": "Добавить связь"
},
"escalation": {
"watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}",
"escalations": "Эскалации",
"escalations_panel_title": "Эскалации — требуется решение",
"dismiss_all": "Принять все",
"dismiss": "Принято"
},
"liveConsole": {
"hide_log": "▲ Скрыть лог",
"show_log": "▼ Показать лог",
"no_records": "Нет записей...",
"error_prefix": "Ошибка:"
},
"attachments": {
"images_only": "Поддерживаются только изображения",
"uploading": "Загрузка...",
"drop_hint": "Перетащите изображение или",
"click_to_select": "нажмите для выбора",
"delete_title": "Удалить"
}
}

View file

@ -7,6 +7,7 @@ import ProjectView from './views/ProjectView.vue'
import TaskDetail from './views/TaskDetail.vue'
import SettingsView from './views/SettingsView.vue'
import ChatView from './views/ChatView.vue'
import { i18n } from './i18n'
const router = createRouter({
history: createWebHistory(),
@ -19,4 +20,4 @@ const router = createRouter({
],
})
createApp(App).use(router).mount('#app')
createApp(App).use(router).use(i18n).mount('#app')

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { ref, watch, nextTick, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type ChatMessage } from '../api'
import Badge from '../components/Badge.vue'
const props = defineProps<{ projectId: string }>()
const router = useRouter()
const { t, locale } = useI18n()
const messages = ref<ChatMessage[]>([])
const input = ref('')
@ -43,9 +45,9 @@ function checkAndPoll() {
if (!hasRunningTasks(updated)) stopPoll()
} catch (e: any) {
consecutiveErrors.value++
console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e)
console.warn('[polling] error #' + consecutiveErrors.value + ':', e)
if (consecutiveErrors.value >= 3) {
error.value = 'Сервер недоступен. Проверьте подключение.'
error.value = t('chat.server_unavailable')
stopPoll()
}
}
@ -134,7 +136,7 @@ function taskStatusColor(status: string): string {
}
function formatTime(dt: string) {
return new Date(dt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return new Date(dt).toLocaleTimeString(locale.value === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' })
}
</script>
@ -145,12 +147,12 @@ function formatTime(dt: string) {
<router-link
:to="`/project/${projectId}`"
class="text-gray-400 hover:text-gray-200 text-sm no-underline"
> Проект</router-link>
>{{ t('chat.back_to_project') }}</router-link>
<span class="text-gray-600">|</span>
<h1 class="text-base font-semibold text-gray-100">
{{ projectName || projectId }}
</h1>
<span class="text-xs text-gray-500 ml-1"> чат</span>
<span class="text-xs text-gray-500 ml-1">{{ t('chat.chat_label') }}</span>
</div>
<!-- Error -->
@ -160,7 +162,7 @@ function formatTime(dt: string) {
<!-- Loading -->
<div v-if="loading" class="flex-1 flex items-center justify-center">
<span class="text-gray-500 text-sm">Загрузка...</span>
<span class="text-gray-500 text-sm">{{ t('chat.loading') }}</span>
</div>
<!-- Messages -->
@ -170,7 +172,7 @@ function formatTime(dt: string) {
class="flex-1 overflow-y-auto py-4 flex flex-col gap-3 min-h-0"
>
<div v-if="messages.length === 0" class="text-center text-gray-500 text-sm mt-8">
Опишите задачу или спросите о статусе проекта
{{ t('chat.empty_hint') }}
</div>
<div
@ -209,7 +211,7 @@ function formatTime(dt: string) {
<textarea
v-model="input"
:disabled="sending || loading"
placeholder="Опишите задачу или вопрос... (Enter — отправить, Shift+Enter — перенос)"
:placeholder="t('chat.input_placeholder')"
rows="2"
class="flex-1 bg-gray-800/60 border border-gray-700 rounded-xl px-4 py-2.5 text-sm text-gray-100 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-600 disabled:opacity-50"
@keydown="onKeydown"
@ -219,7 +221,7 @@ function formatTime(dt: string) {
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm rounded-xl font-medium transition-colors"
@click="send"
>
{{ sending ? '...' : 'Отправить' }}
{{ sending ? t('chat.sending') : t('chat.send') }}
</button>
</div>
</div>

View file

@ -1,9 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type CostEntry } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
const { t } = useI18n()
const projects = ref<Project[]>([])
const costs = ref<CostEntry[]>([])
const loading = ref(true)
@ -25,12 +28,12 @@ const bsResult = ref('')
// New Project with Research modal
const RESEARCH_ROLES = [
{ key: 'business_analyst', label: 'Business Analyst', hint: 'бизнес-модель, аудитория, монетизация' },
{ key: 'market_researcher', label: 'Market Researcher', hint: 'конкуренты, ниша, сильные/слабые стороны' },
{ key: 'legal_researcher', label: 'Legal Researcher', hint: 'юрисдикция, лицензии, KYC/AML, GDPR' },
{ key: 'tech_researcher', label: 'Tech Researcher', hint: 'API, ограничения, стоимость, альтернативы' },
{ key: 'ux_designer', label: 'UX Designer', hint: 'анализ UX конкурентов, user journey, wireframes' },
{ key: 'marketer', label: 'Marketer', hint: 'стратегия продвижения, SEO, conversion-паттерны' },
{ key: 'business_analyst' },
{ key: 'market_researcher' },
{ key: 'legal_researcher' },
{ key: 'tech_researcher' },
{ key: 'ux_designer' },
{ key: 'marketer' },
]
const showNewProject = ref(false)
const npForm = ref({
@ -55,7 +58,6 @@ let dashPollTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
await load()
// Poll if there are running tasks
checkAndPoll()
})
@ -87,11 +89,11 @@ function statusColor(s: string) {
async function addProject() {
formError.value = ''
if (form.value.project_type === 'operations' && !form.value.ssh_host) {
formError.value = 'SSH host is required for operations projects'
formError.value = t('dashboard.ssh_host_required')
return
}
if (form.value.project_type !== 'operations' && !form.value.path) {
formError.value = 'Path is required'
formError.value = t('dashboard.path_required')
return
}
try {
@ -157,7 +159,7 @@ async function deleteProject(id: string) {
async function createNewProject() {
npError.value = ''
if (!npRoles.value.length) {
npError.value = 'Выберите хотя бы одну роль'
npError.value = t('dashboard.role_error')
return
}
npSaving.value = true
@ -189,26 +191,26 @@ async function createNewProject() {
<div>
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-xl font-bold text-gray-100">Dashboard</h1>
<p class="text-sm text-gray-500" v-if="totalCost > 0">Cost this week: ${{ totalCost.toFixed(2) }}</p>
<h1 class="text-xl font-bold text-gray-100">{{ t('dashboard.title') }}</h1>
<p class="text-sm text-gray-500" v-if="totalCost > 0">{{ t('dashboard.cost_this_week') }}: ${{ totalCost.toFixed(2) }}</p>
</div>
<div class="flex gap-2">
<button @click="showBootstrap = true"
class="px-3 py-1.5 text-xs bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900">
Bootstrap
{{ t('dashboard.bootstrap') }}
</button>
<button @click="showNewProject = true"
class="px-3 py-1.5 text-xs bg-green-900/50 text-green-400 border border-green-800 rounded hover:bg-green-900">
+ New Project
{{ t('dashboard.new_project') }}
</button>
<button @click="showAdd = true"
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
+ Blank
{{ t('dashboard.blank') }}
</button>
</div>
</div>
<p v-if="loading" class="text-gray-500 text-sm">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>
<div v-else class="grid gap-3">
@ -216,17 +218,17 @@ async function createNewProject() {
<!-- Inline delete confirmation -->
<div v-if="confirmDeleteId === p.id"
class="border border-red-800 rounded-lg p-4 bg-red-950/20">
<p class="text-sm text-gray-200 mb-3">Удалить проект «{{ p.name }}»? Это действие необратимо.</p>
<p class="text-sm text-gray-200 mb-3">{{ t('dashboard.delete_confirm', { name: p.name }) }}</p>
<div class="flex gap-2">
<button @click="deleteProject(p.id)"
title="Подтвердить удаление"
:title="t('dashboard.delete_project_title')"
class="px-3 py-1.5 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900">
Да, удалить
{{ t('common.yes_delete') }}
</button>
<button @click="confirmDeleteId = null"
title="Отмена удаления"
:title="t('dashboard.cancel_delete_title')"
class="px-3 py-1.5 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700">
Отмена
{{ t('common.cancel') }}
</button>
</div>
<p v-if="deleteError" class="text-red-400 text-xs mt-2">{{ deleteError }}</p>
@ -249,7 +251,7 @@ async function createNewProject() {
<span v-if="costMap[p.id]">${{ costMap[p.id]?.toFixed(2) }}/wk</span>
<span>pri {{ p.priority }}</span>
<button @click.prevent.stop="confirmDeleteId = p.id"
title="Удалить проект"
:title="t('common.delete')"
class="text-gray-600 hover:text-red-400 transition-colors">
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@ -275,82 +277,82 @@ async function createNewProject() {
</div>
<!-- Add Project Modal -->
<Modal v-if="showAdd" title="Add Project" @close="showAdd = false">
<Modal v-if="showAdd" :title="t('dashboard.add_project_title')" @close="showAdd = false">
<form @submit.prevent="addProject" class="space-y-3">
<input v-model="form.id" placeholder="ID (e.g. vdol)" required
<input v-model="form.id" :placeholder="t('dashboard.id_placeholder')" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="form.name" placeholder="Name" required
<input v-model="form.name" :placeholder="t('dashboard.name_placeholder')" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<!-- Project type selector -->
<div>
<p class="text-xs text-gray-500 mb-1.5">Тип проекта:</p>
<p class="text-xs text-gray-500 mb-1.5">{{ t('dashboard.project_type_label') }}</p>
<div class="flex gap-2">
<button v-for="t in ['development', 'operations', 'research']" :key="t"
<button v-for="t_type in ['development', 'operations', 'research']" :key="t_type"
type="button"
@click="form.project_type = t"
@click="form.project_type = t_type"
class="flex-1 py-1.5 text-xs border rounded transition-colors"
:class="form.project_type === t
? t === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
: t === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
:class="form.project_type === t_type
? t_type === 'development' ? 'bg-blue-900/40 text-blue-300 border-blue-700'
: t_type === 'operations' ? 'bg-orange-900/40 text-orange-300 border-orange-700'
: 'bg-green-900/40 text-green-300 border-green-700'
: 'bg-gray-900 text-gray-500 border-gray-800 hover:text-gray-300 hover:border-gray-600'"
>{{ t }}</button>
>{{ t_type }}</button>
</div>
</div>
<!-- Path (development / research) -->
<input v-if="form.project_type !== 'operations'"
v-model="form.path" placeholder="Path (e.g. ~/projects/myproj)"
v-model="form.path" :placeholder="t('dashboard.path_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<!-- SSH fields (operations) -->
<template v-if="form.project_type === 'operations'">
<input v-model="form.ssh_host" placeholder="SSH host (e.g. 192.168.1.1)" required
<input v-model="form.ssh_host" :placeholder="t('dashboard.ssh_host_placeholder')" required
class="w-full bg-gray-800 border border-orange-800/60 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div class="grid grid-cols-2 gap-2">
<input v-model="form.ssh_user" placeholder="SSH user (e.g. root)"
<input v-model="form.ssh_user" :placeholder="t('dashboard.ssh_user_placeholder')"
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="form.ssh_key_path" placeholder="Key path (e.g. ~/.ssh/id_rsa)"
<input v-model="form.ssh_key_path" :placeholder="t('dashboard.ssh_key_placeholder')"
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<div>
<input v-model="form.ssh_proxy_jump" placeholder="ProxyJump (optional, e.g. jumpt)"
<input v-model="form.ssh_proxy_jump" :placeholder="t('dashboard.proxy_jump_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<p class="mt-1 flex items-center gap-1 text-xs text-gray-500">
<svg class="w-3 h-3 flex-shrink-0 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
Алиас из ~/.ssh/config на сервере Kin
{{ t('dashboard.ssh_alias_hint') }}
</p>
</div>
</template>
<input v-model="form.tech_stack" placeholder="Tech stack (comma-separated)"
<input v-model="form.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model.number="form.priority" type="number" min="1" max="10" placeholder="Priority (1-10)"
<input v-model.number="form.priority" type="number" min="1" max="10" :placeholder="t('dashboard.priority_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<p v-if="formError" class="text-red-400 text-xs">{{ formError }}</p>
<button type="submit"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900">
Create
{{ t('dashboard.create_btn') }}
</button>
</form>
</Modal>
<!-- New Project with Research Modal -->
<Modal v-if="showNewProject" title="New Project — Start Research" @close="showNewProject = false">
<Modal v-if="showNewProject" :title="t('dashboard.new_project_title')" @close="showNewProject = false">
<form @submit.prevent="createNewProject" class="space-y-3">
<div class="grid grid-cols-2 gap-2">
<input v-model="npForm.id" placeholder="ID (e.g. myapp)" required
<input v-model="npForm.id" :placeholder="t('dashboard.id_placeholder')" required
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="npForm.name" placeholder="Name" required
<input v-model="npForm.name" :placeholder="t('dashboard.name_placeholder')" required
class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<input v-model="npForm.path" placeholder="Path (e.g. ~/projects/myapp)"
<input v-model="npForm.path" :placeholder="t('dashboard.path_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-model="npForm.description" placeholder="Описание проекта (свободный текст для агентов)" required rows="4"
<textarea v-model="npForm.description" :placeholder="t('dashboard.project_description_placeholder')" required rows="4"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
<input v-model="npForm.tech_stack" placeholder="Tech stack (comma-separated, optional)"
<input v-model="npForm.tech_stack" :placeholder="t('dashboard.tech_stack_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<div>
<p class="text-xs text-gray-500 mb-2">Этапы research (Architect добавляется автоматически последним):</p>
<p class="text-xs text-gray-500 mb-2">{{ t('dashboard.research_stages') }}</p>
<div class="space-y-1.5">
<label v-for="r in RESEARCH_ROLES" :key="r.key"
class="flex items-start gap-2 cursor-pointer group">
@ -359,15 +361,15 @@ async function createNewProject() {
@change="toggleNpRole(r.key)"
class="mt-0.5 accent-green-500 cursor-pointer" />
<div>
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ r.label }}</span>
<span class="text-xs text-gray-600 ml-1"> {{ r.hint }}</span>
<span class="text-sm text-gray-300 group-hover:text-gray-100">{{ t(`dashboard.roles.${r.key}.label`) }}</span>
<span class="text-xs text-gray-600 ml-1"> {{ t(`dashboard.roles.${r.key}.hint`) }}</span>
</div>
</label>
<label class="flex items-start gap-2 opacity-50">
<input type="checkbox" checked disabled class="mt-0.5" />
<div>
<span class="text-sm text-gray-400">Architect</span>
<span class="text-xs text-gray-600 ml-1"> blueprint на основе одобренных исследований</span>
<span class="text-sm text-gray-400">{{ t('dashboard.roles.architect.label') }}</span>
<span class="text-xs text-gray-600 ml-1"> {{ t('dashboard.architect_hint') }}</span>
</div>
</label>
</div>
@ -375,25 +377,25 @@ async function createNewProject() {
<p v-if="npError" class="text-red-400 text-xs">{{ npError }}</p>
<button type="submit" :disabled="npSaving"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
{{ npSaving ? 'Starting...' : 'Start Research' }}
{{ npSaving ? t('dashboard.starting') : t('dashboard.start_research') }}
</button>
</form>
</Modal>
<!-- Bootstrap Modal -->
<Modal v-if="showBootstrap" title="Bootstrap Project" @close="showBootstrap = false">
<Modal v-if="showBootstrap" :title="t('dashboard.bootstrap_title')" @close="showBootstrap = false">
<form @submit.prevent="runBootstrap" class="space-y-3">
<input v-model="bsForm.path" placeholder="Project path (e.g. ~/projects/vdolipoperek)" required
<input v-model="bsForm.path" :placeholder="t('dashboard.bootstrap_path_placeholder')" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="bsForm.id" placeholder="ID (e.g. vdol)" required
<input v-model="bsForm.id" :placeholder="t('dashboard.id_placeholder')" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<input v-model="bsForm.name" placeholder="Name" required
<input v-model="bsForm.name" :placeholder="t('dashboard.name_placeholder')" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<p v-if="bsError" class="text-red-400 text-xs">{{ bsError }}</p>
<p v-if="bsResult" class="text-green-400 text-xs">{{ bsResult }}</p>
<button type="submit"
class="w-full py-2 bg-purple-900/50 text-purple-400 border border-purple-800 rounded text-sm hover:bg-purple-900">
Bootstrap
{{ t('dashboard.bootstrap_btn') }}
</button>
</form>
</Modal>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -8,6 +9,7 @@ import Modal from '../components/Modal.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
@ -48,7 +50,7 @@ function openTaskRevise(taskId: string) {
}
async function submitTaskRevise() {
if (!taskReviseComment.value.trim()) { taskReviseError.value = 'Комментарий обязателен'; return }
if (!taskReviseComment.value.trim()) { taskReviseError.value = t('projectView.comment_required'); return }
taskReviseSaving.value = true
try {
await api.reviseTask(taskReviseTaskId.value!, taskReviseComment.value)
@ -378,7 +380,7 @@ async function submitEnv() {
}
async function deleteEnv(envId: number) {
if (!confirm('Удалить среду?')) return
if (!confirm(t('projectView.delete_env_confirm'))) return
try {
await api.deleteEnvironment(props.id, envId)
await loadEnvironments()
@ -431,7 +433,7 @@ async function loadLinks() {
async function addLink() {
linkFormError.value = ''
if (!linkForm.value.to_project) { linkFormError.value = 'Выберите проект'; return }
if (!linkForm.value.to_project) { linkFormError.value = t('projectView.select_project'); return }
linkSaving.value = true
try {
await api.createProjectLink({
@ -451,7 +453,7 @@ async function addLink() {
}
async function deleteLink(id: number) {
if (!confirm('Удалить связь?')) return
if (!confirm(t('projectView.delete_link_confirm'))) return
try {
await api.deleteProjectLink(id)
await loadLinks()
@ -649,7 +651,7 @@ const runningTaskId = ref<string | null>(null)
async function runTask(taskId: string, event: Event) {
event.preventDefault()
event.stopPropagation()
if (!confirm(`Run pipeline for ${taskId}?`)) return
if (!confirm(t('projectView.run_pipeline_confirm', { n: taskId }))) return
runningTaskId.value = taskId
try {
// Sync task execution_mode with current project toggle state before running
@ -659,7 +661,7 @@ async function runTask(taskId: string, event: Event) {
if (activeTab.value === 'kanban') checkAndPollKanban()
} catch (e: any) {
if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
error.value = t('projectView.pipeline_already_running')
} else {
error.value = e.message
}
@ -681,13 +683,13 @@ async function patchTaskField(taskId: string, data: { priority?: number; route_t
}
// Kanban
const KANBAN_COLUMNS = [
{ status: 'pending', label: 'Pending', headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
{ status: 'in_progress', label: 'In Progress', headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
{ status: 'review', label: 'Review', headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
{ status: 'blocked', label: 'Blocked', headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
{ status: 'done', label: 'Done', headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
]
const KANBAN_COLUMNS = computed(() => [
{ status: 'pending', label: t('projectView.kanban_pending'), headerClass: 'text-gray-400', bgClass: 'bg-gray-900/20' },
{ status: 'in_progress', label: t('projectView.kanban_in_progress'), headerClass: 'text-blue-400', bgClass: 'bg-blue-950/20' },
{ status: 'review', label: t('projectView.kanban_review'), headerClass: 'text-purple-400', bgClass: 'bg-purple-950/20' },
{ status: 'blocked', label: t('projectView.kanban_blocked'), headerClass: 'text-red-400', bgClass: 'bg-red-950/20' },
{ status: 'done', label: t('projectView.kanban_done'), headerClass: 'text-green-400', bgClass: 'bg-green-950/20' },
])
const draggingTaskId = ref<string | null>(null)
const dragOverStatus = ref<string | null>(null)
@ -695,9 +697,9 @@ let kanbanPollTimer: ReturnType<typeof setInterval> | null = null
const kanbanTasksByStatus = computed(() => {
const result: Record<string, Task[]> = {}
for (const col of KANBAN_COLUMNS) result[col.status] = []
for (const t of searchFilteredTasks.value) {
if (result[t.status]) result[t.status].push(t)
for (const col of KANBAN_COLUMNS.value) result[col.status] = []
for (const task of searchFilteredTasks.value) {
if (result[task.status]) result[task.status].push(task)
}
return result
})
@ -782,15 +784,15 @@ async function addDecision() {
</script>
<template>
<div v-if="loading" class="text-gray-500 text-sm">Loading...</div>
<div v-if="loading" class="text-gray-500 text-sm">{{ t('common.loading') }}</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="project">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-1">
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">&larr; back</router-link>
<router-link to="/" class="text-gray-600 hover:text-gray-400 text-sm no-underline">{{ t('projectView.back') }}</router-link>
<span class="text-gray-700">|</span>
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">Чат</router-link>
<router-link :to="`/chat/${project.id}`" class="text-indigo-500 hover:text-indigo-400 text-sm no-underline">{{ t('projectView.chat') }}</router-link>
</div>
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
@ -806,7 +808,7 @@ async function addDecision() {
class="px-3 py-1 text-xs bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50 ml-auto"
>
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ deploying ? 'Deploying...' : 'Deploy' }}
{{ deploying ? t('taskDetail.deploying') : t('projectView.deploy') }}
</button>
</div>
@ -815,7 +817,7 @@ async function addDecision() {
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
</span>
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
@ -841,7 +843,7 @@ async function addDecision() {
</template>
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
@ -878,7 +880,7 @@ async function addDecision() {
:class="activeTab === tab
? 'text-gray-200 border-blue-500'
: 'text-gray-500 border-transparent hover:text-gray-300'">
{{ tab === 'kanban' ? 'Kanban' : tab === 'environments' ? 'Среды' : tab === 'links' ? 'Links' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'tasks' ? t('projectView.tasks_tab') : tab === 'phases' ? t('projectView.phases_tab') : tab === 'decisions' ? t('projectView.decisions_tab') : tab === 'modules' ? t('projectView.modules_tab') : tab === 'kanban' ? t('projectView.kanban_tab') : tab === 'environments' ? t('projectView.environments') : t('projectView.links_tab') }}
<span class="text-xs text-gray-600 ml-1">
{{ tab === 'tasks' ? project.tasks.length
: tab === 'phases' ? phases.length
@ -930,25 +932,25 @@ async function addDecision() {
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '&#x2713; Автотест' : 'Автотест' }}
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
</button>
<button @click="toggleWorktrees"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="worktrees
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
{{ worktrees ? '&#x2713; Worktrees' : 'Worktrees' }}
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
</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...' : 'Audit backlog' }}
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
</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">
+ Task
{{ t('projectView.add_task') }}
</button>
</div>
</div>
@ -959,7 +961,7 @@ async function addDecision() {
:class="!selectedCategory
? 'bg-gray-700/60 text-gray-300 border-gray-600'
: 'bg-gray-900 text-gray-600 border-gray-800 hover:text-gray-400 hover:border-gray-700'"
>Все</button>
>{{ t('projectView.all_statuses') }}</button>
<button v-for="cat in taskCategories" :key="cat"
@click="selectedCategory = cat"
class="px-2 py-0.5 text-xs rounded border transition-colors"
@ -972,7 +974,7 @@ async function addDecision() {
</div>
<!-- Search -->
<div class="flex items-center gap-1">
<input v-model="taskSearch" placeholder="Поиск по задачам..."
<input v-model="taskSearch" :placeholder="t('projectView.search_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>
@ -981,7 +983,7 @@ async function addDecision() {
<!-- Manual escalation tasks -->
<div v-if="manualEscalationTasks.length" class="mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">&#9888; Требуют ручного решения</span>
<span class="text-xs font-semibold text-orange-400 uppercase tracking-wide">{{ t('projectView.manual_escalations_warn') }}</span>
<span class="text-xs text-orange-600">({{ manualEscalationTasks.length }})</span>
</div>
<div class="space-y-1">
@ -1003,7 +1005,7 @@ async function addDecision() {
</div>
</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">No tasks.</div>
<div v-if="filteredTasks.length === 0" class="text-gray-600 text-sm">{{ t('projectView.no_tasks') }}</div>
<div v-else class="space-y-1">
<router-link v-for="t in filteredTasks" :key="t.id"
:to="{ path: `/task/${t.id}`, query: selectedStatuses.length ? { back_status: selectedStatuses.join(',') } : undefined }"
@ -1077,7 +1079,7 @@ async function addDecision() {
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0"></button>
</div>
</div>
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
<p v-if="phasesLoading" class="text-gray-500 text-sm">{{ t('projectView.loading_phases') }}</p>
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
No research phases. Use "New Project" to start a research workflow.
@ -1224,7 +1226,7 @@ async function addDecision() {
<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="Поиск..."
<input v-model="taskSearch" :placeholder="t('projectView.search_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>
@ -1252,25 +1254,25 @@ async function addDecision() {
? 'bg-blue-900/30 text-blue-400 border-blue-800 hover:bg-blue-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="autoTest ? 'Auto-test: on — запускать тесты после pipeline' : 'Auto-test: off'">
{{ autoTest ? '&#x2713; Автотест' : 'Автотест' }}
{{ autoTest ? '✓ ' + t('projectView.auto_test_label') : t('projectView.auto_test_label') }}
</button>
<button @click="toggleWorktrees"
class="px-2 py-1 text-xs border rounded transition-colors"
:class="worktrees
? 'bg-teal-900/30 text-teal-400 border-teal-800 hover:bg-teal-900/50'
: 'bg-gray-800/50 text-gray-400 border-gray-700 hover:bg-gray-800'"
:title="worktrees ? 'Worktrees: on — агенты в изолированных git worktrees' : 'Worktrees: off'">
{{ worktrees ? '&#x2713; Worktrees' : 'Worktrees' }}
:title="worktrees ? 'Worktrees: on' : 'Worktrees: off'">
{{ worktrees ? t('projectView.worktrees_on') : t('projectView.worktrees_off') }}
</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...' : 'Аудит' }}
{{ auditLoading ? 'Auditing...' : t('projectView.audit_backlog') }}
</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">
+ Тас
{{ t('projectView.add_task') }}
</button>
</div>
</div>
@ -1432,7 +1434,7 @@ async function addDecision() {
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
<div class="flex gap-2 justify-end">
<button type="button" @click="showAddLink = false; linkFormError = ''"
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">Отмена</button>
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">{{ t('common.cancel') }}</button>
<button type="submit" :disabled="linkSaving"
class="px-4 py-1.5 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ linkSaving ? 'Saving...' : 'Add Link' }}
@ -1597,7 +1599,7 @@ async function addDecision() {
<p v-if="taskReviseError" class="text-red-400 text-xs">{{ taskReviseError }}</p>
<button @click="submitTaskRevise" :disabled="taskReviseSaving"
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900 disabled:opacity-50">
{{ taskReviseSaving ? 'Отправляем...' : 'Отправить на доработку' }}
{{ taskReviseSaving ? t('common.saving') : t('taskDetail.send_to_revision') }}
</button>
</div>
</Modal>

View file

@ -1,7 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type ObsidianSyncResult, type ProjectLink } from '../api'
const { t } = useI18n()
const projects = ref<Project[]>([])
const vaultPaths = ref<Record<string, string>>({})
const deployCommands = ref<Record<string, string>>({})
@ -71,9 +74,9 @@ async function saveDeployConfig(projectId: string) {
deploy_restart_cmd: deployRestartCmds.value[projectId],
deploy_command: deployCommands.value[projectId],
})
saveDeployConfigStatus.value[projectId] = 'Saved'
saveDeployConfigStatus.value[projectId] = t('common.saved')
} catch (e: unknown) {
saveDeployConfigStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
saveDeployConfigStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
} finally {
savingDeployConfig.value[projectId] = false
}
@ -84,9 +87,9 @@ async function saveVaultPath(projectId: string) {
saveStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { obsidian_vault_path: vaultPaths.value[projectId] })
saveStatus.value[projectId] = 'Saved'
saveStatus.value[projectId] = t('common.saved')
} catch (e: unknown) {
saveStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
saveStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
} finally {
saving.value[projectId] = false
}
@ -97,9 +100,9 @@ async function saveTestCommand(projectId: string) {
saveTestStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { test_command: testCommands.value[projectId] })
saveTestStatus.value[projectId] = 'Saved'
saveTestStatus.value[projectId] = t('common.saved')
} catch (e: unknown) {
saveTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
saveTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
} finally {
savingTest.value[projectId] = false
}
@ -111,10 +114,10 @@ async function toggleAutoTest(projectId: string) {
saveAutoTestStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { auto_test_enabled: autoTestEnabled.value[projectId] })
saveAutoTestStatus.value[projectId] = 'Saved'
saveAutoTestStatus.value[projectId] = t('common.saved')
} catch (e: unknown) {
autoTestEnabled.value[projectId] = !autoTestEnabled.value[projectId]
saveAutoTestStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
saveAutoTestStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
} finally {
savingAutoTest.value[projectId] = false
}
@ -126,10 +129,10 @@ async function toggleWorktrees(projectId: string) {
saveWorktreesStatus.value[projectId] = ''
try {
await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] })
saveWorktreesStatus.value[projectId] = 'Saved'
saveWorktreesStatus.value[projectId] = t('common.saved')
} catch (e: unknown) {
worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId]
saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}`
saveWorktreesStatus.value[projectId] = `${t('common.error')}: ${e instanceof Error ? e.message : String(e)}`
} finally {
savingWorktrees.value[projectId] = false
}
@ -162,7 +165,7 @@ async function loadLinks(projectId: string) {
async function addLink(projectId: string) {
const form = linkForms.value[projectId]
if (!form.to_project) { linkError.value[projectId] = '&#x412;&#x44B;&#x431;&#x435;&#x440;&#x438;&#x442;&#x435; &#x43F;&#x440;&#x43E;&#x435;&#x43A;&#x442;'; return }
if (!form.to_project) { linkError.value[projectId] = t('settings.select_project_error'); return }
linkSaving.value[projectId] = true
linkError.value[projectId] = ''
try {
@ -183,7 +186,7 @@ async function addLink(projectId: string) {
}
async function deleteLink(projectId: string, linkId: number) {
if (!confirm('&#x423;&#x434;&#x430;&#x43B;&#x438;&#x442;&#x44C; &#x441;&#x432;&#x44F;&#x437;&#x44C;?')) return
if (!confirm(t('settings.delete_link_confirm'))) return
try {
await api.deleteProjectLink(linkId)
await loadLinks(projectId)
@ -195,7 +198,7 @@ async function deleteLink(projectId: string, linkId: number) {
<template>
<div>
<h1 class="text-xl font-semibold text-gray-100 mb-6">Settings</h1>
<h1 class="text-xl font-semibold text-gray-100 mb-6">{{ t('settings.title') }}</h1>
<div v-if="error" class="text-red-400 mb-4">{{ error }}</div>
@ -206,7 +209,7 @@ async function deleteLink(projectId: string, linkId: number) {
</div>
<div class="mb-3">
<label class="block text-xs text-gray-400 mb-1">Obsidian Vault Path</label>
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.obsidian_vault_path') }}</label>
<input
v-model="vaultPaths[project.id]"
type="text"
@ -216,14 +219,14 @@ async function deleteLink(projectId: string, linkId: number) {
</div>
<div class="mb-3">
<label class="block text-xs text-gray-400 mb-1">Test Command</label>
<label class="block text-xs text-gray-400 mb-1">{{ t('settings.test_command') }}</label>
<input
v-model="testCommands[project.id]"
type="text"
placeholder="make test"
class="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 font-mono focus:outline-none focus:border-gray-500"
/>
<p class="text-xs text-gray-600 mt-1">&#x41A;&#x43E;&#x43C;&#x430;&#x43D;&#x434;&#x430; &#x437;&#x430;&#x43F;&#x443;&#x441;&#x43A;&#x430; &#x442;&#x435;&#x441;&#x442;&#x43E;&#x432;, &#x432;&#x44B;&#x43F;&#x43E;&#x43B;&#x43D;&#x44F;&#x435;&#x442;&#x441;&#x44F; &#x447;&#x435;&#x440;&#x435;&#x437; shell &#x432; &#x434;&#x438;&#x440;&#x435;&#x43A;&#x442;&#x43E;&#x440;&#x438;&#x438; &#x43F;&#x440;&#x43E;&#x435;&#x43A;&#x442;&#x430;.</p>
<p class="text-xs text-gray-600 mt-1">{{ t('settings.test_command_hint') }}</p>
</div>
<div class="flex items-center gap-3 flex-wrap mb-3">
@ -232,7 +235,7 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="savingTest[project.id]"
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
>
{{ savingTest[project.id] ? 'Saving&#x2026;' : 'Save Test' }}
{{ savingTest[project.id] ? t('settings.saving_test') : t('settings.save_test') }}
</button>
<span v-if="saveTestStatus[project.id]" class="text-xs" :class="saveTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveTestStatus[project.id] }}
@ -241,9 +244,9 @@ async function deleteLink(projectId: string, linkId: number) {
<!-- Deploy Config -->
<div class="mb-2 pt-2 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-400 mb-2">Deploy Config</p>
<p class="text-xs font-semibold text-gray-400 mb-2">{{ t('settings.deploy_config') }}</p>
<div class="mb-2">
<label class="block text-xs text-gray-500 mb-1">Server host</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.server_host') }}</label>
<input
v-model="deployHosts[project.id]"
type="text"
@ -252,7 +255,7 @@ async function deleteLink(projectId: string, linkId: number) {
/>
</div>
<div class="mb-2">
<label class="block text-xs text-gray-500 mb-1">Project path on server</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.project_path_on_server') }}</label>
<input
v-model="deployPaths[project.id]"
type="text"
@ -261,12 +264,12 @@ async function deleteLink(projectId: string, linkId: number) {
/>
</div>
<div class="mb-2">
<label class="block text-xs text-gray-500 mb-1">Runtime</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.runtime') }}</label>
<select
v-model="deployRuntimes[project.id]"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300 focus:outline-none focus:border-gray-500"
>
<option value="">&#x2014; &#x432;&#x44B;&#x431;&#x435;&#x440;&#x438;&#x442;&#x435; runtime &#x2014;</option>
<option value="">{{ t('settings.select_runtime') }}</option>
<option value="docker">docker</option>
<option value="node">node</option>
<option value="python">python</option>
@ -274,7 +277,7 @@ async function deleteLink(projectId: string, linkId: number) {
</select>
</div>
<div class="mb-2">
<label class="block text-xs text-gray-500 mb-1">Restart command (optional override)</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.restart_command') }}</label>
<input
v-model="deployRestartCmds[project.id]"
type="text"
@ -283,7 +286,7 @@ async function deleteLink(projectId: string, linkId: number) {
/>
</div>
<div class="mb-2">
<label class="block text-xs text-gray-500 mb-1">Fallback command (legacy, used when runtime not set)</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('settings.fallback_command') }}</label>
<input
v-model="deployCommands[project.id]"
type="text"
@ -297,7 +300,7 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="savingDeployConfig[project.id]"
class="px-3 py-1.5 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50"
>
{{ savingDeployConfig[project.id] ? 'Saving&#x2026;' : 'Save Deploy Config' }}
{{ savingDeployConfig[project.id] ? t('settings.saving_deploy') : t('settings.save_deploy_config') }}
</button>
<span v-if="saveDeployConfigStatus[project.id]" class="text-xs" :class="saveDeployConfigStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveDeployConfigStatus[project.id] }}
@ -308,28 +311,28 @@ async function deleteLink(projectId: string, linkId: number) {
<!-- Project Links -->
<div class="mb-2 pt-2 border-t border-gray-800">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-gray-400">Project Links</p>
<p class="text-xs font-semibold text-gray-400">{{ t('settings.project_links') }}</p>
<button
@click="showAddLinkForm[project.id] = !showAddLinkForm[project.id]"
class="px-2 py-0.5 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700"
>
+ Add Link
{{ t('settings.add_link') }}
</button>
</div>
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">&#x417;&#x430;&#x433;&#x440;&#x443;&#x437;&#x43A;&#x430;...</p>
<p v-if="linksLoading[project.id]" class="text-xs text-gray-500">{{ t('settings.links_loading') }}</p>
<p v-else-if="linkError[project.id]" class="text-xs text-red-400">{{ linkError[project.id] }}</p>
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">&#x41D;&#x435;&#x442; &#x441;&#x432;&#x44F;&#x437;&#x435;&#x439;</div>
<div v-else-if="!projectLinksMap[project.id]?.length" class="text-xs text-gray-600">{{ t('settings.no_links') }}</div>
<div v-else class="space-y-1 mb-2">
<div v-for="link in projectLinksMap[project.id]" :key="link.id"
class="flex items-center gap-2 px-2 py-1 bg-gray-900 border border-gray-800 rounded text-xs">
<span class="text-gray-500 font-mono">{{ link.from_project }}</span>
<span class="text-gray-600">&#x2192;</span>
<span class="text-gray-600"></span>
<span class="text-gray-500 font-mono">{{ link.to_project }}</span>
<span class="px-1 bg-indigo-900/30 text-indigo-400 border border-indigo-800 rounded">{{ link.type }}</span>
<span v-if="link.description" class="text-gray-600">{{ link.description }}</span>
<button @click="deleteLink(project.id, link.id)"
class="ml-auto text-red-500 hover:text-red-400 bg-transparent border-none cursor-pointer text-xs shrink-0">
&#x2715;
</button>
</div>
</div>
@ -342,8 +345,8 @@ async function deleteLink(projectId: string, linkId: number) {
<label class="block text-[10px] text-gray-500 mb-0.5">To project</label>
<select v-model="linkForms[project.id].to_project" required
class="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-gray-300">
<option value="">&#x2014; &#x432;&#x44B;&#x431;&#x435;&#x440;&#x438;&#x442;&#x435; &#x43F;&#x440;&#x43E;&#x435;&#x43A;&#x442; &#x2014;</option>
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} &#x2014; {{ p.name }}</option>
<option value="">{{ t('settings.select_project') }}</option>
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} {{ p.name }}</option>
</select>
</div>
<div>
@ -363,11 +366,11 @@ async function deleteLink(projectId: string, linkId: number) {
<div class="flex gap-2">
<button type="submit" :disabled="linkSaving[project.id]"
class="px-3 py-1 text-xs bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
{{ linkSaving[project.id] ? 'Saving...' : 'Add' }}
{{ linkSaving[project.id] ? t('settings.saving_link') : t('common.add') }}
</button>
<button type="button" @click="showAddLinkForm[project.id] = false; linkError[project.id] = ''"
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-300 bg-transparent border-none cursor-pointer">
&#x41E;&#x442;&#x43C;&#x435;&#x43D;&#x430;
{{ t('settings.cancel_link') }}
</button>
</div>
</form>
@ -382,8 +385,8 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="savingAutoTest[project.id]"
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
/>
<span class="text-sm text-gray-300">Auto-test</span>
<span class="text-xs text-gray-500">&#x2014; &#x437;&#x430;&#x43F;&#x443;&#x441;&#x43A;&#x430;&#x442;&#x44C; &#x442;&#x435;&#x441;&#x442;&#x44B; &#x430;&#x432;&#x442;&#x43E;&#x43C;&#x430;&#x442;&#x438;&#x447;&#x435;&#x441;&#x43A;&#x438; &#x43F;&#x43E;&#x441;&#x43B;&#x435; pipeline</span>
<span class="text-sm text-gray-300">{{ t('settings.auto_test') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.auto_test_hint') }}</span>
</label>
<span v-if="saveAutoTestStatus[project.id]" class="text-xs" :class="saveAutoTestStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveAutoTestStatus[project.id] }}
@ -399,8 +402,8 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="savingWorktrees[project.id]"
class="w-4 h-4 rounded border-gray-600 bg-gray-800 accent-blue-500 cursor-pointer disabled:opacity-50"
/>
<span class="text-sm text-gray-300">Worktrees</span>
<span class="text-xs text-gray-500"> агенты запускаются в изолированных git worktrees</span>
<span class="text-sm text-gray-300">{{ t('settings.worktrees') }}</span>
<span class="text-xs text-gray-500">{{ t('settings.worktrees_hint') }}</span>
</label>
<span v-if="saveWorktreesStatus[project.id]" class="text-xs" :class="saveWorktreesStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">
{{ saveWorktreesStatus[project.id] }}
@ -413,7 +416,7 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="saving[project.id]"
class="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 text-gray-200 rounded disabled:opacity-50"
>
{{ saving[project.id] ? 'Saving&#x2026;' : 'Save Vault' }}
{{ saving[project.id] ? t('settings.saving_vault') : t('settings.save_vault') }}
</button>
<button
@ -421,7 +424,7 @@ async function deleteLink(projectId: string, linkId: number) {
:disabled="syncing[project.id] || !vaultPaths[project.id]"
class="px-3 py-1.5 text-sm bg-indigo-700 hover:bg-indigo-600 text-white rounded disabled:opacity-50"
>
{{ syncing[project.id] ? 'Syncing&#x2026;' : 'Sync Obsidian' }}
{{ syncing[project.id] ? t('settings.syncing') : t('settings.sync_obsidian') }}
</button>
<span v-if="saveStatus[project.id]" class="text-xs" :class="saveStatus[project.id].startsWith('Error') ? 'text-red-400' : 'text-green-400'">

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type TaskFull, type PipelineStep, type PendingAction, type DeployResult, type Attachment } from '../api'
import Badge from '../components/Badge.vue'
import Modal from '../components/Modal.vue'
@ -11,6 +12,7 @@ import LiveConsole from '../components/LiveConsole.vue'
const props = defineProps<{ id: string }>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const task = ref<TaskFull | null>(null)
const loading = ref(true)
@ -45,15 +47,14 @@ const parsedSelectedOutput = computed<ParsedAgentOutput | null>(() => {
// Auto/Review mode (per-task, persisted in DB; falls back to localStorage per project)
const autoMode = ref(false)
function loadMode(t: typeof task.value) {
if (!t) return
if (t.execution_mode) {
autoMode.value = t.execution_mode === 'auto_complete'
} else if (t.status === 'review') {
// Task is in review always show Approve/Reject regardless of localStorage
function loadMode(t_val: typeof task.value) {
if (!t_val) return
if (t_val.execution_mode) {
autoMode.value = t_val.execution_mode === 'auto_complete'
} else if (t_val.status === 'review') {
autoMode.value = false
} else {
autoMode.value = localStorage.getItem(`kin-mode-${t.project_id}`) === 'auto_complete'
autoMode.value = localStorage.getItem(`kin-mode-${t_val.project_id}`) === 'auto_complete'
}
}
@ -74,11 +75,9 @@ async function load() {
const prev = task.value
task.value = await api.taskFull(props.id)
loadMode(task.value)
// Auto-start polling if task is in_progress
if (task.value.status === 'in_progress' && !polling.value) {
startPolling()
}
// Stop polling when pipeline done
if (prev?.status === 'in_progress' && task.value.status !== 'in_progress') {
stopPolling()
}
@ -241,7 +240,6 @@ async function runPipeline() {
claudeLoginError.value = false
pipelineStarting.value = true
try {
// Sync task execution_mode with current toggle state before running
const targetMode = autoMode.value ? 'auto_complete' : 'review'
if (task.value && task.value.execution_mode !== targetMode) {
const updated = await api.patchTask(props.id, { execution_mode: targetMode })
@ -254,7 +252,7 @@ async function runPipeline() {
if (e instanceof ApiError && e.code === 'claude_auth_required') {
claudeLoginError.value = true
} else if (e instanceof ApiError && e.code === 'task_already_running') {
error.value = 'Pipeline уже запущен'
error.value = t('taskDetail.pipeline_already_running')
} else {
error.value = e.message
}
@ -271,7 +269,7 @@ const resolvingManually = ref(false)
async function resolveManually() {
if (!task.value) return
if (!confirm('Пометить задачу как решённую вручную?')) return
if (!confirm(t('taskDetail.mark_resolved_confirm'))) return
resolvingManually.value = true
try {
const updated = await api.patchTask(props.id, { status: 'done' })
@ -386,7 +384,7 @@ async function saveEdit() {
</script>
<template>
<div v-if="loading && !task" class="text-gray-500 text-sm">Loading...</div>
<div v-if="loading && !task" class="text-gray-500 text-sm">{{ t('taskDetail.loading') }}</div>
<div v-else-if="error && !task" class="text-red-400 text-sm">{{ error }}</div>
<div v-else-if="task">
<!-- Header -->
@ -422,7 +420,7 @@ async function saveEdit() {
<!-- Manual escalation context banner -->
<div v-if="isManualEscalation" class="mb-3 px-3 py-2 border border-orange-800/60 bg-orange-950/20 rounded">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-semibold text-orange-400">&#9888; Требует ручного решения</span>
<span class="text-xs font-semibold text-orange-400">{{ t('taskDetail.requires_manual') }}</span>
<span v-if="task.parent_task_id" class="text-xs text-gray-600">
эскалация из
<router-link :to="`/task/${task.parent_task_id}`" class="text-orange-600 hover:text-orange-400">
@ -432,15 +430,15 @@ async function saveEdit() {
</div>
<p class="text-xs text-orange-300">{{ task.title }}</p>
<p v-if="task.brief?.description" class="text-xs text-gray-400 mt-1">{{ task.brief.description }}</p>
<p class="text-xs text-gray-600 mt-1">Автопилот не смог выполнить это автоматически. Примите меры вручную и нажмите «Решить вручную».</p>
<p class="text-xs text-gray-600 mt-1">{{ t('taskDetail.autopilot_failed') }}</p>
</div>
<!-- Dangerous skip warning banner -->
<div v-if="task.dangerously_skipped" class="mb-3 px-3 py-2 border border-red-700 bg-red-950/40 rounded flex items-start gap-2">
<span class="text-red-400 text-base shrink-0">&#9888;</span>
<div>
<span class="text-xs font-semibold text-red-400">--dangerously-skip-permissions использовался в этой задаче</span>
<p class="text-xs text-red-300/70 mt-0.5">Агент выполнял команды с обходом проверок разрешений. Проверьте pipeline-шаги и сделанные изменения.</p>
<span class="text-xs font-semibold text-red-400">{{ t('taskDetail.dangerously_skipped') }}</span>
<p class="text-xs text-red-300/70 mt-0.5">{{ t('taskDetail.dangerously_skipped_hint') }}</p>
</div>
</div>
@ -448,7 +446,7 @@ async function saveEdit() {
Brief: {{ JSON.stringify(task.brief) }}
</div>
<div v-if="task.acceptance_criteria" class="mb-2 px-3 py-2 border border-gray-700 bg-gray-900/40 rounded">
<div class="text-xs font-semibold text-gray-400 mb-1">Критерии приёмки</div>
<div class="text-xs font-semibold text-gray-400 mb-1">{{ t('taskDetail.acceptance_criteria') }}</div>
<p class="text-xs text-gray-300 whitespace-pre-wrap">{{ task.acceptance_criteria }}</p>
</div>
<div v-if="task.status === 'blocked' && task.blocked_reason" class="text-xs text-red-400 mb-1 bg-red-950/30 border border-red-800/40 rounded px-2 py-1">
@ -462,8 +460,8 @@ async function saveEdit() {
<!-- Pipeline Graph -->
<div v-if="hasSteps || isRunning" class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-3">
Pipeline
<span v-if="isRunning" class="text-blue-400 text-xs font-normal ml-2 animate-pulse">running...</span>
{{ 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">
<template v-for="(step, i) in task.pipeline_steps" :key="step.id">
@ -493,7 +491,7 @@ async function saveEdit() {
<!-- No pipeline -->
<div v-if="!hasSteps && !isRunning" class="mb-6 text-sm text-gray-600">
No pipeline steps yet.
{{ t('taskDetail.no_pipeline') }}
</div>
<!-- Live Console -->
@ -516,7 +514,7 @@ async function saveEdit() {
<div class="p-4">
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ parsedSelectedOutput.verdict }}</p>
<details v-if="parsedSelectedOutput.details !== null" class="mt-3">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">&darr; подробнее</summary>
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-400 select-none">{{ t('taskDetail.more_details') }}</summary>
<pre class="mt-2 text-xs text-gray-500 overflow-x-auto whitespace-pre-wrap max-h-[400px] overflow-y-auto">{{ parsedSelectedOutput.details }}</pre>
</details>
</div>
@ -542,7 +540,7 @@ async function saveEdit() {
<!-- Attachments -->
<div class="mb-6">
<h2 class="text-sm font-semibold text-gray-300 mb-2">Вложения</h2>
<h2 class="text-sm font-semibold text-gray-300 mb-2">{{ t('taskDetail.attachments') }}</h2>
<AttachmentList :attachments="attachments" :task-id="props.id" @deleted="loadAttachments" />
<AttachmentUploader :task-id="props.id" @uploaded="loadAttachments" />
</div>
@ -552,22 +550,22 @@ async function saveEdit() {
<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>
Автопилот активен
{{ t('taskDetail.autopilot_active') }}
</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
{{ t('taskDetail.approve_task') }}
</button>
<button v-if="task.status === 'review' && !autoMode"
@click="showRevise = true"
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900">
&#x1F504; Revise
{{ t('taskDetail.revise_task') }}
</button>
<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
{{ t('taskDetail.reject_task') }}
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked' || task.status === 'review'"
@click="toggleMode"
@ -581,28 +579,28 @@ async function saveEdit() {
<button v-if="task.status === 'pending'"
@click="openEdit"
class="px-3 py-2 text-sm bg-gray-800/50 text-gray-400 border border-gray-700 rounded hover:bg-gray-800">
&#9998; Edit
{{ t('taskDetail.edit') }}
</button>
<button v-if="task.status === 'pending' || task.status === 'blocked'"
@click="runPipeline"
:disabled="polling || pipelineStarting"
class="px-4 py-2 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
<span v-if="polling || pipelineStarting" class="inline-block w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ (polling || pipelineStarting) ? 'Pipeline running...' : '&#9654; Run Pipeline' }}
{{ (polling || pipelineStarting) ? t('taskDetail.pipeline_running') : t('taskDetail.run_pipeline') }}
</button>
<button v-if="isManualEscalation && task.status !== 'done' && task.status !== 'cancelled'"
@click="resolveManually"
:disabled="resolvingManually"
class="px-4 py-2 text-sm bg-orange-900/50 text-orange-400 border border-orange-800 rounded hover:bg-orange-900 disabled:opacity-50">
<span v-if="resolvingManually" class="inline-block w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ resolvingManually ? 'Сохраняем...' : '&#10003; Решить вручную' }}
{{ resolvingManually ? t('taskDetail.resolving') : t('taskDetail.resolve_manually') }}
</button>
<button v-if="task.status === 'done' && (task.project_deploy_command || task.project_deploy_runtime)"
@click.stop="runDeploy"
:disabled="deploying"
class="px-4 py-2 text-sm bg-teal-900/50 text-teal-400 border border-teal-800 rounded hover:bg-teal-900 disabled:opacity-50">
<span v-if="deploying" class="inline-block w-3 h-3 border-2 border-teal-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ deploying ? 'Deploying...' : '&#x1F680; Deploy' }}
{{ deploying ? t('taskDetail.deploying') : t('taskDetail.deploy') }}
</button>
</div>
@ -611,9 +609,9 @@ async function saveEdit() {
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-sm font-semibold text-yellow-300">&#9888; Claude CLI requires login</p>
<p class="text-xs text-yellow-200/80 mt-1">Откройте терминал и выполните:</p>
<p class="text-xs text-yellow-200/80 mt-1">{{ t('taskDetail.terminal_login_hint') }}</p>
<code class="text-xs text-yellow-400 font-mono bg-black/30 px-2 py-0.5 rounded mt-1 inline-block">claude login</code>
<p class="text-xs text-gray-500 mt-1">После входа повторите запуск pipeline.</p>
<p class="text-xs text-gray-500 mt-1">{{ t('taskDetail.login_after_hint') }}</p>
</div>
<button @click="claudeLoginError = false" class="text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs shrink-0"></button>
</div>
@ -624,7 +622,7 @@ async function saveEdit() {
:class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
<div class="flex items-center gap-2 mb-1">
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
{{ deployResult.overall_success !== false && deployResult.success ? t('taskDetail.deploy_succeeded') : t('taskDetail.deploy_failed') }}
</span>
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
@ -650,7 +648,7 @@ async function saveEdit() {
</template>
<!-- Dependents -->
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
<p class="text-xs text-gray-400 font-semibold mb-1">{{ t('taskDetail.dependent_projects') }}</p>
<div v-for="dep in deployResult.dependents_deployed" :key="dep" class="flex items-center gap-2 px-2 py-0.5">
<span class="text-teal-400 font-semibold text-[10px]">ok</span>
<span class="text-gray-300 text-[11px]">{{ dep }}</span>
@ -704,9 +702,9 @@ async function saveEdit() {
Create follow-up tasks from pipeline results
</label>
<p class="text-xs text-gray-500">Optionally record a decision:</p>
<input v-model="approveForm.title" placeholder="Decision title (optional)"
<input v-model="approveForm.title" :placeholder="t('taskDetail.decision_title_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<textarea v-if="approveForm.title" v-model="approveForm.description" placeholder="Description"
<textarea v-if="approveForm.title" v-model="approveForm.description" :placeholder="t('taskDetail.description_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y" rows="2"></textarea>
<button type="submit" :disabled="approveLoading"
class="w-full py-2 bg-green-900/50 text-green-400 border border-green-800 rounded text-sm hover:bg-green-900 disabled:opacity-50">
@ -728,14 +726,14 @@ async function saveEdit() {
</Modal>
<!-- Revise Modal -->
<Modal v-if="showRevise" title="&#x1F504; Revise Task" @close="showRevise = false">
<Modal v-if="showRevise" :title="t('taskDetail.send_to_revision')" @close="showRevise = false">
<form @submit.prevent="revise" class="space-y-3">
<p class="text-xs text-gray-500">Опишите, что доработать или уточнить агенту. Задача вернётся в работу с вашим комментарием.</p>
<textarea v-model="reviseComment" placeholder="Что доработать / уточнить..." rows="4" required
<textarea v-model="reviseComment" :placeholder="t('taskDetail.revise_placeholder')" rows="4" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
<button type="submit"
class="w-full py-2 bg-orange-900/50 text-orange-400 border border-orange-800 rounded text-sm hover:bg-orange-900">
&#x1F504; Отправить на доработку
{{ t('taskDetail.send_to_revision') }}
</button>
</form>
</Modal>
@ -744,30 +742,30 @@ async function saveEdit() {
<Modal v-if="showEdit" title="Edit Task" @close="showEdit = false">
<form @submit.prevent="saveEdit" class="space-y-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Title</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.title_label') }}</label>
<input v-model="editForm.title" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Brief</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.brief_label') }}</label>
<textarea v-model="editForm.briefText" rows="4" placeholder="Task description..."
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Priority (110)</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.priority_label') }}</label>
<input v-model.number="editForm.priority" type="number" min="1" max="10" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Критерии приёмки</label>
<label class="block text-xs text-gray-500 mb-1">{{ t('taskDetail.acceptance_criteria_label') }}</label>
<textarea v-model="editForm.acceptanceCriteria" rows="3"
placeholder="Что должно быть на выходе? Какой результат считается успешным?"
:placeholder="t('taskDetail.acceptance_criteria_placeholder')"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-y"></textarea>
</div>
<p v-if="editError" class="text-red-400 text-xs">{{ editError }}</p>
<button type="submit" :disabled="editLoading"
class="w-full py-2 bg-blue-900/50 text-blue-400 border border-blue-800 rounded text-sm hover:bg-blue-900 disabled:opacity-50">
{{ editLoading ? 'Saving...' : 'Save' }}
{{ editLoading ? t('common.saving') : t('common.save') }}
</button>
</form>
</Modal>