Compare commits

..

9 commits

Author SHA1 Message Date
Gros Frumos
e3a286ef6f kin: auto-commit after pipeline 2026-03-18 14:06:23 +02:00
Gros Frumos
824341a972 Merge branch 'KIN-124-backend_dev' 2026-03-18 14:04:02 +02:00
Gros Frumos
02e0628067 kin: KIN-124-backend_dev 2026-03-18 14:04:02 +02:00
Gros Frumos
77da120146 Merge branch 'KIN-124-backend_dev' 2026-03-18 13:33:29 +02:00
Gros Frumos
6fa2d8b3a6 kin: KIN-124-backend_dev 2026-03-18 13:33:29 +02:00
Gros Frumos
ce0b11ca3f Merge branch 'KIN-123-frontend_dev' 2026-03-18 11:33:24 +02:00
Gros Frumos
06c868b23a kin: KIN-123-frontend_dev 2026-03-18 11:33:24 +02:00
Gros Frumos
03d49f42e6 Merge branch 'KIN-108-frontend_dev' 2026-03-18 07:57:15 +02:00
Gros Frumos
353416ead1 kin: KIN-108-frontend_dev 2026-03-18 07:57:15 +02:00
24 changed files with 2454 additions and 221 deletions

View file

@ -826,7 +826,7 @@ _WORKTREE_ROLES = {"backend_dev", "frontend_dev", "debugger"}
_DEV_GUARD_ROLES = {"backend_dev", "frontend_dev", "debugger"}
def _detect_test_command(project_path: str) -> str | None:
def _detect_test_command(project_path: str, role: str | None = None) -> str | None:
"""Auto-detect test command by inspecting project files.
Candidates (in priority order):
@ -835,10 +835,22 @@ def _detect_test_command(project_path: str) -> str | None:
3. pytest pyproject.toml or setup.py exists
4. npx tsc --noEmit tsconfig.json exists
When role='backend_dev' and a Python project marker (pyproject.toml / setup.py)
is present, pytest is returned directly bypassing make test. This prevents
false-positive failures in mixed projects whose Makefile test target also runs
frontend (e.g. vitest) commands that may be unrelated to backend changes.
Returns the first matching command, or None if no framework is detected.
"""
path = Path(project_path)
# For backend_dev: Python project marker takes precedence over Makefile.
# Rationale: make test in mixed projects often runs frontend tests too;
# backend changes should only be validated by the Python test runner.
if role == "backend_dev":
if (path / "pyproject.toml").is_file() or (path / "setup.py").is_file():
return f"{sys.executable} -m pytest"
# 1. make test
makefile = path / "Makefile"
if makefile.is_file():
@ -870,11 +882,13 @@ def _detect_test_command(project_path: str) -> str | None:
return None
def _run_project_tests(project_path: str, test_command: str = 'make test', timeout: int = 120) -> dict:
def _run_project_tests(project_path: str, test_command: str = 'make test', timeout: int | None = None) -> dict:
"""Run test_command in project_path. Returns {success, output, returncode}.
Never raises all errors are captured and returned in output.
"""
if timeout is None:
timeout = int(os.environ.get("KIN_AUTO_TEST_TIMEOUT") or 600)
env = _build_claude_env()
parts = shlex.split(test_command)
if not parts:
@ -1880,7 +1894,7 @@ def run_pipeline(
if p_test_cmd_override:
p_test_cmd = p_test_cmd_override
else:
p_test_cmd = _detect_test_command(p_path_str)
p_test_cmd = _detect_test_command(p_path_str, role=role)
if p_test_cmd is None:
# No test framework detected — skip without blocking pipeline
@ -1894,7 +1908,8 @@ def run_pipeline(
})
else:
max_auto_test_attempts = int(os.environ.get("KIN_AUTO_TEST_MAX_ATTEMPTS") or 3)
test_run = _run_project_tests(p_path_str, p_test_cmd)
auto_test_timeout = int(os.environ.get("KIN_AUTO_TEST_TIMEOUT") or 600)
test_run = _run_project_tests(p_path_str, p_test_cmd, timeout=auto_test_timeout)
results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True})
auto_test_attempt = 0
@ -1917,7 +1932,7 @@ def run_pipeline(
total_tokens += fix_result.get("tokens_used") or 0
total_duration += fix_result.get("duration_seconds") or 0
results.append({**fix_result, "_auto_test_fix_attempt": auto_test_attempt})
test_run = _run_project_tests(p_path_str, p_test_cmd)
test_run = _run_project_tests(p_path_str, p_test_cmd, timeout=auto_test_timeout)
results.append({"role": "_auto_test", "success": test_run["success"],
"output": test_run["output"], "_project_test": True,
"_attempt": auto_test_attempt})

View file

@ -126,16 +126,25 @@ def generate_followups(
parsed = _try_parse_json(output)
if not isinstance(parsed, list):
if isinstance(parsed, dict):
parsed = parsed.get("tasks") or parsed.get("followups") or []
if "tasks" in parsed:
parsed = parsed["tasks"]
elif "followups" in parsed:
parsed = parsed["followups"]
else:
parsed = []
else:
return {"created": [], "pending_actions": []}
# Guard: extracted value might be null/non-list (e.g. {"tasks": null})
if not isinstance(parsed, list):
parsed = []
# Separate permission-blocked items from normal ones
created = []
pending_actions = []
for item in parsed:
if not isinstance(item, dict) or "title" not in item:
if not isinstance(item, dict) or not item.get("title"):
continue
if _is_permission_blocked(item):

View file

@ -31,13 +31,27 @@ def validate_completion_mode(value: str) -> str:
return "review"
# Columns that are stored as JSON strings and must be decoded on read.
# Text fields (title, description, name, etc.) are NOT in this set.
_JSON_COLUMNS: frozenset[str] = frozenset({
"tech_stack",
"brief", "spec", "review", "test_result", "security_result", "labels",
"tags",
"dependencies",
"steps",
"artifacts", "decisions_made", "blockers",
"extra_json",
"pending_actions",
})
def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
"""Convert sqlite3.Row to dict with JSON fields decoded."""
if row is None:
return None
d = dict(row)
for key, val in d.items():
if isinstance(val, str) and val.startswith(("[", "{")):
if key in _JSON_COLUMNS and isinstance(val, str) and val.startswith(("[", "{")):
try:
d[key] = json.loads(val)
except (json.JSONDecodeError, ValueError):

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,245 @@
"""Regression tests for KIN-124 — auto-test ложно определяет failure.
Root cause: make test запускал vitest после pytest, vitest падал на всех 16
тест-файлах с useI18n() без плагина i18n make test возвращал exit code != 0
auto-test считал весь прогон failed.
Исправления:
1. web/frontend/vite.config.ts: добавлен setupFiles с vitest-setup.ts
2. web/frontend/src/__tests__/vitest-setup.ts: глобальный i18n plugin для mount()
3. _detect_test_command(role='backend_dev'): возвращает pytest напрямую (не make test)
это предотвращает запуск vitest при backend_dev auto-test
Coverage:
(1) _run_project_tests: exit code 0 + "1533 passed" success=True (главный регрессион)
(2) _run_project_tests: exit code 1 + "2 failed" success=False
(3) _run_project_tests: exit code 0 + output содержит "failed" в середине success=True
(success определяется ТОЛЬКО по returncode, не по строке вывода)
(4) _detect_test_command: backend_dev + pyproject.toml возвращает pytest, не make test
(5) _detect_test_command: backend_dev + pyproject.toml + Makefile всё равно pytest
(6) _detect_test_command: frontend_dev + Makefile с test: возвращает make test
(7) _detect_test_command: frontend_dev + pyproject.toml (без Makefile) возвращает pytest
(8) _run_project_tests: timeout success=False, returncode=124
(9) _run_project_tests: команда не найдена success=False, returncode=127
(10) vite.config.ts содержит setupFiles с vitest-setup.ts
(11) vitest-setup.ts устанавливает i18n plugin глобально
"""
import subprocess
import sys
import os
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_subprocess_result(returncode: int, stdout: str = "", stderr: str = "") -> MagicMock:
"""Build a MagicMock simulating subprocess.CompletedProcess."""
r = MagicMock()
r.returncode = returncode
r.stdout = stdout
r.stderr = stderr
return r
# ---------------------------------------------------------------------------
# (1-3) _run_project_tests: success determined solely by returncode
# ---------------------------------------------------------------------------
class TestRunProjectTestsSuccessDetermination:
"""_run_project_tests must use returncode, never parse stdout for pass/fail."""
@patch("subprocess.run")
def test_exit_code_0_with_1533_passed_returns_success_true(self, mock_run):
"""Regression KIN-124: exit code 0 + '1533 passed' → success=True."""
from agents.runner import _run_project_tests
mock_run.return_value = _make_subprocess_result(
returncode=0,
stdout="===================== 1533 passed in 42.7s =====================\n",
)
result = _run_project_tests("/tmp/proj", "pytest")
assert result["success"] is True, (
f"Expected success=True for returncode=0, got: {result}"
)
assert result["returncode"] == 0
@patch("subprocess.run")
def test_exit_code_1_with_failed_output_returns_success_false(self, mock_run):
"""exit code 1 + '2 failed' output → success=False."""
from agents.runner import _run_project_tests
mock_run.return_value = _make_subprocess_result(
returncode=1,
stdout="FAILED tests/test_foo.py::test_bar\n2 failed, 10 passed in 3.1s\n",
)
result = _run_project_tests("/tmp/proj", "pytest")
assert result["success"] is False, (
f"Expected success=False for returncode=1, got: {result}"
)
assert result["returncode"] == 1
@patch("subprocess.run")
def test_exit_code_0_with_failed_substring_in_output_returns_success_true(self, mock_run):
"""exit code 0 but output has 'failed' in a log line → success=True.
Success must be based on returncode only not string matching.
Example: a test description containing 'failed' should not confuse auto-test.
"""
from agents.runner import _run_project_tests
mock_run.return_value = _make_subprocess_result(
returncode=0,
stdout=(
"tests/test_retry.py::test_handles_previously_failed_request PASSED\n"
"1 passed in 0.5s\n"
),
)
result = _run_project_tests("/tmp/proj", "pytest")
assert result["success"] is True, (
"success must be True when returncode=0, even if 'failed' appears in output"
)
@patch("subprocess.run")
def test_output_is_concatenation_of_stdout_and_stderr(self, mock_run):
"""output field = stdout + stderr (both captured)."""
from agents.runner import _run_project_tests
mock_run.return_value = _make_subprocess_result(
returncode=0,
stdout="1 passed\n",
stderr="PytestWarning: something\n",
)
result = _run_project_tests("/tmp/proj", "pytest")
assert "1 passed" in result["output"]
assert "PytestWarning" in result["output"]
# ---------------------------------------------------------------------------
# (8-9) _run_project_tests: error handling
# ---------------------------------------------------------------------------
class TestRunProjectTestsErrorHandling:
@patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="pytest", timeout=60))
def test_timeout_returns_success_false_and_returncode_124(self, mock_run):
"""Timeout → success=False, returncode=124."""
from agents.runner import _run_project_tests
result = _run_project_tests("/tmp/proj", "pytest", timeout=60)
assert result["success"] is False
assert result["returncode"] == 124
assert "timed out" in result["output"].lower()
@patch("subprocess.run", side_effect=FileNotFoundError("pytest: not found"))
def test_command_not_found_returns_success_false_and_returncode_127(self, mock_run):
"""Command not found → success=False, returncode=127."""
from agents.runner import _run_project_tests
result = _run_project_tests("/tmp/proj", "pytest")
assert result["success"] is False
assert result["returncode"] == 127
# ---------------------------------------------------------------------------
# (4-7) _detect_test_command: role-based logic
# ---------------------------------------------------------------------------
class TestDetectTestCommandRoleLogic:
"""_detect_test_command must return pytest (not make test) for backend_dev
when pyproject.toml is present. This prevents vitest from running during
backend-only changes (the root cause of KIN-124)."""
def test_backend_dev_with_pyproject_toml_returns_pytest_not_make_test(self, tmp_path):
"""Regression KIN-124: backend_dev + pyproject.toml → pytest, not make test."""
from agents.runner import _detect_test_command
# Create both pyproject.toml and Makefile with test target
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\n")
makefile = tmp_path / "Makefile"
makefile.write_text("test:\n\tmake test\n")
cmd = _detect_test_command(str(tmp_path), role="backend_dev")
assert cmd is not None
assert "pytest" in cmd, (
f"Expected pytest command for backend_dev, got: {cmd!r}. "
"backend_dev must not run make test (which triggers vitest)."
)
assert "make" not in cmd, (
f"backend_dev must not use make test, got: {cmd!r}"
)
def test_backend_dev_with_only_pyproject_toml_returns_pytest(self, tmp_path):
"""backend_dev + only pyproject.toml (no Makefile) → pytest."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[build-system]\n")
cmd = _detect_test_command(str(tmp_path), role="backend_dev")
assert cmd is not None
assert "pytest" in cmd
def test_frontend_dev_with_makefile_returns_make_test(self, tmp_path):
"""frontend_dev + Makefile with test: target → make test (correct for frontend)."""
from agents.runner import _detect_test_command
(tmp_path / "Makefile").write_text("test:\n\tnpm test\n")
cmd = _detect_test_command(str(tmp_path), role="frontend_dev")
assert cmd == "make test", (
f"Expected 'make test' for frontend_dev with Makefile, got: {cmd!r}"
)
def test_frontend_dev_with_pyproject_toml_no_makefile_returns_pytest(self, tmp_path):
"""frontend_dev + pyproject.toml (no Makefile) → pytest (fallback)."""
from agents.runner import _detect_test_command
(tmp_path / "pyproject.toml").write_text("[tool.pytest]\n")
cmd = _detect_test_command(str(tmp_path), role="frontend_dev")
assert cmd is not None
assert "pytest" in cmd
def test_no_markers_returns_none(self, tmp_path):
"""Empty directory → None (no test framework detected)."""
from agents.runner import _detect_test_command
cmd = _detect_test_command(str(tmp_path))
assert cmd is None
# ---------------------------------------------------------------------------
# (10-11) Frontend vitest setup files
# ---------------------------------------------------------------------------
class TestVitestSetupFiles:
"""Verify the vitest setup changes that fix the KIN-124 root cause."""
def test_vite_config_has_setup_files(self):
"""vite.config.ts must declare setupFiles pointing to vitest-setup.ts."""
vite_config = Path(__file__).parent.parent / "web/frontend/vite.config.ts"
assert vite_config.exists(), "vite.config.ts not found"
content = vite_config.read_text()
assert "setupFiles" in content, (
"vite.config.ts must have setupFiles to load global vitest setup"
)
assert "vitest-setup" in content, (
"setupFiles must reference vitest-setup.ts"
)
def test_vitest_setup_file_exists(self):
"""web/frontend/src/__tests__/vitest-setup.ts must exist."""
setup_file = (
Path(__file__).parent.parent
/ "web/frontend/src/__tests__/vitest-setup.ts"
)
assert setup_file.exists(), (
"vitest-setup.ts not found — global i18n setup is missing, "
"vitest will fail on all useI18n() components"
)
def test_vitest_setup_registers_i18n_plugin(self):
"""vitest-setup.ts must register i18n as a global plugin."""
setup_file = (
Path(__file__).parent.parent
/ "web/frontend/src/__tests__/vitest-setup.ts"
)
assert setup_file.exists()
content = setup_file.read_text()
assert "i18n" in content, (
"vitest-setup.ts must register the i18n plugin"
)
assert "config.global.plugins" in content, (
"vitest-setup.ts must set config.global.plugins to inject i18n into all mounts"
)

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

@ -0,0 +1,4 @@
import { config } from '@vue/test-utils'
import { i18n } from '../i18n'
config.global.plugins = [i18n]

View file

@ -350,6 +350,8 @@ export const api = {
post<{ status: string; comment: string }>(`/tasks/${id}/revise`, { comment }),
runTask: (id: string) =>
post<{ status: string }>(`/tasks/${id}/run`, {}),
followupTask: (id: string) =>
post<{ created: Task[]; pending_actions: PendingAction[]; needs_decision: boolean }>(`/tasks/${id}/followup`, {}),
bootstrap: (data: { path: string; id: string; name: string }) =>
post<{ project: Project }>('/bootstrap', data),
auditProject: (projectId: string) =>

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,231 @@
{
"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?",
"create_followup": "🔗 Create Follow-up",
"generating_followup": "Generating..."
},
"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,231 @@
{
"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": "Что должно быть на выходе? Какой результат считается успешным?",
"create_followup": "🔗 Создать зависимости",
"generating_followup": "Создаём..."
},
"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)
@ -28,6 +30,7 @@ const approveLoading = ref(false)
const followupResults = ref<{ id: string; title: string }[]>([])
const pendingActions = ref<PendingAction[]>([])
const resolvingAction = ref(false)
const followupLoading = ref(false)
// Reject modal
const showReject = ref(false)
@ -45,15 +48,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 +76,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()
}
@ -213,6 +213,23 @@ async function resolveAction(action: PendingAction, choice: string) {
}
}
async function runFollowup() {
if (!task.value) return
followupLoading.value = true
followupResults.value = []
try {
const res = await api.followupTask(props.id)
if (res.created?.length) {
followupResults.value = res.created.map(ft => ({ id: ft.id, title: ft.title }))
}
await load()
} catch (e: any) {
error.value = e.message
} finally {
followupLoading.value = false
}
}
async function reject() {
if (!task.value || !rejectReason.value) return
try {
@ -241,7 +258,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 +270,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 +287,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 +402,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 +438,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 +448,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 +464,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 +478,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 +509,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 +532,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 +558,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 +568,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 +597,50 @@ 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="task.status === 'blocked'"
@click="runFollowup"
:disabled="followupLoading"
class="px-4 py-2 text-sm bg-purple-900/50 text-purple-400 border border-purple-800 rounded hover:bg-purple-900 disabled:opacity-50">
<span v-if="followupLoading" class="inline-block w-3 h-3 border-2 border-purple-400 border-t-transparent rounded-full animate-spin mr-1"></span>
{{ followupLoading ? t('taskDetail.generating_followup') : t('taskDetail.create_followup') }}
</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>
<!-- Followup results block -->
<div v-if="followupResults.length && !showApprove" class="mt-3 p-3 border border-purple-800 bg-purple-950/30 rounded">
<p class="text-sm text-purple-400 mb-2">Создано {{ followupResults.length }} follow-up задач:</p>
<div class="space-y-1">
<router-link v-for="f in followupResults" :key="f.id" :to="`/task/${f.id}`"
class="block px-3 py-2 border border-gray-800 rounded text-sm text-gray-300 hover:border-gray-600 no-underline">
<span class="text-gray-500">{{ f.id }}</span> {{ f.title }}
</router-link>
</div>
<button @click="followupResults = []"
class="mt-2 w-full py-1.5 bg-gray-800 text-gray-400 border border-gray-700 rounded text-xs hover:bg-gray-700">
{{ t('common.close') }}
</button>
</div>
@ -611,9 +649,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 +662,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 +688,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 +742,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 +766,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 +782,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>

View file

@ -12,5 +12,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/__tests__/vitest-setup.ts'],
},
})