Compare commits
9 commits
8b409fd7db
...
e3a286ef6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a286ef6f | ||
|
|
824341a972 | ||
|
|
02e0628067 | ||
|
|
77da120146 | ||
|
|
6fa2d8b3a6 | ||
|
|
ce0b11ca3f | ||
|
|
06c868b23a | ||
|
|
03d49f42e6 | ||
|
|
353416ead1 |
24 changed files with 2454 additions and 221 deletions
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
245
tests/test_kin_124_regression.py
Normal file
245
tests/test_kin_124_regression.py
Normal 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"
|
||||
)
|
||||
83
web/frontend/package-lock.json
generated
83
web/frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.30",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
4
web/frontend/src/__tests__/vitest-setup.ts
Normal file
4
web/frontend/src/__tests__/vitest-setup.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { config } from '@vue/test-utils'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
config.global.plugins = [i18n]
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">⚠</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">×</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>
|
||||
|
|
|
|||
|
|
@ -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
12
web/frontend/src/i18n.ts
Normal 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 },
|
||||
})
|
||||
231
web/frontend/src/locales/en.json
Normal file
231
web/frontend/src/locales/en.json
Normal 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 (1–10)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
231
web/frontend/src/locales/ru.json
Normal file
231
web/frontend/src/locales/ru.json
Normal 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": "Приоритет (1–10)",
|
||||
"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": "Удалить"
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">← 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 ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ 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 ? '✓ 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">⚠ Требуют ручного решения</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 ? '✓ Автотест' : 'Автотест' }}
|
||||
{{ 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 ? '✓ 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>
|
||||
|
|
|
|||
|
|
@ -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] = 'Выберите проект'; 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('Удалить связь?')) 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">Команда запуска тестов, выполняется через shell в директории проекта.</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…' : '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="">— выберите runtime —</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…' : '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">Загрузка...</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">Нет связей</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">→</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">
|
||||
✕
|
||||
✕
|
||||
</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="">— выберите проект —</option>
|
||||
<option v-for="p in allProjectList.filter(p => p.id !== project.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ 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">
|
||||
Отмена
|
||||
{{ 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">— запускать тесты автоматически после 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…' : '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…' : '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'">
|
||||
|
|
|
|||
|
|
@ -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">⚠ Требует ручного решения</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">⚠</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">↓ подробнее</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">
|
||||
✓ 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">
|
||||
🔄 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">
|
||||
✗ 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">
|
||||
✎ 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...' : '▶ 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 ? 'Сохраняем...' : '✓ Решить вручную' }}
|
||||
{{ 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...' : '🚀 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">⚠ 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="🔄 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">
|
||||
🔄 Отправить на доработку
|
||||
{{ 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 (1–10)</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>
|
||||
|
|
|
|||
|
|
@ -12,5 +12,6 @@ export default defineConfig({
|
|||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/__tests__/vitest-setup.ts'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue