` (роль поменял Task 4) добавить
+ `ref={confirmRef}`.
+
+- [ ] **Step 3: Карточка**
+
+Заменить `web/src/issues/IssueCard.tsx` целиком:
+
+```tsx
+import { useLayoutEffect, useRef, useState } from "react";
+import { ApiError } from "../api/client.ts";
+import { useIssue, useSettings } from "../api/queries.ts";
+import type { IssueComment, IssueDetail } from "../api/types.ts";
+import { JIRA_URL } from "../board/constants.ts";
+import { fmtDate, fmtStamp } from "../board/model/util.ts";
+import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
+import { useCommentsHidden } from "./commentsPref.ts";
+import { IssueLink } from "./IssueLink.tsx";
+import { Notes } from "./Notes.tsx";
+import { dueOf } from "./options.ts";
+import "./issues.css";
+
+const DESC_LINES = 8;
+
+export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
+ const issue = useIssue(key);
+ const settings = useSettings();
+ const close =
✕ ;
+ if (issue.isPending) return
;
+ if (issue.error instanceof ApiError && issue.error.status === 404) {
+ return
Задачи {key} нет в зеркале хаба.
Закрыть ;
+ }
+ if (issue.isError && !issue.data) {
+ return (
+
+
{issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+
Закрыть
+
+ );
+ }
+ const i = issue.data!;
+ const labels = settings.data?.status_labels as Record
| undefined;
+ const fact = i.note?.fact_status ?? null;
+ const cls = factClass(fact, labels);
+ const due = dueOf(i);
+ const facts = [
+ i.assignee && {i.assignee} ,
+ due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено >} ,
+ i.sprint && {i.sprint} ,
+ i.epic_key && эпик: {i.epic_summary ?? i.epic_key} ,
+ ].filter(Boolean);
+ return (
+
+
+
+
{i.key}
+ {i.status &&
{i.status} }
+ {fact &&
{fact} }
+
+
Открыть в Jira ↗
+ {close}
+
+ {i.summary || i.key}
+ {facts.length > 0 && {facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}
}
+ {issue.isError && (
+ {issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+ )}
+
+
+
+ } />
+ {i.description && }
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) {
+ const groups = groupLinks(issue, linkTypes);
+ return (
+
+ Связи
+ {!groups.length && связей нет
}
+ {groups.map(([id, title, links]) => (
+
+
{title}
+
+ {links.map((l) => (
+
+ {l.tag && <>{l.tag} {" "}>}
+ {" "}
+ {l.summary === null && l.status === null
+ ? нет в зеркале
+ : <>{l.summary} {l.status && <>{" "}{l.status} >}>}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+function Description({ text }: { text: string }) {
+ const ref = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [overflow, setOverflow] = useState(false);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
+ }, [text, open]);
+ const long = text.split("\n").length > DESC_LINES || overflow;
+ return (
+
+ Описание
+ {text}
+ {(long || open) && setOpen(!open)}>{open ? "свернуть" : "развернуть"} }
+
+ );
+}
+
+function Comments({ items }: { items: IssueComment[] }) {
+ const [hidden, setHidden] = useCommentsHidden();
+ return (
+
+
+
Комментарии ({items.length})
+ {items.length > 0 && setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"} }
+
+ {!items.length && комментариев нет
}
+ {items.length > 0 && !hidden && (
+
+ )}
+
+ );
+}
+
+function OtherFields({ issue: i }: { issue: IssueDetail }) {
+ const rows: [string, string | null][] = [
+ ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
+ ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
+ ["Сосед по связям", i.neighbor ? "да" : null],
+ ];
+ return (
+
+ Прочие поля
+ {rows.filter(([, v]) => v).map(([k, v]) =>
{k} {v} )}
+
+ );
+}
+
+function Commands({ issue: i }: { issue: IssueDetail }) {
+ if (!i.commands.length) {
+ return Команды моста команд по задаче не было
;
+ }
+ return (
+
+
+ Команды моста ({i.commands.length})
+
+ Команда Пакет Тип Статус Завершена Ошибка
+ {i.commands.map((c) => (
+ #{c.id} #{c.batch_id} {c.type} {c.status}
+ {c.finished_at ? fmtStamp(c.finished_at) : ""} {c.error}
+ ))}
+
+
+
+ );
+}
+```
+
+Тест O-11 ищет `screen.queryByRole("columnheader", { name: "Команда" })` = `null` — при пустых командах таблицы нет.
+
+- [ ] **Step 4: Стили карточки**
+
+В `web/src/issues/issues.css` заменить строки от `.issue a{color:var(--accent)}` до
+`.issue .links{padding-left:18px}` включительно на:
+
+```css
+.issue a{color:var(--accent)}
+.issue-head{position:sticky;top:0;z-index:1;background:var(--bg);padding:14px 0 10px;border-bottom:1px solid var(--line);margin-bottom:14px}
+.issue-head .row1{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+.issue-head .key{font-family:var(--font-mono);font-size:13px;color:var(--muted)}
+.issue-head .spacer{flex:1 1 auto}
+.issue-head h2{margin:6px 0 4px;font-size:20px;line-height:1.3}
+.issue-head .facts{margin:0;color:var(--muted);font-size:14px}
+.issue .overdue{color:var(--p-crit);font-weight:600}
+.issue .close{font-size:18px;line-height:1;background:none;border:0;color:var(--muted);cursor:pointer;padding:4px 6px}
+.badge{display:inline-block;font-size:12px;line-height:18px;padding:0 8px;border-radius:9px;background:var(--soft);color:var(--fg);white-space:nowrap}
+.badge.fact{background:transparent;border:1px solid var(--accent);color:var(--accent)}
+.badge.fact.fact-done{border-color:var(--k-test);color:var(--k-test)}
+.badge.fact.fact-cancelled{border-color:var(--muted);color:var(--muted);text-decoration:line-through}
+.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
+.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
+.issue .main{min-width:0}
+@media (max-width:900px){
+ .issue .cols{display:flex;flex-direction:column;gap:16px}
+ .issue .side{display:contents}
+ .issue .side .note{order:-1}
+ .issue .side .other{order:1}
+ .issue .side .commands{order:2}
+}
+.issue section{margin-bottom:18px}
+.issue h3{font-size:13px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0 0 8px}
+.issue .sec-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
+.issue .group{margin:0 0 10px}
+.issue .group h4{font-size:13px;font-weight:600;margin:0 0 4px}
+.issue .links{list-style:none;margin:0;padding:0}
+.issue .links li{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px;padding:3px 0}
+.issue .links .tag{font-size:12px;color:var(--muted);min-width:76px}
+.issue .links .sum{flex:1 1 200px;min-width:0}
+.issue .links .absent{color:var(--muted);font-style:italic}
+.issue .description{white-space:pre-wrap;font:inherit;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px;margin:0 0 4px}
+.issue .description.clamped{display:-webkit-box;-webkit-line-clamp:8;-webkit-box-orient:vertical;overflow:hidden}
+.issue .comments ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px}
+.issue .comments .who{font-size:12px;color:var(--muted);margin-bottom:2px}
+.issue .comments .body{white-space:pre-wrap;font:inherit;margin:0}
+.issue .fields{display:grid;grid-template-columns:1fr;gap:6px;margin:0}
+.issue .fields dt{font-size:12px;color:var(--muted)}
+.issue .fields dd{margin:0;white-space:pre-wrap}
+.issue .commands summary{cursor:pointer;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
+.issue .commands table{margin-top:8px;font-size:13px}
+```
+
+- [ ] **Step 5: Тесты проходят, весь набор зелёный**
+
+Run: `cd web && npx vitest run src/issues`
+Expected: PASS (`IssueCard.test.tsx`, `IssueModal.test.tsx`, `card.test.ts`, `modal.test.ts`, `IssuesPage.test.tsx`).
+
+Run: `cd web && npm test && npm run lint && npx tsc -b`
+Expected: всё зелёное.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src/issues
+git commit -m "feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37)
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+### Task 6: Тестовый хаб, e2e и документация
+
+**Модель исполнителя:** sonnet (e2e и проверка в браузере).
+
+**Files:**
+- Create: `web/scripts/seed-extras.json`
+- Modify: `web/scripts/seed-hub.py`
+- Modify: `web/e2e/issues.spec.ts`, `web/e2e/board.spec.ts:56-81`
+- Modify: `README.md` (раздел веб-приложения, строка 161), `ARCHITECTURE.md` (если описывает страницы `web/`)
+
+**Interfaces:**
+- Consumes: Tasks 1–5 (комментарии в зеркале, окно, раскладка).
+
+- [ ] **Step 1: Дополнения тестового хаба**
+
+Создать `web/scripts/seed-extras.json`:
+
+```json
+{
+ "issues": {
+ "AURORA-9001": {
+ "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
+ "comments": [
+ {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
+ {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
+ {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
+ ]
+ }
+ },
+ "commands": [
+ {"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
+ {"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
+ ]
+}
+```
+
+В `web/scripts/seed-hub.py`:
+- в строку документации добавить предложение «Дополнения только для тестового хаба (описание, комментарии, команды) — из
+ seed-extras.json рядом со скриптом.»;
+- после `snap = json.loads(...)` добавить:
+
+```python
+extras_path = Path(__file__).with_name("seed-extras.json")
+extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
+extra_issues = extras.get("issues", {})
+```
+
+- строку `issues = [...]` заменить на:
+
+```python
+issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
+ | extra_issues.get(i["key"], {}) for i in snap["issues"]]
+```
+
+- перед строкой `(paths.home() / "tokens.json").write_text(...)` добавить:
+
+```python
+if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
+ with db.transaction(conn):
+ batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
+ (db.now(),)).lastrowid
+ for seq, c in enumerate(extras["commands"], 1):
+ conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
+ c.get("error")))
+```
+
+Проверка: `JIRA_HUB_HOME=web/.e2e-hub bash web/scripts/test-hub.sh` в фоне, затем
+`curl -s -H "Authorization: Bearer $(python3 -c "import json;print(json.load(open('web/.e2e-hub/tokens.json'))['client'])")" http://127.0.0.1:8791/issues/AURORA-9001 | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['comments']), len(d['commands']), d['description'].count(chr(10)))"`
+Expected: `3 2 11`. Остановить тестовый хаб.
+
+- [ ] **Step 2: e2e списка задач**
+
+Заменить `web/e2e/issues.spec.ts` целиком:
+
+```ts
+import { expect, test } from "@playwright/test";
+import { login } from "./hub.ts";
+
+test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
+ await login(page, "/app/issues");
+ await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
+ await expect(page).toHaveURL(/assignee=/);
+ await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Осталось").fill("e2e: дописать тесты");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
+ await page.reload();
+ await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
+ await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
+});
+
+test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 320 });
+ await login(page, "/app/issues");
+ await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
+ const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
+ expect(y).toBeGreaterThan(50);
+ await page.locator("table.grid tbody tr").last().getByRole("link").click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+});
+
+test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
+ await login(page); // доска
+ await page.getByRole("link", { name: "Задачи", exact: true }).click();
+ await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await page.goBack();
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await d.getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.mouse.click(5, 300); // фон слева от окна
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.goBack();
+ await expect(page).toHaveURL(/\/app\/board$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-issues")).toBeAttached();
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
+ await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
+ await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
+ await expect(d.getByText("Конец описания.")).toBeHidden();
+ await d.getByRole("button", { name: "развернуть" }).click();
+ await expect(d.getByText("Конец описания.")).toBeVisible();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
+ await d.getByText("Команды моста (2)").click();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
+ await d.getByRole("button", { name: "Скрыть" }).click();
+ await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
+ await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
+});
+
+test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
+ await login(page, "/app/issues");
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Сделано").fill("черновик");
+ await page.keyboard.press("Escape");
+ await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
+ await d.getByRole("button", { name: "Остаться" }).click();
+ await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
+ await page.keyboard.press("Escape");
+ await d.getByRole("button", { name: "Уйти" }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues$/);
+});
+
+test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
+ await page.setViewportSize({ width: 600, height: 800 });
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ expect((await d.boundingBox())!.width).toBeGreaterThan(590);
+ const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
+ const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
+ expect(notesY).toBeLessThan(linksY);
+ expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
+});
+```
+
+- [ ] **Step 3: e2e доски**
+
+В `web/e2e/board.spec.ts` заменить тесты «ⓘ открывает карточку задачи в приложении» и «M-3: прокрутка доски после
+возврата из карточки задачи» на:
+
+```ts
+test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
+ await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
+ await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-board")).toBeAttached();
+ await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
+});
+
+test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 600 });
+ await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
+ const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
+ expect(y).toBeGreaterThan(100);
+ // реальный щелчок по ⓘ карточки, видимой в окне после прокрутки (иначе Playwright сам сдвинет страницу)
+ const key = await page.evaluate(() => {
+ const vh = window.innerHeight;
+ const a = [...document.querySelectorAll(".board .card[data-key] a.open-card")]
+ .find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
+ return a?.closest(".card")?.dataset.key ?? null;
+ });
+ expect(key).not.toBeNull();
+ let boardLoads = 0;
+ page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
+ await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(/\/app\/board$/);
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
+ expect(boardLoads).toBe(0); // доска не перезагружалась
+ await page.getByRole("link", { name: "Задачи", exact: true }).click();
+ await page.getByRole("link", { name: "Доска", exact: true }).click();
+ await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
+});
+
+test("заметка из окна видна на доске под ним", async ({ page }) => {
+ await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Вид работы").selectOption("тестирование");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.locator('.board .card[data-key="AURORA-9002"] .kind')).toHaveText("тестирование");
+});
+```
+
+- [ ] **Step 4: Прогон e2e**
+
+Run: `cd web && npm run e2e`
+Expected: все тесты зелёные. Затем три прогона подряд: `cd web && for n in 1 2 3; do npm run e2e || break; done`
+Expected: три зелёных прогона (устойчивость). Падение — чинить причину, не добавлять ожидания «на всякий случай».
+
+- [ ] **Step 5: Документация**
+
+В `README.md` в строке со списком адресов (`Адреса: … карточка \`/app/issues/{key}\` …`) заменить фрагмент
+«карточка `/app/issues/{key}`» на «карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc,
+щелчок по фону; прямой адрес открывает окно поверх списка задач)». В том же разделе, после абзаца про «Задачи», добавить
+абзац:
+
+```markdown
+Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
+связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
+комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
+открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
+```
+
+В `ARCHITECTURE.md`:
+- строку таблицы `| \`mirror\` | зеркало задач и связей, \`apply_state\`, заметки, фильтры, снимок доски \`board\` | \`db\` |`
+ заменить на `| \`mirror\` | зеркало задач, связей и комментариев, \`apply_state\` (с проверкой \`check_comments\`), заметки, фильтры, снимок доски \`board\` | \`db\` |`;
+- в абзаце про `web/` фрагмент «и «Задачи» (`/app/issues`, `/app/issues/{key}`).» заменить на «и «Задачи»
+ (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
+ страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё.».
+
+- [ ] **Step 6: Полная проверка**
+
+Run: `make check`
+Expected: pytest и vitest зелёные, lint чистый.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add web/scripts/seed-extras.json web/scripts/seed-hub.py web/e2e README.md ARCHITECTURE.md
+git commit -m "test(web): e2e окна карточки, тестовый хаб с комментариями и командами; README (#37)
+
+Co-Authored-By: Claude Opus 5.5 "
+```
+
+## После плана
+
+- Финальное ревью ветки (opus); ручной проход сценариев в браузере на тестовом хабе :8791 со скриншотами (делает
+ сессия, не контроллер).
+- PR в aep/jira-hub со ссылкой на #37 и на PR агента; merge — по команде владельца, после merge и публикации агента v10.
diff --git a/openspec/changes/issue-card-modal/.openspec.yaml b/openspec/changes/issue-card-modal/.openspec.yaml
new file mode 100644
index 0000000..a216797
--- /dev/null
+++ b/openspec/changes/issue-card-modal/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-15
diff --git a/openspec/changes/issue-card-modal/design.md b/openspec/changes/issue-card-modal/design.md
new file mode 100644
index 0000000..a98e619
--- /dev/null
+++ b/openspec/changes/issue-card-modal/design.md
@@ -0,0 +1,173 @@
+## Context
+
+`main` 13e6c16. Устройство, на которое опираются правки:
+
+- **Агент** (`jira-agent/src/agent.py`, v9): `refresh` читает задачи `search` с `fields = STATE_FIELDS` (+ плановые даты и
+ поле эпика), `to_state` собирает словарь задачи, пачки по 100 уходят в `POST /agent/state`. Комментарии агент читает
+ только в `ensure_comment` (`GET /issue/{key}/comment`, постранично по 100).
+- **Хаб**: `mirror.apply_state` пишет в `issue` только столбцы `ISSUE_COLUMNS` — лишние поля задачи (в том числе будущее
+ `comments`) молча пропускаются, поэтому агент v10 совместим с нынешним хабом. `mirror.get_issue` отдаёт задачу, связи
+ (`target_key`/`source_key`, `link_type`), заметку, 10 последних команд. Схема базы — v2 (`db.SCHEMA_VERSION`),
+ миграция с копией базы — `db.migrate`. Тело запроса ограничено 16 МБ (`api.MAX_BODY`).
+- **Веб-приложение**: `createBrowserRouter(routes, {basename: "/app"})`; `Layout` с ``; маршруты `board`,
+ `issues`, `issues/:key` (страница `IssuePage`). Ссылки на карточку — ключ в `IssuesPage` и «ⓘ» в `board/components/
+ Card.tsx`. `NoteForm` — форма заметок с `useBlocker` (смена `pathname` при правках требует подтверждения) и
+ `beforeunload`. `useSaveNote` после успеха обновляет кэш задачи и инвалидирует `["issues"]` и `board`.
+ Прокрутка доски при возврате на `/app/board` восстанавливается (M-3 #29).
+- **Справочники**: `link_types` настроек — логическое имя → тип Jira (`блокирует начало` → `ASUPR_Блокер_Начала`,
+ `блокирует завершение` → `ASUPR_Блокер_Завершения`, `связанные` → `ASUPR_Связанные`); `status_labels` — статус по факту →
+ класс (`fact-done`, `fact-cancelled`, …).
+
+## Goals / Non-Goals
+
+**Goals:** карточка открывается окном поверх списка и доски без потери их состояния; по шапке видно состояние задачи,
+по связям — что держит задачу и что ждёт её, с темой и статусом связанных; комментарии задачи из Jira в карточке со
+скрытием; агент v10 и хаб совместимы в любом порядке выкатки.
+
+**Non-Goals:** см. proposal.md.
+
+## Decisions
+
+### Агент v10 (решения агентской части — `jira-agent/openspec/changes/agent-v10-comments/design.md`)
+
+1. **Поле `comment` в том же `search`.** `STATE_FIELDS` получает `comment`; новых вызовов Jira на обычную задачу нет.
+ Jira Server отдаёт `fields.comment = {comments: [...], total, maxResults, startAt}`.
+2. **Дочитывание.** Если `len(comments) < total`, агент читает комментарии этой задачи `GET /issue/{key}/comment`
+ (`startAt`, `maxResults = 100`) до конца и берёт их вместо частичного списка. Вызовы учитываются в темпе и 429 как
+ любые вызовы Jira.
+3. **Форма.** `comments: [{id, author, created, updated, body}]` в порядке Jira (старые первыми): `id` — строка,
+ `author` — `author.displayName`, иначе `author.name`, иначе `null`; `created`/`updated` — строки Jira как есть;
+ `body` — текст как есть (`""`, если в Jira пусто). Поле `comments` есть у каждой задачи, прочитанной агентом v10
+ (пустой список — комментариев нет).
+4. **Версия и выкатка.** `AGENT_VERSION = 10`; после merge PR агента — `jira-hub agent publish src/agent.py` (пишет в
+ каталог живого хаба: выполняет владелец или агент по его отдельной команде).
+
+### Хаб
+
+5. **Таблица `issue_comment`** `(key, comment_id, author, created, updated, body, PRIMARY KEY (key, comment_id))`; выборка
+ по `key` идёт по первичному ключу, отдельный индекс не нужен. Схема v3; миграция v2 → v3 создаёт таблицу (копия базы `schema-v2` — существующим механизмом).
+6. **Запись.** `apply_state`: для задачи с полем `comments` (список) комментарии этой задачи удаляются и вставляются
+ присланные — в одной транзакции с задачей; задача без поля `comments` своих комментариев не теряет (агент до v10).
+7. **Проверка формы** в `h_agent_state` до записи: `comments` — список объектов; `id` — непустая строка; `body` — строка;
+ `author`, `created`, `updated` — строка или `null`. Нарушение — 422 `bad_state` с ключом задачи и полем, пачка не
+ применяется целиком (агент получает ошибку хаба в отчёте `refresh`).
+8. **`GET /issues/{key}`**: добавляются `comments` (новые сверху: по `created` убыв., затем `comment_id` убыв.),
+ `epic_summary` (тема задачи-эпика из зеркала или `null`), у каждой связи — `summary` и `status` связанной задачи
+ (`LEFT JOIN issue`, `null`, если её нет в зеркале). Прежние поля и их форма не меняются.
+9. **MCP `issue`**: после заметок — «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), затем
+ «ещё M», если их больше 5; нет комментариев — «комментариев нет».
+
+### Веб-приложение: окно
+
+10. **Фоновая страница.** Ссылки на карточку (ключ в списке, «ⓘ» на доске, связи в карточке) передают в
+ `location.state` `{background, depth, direct}`: `background` — адрес страницы под окном (`pathname + search`),
+ `depth` — сколько записей истории занято карточками поверх неё (1 при открытии со страницы, +1 при переходе по
+ связи), `direct` — окно открыто прямым адресом (переносится в переходы по связям). Маршрут верхнего уровня —
+ `{path: "*", element: }`; `Layout` всегда рисует страницы через `useRoutes(PAGE_ROUTES, pageLocation)` с
+ явным адресом: `background` при открытом окне, иначе текущий адрес. Явный адрес нужен всегда: так дерево
+ компонентов страницы не меняется при открытии окна (страница не пересоздаётся), а `useSearchParams`/`useLocation`
+ внутри фоновой страницы видят её собственный адрес, и фильтры списка не сбрасываются. Состояние страницы (фильтры,
+ загруженные порции, прокрутка, раскладка доски, опрос пакета) сохраняется. `ErrorBoundary` страницы получает ключ по
+ адресу страницы, у окна — свой.
+11. **Прямой адрес** `/app/issues/{key}` без `state` (новая вкладка, закладка): `Layout` заменяет запись истории тем же
+ адресом со `state = {background: /issues, depth: 0, direct: true}`; под окном — список задач без фильтров.
+ Перезагрузка страницы с открытым окном сохраняет `state` (браузер хранит его в записи истории).
+12. **Закрытие** (✕, Esc, щелчок по фону): `depth = 0` — `navigate("/issues", {replace: true})`; `depth ≥ 1` —
+ `navigate(-depth)`, браузер возвращается к записи страницы под окном, и «назад» после закрытия ведёт на шаг раньше
+ неё; если окно было открыто прямым адресом (`direct`), запись прямого адреса, на которую вернулись, заменяется на
+ `/issues` (`replace`). Закрытие проходит через блокировщик заметок (п. 14). Вкладки «Доска»/«Задачи» в шапке
+ подсвечиваются по адресу страницы под окном.
+13. **Элемент ``** с `showModal()`: фокус удерживается в окне, фон недоступен, Esc вызывает `cancel` (обрабатываем
+ сами — закрытие по п. 12 с проверкой заметок; если `cancel` не отменяемый — браузер закрыл окно сам без активации
+ пользователя, — по событию `close` окно открывается снова и закрытие идёт тем же путём). Щелчок по фону — щелчок по
+ самому `` вне области содержимого, причём нажатие (`pointerdown`) тоже было на фоне (I-2: выделение текста,
+ отпущенное за краем окна, иначе закрывало окно).
+ Прокрутка страницы под окном на время открытия блокируется (`overflow: hidden` у `body`), содержимое окна
+ прокручивается внутри, шапка окна закреплена. Ширина до 1000 px, высота до 90vh; на экранах уже 700 px — на всю
+ ширину.
+14. **Несохранённые заметки.** Подтверждение «Есть несохранённые правки заметки. Уйти без сохранения?» при закрытии окна,
+ переходе по связи и уходе в раздел; условие блокировки — смена `pathname` при правках (как сейчас), закрытие по
+ п. 12 проходит через тот же блокировщик. Отказ — окно остаётся с введённым текстом.
+15. **Страница под окном после сохранения заметки.** Сохранение уже инвалидирует `board` и `["issues"]`
+ (`useSaveNote`): фоновая страница смонтирована и перечитывается. Список задач показывает новые «вид работы» и
+ «статус по факту» (колонки из заметки). Доска перечитывает снимок, но её карточки берут вид работы из меток
+ `kind-*` Jira, а статус — из Jira (поведение `links.html`, требование «Доска связей»), поэтому правка заметки на
+ карточке доски не видна (R-7). Отдельного кода нет — поведение закрепляется тестом.
+
+### Веб-приложение: раскладка карточки
+
+16. **Шапка**: ключ; статус Jira (плашка); статус по факту (плашка цветом класса из `status_labels`, если есть);
+ «Открыть в Jira ↗»; ✕; тема; строка «исполнитель · срок · спринт · эпик: <тема эпика>» — пустые части пропускаются,
+ эпик без темы в зеркале — ключом. Срок — `planned_end`, иначе `duedate`; «просрочено» (красным) — срок раньше
+ сегодняшней даты и статус по факту не относится к классам `fact-done`, `fact-cancelled`.
+17. **Связи по смыслу.** По `link_types` настроек: входящие `блокирует начало` / `блокирует завершение` — группа «Зависит
+ от», исходящие — «Блокирует»; у строки метка «начало» / «завершение». `связанные` в обе стороны — «Связана с».
+ Тип, которого нет в `link_types`, — «Прочие» с именем типа Jira и направлением («→» исходящая, «←» входящая).
+ Строка: ключ (ссылка — открывает связанную задачу в окне), тема, статус Jira (плашка); связанной задачи нет в
+ зеркале — ключ и «нет в зеркале». Пустая группа не показывается; связей нет — «связей нет». Порядок групп: «Зависит
+ от», «Блокирует», «Связана с», «Прочие»; внутри — по ключу.
+18. **Описание** — `pre-wrap` без разбора разметки; выше 8 строк сворачивается с кнопкой «развернуть» / «свернуть»
+ (кнопка только если текст не помещается). Нет описания — раздела нет.
+19. **Комментарии** — «Комментарии (N)» с кнопкой «Скрыть» / «Показать»; новые сверху; автор, время (`fmtStamp`), текст
+ `pre-wrap`. Выбор «скрыто» — один на все карточки, в `localStorage` `jira-hub-comments-hidden` (`"1"` — скрыты);
+ недоступный `localStorage` — показаны. Нет комментариев — «комментариев нет» (кнопки нет).
+20. **Заметки** — по умолчанию режим чтения: «Вид работы», «Статус по факту», «Сделано», «Осталось», «Заметка владельца»
+ (пустые — «—»), строка «изменено <время>, <автор>»; кнопка «Править» показывает нынешнюю форму `NoteForm`
+ (поля, «Сохранить», «Отмена»); после успешного сохранения — снова режим чтения; «Отмена» без правок — сразу, с
+ правками — подтверждение «Отменить правки заметки?». Заметки ещё нет — режим чтения с «—» и «Править».
+21. **Прочие поля**: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, «сосед по связям» — непустые.
+22. **Команды моста (N)** — свёрнуты (``), внутри нынешняя таблица; команд нет — «команд по задаче не было» и
+ раздел не сворачивается.
+23. **Колонки**: под шапкой слева — «Связи», «Описание», «Комментарии»; справа (≈320 px) «Заметки», «Прочие
+ поля», «Команды моста». Уже 900 px — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды.
+24. **Состояния окна**: загрузка — «загрузка …»; 404 — «Задачи нет в зеркале хаба.» и «Закрыть»; хаб не
+ отвечает — сообщение и «Повторить», ранее загруженные данные остаются; поверх ошибки при данных — предупреждение
+ в шапке окна.
+
+### Проверки
+
+25. **Тестовый хаб**: `web/scripts/seed-extras.json` — дополнения к фикстуре доски только для тестового хаба: у AURORA-9001
+ описание длиннее 8 строк, три комментария и две команды (пакет автора `seed`, команды завершены). `seed-hub.py`
+ применяет их после фикстуры. Связи не добавляются (они меняют доску и её e2e); группа «Прочие» и связь на задачу вне
+ зеркала проверяются в vitest, а связь на задачу вне зеркала (`lower-1`) уже есть в фикстуре. Фикстура доски и её
+ golden-эталоны не меняются.
+26. **e2e** (Playwright на :8791): открытие из списка (фильтр и прокрутка списка после закрытия на месте), по «ⓘ» с доски
+ (прокрутка и раскладка доски на месте), переход по связи и «назад», Esc / ✕ / фон, «назад» после закрытия не
+ открывает карточку, прямой адрес и перезагрузка с открытым окном, подтверждение при несохранённой заметке, ширина
+ 600 px, «Скрыть» комментарии переживает перезагрузку.
+
+## Risks / Trade-offs
+
+- **Фоновая страница в `Layout`** — нестандартный для data router приём (две отрисовки маршрутов). Риск: `useBlocker`
+ только один на страницу — блокировщик заметок остаётся в окне, фоновая страница блокировщиков не имеет. Проверяется
+ тестами п. 14 и e2e.
+- **`navigate(-depth)`** полагается на то, что все `depth` шагов истории — наши переходы. Ручное изменение адреса внутри
+ окна сбрасывает `state` → `depth = 0`, закрытие ведёт в список задач (`replace`). Принято.
+- **Объём состояния**: комментарии увеличивают пачку `POST /agent/state`; при 100 задачах и типичных комментариях это
+ сотни килобайт при пределе 16 МБ. Принято без разбиения.
+- **Просрочка по статусу по факту** (п. 16): задача, закрытая в Jira без заметки, покажется просроченной. Цена — лишняя
+ красная пометка; списка «закрытых» статусов Jira в настройках нет.
+
+## Migration Plan
+
+1. PR jira-agent (v10) → merge → `jira-hub agent publish` → `agent_status` «обновление: ok v9 → v10».
+2. PR jira-hub (#37) → merge → `git pull --ff-only`, `make web` в основном checkout → перезапуск хаба владельцем
+ (`launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`): миграция v2 → v3 с копией базы → `/mcp` reconnect.
+3. Ближайший `refresh` (час или «Обновить из Jira») заполняет комментарии.
+Откат хаба: копия `backups/<…>-schema-v2.sqlite` и предыдущий коммит; агент v10 со старым хабом работает (п. Context).
+
+## Open Questions
+
+Нет.
+
+## Decisions made without discussion
+
+| Маркер | Дата | Решение | Обоснование | Цена ошибки |
+|---|---|---|---|---|
+| R-1 | 2026-10-15 | Требования к агенту v10 — дельтой `specs/agent` в этом change jira-hub; в jira-agent — change `agent-v10-comments` только с решениями агентской части (design.md) | Заведённый порядок: спека агента живёт в `jira-hub/openspec/specs/agent/`, так сделаны v9 (hub#30 + agent#24) и предыдущие; «один change на репозиторий» соблюдается — у агента свой change и свой PR | Низкая: перенос дельты в другой change — правка документов |
+| R-2 | 2026-10-15 | Просрочка — срок раньше сегодня и статус по факту не `fact-done`/`fact-cancelled`; статус Jira не учитывается | Списка «закрытых» статусов Jira в настройках нет, а свой словарь в приложении запрещён требованием «Справочники из настроек хаба» | Лишняя красная пометка у закрытой в Jira задачи без заметки |
+| R-3 | 2026-10-15 | В карточке показываются все комментарии задачи, без постраничности; в MCP — 5 последних | В задачах проекта комментариев единицы; окно прокручивается внутри | При десятках длинных комментариев — длинная прокрутка окна; лечится «Скрыть» |
+| R-4 | 2026-10-15 | «Команды моста» свёрнуты всегда при открытии, выбор не запоминается; «Скрыть» комментарии запоминается | Команды нужны редко и по конкретному поводу; комментарии владелец просил скрывать — это предпочтение | Лишний щелчок, если команды смотрят часто |
+| R-5 | 2026-10-15 | Отмена правки заметки — кнопка «Отмена» с подтверждением при правках (в разделе 1 дизайна был только «Править»/«Сохранить») | Из режима правки нужен выход без сохранения, не закрывая окно | Лишний вопрос при отмене |
+| R-6 | 2026-10-15 | В Task 4 тесты адаптированы минимально: `within(main)` вместо глобального `getByRole("table")` (в окне есть таблица команд); убран `{exact: true}` (нет в типах); тест ErrorBoundary в App.test.tsx добавляет Boom-маршрут в `PAGE_ROUTES` (unshift/splice в finally), т.к. Layout больше не рисует Outlet | Код плана не компилировался/находил две таблицы; поведение и проверяемые утверждения не изменены | Низкая: только тесты |
+| R-7 | 2026-10-15 | Сценарий «Заметка видна на доске» (web-app) сужен: проверяется перезапрос `GET /board` с новым `work_kind` и сохранность доски под окном, а не отображение вида работы на карточке доски | Доска берёт вид работы из меток `kind-*` Jira, `work_kind` заметки в карточку доски не попадает; менять отрисовку доски вне объёма change (Non-Goals) | Владелец может ждать видимого обновления вида работы на доске; нужен отдельный change |
diff --git a/openspec/changes/issue-card-modal/proposal.md b/openspec/changes/issue-card-modal/proposal.md
new file mode 100644
index 0000000..1ac6261
--- /dev/null
+++ b/openspec/changes/issue-card-modal/proposal.md
@@ -0,0 +1,62 @@
+## Why
+
+Карточка задачи (`/app/issues/{key}`, change `web-app` #21) — отдельная страница: уход со списка или доски теряет
+контекст, а сама карточка плохо читается — поля одного веса, связи без темы и статуса связанной задачи, входящие связи
+записаны «X → тип → эта задача», техническая таблица команд на виду, форма заметок занимает пол-экрана. Комментариев
+задачи из Jira в карточке нет: хаб их не хранит, агент их не читает.
+
+Решения владельца 15.10.2026 (brainstorming, jira-hub#37):
+- карточку открывают, чтобы быстро понять состояние задачи (статус, кто, срок, что осталось) и разобраться в связях
+ (что держит задачу, что ждёт её); в карточке нужны комментарии задачи из Jira с возможностью их скрыть;
+- карточка открывается модальным окном поверх того места, откуда её открыли (список «Задачи» или доска), у окна свой
+ адрес `/app/issues/{key}`; переходы по связям — внутри окна, «назад» браузера возвращает к прежней карточке;
+- раскладка карточки — по разделу 1 дизайна (шапка состояния, связи по смыслу, заметки в режиме чтения, комментарии,
+ второстепенное внизу); визуальные макеты не нужны, раскладку предлагает агент;
+- комментарии — два change по репозиториям, сначала агент: jira-agent v10 (aep/jira-agent#27) передаёт комментарии в
+ состоянии задачи, jira-hub (#37) хранит, отдаёт и показывает их. Требования к агенту по заведённому порядку живут в
+ этом change (`specs/agent`), решения агентской части — в `jira-agent/openspec/changes/agent-v10-comments/design.md`.
+
+## What Changes
+
+- **Агент v10** (`AGENT_VERSION = 10`): `refresh` запрашивает в том же `search` поле `comment`; по каждой задаче
+ передаёт `comments: [{id, author, created, updated, body}]` (автор — отображаемое имя, иначе логин; текст как есть);
+ если Jira отдала не все комментарии задачи — дочитывание через `GET /issue/{key}/comment`. Выкатка самообновлением.
+- **Хаб, данные**: таблица `issue_comment`, схема базы v2 → v3 (копия базы перед миграцией — как сейчас). Присланные
+ комментарии задачи заменяют её прежние; задача без поля `comments` (агент до v10) свои комментарии не теряет.
+ Неверная форма комментария — 422 на всю пачку `POST /agent/state`.
+- **Хаб, API**: `GET /issues/{key}` дополнительно отдаёт `comments` (новые сверху), у связей — `summary` и `status`
+ связанной задачи (`null`, если её нет в зеркале), `epic_summary`. Прежние поля не меняются.
+- **MCP**: инструмент `issue` показывает 5 последних комментариев и «ещё N».
+- **Веб-приложение**:
+ - карточка задачи — модальное окно (``) поверх списка «Задачи» или доски; адрес `/app/issues/{key}`; прямой
+ адрес открывает окно поверх списка задач без фильтров; закрытие (✕, Esc, щелчок по фону) возвращает туда, откуда
+ открыли, и «назад» после закрытия карточку не открывает;
+ - новая раскладка: шапка (ключ, статус Jira, статус по факту, тема, исполнитель · срок · спринт · эпик по имени,
+ «Открыть в Jira», ✕); связи по группам «Зависит от», «Блокирует», «Связана с», «Прочие» с темой и статусом;
+ описание свёрнуто до 8 строк; комментарии со «Скрыть/Показать», выбор запоминается в браузере; заметки в режиме
+ чтения с «Править»; «Прочие поля»; «Команды моста» свёрнуты;
+ - после сохранения заметки список задач под окном показывает новые «вид работы» и «статус по факту»; доска под
+ окном перечитывает снимок, но её карточки берут вид работы из меток Jira, а статус — из Jira (R-7), поэтому
+ правка заметки на карточке доски не видна.
+- Спеки: `agent` — MODIFIED «Чтение состояния»; `data-model` — MODIFIED «Схема базы хаба», «Зеркало задачи»; `hub-api` —
+ MODIFIED «Маршруты агента», «Маршруты интерфейсов»; `mcp` — MODIFIED «Инструменты»; `web-app` — MODIFIED «Оболочка и
+ адреса», «Доска связей», «Список задач», «Карточка задачи», «Служебные заметки».
+
+## Non-Goals
+
+- Запись в Jira из карточки (правка полей, статуса, добавление комментариев) — следующий change `viewer-edit`.
+- Разбор разметки Jira (wiki) в описании и комментариях: текст показывается как есть, с переносами строк.
+- Комментарии в списке задач, на доске и в `GET /board`.
+- Изменения поведения доски, кроме открытия карточки окном и обновления заметки под окном.
+- Хранение истории правок комментариев: в зеркале — последнее состояние из Jira.
+
+## Impact
+
+- jira-agent: `src/agent.py` (`STATE_FIELDS`, `to_state`, дочитывание комментариев), тесты `tests/test_refresh.py`,
+ фейк `tests/fake_jira.py`; `AGENT_VERSION = 10`; публикация `jira-hub agent publish` после merge.
+- jira-hub: `db.py` (v3, `issue_comment`), `mirror.py` (`apply_state`, `get_issue`), `api.py` (`h_agent_state`
+ валидация), `mcp_server.py` (`issue`), тесты; `web/src/routes.tsx`, `shell/Layout.tsx`, `issues/*` (окно, раскладка,
+ заметки), `board/components/Card.tsx` (ссылка «ⓘ»), `api/types.ts`, `api/queries.ts`, e2e; фикстура тестового хаба
+ с описанием, комментариями и командами.
+- Порядок выкатки: агент v10 (любой хаб принимает его состояние — лишнее поле `comments` нынешний хаб пропускает), затем
+ хаб; комментарии появляются после первого `refresh` агента v10.
diff --git a/openspec/changes/issue-card-modal/specs/agent/spec.md b/openspec/changes/issue-card-modal/specs/agent/spec.md
new file mode 100644
index 0000000..01aaa37
--- /dev/null
+++ b/openspec/changes/issue-card-modal/specs/agent/spec.md
@@ -0,0 +1,36 @@
+## MODIFIED Requirements
+
+### Requirement: Чтение состояния
+`refresh` SHALL выполняться запросом `search` (`jql`, `fields` = поля зеркала включая `issuelinks`, `comment` и спринт,
+`maxResults = 100`, постранично) и отправляться хабу пачками по 100 задач со связями; ключи из `issuelinks`, которых нет
+в выборке и в зеркале хаба (хаб сообщает список известных), агент SHALL дочитать одним `search` по `key in (...)`
+и отправить с признаком `neighbor`. Ошибка хаба при запросе известных ключей или отправке пачки SHALL попадать в
+`error` с префиксом «хаб: »; недоступность хаба и его 5xx повторяются, как обрыв связи, с источником «хаб».
+Каждая задача в состоянии (с v10) SHALL нести `comments` — все комментарии задачи в порядке Jira, каждый
+`{id, author, created, updated, body}`: `id` — строка; `author` — отображаемое имя автора, иначе его логин, иначе `null`;
+`created`, `updated` — время Jira как есть; `body` — текст как есть (пустой — `""`). Если `search` отдал у задачи меньше
+комментариев, чем `comment.total`, агент SHALL дочитать комментарии этой задачи через `GET /issue/{key}/comment`
+постранично (`maxResults = 100`) и передать полный список.
+
+#### Scenario: Полная синхронизация
+- **WHEN** `refresh {jql: "project = AURORA AND labels = AWPCA"}` при 180 задачах
+- **THEN** 2 вызова `search` + 1 на соседей; `sync_run.issues_count = 180 + соседи`
+
+#### Scenario: Хаб не ответил при отправке состояния
+- **WHEN** `refresh` через папку, реле не ответило на отправку пачки ни в одной из 4 попыток
+- **THEN** отчёт `failed` с ошибкой «результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: не ответил через
+ папку …»; в логе повторов — «обрыв связи с хабом»
+
+#### Scenario: Комментарии в состоянии
+- **WHEN** у AURORA-1620 в Jira два комментария, автор первого — `{name: "petrov", displayName: "Петров Пётр"}`, у автора
+ второго нет `displayName`, его `name` — `ivanov`
+- **THEN** в состоянии AURORA-1620 `comments` из двух элементов в порядке Jira, `author` — «Петров Пётр» и «ivanov»; число
+ вызовов `search` не изменилось
+
+#### Scenario: Неполный список комментариев
+- **WHEN** `search` вернул у AURORA-1620 20 комментариев при `comment.total = 130`
+- **THEN** агент дочитал комментарии двумя вызовами `GET /issue/AURORA-1620/comment`; в состоянии 130 комментариев
+
+#### Scenario: Задача без комментариев
+- **WHEN** у задачи в Jira нет комментариев
+- **THEN** в её состоянии `comments = []`
diff --git a/openspec/changes/issue-card-modal/specs/data-model/spec.md b/openspec/changes/issue-card-modal/specs/data-model/spec.md
new file mode 100644
index 0000000..279bc7c
--- /dev/null
+++ b/openspec/changes/issue-card-modal/specs/data-model/spec.md
@@ -0,0 +1,66 @@
+## MODIFIED Requirements
+
+### Requirement: Схема базы хаба
+База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`,
+`command`, `sync_run`, `agent_status`, `setting`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру
+версии в `setting.schema_version` (текущая — 3). Миграция существующей базы SHALL выполняться в одной транзакции с
+блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
+`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
+открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
+при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
+дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени
+комментариев (строки Jira как есть).
+
+#### Scenario: Первый запуск
+- **WHEN** хаб стартует с несуществующим файлом базы
+- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700`
+
+#### Scenario: Миграция базы v1
+- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
+- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая
+ `issue_comment`; данные на месте; `schema_version = 3`
+
+#### Scenario: Миграция базы v2
+- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки
+- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 3`
+
+#### Scenario: Два соединения к базе v1
+- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения
+- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1`
+
+#### Scenario: Открытый каталог данных
+- **WHEN** хаб стартует, каталог данных существует с правами `0755`
+- **THEN** права каталога и `backups/` — `0700`
+
+### Requirement: Зеркало задачи
+Таблица `issue` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki),
+`status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список),
+`sprint`, `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link`
+SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается
+на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть:
+`key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Обновлять `issue`, `issue_link` и
+`issue_comment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные
+комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии
+SHALL сохранять. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не
+обновлённые им, — только если прогон загрузил хотя бы одну задачу.
+
+#### Scenario: Загрузка состояния заменяет связи
+- **WHEN** агент прислал состояние задачи X с двумя связями
+- **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные
+ со стороны X, не трогаются до загрузки их источника
+
+#### Scenario: Соседняя задача
+- **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL
+- **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0`
+
+#### Scenario: Пустой полный прогон
+- **WHEN** полный `refresh` завершился `done`, загрузив 0 задач
+- **THEN** признак `neighbor` задач зеркала не меняется
+
+#### Scenario: Комментарий удалён в Jira
+- **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]`
+- **THEN** у X в `issue_comment` только 102
+
+#### Scenario: Состояние от агента до v10
+- **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments`
+- **THEN** поля X обновлены, три комментария на месте
diff --git a/openspec/changes/issue-card-modal/specs/hub-api/spec.md b/openspec/changes/issue-card-modal/specs/hub-api/spec.md
new file mode 100644
index 0000000..56601aa
--- /dev/null
+++ b/openspec/changes/issue-card-modal/specs/hub-api/spec.md
@@ -0,0 +1,125 @@
+## MODIFIED Requirements
+
+### Requirement: Маршруты агента
+Хаб SHALL предоставлять агенту следующие маршруты (все под агентским токеном):
+- `POST /agent/claim` `{agent_version, settings_version, last_jira_call_at, update_state, launcher_version}` — долгий
+ запрос до 30 с; ответ `{command: {id, type, params, pause_seconds}, settings_version, agent_release}` или `{command:
+ null, settings_version, agent_release}`, где `agent_release` — `{version, sha256}` опубликованной версии агента или
+ `null`; обновляет `agent_status` (`last_jira_call_at = null` не затирает прежнее значение). `update_state` (объект или
+ `null`, `error` хранится до 500 символов) и `launcher_version` (целое или `null`) необязательны; хаб SHALL хранить
+ последние значения в памяти процесса, значения неверного типа игнорировать, не отвергая `claim`. Если есть команда в
+ `claimed`, `claim` SHALL выдать её снова (повторная выдача, см. `commands`), а не следующую из очереди.
+- `GET /agent/settings` — настройки для агента (без токенов).
+- `GET /agent/commands/{id}` — команда по id (для восстановления из журнала), включая `pause_seconds`.
+- `POST /agent/commands/{id}/result` `{status, result, error, jira_calls, rate_limited}` — идемпотентно: повторный
+ отчёт по команде в терминальном статусе отвечает 200 без изменений, кроме позднего отчёта `done` с `result.key` по
+ `ensure_issue` в `cancelled` или `failed` без результата (результат записывается, статус не меняется, заметка `new:N`
+ переносится на ключ). Отчёт по команде в `queued` SHALL отвергаться 409 `not_claimed`. `error` хаб SHALL обрезать до
+ 1500 символов.
+- `POST /agent/state` `{command_id, issues: [...], links: [...], neighbor_keys: [...], page, last_page}` — загрузка состояния
+ пачками; `neighbor_keys` — ключи вне базовой JQL, дочитанные по связям; хаб обновляет зеркало и `sync_run`. Каждая
+ пачка применяется независимо; `page` и `last_page` необязательны и хабом не используются. Поле задачи `comments`
+ необязательно; если оно есть, хаб SHALL до записи проверить его форму: список объектов, `id` — непустая строка, `body` —
+ строка, `author`, `created`, `updated` — строка или `null`; нарушение — 422 `bad_state` с ключом задачи и именем поля,
+ пачка не применяется.
+- `GET /agent/known-keys` — ключи зеркала (для определения соседей).
+- `GET /agent/release` — опубликованная версия агента `{version, sha256, content}` (`content` — текст `agent.py`);
+ нет публикации или файл не сходится с манифестом по sha256 — 404 `no_release`.
+
+#### Scenario: Выдача с подстановкой ключа
+- **WHEN** следующая команда ссылается на `new:60`, а `ensure_issue` для `new:60` завершилась с `AURORA-1590`
+- **THEN** `claim` отдаёт команду с `AURORA-1590` в параметрах
+
+#### Scenario: Повторный отчёт
+- **WHEN** агент дважды прислал `result` для команды 15
+- **THEN** второй ответ 200, статус и результат не изменились
+
+#### Scenario: Отчёт по невыданной команде
+- **WHEN** `POST /agent/commands/16/result` с `status = done`, команда 16 в `queued`
+- **THEN** 409 `not_claimed`, команда осталась `queued`
+
+#### Scenario: Длинная ошибка
+- **WHEN** агент прислал `failed` с `error` длиной 5000 символов
+- **THEN** в `command.error` первые 1500 символов
+
+#### Scenario: Поздний отчёт после предела выдач
+- **WHEN** `ensure_issue {temp_key: new:8, …}` переведена хабом в `failed` «агент не отчитался после 3 выдач», затем агент
+ прислал `done` с `result.key = AURORA-1630`
+- **THEN** команда `failed`, в `result` — `AURORA-1630`; заметка `new:8` перенесена на `AURORA-1630`
+
+#### Scenario: Публикация в ответе claim
+- **WHEN** опубликована v6, агент v5 отправил `claim`, команд нет
+- **THEN** ответ `{command: null, settings_version, agent_release: {version: 6, sha256: …}}`
+
+#### Scenario: Состояние обновления от агента
+- **WHEN** агент прислал в `claim` `update_state = {state: rolled_back, from_version: 6, to_version: 7, error: "код 1 через 3 с"}`
+ и `launcher_version = 1`
+- **THEN** `GET /agent` отдаёт это `update_state`, `launcher_version = 1` и `update_hint` «откат с v7 на v6: код 1 через 3 с»
+
+#### Scenario: Скачивание версии
+- **WHEN** агентским токеном `GET /agent/release`, опубликована v6
+- **THEN** 200 `{version: 6, sha256, content}`, sha256 от `content` в UTF-8 равен `sha256`; клиентским токеном — 401;
+ без публикации — 404 `no_release`
+
+#### Scenario: Неверный комментарий в пачке
+- **WHEN** `POST /agent/state` с задачами X и Y, у Y комментарий `{id: "7", body: 42}`
+- **THEN** 422 `bad_state` с ключом Y и полем `body`; ни X, ни Y в зеркале не изменились
+
+### Requirement: Маршруты интерфейсов
+Хаб SHALL предоставлять интерфейсам (MCP, позже просмотрщик) следующие маршруты под клиентским токеном:
+- `GET /issues` с фильтрами `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`,
+ `q` (подстрока темы), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` и `work_kind` из заметок;
+ сокращение до карточки — дело интерфейса.
+- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны
+ (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где
+ `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней,
+ `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем
+ `comment_id`, по убыванию).
+- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела).
+- `POST /batches` `{title, author, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, неверных
+ параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более `ensure_issue` с
+ одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся темы).
+- `GET /batches?limit=`, `GET /batches/{id}` (с командами), `POST /batches/{id}/cancel` — отменяет `queued` команды
+ пакета; ответ — вид пакета с полем `still_claimed`: список `{id, type}` команд пакета в `claimed` (они будут выполнены
+ агентом; остановить — отмена команды с `force`).
+- `POST /commands/{id}/retry` — новая команда с теми же параметрами в новом пакете «Повтор команды N».
+- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`.
+- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`.
+- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`;
+ ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`).
+
+#### Scenario: Фильтр по спринту
+- **WHEN** `GET /issues?sprint=Спринт 2`
+- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу
+
+#### Scenario: Отмена взятой команды
+- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed`
+- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется
+
+#### Scenario: Поздний отчёт по отменённому созданию
+- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620`
+- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620`
+
+#### Scenario: Дубль темы в пакете
+- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"`
+- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан
+
+#### Scenario: Тема не строка
+- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}`
+- **THEN** 422 с указанием параметра `summary`, пакет не создан
+
+#### Scenario: Отмена пакета с командой у агента
+- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel`
+- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed`
+
+#### Scenario: Ответ на запись настроек
+- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}`
+- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки;
+ токенов в ответе нет
+
+#### Scenario: Карточка с комментариями и связями
+- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на
+ AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик»
+- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 —
+ `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`;
+ `epic_summary = "Тестовый эпик"`
diff --git a/openspec/changes/issue-card-modal/specs/mcp/spec.md b/openspec/changes/issue-card-modal/specs/mcp/spec.md
new file mode 100644
index 0000000..3c3186e
--- /dev/null
+++ b/openspec/changes/issue-card-modal/specs/mcp/spec.md
@@ -0,0 +1,53 @@
+## MODIFIED Requirements
+
+### Requirement: Инструменты
+| Инструмент | Параметры | Результат |
+|---|---|---|
+| `issues` | фильтры как у `GET /issues`, `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту |
+| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» |
+| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки |
+| `batch_create` | `title`, `commands` (список объектов по контракту `commands`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации |
+| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
+| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту |
+| `batches` | `limit` | последние пакеты со статусом |
+| `command_retry` | `id` | новый пакет |
+| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» |
+| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` |
+| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть |
+| `sync_log` | `limit` | последние `sync_run` |
+| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` |
+
+Описание инструмента `batch_create` SHALL предупреждать, что `ensure_issue` должна нести `labels` с базовой меткой
+проекта (`AWPCA`): хаб и агент метки сами не добавляют, и задача без неё выпадает из базовой JQL и из зеркала.
+
+Автор команд из MCP SHALL быть `claude`; из API без автора — `api`.
+
+#### Scenario: Пакет из чата
+- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}`
+- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах
+
+#### Scenario: Ожидание результата
+- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут
+- **THEN** ответ через 6 минут со статусами всех команд
+
+#### Scenario: Статус агента с довыдачей
+- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта
+- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2
+
+#### Scenario: Отмена пакета с командой у агента
+- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed`
+- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …»
+
+#### Scenario: Статус агента после отката
+- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7
+- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и
+ «откат с v7 на v6: …»
+
+#### Scenario: Статус агента сразу после перезапуска хаба
+- **WHEN** хаб перезапущен, агент ещё не прислал `claim`
+- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска
+ хаба)» и не содержит «без запускателя» и «агент запущен без run.py»
+
+#### Scenario: Задача с семью комментариями
+- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов
+- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
diff --git a/openspec/changes/issue-card-modal/specs/web-app/spec.md b/openspec/changes/issue-card-modal/specs/web-app/spec.md
new file mode 100644
index 0000000..a5b8a05
--- /dev/null
+++ b/openspec/changes/issue-card-modal/specs/web-app/spec.md
@@ -0,0 +1,165 @@
+## MODIFIED Requirements
+
+### Requirement: Оболочка и адреса
+Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список
+задач) и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь —
+на страницу «не найдено» со ссылками на разделы. Верхняя панель SHALL содержать переходы «Доска», «Задачи» и действие
+«Сменить токен». Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не
+пустым экраном.
+
+#### Scenario: Переход между разделами
+- **WHEN** на доске выбран раздел «Задачи»
+- **THEN** адрес — `/app/issues`, показан список задач; «назад» возвращает на доску
+
+### Requirement: Доска связей
+Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений,
+кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV
+фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу;
+карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием;
+фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «+ соседи по связям», «⇄ передачи»,
+«скрыть без связей», «⚑ критический путь», «▥ по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица
+связей; экспорт и импорт JSON; тема; «Сбросить раскладку». Раскладка, дорожки, фильтры и тема SHALL храниться в
+`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей.
+Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html`
+на одних и тех же данных и фильтрах. У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку»,
+открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто
+и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния пакета. Действия «Сменить токен» в меню
+доски нет — оно в верхней панели приложения. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку
+карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке
+захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет.
+При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю
+прокрутку; после перезагрузки страницы доска открывается сверху.
+
+#### Scenario: Раскладка переживает переход
+- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными
+- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же
+
+#### Scenario: Совпадение со старой доской
+- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь
+ и проблемы старой страницей и приложением
+- **THEN** результаты совпадают
+
+#### Scenario: Открыть карточку
+- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку»
+- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска
+
+#### Scenario: Возврат с карточки
+- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто
+- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board`
+
+#### Scenario: Возврат из другого раздела
+- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска»
+- **THEN** доска показана с той же прокруткой
+
+### Requirement: Список задач
+Страница `/app/issues` SHALL показывать задачи зеркала из `GET /issues` таблицей: ключ (открывает карточку задачи окном поверх списка), тема,
+тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы,
+статус по факту, признак «сосед». Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид
+работы, статус по факту, статус Jira, метка, эпик, только соседи, поиск по ключу или теме), храниться в query-строке
+адреса и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board`; значение фильтра из адреса, которого нет среди
+вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Задачи SHALL
+загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная.
+
+#### Scenario: Фильтр в адресе
+- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель
+- **THEN** адрес содержит `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого
+ адреса в новой вкладке показывает ту же выборку
+
+#### Scenario: Конец списка
+- **WHEN** вторая порция вернула 17 задач
+- **THEN** в таблице 67 задач, «Показать ещё» не показана
+
+#### Scenario: Фильтр по закрытому спринту
+- **WHEN** открыт адрес `/app/issues?sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет
+- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру
+
+#### Scenario: Карточка поверх списка
+- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто
+- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же
+
+### Requirement: Карточка задачи
+Адрес `/app/issues/{key}` SHALL открывать карточку задачи из `GET /issues/{key}` модальным окном поверх страницы, с
+которой её открыли (список «Задачи» или доска); страница под окном SHALL оставаться смонтированной и сохранять фильтры,
+загруженные строки, прокрутку и раскладку. Перезагрузка страницы с открытым окном SHALL сохранять страницу под окном. Открытый напрямую
+адрес (новая вкладка, закладка) SHALL
+показывать окно поверх списка задач без фильтров. Окно SHALL закрываться кнопкой ✕, клавишей Esc и щелчком по фону (нажатие и отпускание — на фоне; выделение текста,
+отпущенное за краем окна, окно не закрывает) и
+возвращать на страницу, с которой его открыли, даже после переходов по связям внутри окна; «назад» браузера после
+закрытия MUST NOT открывать карточку снова. Переход по связанной задаче SHALL менять содержимое окна и адрес, «назад»
+браузера — возвращать к прежней карточке. Пока окно открыто, фокус SHALL оставаться в окне, страница под ним не
+прокручивается.
+
+Содержимое окна:
+- шапка: ключ, статус Jira, статус по факту (цветом класса из `status_labels`), «Открыть в Jira», ✕, тема; строка
+ «исполнитель · срок · спринт · эпик» без пустых частей, срок — `planned_end`, иначе `duedate`, с пометкой «просрочено»,
+ если он раньше сегодняшней даты и класс статуса по факту не `fact-done` и не `fact-cancelled`; эпик — темой
+ (`epic_summary`), иначе ключом;
+- «Связи» по группам в порядке: «Зависит от» (входящие связи логических типов `блокирует начало` и `блокирует
+ завершение` из `link_types`, с меткой «начало» / «завершение»), «Блокирует» (исходящие тех же типов, с теми же метками),
+ «Связана с» (тип `связанные` в обе стороны), «Прочие» (тип Jira вне `link_types`: имя типа и направление «→» / «←»);
+ в строке — ключ (открывает связанную задачу в окне), тема и статус Jira, а если задачи нет в зеркале — ключ и «нет в
+ зеркале»; пустые группы не показываются, без связей — «связей нет»;
+- «Описание» — текст с сохранёнными переносами строк, без разбора разметки Jira; длиннее 8 строк — свёрнуто с кнопкой
+ «развернуть» / «свернуть»; без описания раздела нет;
+- «Комментарии (N)» — новые сверху, автор, время, текст с сохранёнными переносами без разбора разметки; кнопка
+ «Скрыть» / «Показать» сворачивает раздел, выбор SHALL запоминаться в браузере для всех карточек; без комментариев —
+ «комментариев нет»;
+- «Служебные заметки» (требование «Служебные заметки»);
+- «Прочие поля» — непустые из: тип, приоритет, автор, метки, плановое начало, обновлена в Jira, сосед по связям;
+- «Команды моста (N)» — 10 последних команд по задаче (id, пакет, тип, статус, время завершения, ошибка), раздел свёрнут
+ при открытии; если команд не было — текст «команд по задаче не было».
+На ширине от 900 px «Связи», «Описание», «Комментарии» — в левой колонке, заметки, прочие поля и команды — в правой;
+уже — одна колонка: заметки, связи, описание, комментарии, прочие поля, команды. Если задачи нет в зеркале (404), окно
+SHALL сообщать об этом с кнопкой «Закрыть»; хаб не отвечает — сообщение и «Повторить», ранее загруженные данные
+остаются.
+
+#### Scenario: Связанная задача
+- **WHEN** у AURORA-1620 исходящая связь `ASUPR_Блокер_Начала` на AURORA-1621 «Расчёт свода» в статусе «В работе»
+- **THEN** в карточке в группе «Блокирует» строка «начало · AURORA-1621 · Расчёт свода · В работе»; щелчок по ключу
+ меняет содержимое окна на AURORA-1621, адрес — `/app/issues/AURORA-1621`; «назад» браузера возвращает AURORA-1620
+
+#### Scenario: Входящий блокер
+- **WHEN** у AURORA-1620 входящая связь `ASUPR_Блокер_Завершения` от AURORA-1500
+- **THEN** строка с AURORA-1500 и меткой «завершение» — в группе «Зависит от»
+
+#### Scenario: Задача без команд
+- **WHEN** по AURORA-1620 не было команд
+- **THEN** в разделе «Команды моста» — текст «команд по задаче не было», таблицы нет
+
+#### Scenario: Закрытие после переходов
+- **WHEN** со списка «Задачи» открыта AURORA-1620, в окне — переход по связи на AURORA-1621, нажата Esc
+- **THEN** окно закрыто, адрес — адрес списка с прежними фильтрами; «назад» браузера не открывает карточку
+
+#### Scenario: Прямой адрес
+- **WHEN** в новой вкладке открыт `/app/issues/AURORA-1620`, затем нажат ✕
+- **THEN** под окном был список задач без фильтров; после закрытия адрес `/app/issues`, «назад» не открывает карточку
+
+#### Scenario: Скрытые комментарии
+- **WHEN** в карточке нажато «Скрыть» у комментариев, затем страница перезагружена и открыта другая карточка
+- **THEN** комментарии в ней скрыты, видна кнопка «Показать» и число комментариев
+
+### Requirement: Служебные заметки
+Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Статус по факту», «Сделано»,
+«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать
+форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого),
+«статус по факту» — текст с подсказками из ключей `status_labels`, «сделано», «осталось», «заметка владельца» —
+многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения.
+Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле
+отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL
+показывать новые «вид работы» и «статус по факту» без «Перечитать», доска под окном SHALL перечитать снимок
+(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без
+потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать
+подтверждения. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет.
+
+#### Scenario: Только изменённые поля
+- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить»
+- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым
+ «Осталось»
+
+#### Scenario: Уход с несохранёнными правками
+- **WHEN** в форме заметки есть несохранённая правка и нажата Esc
+- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом
+
+#### Scenario: Заметка видна на доске
+- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён
+- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py
index 9c892e9..1d5efce 100644
--- a/src/jira_hub/api.py
+++ b/src/jira_hub/api.py
@@ -163,8 +163,13 @@ def h_agent_result(hub, conn, m, q, body):
def h_agent_state(hub, conn, m, q, body):
+ issues = body.get("issues") or []
+ try:
+ mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
+ except ValueError as e:
+ raise ApiError(422, "bad_state", str(e))
with db.transaction(conn):
- out = mirror.apply_state(conn, body.get("issues") or [], body.get("links") or [], body.get("neighbor_keys") or [],
+ out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [],
authoritative=queue.is_full_refresh(conn, body.get("command_id")))
if body.get("command_id"):
queue.record_state(conn, body["command_id"], out["issues"], out["links"])
diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py
index 58f3b71..0e091b6 100644
--- a/src/jira_hub/db.py
+++ b/src/jira_hub/db.py
@@ -8,7 +8,7 @@ from pathlib import Path
from . import backup, paths
-SCHEMA_VERSION = 2
+SCHEMA_VERSION = 3
SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
@@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS issue_link (
PRIMARY KEY (source_key, target_key, link_type)
);
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key);
+CREATE TABLE IF NOT EXISTS issue_comment (
+ key TEXT NOT NULL,
+ comment_id TEXT NOT NULL,
+ author TEXT,
+ created TEXT,
+ updated TEXT,
+ body TEXT NOT NULL DEFAULT '',
+ PRIMARY KEY (key, comment_id)
+);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
@@ -105,6 +114,7 @@ CREATE TABLE IF NOT EXISTS agent_status (
MIGRATIONS = {
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
+ 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
}
diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py
index e538b1a..2fdcbd8 100644
--- a/src/jira_hub/mcp_server.py
+++ b/src/jira_hub/mcp_server.py
@@ -80,8 +80,14 @@ def fmt_issue(d) -> str:
note = d.get("note") or {}
notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)]
cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])]
- return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"])
- + ["описание:", d.get("description") or "—"])
+ comments = d.get("comments") or []
+ if comments:
+ shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]]
+ cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
+ else:
+ cmts = ["комментариев нет"]
+ return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
+ + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
def fmt_batch(b) -> str:
diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py
index 1405e7c..923b670 100644
--- a/src/jira_hub/mirror.py
+++ b/src/jira_hub/mirror.py
@@ -1,4 +1,4 @@
-"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note).
+"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
@@ -8,6 +8,26 @@ ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"]
+def check_comments(issues) -> None:
+ """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем."""
+ for it in issues:
+ if "comments" not in it:
+ continue
+ key, lst = it.get("key"), it["comments"]
+ if not isinstance(lst, list):
+ raise ValueError(f"{key}: comments — список комментариев")
+ for n, c in enumerate(lst):
+ where = f"{key}: comments[{n}]"
+ if not isinstance(c, dict):
+ raise ValueError(f"{where} — объект комментария")
+ if not isinstance(c.get("id"), str) or not c["id"]:
+ raise ValueError(f"{where}.id — непустая строка")
+ if not isinstance(c.get("body"), str) and c.get("body") is not None:
+ raise ValueError(f"{where}.body — строка")
+ for f in ("author", "created", "updated"):
+ if c.get(f) is not None and not isinstance(c[f], str):
+ raise ValueError(f"{where}.{f} — строка или null")
+
_FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"}
@@ -35,6 +55,13 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
"ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
[row[c] for c in cols])
keys.append(it["key"])
+ if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
+ conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
+ conn.executemany(
+ "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "")
+ for c in it["comments"]])
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
@@ -80,10 +107,17 @@ def get_issue(conn, key) -> dict | None:
if row is None:
return None
d = _issue_dict(row)
+ epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None
+ d["epic_summary"] = epic["summary"] if epic else None
d["links_out"] = [dict(r) for r in conn.execute(
- "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))]
+ "SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key "
+ "WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))]
d["links_in"] = [dict(r) for r in conn.execute(
- "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))]
+ "SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key "
+ "WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))]
+ d["comments"] = [dict(r) for r in conn.execute(
+ "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
+ "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
d["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute(
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",
diff --git a/tests/test_api.py b/tests/test_api.py
index e42bacd..e11d7c0 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -72,7 +72,7 @@ def test_agent_flow(hub):
a.post(f"/agent/commands/{tail['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get("/batches/%d" % b["id"])["status"] == "done"
issue = c.get("/issues/A-1")
- assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные"}]
+ assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}]
assert c.get("/issues/B-1")["neighbor"] is True
runs = c.get("/sync-runs", limit=5)["runs"]
assert runs[0]["issues_count"] == 2 and runs[0]["finished_at"]
@@ -572,3 +572,29 @@ def test_tick_creates_auto_refresh_once(hub):
assert [b["title"] for b in batches] == ["Автообновление"] and batches[0]["author"] == "hub"
hub["hub"].tick()
assert len(hub["client"].get("/batches", limit=5)["batches"]) == 1
+
+
+def test_agent_state_comments_validated_whole_batch(hub):
+ a, c = hub["agent"], hub["client"]
+ a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}], "links": []})
+ with pytest.raises(HubError) as e:
+ a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс новый", "labels": []},
+ {"key": "Y-1", "summary": "Игрек", "labels": [],
+ "comments": [{"id": "7", "body": 42}]}], "links": []})
+ assert e.value.status == 422 and e.value.error == "bad_state"
+ assert "Y-1" in e.value.detail and "body" in e.value.detail
+ assert c.get("/issues/X-1")["summary"] == "Икс" # пачка не применена целиком
+ with pytest.raises(HubError) as e:
+ c.get("/issues/Y-1")
+ assert e.value.status == 404
+
+
+def test_issue_detail_with_comments(hub):
+ hub["agent"].post("/agent/state", {"issues": [
+ {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [
+ {"id": "1", "author": "Петров Пётр", "created": "2026-10-14T12:00:00.000+0300", "updated": None, "body": "старый"},
+ {"id": "2", "author": None, "created": "2026-10-15T12:00:00.000+0300", "updated": None, "body": "новый"}]},
+ {"key": "E-1", "summary": "Эпик", "labels": []}], "links": []})
+ got = hub["client"].get("/issues/A-1")
+ assert [x["body"] for x in got["comments"]] == ["новый", "старый"]
+ assert got["epic_summary"] == "Эпик"
diff --git a/tests/test_db.py b/tests/test_db.py
index 25d788e..b0ba510 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -10,8 +10,9 @@ from jira_hub import backup, db, paths, queue, settings
def test_connect_creates_schema(open_db):
conn = open_db()
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
- assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names
- assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
+ assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
+ "agent_status"} <= names
+ assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
assert "deliveries" in cols
@@ -34,7 +35,7 @@ def test_migrates_v1_with_backup(tmp_path):
path = tmp_path / "hub.sqlite"
_v1_database(path)
conn = db.connect(path)
- assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
+ assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall()
assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)]
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
@@ -181,7 +182,26 @@ def test_parallel_connect_makes_one_backup(tmp_path):
assert errors == []
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
conn = db.connect(path)
- assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2"
+ assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
+ conn.close()
+
+
+def test_migrates_v2_with_backup(tmp_path):
+ """База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте."""
+ path = tmp_path / "hub.sqlite"
+ conn = db.connect(path)
+ conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
+ conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
+ conn.execute("DROP TABLE issue_comment")
+ conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
+ conn.close()
+ conn = db.connect(path)
+ assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
+ assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
+ assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
+ assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
+ copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite"))
+ assert len(copies) == 1
conn.close()
diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py
index f6b03aa..60c17b8 100644
--- a/tests/test_e2e_hub.py
+++ b/tests/test_e2e_hub.py
@@ -75,5 +75,6 @@ def test_create_link_sprint_flow(live):
assert issue["note"]["work_kind"] == "разработка"
assert len(issue["links_out"]) == 1
assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные"
- assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}]
+ links_in = client.get("/issues/A-1")["links_in"]
+ assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")]
assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2
diff --git a/tests/test_mcp.py b/tests/test_mcp.py
index 029be47..429929e 100644
--- a/tests/test_mcp.py
+++ b/tests/test_mcp.py
@@ -236,3 +236,25 @@ def test_agent_status_right_after_hub_start(live):
live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2})
text = mcp_server.agent_status()
assert "запускатель: v2" in text and "неизвестно" not in text
+
+
+def _detail(**over):
+ d = {"key": "A-1", "summary": "Т", "labels": [], "links_out": [], "links_in": [], "note": None, "commands": [],
+ "comments": [], "description": None}
+ d.update(over)
+ return d
+
+
+def test_fmt_issue_comments():
+ assert "комментариев нет" in mcp_server.fmt_issue(_detail())
+ comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300",
+ "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)]
+ text = mcp_server.fmt_issue(_detail(comments=comments))
+ assert "комментарии (7):" in text
+ assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300
+ assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3
+ assert "ещё 2" in text
+ assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:")
+ no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
+ "updated": None, "body": ""}]))
+ assert "комментарии (1):" in no_author and "ещё" not in no_author
diff --git a/tests/test_mirror.py b/tests/test_mirror.py
index 434be71..5215c94 100644
--- a/tests/test_mirror.py
+++ b/tests/test_mirror.py
@@ -27,15 +27,18 @@ def test_apply_state_upserts_and_replaces_links(conn):
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1")
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
- assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}]
- assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}]
+ assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
+ "status": "Ожидание разработки"}]
+ assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные",
+ "summary": "Новая тема", "status": "Ожидание разработки"}]
def test_links_from_other_source_survive(conn):
mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей
- assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}]
+ assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
+ "summary": "Тема A-2", "status": "Ожидание разработки"}]
def test_apply_state_marks_neighbors(conn):
@@ -183,3 +186,57 @@ def test_board_inside_open_transaction_keeps_it_open(conn):
assert conn.in_transaction
assert not conn.in_transaction
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"]
+
+
+def comment(cid, created="2026-10-14T12:00:00.000+0300", **over):
+ base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"}
+ base.update(over)
+ return base
+
+
+def test_comments_replaced_and_kept_without_field(conn):
+ mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], [])
+ mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], [])
+ got = mirror.get_issue(conn, "A-1")["comments"]
+ assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")]
+ mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10
+ assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"]
+ mirror.apply_state(conn, [issue("A-1", comments=[])], [])
+ assert mirror.get_issue(conn, "A-1")["comments"] == []
+
+
+def test_comments_newest_first_and_shape(conn):
+ mirror.apply_state(conn, [issue("A-1", comments=[
+ comment("9", created="2026-10-14T10:00:00.000+0300"),
+ comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None),
+ comment("11", created="2026-10-15T10:00:00.000+0300")])], [])
+ got = mirror.get_issue(conn, "A-1")["comments"]
+ assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число
+ assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300",
+ "updated": "2026-10-15T10:00:00.000+0300", "body": ""}
+
+
+def test_issue_epic_summary_and_absent_link(conn):
+ mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)],
+ [{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}])
+ got = mirror.get_issue(conn, "A-1")
+ assert got["epic_summary"] == "Эпик свода"
+ assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}]
+ assert mirror.get_issue(conn, "E-1")["epic_summary"] is None
+
+
+@pytest.mark.parametrize("bad, field", [
+ ("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"),
+ ([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"),
+ ([{"id": "5", "body": "т", "author": 7}], "comments[0].author"),
+ ([{"id": "5", "body": "т", "created": 1}], "comments[0].created"),
+ ([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"),
+])
+def test_check_comments_rejects(bad, field):
+ with pytest.raises(ValueError) as e:
+ mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)])
+ assert str(e.value).startswith(f"A-2: {field}")
+
+
+def test_check_comments_accepts_missing_and_valid():
+ mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])])
diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts
index 7a8886a..ca5f2da 100644
--- a/web/e2e/board.spec.ts
+++ b/web/e2e/board.spec.ts
@@ -53,13 +53,15 @@ test("«Обновить из Jira» с учебным агентом доход
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
});
-test("ⓘ открывает карточку задачи в приложении", async ({ page }) => {
+test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
- await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-board")).toBeAttached();
+ await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
});
-test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
+test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 600 });
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
@@ -72,9 +74,37 @@ test("M-3: прокрутка доски после возврата из кар
return a?.closest(".card")?.dataset.key ?? null;
});
expect(key).not.toBeNull();
- const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
- await link.click();
- await expect(page).toHaveURL(/\/app\/issues\//);
- await page.getByRole("link", { name: "Доска", exact: true }).click();
+ let boardLoads = 0;
+ page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
+ await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(/\/app\/board$/);
+ expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
+ expect(boardLoads).toBe(0); // доска не перезагружалась
+ // ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
+ await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
});
+
+test("заметка из окна уходит в хаб, карточка на доске остаётся на месте, прокрутка сохраняется", async ({ page }) => {
+ // доска показывает вид работы по меткам Jira (fromHub), заметка work_kind в карточку доски не попадает: проверяем снимок /board
+ await page.locator('.board .card[data-key="AURORA-9002"]').evaluate((e) => { (e as HTMLElement).dataset.marker = "keep"; });
+ await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
+ const d = page.getByRole("dialog");
+ const y = await page.evaluate(() => window.scrollY);
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Вид работы").selectOption("тестирование");
+ const snap = page.waitForResponse((r) => new URL(r.url()).pathname === "/board" && r.request().method() === "GET");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ const body = await (await snap).json();
+ expect(body.issues.find((i: { key: string }) => i.key === "AURORA-9002").work_kind).toBe("тестирование");
+ await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("page-board")).toBeAttached();
+ await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toBeVisible();
+ await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toHaveAttribute("data-marker", "keep"); // узел не пересоздан
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+});
diff --git a/web/e2e/geometry.spec.ts b/web/e2e/geometry.spec.ts
index 28231f5..87c09f4 100644
--- a/web/e2e/geometry.spec.ts
+++ b/web/e2e/geometry.spec.ts
@@ -66,12 +66,13 @@ test("смена спринта и исполнителя, убирающая к
expect(errs).toEqual([]);
});
-test("h1 на страницах доски, задач и карточки", async ({ page }) => {
+test("h1 на страницах доски и задач, h2 в окне карточки", async ({ page }) => {
await login(page);
await expect(page.locator("h1")).toHaveText("Доска связей");
await page.getByRole("link", { name: "Задачи" }).click();
await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible();
await page.getByRole("link", { name: "AURORA-9002" }).click();
- await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible();
+ // карточка — окно поверх списка: её заголовок h2 (тема задачи), h1 остаётся у страницы под окном
+ await expect(page.getByRole("dialog").getByRole("heading", { level: 2, name: "API свода" })).toBeVisible();
await expect(page.locator("h1")).toHaveCount(1);
});
diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts
index 1887060..fb56137 100644
--- a/web/e2e/issues.spec.ts
+++ b/web/e2e/issues.spec.ts
@@ -1,15 +1,121 @@
import { expect, test } from "@playwright/test";
import { login } from "./hub.ts";
-test("фильтр → карточка → заметка сохраняется", async ({ page }) => {
+test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
await login(page, "/app/issues");
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
await expect(page).toHaveURL(/assignee=/);
- await page.getByRole("link", { name: "AURORA-9002" }).click();
- const rest = page.getByLabel("Осталось");
- await rest.fill("e2e: дописать тесты");
- await page.getByRole("button", { name: "Сохранить" }).click();
- await expect(page.getByText(/сохранено .*web/)).toBeVisible();
+ await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Осталось").fill("e2e: дописать тесты");
+ await d.getByRole("button", { name: "Сохранить" }).click();
+ await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
await page.reload();
- await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
+ await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
+ await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
+});
+
+test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 320 });
+ await login(page, "/app/issues");
+ await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
+ const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
+ expect(y).toBeGreaterThan(50);
+ await page.locator("table.grid tbody tr").last().getByRole("link").click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+ await page.keyboard.press("Escape");
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ expect(await page.evaluate(() => window.scrollY)).toBe(y);
+});
+
+test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
+ await login(page); // доска
+ await page.getByRole("link", { name: "Задачи", exact: true }).click();
+ await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
+ await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
+ await page.goBack();
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
+ await d.getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await page.mouse.click(5, 300); // фон слева от окна
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await page.goBack();
+ await expect(page).toHaveURL(/\/app\/board$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await expect(page.getByTestId("page-issues")).toBeAttached();
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
+ await expect(page).toHaveURL(/\/app\/issues$/);
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+});
+
+test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
+ await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
+ await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
+ await expect(d.locator("pre.description")).toHaveClass(/clamped/); // свёрнуто построчным обрезанием (текст в DOM остаётся)
+ const hClamped = (await d.locator("pre.description").boundingBox())!.height;
+ expect(await d.locator("pre.description").evaluate((e) => e.scrollHeight > e.clientHeight)).toBe(true);
+ await d.getByRole("button", { name: "развернуть" }).click();
+ await expect(d.locator("pre.description")).not.toHaveClass(/clamped/);
+ expect((await d.locator("pre.description").boundingBox())!.height).toBeGreaterThan(hClamped);
+ await expect(d.getByText("Конец описания.")).toBeVisible();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
+ await d.getByText("Команды моста (2)").click();
+ await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
+ await d.getByRole("button", { name: "Скрыть" }).click();
+ await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
+ await page.reload();
+ await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
+ await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
+});
+
+test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
+ await login(page, "/app/issues");
+ await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
+ const d = page.getByRole("dialog");
+ await d.getByRole("button", { name: "Править" }).click();
+ await d.getByLabel("Сделано").fill("черновик");
+ await page.keyboard.press("Escape");
+ await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
+ await d.getByRole("button", { name: "Остаться" }).click();
+ await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
+ await page.keyboard.press("Escape");
+ await d.getByRole("button", { name: "Уйти" }).click();
+ await expect(page.getByRole("dialog")).toHaveCount(0);
+ await expect(page).toHaveURL(/\/app\/issues$/);
+});
+
+test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
+ await page.setViewportSize({ width: 600, height: 800 });
+ await login(page, "/app/issues/AURORA-9001");
+ const d = page.getByRole("dialog");
+ await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
+ expect((await d.boundingBox())!.width).toBeGreaterThan(590);
+ const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
+ const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
+ expect(notesY).toBeLessThan(linksY);
+ expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
});
diff --git a/web/scripts/seed-extras.json b/web/scripts/seed-extras.json
new file mode 100644
index 0000000..38d6f01
--- /dev/null
+++ b/web/scripts/seed-extras.json
@@ -0,0 +1,16 @@
+{
+ "issues": {
+ "AURORA-9001": {
+ "description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
+ "comments": [
+ {"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
+ {"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
+ {"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
+ ]
+ }
+ },
+ "commands": [
+ {"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
+ {"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
+ ]
+}
diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py
index a776854..767d93f 100644
--- a/web/scripts/seed-hub.py
+++ b/web/scripts/seed-hub.py
@@ -1,5 +1,6 @@
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
-Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
+Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.
+Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
import json
import sys
from pathlib import Path
@@ -7,15 +8,28 @@ from pathlib import Path
from jira_hub import db, mirror, paths, settings
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
+extras_path = Path(__file__).with_name("seed-extras.json")
+extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
+extra_issues = extras.get("issues", {})
conn = db.connect(paths.db_path())
settings.ensure_defaults(conn)
-issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
+issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
+ | extra_issues.get(i["key"], {}) for i in snap["issues"]]
links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]]
mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]])
for i in snap["issues"]:
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
if note:
mirror.set_note(conn, i["key"], note, "seed")
+if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
+ with db.transaction(conn):
+ batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
+ (db.now(),)).lastrowid
+ for seq, c in enumerate(extras["commands"], 1):
+ conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
+ c.get("error")))
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
conn.close()
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index fde4622..1cce5c8 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { App } from "./App.tsx";
import { clearToken, getAuth, setToken } from "./auth/token.ts";
import { routes } from "./routes.tsx";
-import { Layout } from "./shell/Layout.tsx";
+import { PAGE_ROUTES } from "./shell/pages.tsx";
import { json, mockFetch } from "./test/fetchMock.ts";
const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" });
@@ -45,12 +45,15 @@ describe("оболочка", () => {
it("исключение в странице — сообщение и «Перезагрузить», панель на месте", async () => {
const Boom = () => { throw new Error("сломалось"); };
- const router = createMemoryRouter([{ path: "/", element: , children: [{ path: "x", element: }] }],
- { initialEntries: ["/x"] });
- render( );
- expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
+ const boom = { path: "/x", element: };
+ PAGE_ROUTES.unshift(boom);
+ try {
+ const router = createMemoryRouter(routes, { initialEntries: ["/app/x"], basename: "/app" });
+ render( );
+ expect(await screen.findByText(/сломалось/)).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Перезагрузить" })).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Доска" })).toBeInTheDocument();
+ } finally { PAGE_ROUTES.splice(PAGE_ROUTES.indexOf(boom), 1); }
});
it("без токена — только экран входа", () => {
diff --git a/web/src/api/types.ts b/web/src/api/types.ts
index c8ca895..da86f18 100644
--- a/web/src/api/types.ts
+++ b/web/src/api/types.ts
@@ -35,11 +35,17 @@ export type Note = Record & { key: string; updated_at:
export interface IssueCommand {
id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null;
}
+export interface IssueLinkOut { target_key: string; link_type: string; summary: string | null; status: string | null }
+export interface IssueLinkIn { source_key: string; link_type: string; summary: string | null; status: string | null }
+// комментарий задачи из Jira (зеркало хаба, агент v10+): текст и время — как в Jira
+export interface IssueComment { comment_id: string; author: string | null; created: string | null; updated: string | null; body: string }
export interface IssueDetail extends IssueRow {
- links_out: { target_key: string; link_type: string }[];
- links_in: { source_key: string; link_type: string }[];
+ epic_summary: string | null;
+ links_out: IssueLinkOut[];
+ links_in: IssueLinkIn[];
note: Note | null;
commands: IssueCommand[];
+ comments: IssueComment[];
}
export type BatchStatus = "queued" | "running" | "done" | "cancelled";
diff --git a/web/src/board/components/Card.tsx b/web/src/board/components/Card.tsx
index 9d05362..0623e40 100644
--- a/web/src/board/components/Card.tsx
+++ b/web/src/board/components/Card.tsx
@@ -1,5 +1,6 @@
import { memo, type PointerEvent } from "react";
import { Link } from "react-router";
+import { BOARD_CARD_STATE } from "../../issues/modal.ts";
import { CW, JIRA_URL, KIND, PRIO } from "../constants.ts";
import { lineClamp, type Rect, type RelBox } from "../layout/layout.ts";
import { fmtDate, shortName, unassigned } from "../model/util.ts";
@@ -31,7 +32,7 @@ export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintS
onPointerDown={onPointerDown ? (e) => onPointerDown(e, n.key) : undefined}>
{n.key}
- {!n.stub &&
ⓘ}
{showTag && (n.sprint
?
{n.sprint.replace(/^AWPCA_/, "")}
diff --git a/web/src/issues/IssueCard.test.tsx b/web/src/issues/IssueCard.test.tsx
new file mode 100644
index 0000000..af13998
--- /dev/null
+++ b/web/src/issues/IssueCard.test.tsx
@@ -0,0 +1,341 @@
+import { act, cleanup, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { createMemoryRouter, RouterProvider, useNavigate, useParams } from "react-router";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { qk } from "../api/queries.ts";
+import { makeQueryClient } from "../api/queryClient.ts";
+import { setToken } from "../auth/token.ts";
+import settings from "../board/__fixtures__/settings.json";
+import { json, mockFetch } from "../test/fetchMock.ts";
+import { issue, note } from "../test/issueFixtures.ts";
+import { IssueCard } from "./IssueCard.tsx";
+
+const deferred =
() => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
+
+function CardAt() {
+ const { key = "" } = useParams();
+ const navigate = useNavigate();
+ return void navigate("/board")} />;
+}
+
+function open(path: string, client = makeQueryClient()) {
+ const router = createMemoryRouter([
+ { path: "/issues/:key", element: },
+ { path: "/board", element: доска
},
+ ], { initialEntries: [path] });
+ render( );
+ return router;
+}
+
+describe("карточка задачи", () => {
+ beforeEach(() => setToken("tok"));
+
+ it("шапка: ключ, статусы, тема, исполнитель · срок · спринт · эпик по имени; ссылка в Jira", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { planned_end: "2020-01-10" })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByRole("heading", { level: 2, name: "Тема AURORA-9001" })).toBeInTheDocument();
+ const head = screen.getByTestId("issue-head");
+ expect(within(head).getByText("AURORA-9001")).toBeInTheDocument();
+ expect(within(head).getByText("В работе", { selector: ".badge.status" })).toBeInTheDocument();
+ expect(within(head).getByText("факт: В работе", { selector: ".badge.fact" })).toHaveClass("fact-in-progress"); // как в макете раздела 1
+ expect(within(head).getByTestId("issue-facts")).toHaveTextContent("Иванов Иван Иванович · срок 10.01 просрочено · AWPCA_Спринт 2 · эпик: Тестовый эпик");
+ expect(within(head).getByText("просрочено")).toHaveClass("overdue");
+ expect(screen.getByRole("link", { name: "Открыть в Jira ↗" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
+ });
+
+ it("связи по группам с темой и статусом; задача вне зеркала; прочий тип с направлением", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", {
+ links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" },
+ { target_key: "lower-1", link_type: "ASUPR_Связанные", summary: null, status: null }],
+ links_in: [{ source_key: "AURORA-9003", link_type: "ASUPR_Блокер_Завершения", summary: "Макет экрана", status: "Готово" },
+ { source_key: "AURORA-9008", link_type: "Duplicate", summary: "Актуализация ТЗ", status: "В работе" }],
+ })));
+ open("/issues/AURORA-9001");
+ const depends = await screen.findByRole("group", { name: "Зависит от" });
+ expect(within(depends).getByRole("listitem")).toHaveTextContent("завершение AURORA-9003 Макет экрана Готово");
+ const blocks = screen.getByRole("group", { name: "Блокирует" });
+ expect(within(blocks).getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
+ expect(within(blocks).getByRole("listitem")).toHaveTextContent("начало AURORA-9002 API свода Новая");
+ expect(within(screen.getByRole("group", { name: "Связана с" })).getByRole("listitem")).toHaveTextContent("lower-1 нет в зеркале");
+ expect(within(screen.getByRole("group", { name: "Прочие" })).getByRole("listitem")).toHaveTextContent("← Duplicate AURORA-9008");
+ expect(screen.getByRole("link", { name: "AURORA-9002" }).closest(".issue")).not.toBeNull(); // O-10: цвет темы
+ });
+
+ it("без связей — «связей нет»", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [], links_in: [] })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByText("связей нет")).toBeInTheDocument();
+ });
+
+ it("описание свыше 8 строк свёрнуто, «развернуть»/«свернуть»; короткое — без кнопки", async () => {
+ const long = Array.from({ length: 10 }, (_, n) => `строка ${n + 1}`).join("\n");
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: long })));
+ open("/issues/AURORA-9001");
+ const pre = await screen.findByText(long, { normalizer: (s) => s });
+ expect(pre).toHaveClass("description", "clamped");
+ await userEvent.click(screen.getByRole("button", { name: "развернуть" }));
+ expect(pre).not.toHaveClass("clamped");
+ await userEvent.click(screen.getByRole("button", { name: "свернуть" }));
+ expect(pre).toHaveClass("clamped");
+ });
+
+ it("короткое описание — без кнопки; нет описания — нет раздела", async () => {
+ let d: string | null = "строка 1\nстрока 2";
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { description: d })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
+ expect(screen.queryByRole("button", { name: "развернуть" })).toBeNull();
+ d = null;
+ cleanup();
+ open("/issues/AURORA-9001");
+ await screen.findByRole("heading", { level: 2 });
+ expect(screen.queryByRole("heading", { name: "Описание" })).toBeNull();
+ });
+
+ it("комментарии новые сверху; «Скрыть» запоминается для следующей карточки", async () => {
+ const comments = [
+ { comment_id: "2", author: "Петров Пётр", created: "2026-10-15T12:00:00.000+0300", updated: null, body: "новый\nвторая строка" },
+ { comment_id: "1", author: "Иванов Иван", created: "2026-10-14T12:00:00.000+0300", updated: null, body: "старый" }];
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByRole("heading", { name: "Комментарии (2)" })).toBeInTheDocument();
+ const items = within(screen.getByTestId("comments")).getAllByRole("listitem");
+ expect(items[0]).toHaveTextContent("Петров Пётр");
+ expect(within(items[0]).getByText("новый\nвторая строка", { normalizer: (s) => s })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
+ expect(screen.queryByText("старый")).toBeNull();
+ expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
+ cleanup();
+ open("/issues/AURORA-9001");
+ expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument();
+ expect(screen.queryByText("старый")).toBeNull();
+ });
+
+ it("комментарий без автора и текста не ломает карточку; без комментариев — «комментариев нет»", async () => {
+ let comments = [{ comment_id: "1", author: null, created: null, updated: null, body: "" }];
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { comments })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByRole("heading", { name: "Комментарии (1)" })).toBeInTheDocument();
+ expect(within(screen.getByTestId("comments")).getByRole("listitem")).toHaveTextContent("—");
+ comments = [];
+ cleanup();
+ open("/issues/AURORA-9001");
+ expect(await screen.findByText("комментариев нет")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Скрыть" })).toBeNull();
+ });
+
+ it("прочие поля и команды моста свёрнуты", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ open("/issues/AURORA-9001");
+ const other = await screen.findByRole("region", { name: "Прочие поля" });
+ expect(other).toHaveTextContent("Приоритет");
+ expect(other).toHaveTextContent("Высокий");
+ const details = screen.getByText("Команды моста (1)").closest("details")!;
+ expect(details).not.toHaveAttribute("open");
+ expect(within(details).getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
+ });
+
+ it("заголовок карточки — h2 с id для aria-labelledby окна", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByRole("heading", { level: 2, name: /AURORA-9001/ })).toHaveAttribute("id", "issue-modal-title");
+ });
+
+ it("задачи нет в зеркале — сообщение и «Закрыть»", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
+ const router = open("/issues/AURORA-1");
+ expect(await screen.findByText("Задачи AURORA-1 нет в зеркале хаба.")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Закрыть" }));
+ expect(router.state.location.pathname).toBe("/board");
+ });
+
+ it("сохраняются только изменённые поля, очищенное — null", async () => {
+ let put: unknown = null;
+ mockFetch((m, url, body) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
+ return json(200, issue("AURORA-9001"));
+ });
+ open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ const rest = await screen.findByLabelText("Осталось");
+ await userEvent.clear(rest); await userEvent.type(rest, "тесты");
+ await userEvent.clear(screen.getByLabelText("Сделано"));
+ await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
+ expect(await screen.findByText(/изменено .*web/)).toBeInTheDocument(); // снова режим чтения
+ expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Сохранить" })).toBeNull();
+ });
+
+ it("422 — текст ошибки у формы, введённое на месте", async () => {
+ mockFetch((m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
+ return json(200, issue("AURORA-9001"));
+ });
+ open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ const owner = await screen.findByLabelText("Заметка владельца");
+ await userEvent.type(owner, "проверить");
+ await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
+ expect(owner).toHaveValue("проверить");
+ });
+
+ it("уход с несохранёнными правками требует подтверждения", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ const router = open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
+ await act(async () => { await router.navigate("/board"); });
+ expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
+ expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
+ expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
+ });
+
+ it("поздний ответ прежней карточки не подменяет новую", async () => {
+ const client = makeQueryClient();
+ const slow = deferred();
+ mockFetch((_m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (url.pathname === "/issues/AURORA-9001") return slow.promise;
+ return json(200, issue("AURORA-9002"));
+ });
+ const router = open("/issues/AURORA-9001", client);
+ await act(async () => { await router.navigate("/issues/AURORA-9002"); });
+ expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ slow.resolve(json(200, issue("AURORA-9001")));
+ await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
+ expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
+ });
+
+ it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
+ const slowPut = deferred();
+ mockFetch((m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (m === "PUT") return slowPut.promise;
+ if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
+ return json(200, issue("AURORA-9001"));
+ });
+ // 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
+ const client = makeQueryClient();
+ client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
+ const router = open("/issues/AURORA-9001", client);
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
+ await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ await act(async () => { await router.navigate("/issues/AURORA-9002"); });
+ await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
+ expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
+ await waitFor(() => expect(client.isMutating()).toBe(0));
+ expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument(); // новая карточка — в режиме чтения
+ expect(screen.queryByText("черновик")).toBeNull();
+ });
+
+ it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
+ const slowPut = deferred();
+ mockFetch((m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (m === "PUT") return slowPut.promise;
+ return json(200, issue("AURORA-9001"));
+ });
+ open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
+ await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
+ const box = screen.getByLabelText("Заметка владельца");
+ await waitFor(() => expect(box).toBeDisabled());
+ slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
+ await screen.findByRole("button", { name: "Править" }); // режим чтения (findByText("раз") ловил бы ещё живой textarea)
+ expect(screen.getByText("раз")).toBeInTheDocument(); // с сохранённым значением
+ expect(screen.queryByLabelText("Заметка владельца")).toBeNull(); // форма закрыта
+ });
+
+ it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
+ const client = makeQueryClient();
+ client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
+ let down = true;
+ mockFetch((_m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (down) throw new TypeError("Failed to fetch");
+ return json(200, issue("AURORA-9001"));
+ });
+ open("/issues/AURORA-9001", client);
+ expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
+ expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
+ expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ down = false;
+ await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
+ await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
+ });
+
+ it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
+ open("/issues/AURORA-9001");
+ expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
+ expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
+ });
+
+ it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ const input = await screen.findByLabelText("Статус по факту");
+ const id = input.getAttribute("list")!;
+ expect(id).not.toBe("fact-statuses");
+ expect(document.getElementById(id)?.tagName).toBe("DATALIST");
+ });
+
+ it("заметки в режиме чтения: пустые — «—», время и автор; «Отмена» без правок — сразу", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ open("/issues/AURORA-9001");
+ const view = await screen.findByRole("region", { name: "Служебные заметки" });
+ expect(view).toHaveTextContent("Сделано");
+ expect(view).toHaveTextContent("схема");
+ expect(within(view).getByText("Заметка владельца").nextElementSibling).toHaveTextContent("—");
+ expect(view).toHaveTextContent(/изменено .*chat/);
+ await userEvent.click(within(view).getByRole("button", { name: "Править" }));
+ await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
+ expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
+ });
+
+ it("«Отмена» с правками спрашивает; «Отменить правки» возвращает чтение без изменений", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
+ open("/issues/AURORA-9001");
+ await userEvent.click(await screen.findByRole("button", { name: "Править" }));
+ await userEvent.type(screen.getByLabelText("Заметка владельца"), "черновик");
+ await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
+ expect(screen.getByText("Отменить правки заметки?")).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Продолжить правку" }));
+ expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
+ await userEvent.click(screen.getByRole("button", { name: "Отмена" }));
+ await userEvent.click(screen.getByRole("button", { name: "Отменить правки" }));
+ expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();
+ expect(screen.queryByText("черновик")).toBeNull();
+ });
+
+ it("заметки ещё нет — режим чтения с «—» и «Править»", async () => {
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { note: null })));
+ open("/issues/AURORA-9001");
+ const view = await screen.findByRole("region", { name: "Служебные заметки" });
+ expect(within(view).getAllByText("—")).toHaveLength(5);
+ expect(within(view).getByRole("button", { name: "Править" })).toBeInTheDocument();
+ });
+
+ it("T5.3: одинаковые связи не дают предупреждения о повторяющихся key", async () => {
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
+ const dup = { target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API", status: "Новая" };
+ mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { links_out: [dup, dup] })));
+ open("/issues/AURORA-9001");
+ await screen.findAllByRole("link", { name: "AURORA-9002" });
+ expect(err.mock.calls.flat().join(" ")).not.toContain("same key");
+ err.mockRestore();
+ });
+});
diff --git a/web/src/issues/IssueCard.tsx b/web/src/issues/IssueCard.tsx
new file mode 100644
index 0000000..8296dac
--- /dev/null
+++ b/web/src/issues/IssueCard.tsx
@@ -0,0 +1,177 @@
+import { useLayoutEffect, useRef, useState } from "react";
+import { ApiError } from "../api/client.ts";
+import { useIssue, useSettings } from "../api/queries.ts";
+import type { IssueComment, IssueDetail } from "../api/types.ts";
+import { JIRA_URL } from "../board/constants.ts";
+import { fmtDate, fmtStamp } from "../board/model/util.ts";
+import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";
+import { useCommentsHidden } from "./commentsPref.ts";
+import { IssueLink } from "./IssueLink.tsx";
+import { Notes } from "./Notes.tsx";
+import { dueOf } from "./options.ts";
+import "./issues.css";
+
+const DESC_LINES = 8;
+
+export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {
+ const issue = useIssue(key);
+ const settings = useSettings();
+ const close = ✕ ;
+ if (issue.isPending) return ;
+ if (issue.error instanceof ApiError && issue.error.status === 404) {
+ return Задачи {key} нет в зеркале хаба.
Закрыть ;
+ }
+ if (issue.isError && !issue.data) {
+ return (
+
+
{issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+
Закрыть
+
+ );
+ }
+ const i = issue.data!;
+ const labels = settings.data?.status_labels as Record | undefined;
+ const fact = i.note?.fact_status ?? null;
+ const cls = factClass(fact, labels);
+ const due = dueOf(i);
+ const facts = [
+ i.assignee && {i.assignee} ,
+ due && срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> просрочено >} ,
+ i.sprint && {i.sprint} ,
+ i.epic_key && эпик: {i.epic_summary ?? i.epic_key} ,
+ ].filter(Boolean);
+ return (
+
+
+
+
{i.key}
+ {i.status &&
{i.status} }
+ {fact &&
факт: {fact} }
+
+
Открыть в Jira ↗
+ {close}
+
+ {i.summary || i.key}
+ {facts.length > 0 && {facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}
}
+ {issue.isError && (
+ {issue.error.message}{" "}
+ void issue.refetch()}>Повторить
+ )}
+
+
+
+ } />
+ {i.description && }
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record }) {
+ const groups = groupLinks(issue, linkTypes);
+ return (
+
+ Связи
+ {!groups.length && связей нет
}
+ {groups.map(([id, title, links]) => (
+
+
{title}
+
+ {links.map((l, n) => (
+
+ {l.tag && <>{l.tag} {" "}>}
+ {" "}
+ {l.summary === null && l.status === null
+ ? нет в зеркале
+ : <>{l.summary} {l.status && <>{" "}{l.status} >}>}
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
+function Description({ text }: { text: string }) {
+ const ref = useRef(null);
+ const [open, setOpen] = useState(false);
+ const [overflow, setOverflow] = useState(false);
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (el && !open) setOverflow(el.scrollHeight > el.clientHeight + 1);
+ }, [text, open]);
+ const long = text.split("\n").length > DESC_LINES || overflow;
+ return (
+
+ Описание
+ {text}
+ {(long || open) && setOpen(!open)}>{open ? "свернуть" : "развернуть"} }
+
+ );
+}
+
+function Comments({ items }: { items: IssueComment[] }) {
+ const [hidden, setHidden] = useCommentsHidden();
+ return (
+
+
+
Комментарии ({items.length})
+ {items.length > 0 && setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"} }
+
+ {!items.length && комментариев нет
}
+ {items.length > 0 && !hidden && (
+
+ )}
+
+ );
+}
+
+function OtherFields({ issue: i }: { issue: IssueDetail }) {
+ const rows: [string, string | null][] = [
+ ["Тип", i.issue_type], ["Приоритет", i.priority], ["Автор", i.reporter], ["Метки", i.labels.join(", ") || null],
+ ["Плановое начало", i.planned_start], ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
+ ["Сосед по связям", i.neighbor ? "да" : null],
+ ];
+ return (
+
+ Прочие поля
+ {rows.filter(([, v]) => v).map(([k, v]) =>
{k} {v} )}
+
+ );
+}
+
+function Commands({ issue: i }: { issue: IssueDetail }) {
+ if (!i.commands.length) {
+ return Команды моста команд по задаче не было
;
+ }
+ return (
+
+
+ Команды моста ({i.commands.length})
+
+ Команда Пакет Тип Статус Завершена Ошибка
+ {i.commands.map((c) => (
+ #{c.id} #{c.batch_id} {c.type} {c.status}
+ {c.finished_at ? fmtStamp(c.finished_at) : ""} {c.error}
+ ))}
+
+
+
+ );
+}
diff --git a/web/src/issues/IssueLink.tsx b/web/src/issues/IssueLink.tsx
new file mode 100644
index 0000000..dff0d22
--- /dev/null
+++ b/web/src/issues/IssueLink.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react";
+import { Link, useLocation } from "react-router";
+import { openCardState } from "./modal.ts";
+
+// ссылка на карточку задачи: открывает окно поверх текущей страницы (или меняет содержимое открытого окна)
+export function IssueLink({ issueKey, className, children }: { issueKey: string; className?: string; children?: ReactNode }) {
+ const loc = useLocation();
+ return {children ?? issueKey};
+}
diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx
new file mode 100644
index 0000000..4a07f01
--- /dev/null
+++ b/web/src/issues/IssueModal.test.tsx
@@ -0,0 +1,169 @@
+import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { createMemoryRouter } from "react-router";
+import { beforeEach, describe, expect, it } from "vitest";
+import { App } from "../App.tsx";
+import { setToken } from "../auth/token.ts";
+import board from "../board/__fixtures__/hub-basic.board.json";
+import settings from "../board/__fixtures__/settings.json";
+import { routes } from "../routes.tsx";
+import { json, mockFetch } from "../test/fetchMock.ts";
+import { issue } from "../test/issueFixtures.ts";
+
+const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, reporter: null, created: null, jira_updated: null, fetched_at: "" }));
+
+function hub() {
+ return mockFetch((_m, url) => {
+ const p = url.pathname;
+ if (p === "/settings") return json(200, settings);
+ if (p === "/board") return json(200, board);
+ if (p === "/issues") return json(200, { issues: rows });
+ if (p.startsWith("/issues/")) return json(200, issue(decodeURIComponent(p.slice("/issues/".length))));
+ if (p === "/batches") return json(200, { batches: [] });
+ return json(200, {});
+ });
+}
+const at = (entries: string[], index = entries.length - 1) =>
+ createMemoryRouter(routes, { initialEntries: entries, initialIndex: index, basename: "/app" });
+const dialog = () => screen.getByRole("dialog");
+
+describe("карточка окном", () => {
+ beforeEach(() => { setToken("tok"); hub(); });
+
+ it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
+ const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
+ render( );
+ const table = await screen.findByRole("table");
+ expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");
+ await userEvent.click(await within(table).findByRole("link", { name: "AURORA-9002" }));
+ expect(router.state.location.pathname).toBe("/app/issues/AURORA-9002");
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main // фон не пересоздаётся
+ expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2"); // фильтр фона — из его адреса
+ expect(screen.getByRole("link", { name: "Задачи" })).toHaveClass("active");
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname).toBe("/app/issues");
+ expect(new URLSearchParams(router.state.location.search).get("sprint")).toBe("AWPCA_Спринт 2");
+ expect(within(screen.getByRole("main")).getByRole("table")).toBe(table); // таблица команд карточки — в окне, не в main
+ await act(async () => { await router.navigate(-1); });
+ expect(router.state.location.pathname).toBe("/app/board");
+ });
+
+ it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
+ const router = at(["/app/issues"]);
+ render( );
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ await userEvent.click(await within(dialog()).findByRole("link", { name: "AURORA-9002" }));
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
+ await act(async () => { await router.navigate(-1); });
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ await userEvent.click(within(dialog()).getByRole("link", { name: "AURORA-9002" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
+ fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname).toBe("/app/issues");
+ });
+
+ it("щелчок по фону окна закрывает его", async () => {
+ const router = at(["/app/issues"]);
+ render( );
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
+ await userEvent.click(dialog()); // цель — сам , не содержимое
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ expect(router.state.location.pathname).toBe("/app/issues");
+ });
+
+ it("I-2: выделение текста внутри окна, отпущенное над фоном, окно не закрывает", async () => {
+ const router = at(["/app/issues"]);
+ render( );
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ const title = await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
+ fireEvent.pointerDown(title); // нажали на тексте карточки…
+ fireEvent.pointerUp(dialog());
+ fireEvent.click(dialog()); // …отпустили над фоном: click — на
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
+ });
+
+ it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
+ const router = at(["/app/board"]);
+ render( );
+ const ilink = await screen.findByRole("link", { name: "Открыть карточку AURORA-9001" });
+ const boardEl = screen.getByTestId("page-board");
+ await userEvent.click(ilink);
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ expect(screen.getByTestId("page-board")).toBe(boardEl);
+ expect(screen.getByRole("link", { name: "Доска" })).toHaveClass("active");
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/board"));
+ expect(screen.getByTestId("page-board")).toBe(boardEl);
+ });
+
+ it("прямой адрес: под окном список задач, ✕ ведёт в список заменой записи", async () => {
+ const router = at(["/app/board", "/app/issues/AURORA-9001"]);
+ render( );
+ expect(await within(await screen.findByRole("dialog")).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ expect(await screen.findByTestId("page-issues")).toBeInTheDocument();
+ await waitFor(() => expect(router.state.location.state).toMatchObject({ depth: 0, direct: true }));
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Закрыть" }));
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
+ await act(async () => { await router.navigate(-1); });
+ expect(router.state.location.pathname).toBe("/app/board"); // не карточка
+ });
+
+ it("прямой адрес и переход по связи: закрытие возвращает в список, а не на первую карточку", async () => {
+ const router = at(["/app/issues/AURORA-9001"]);
+ render( );
+ await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
+ fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ await waitFor(() => expect(router.state.location.pathname).toBe("/app/issues"));
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
+ });
+
+ it("Esc с несохранённой заметкой спрашивает; «Остаться» оставляет окно с текстом", async () => {
+ const router = at(["/app/issues"]);
+ render( );
+ await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
+ await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
+ const box = await within(dialog()).findByLabelText("Заметка владельца");
+ await userEvent.type(box, "черновик");
+ fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ expect(await within(dialog()).findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Остаться" }));
+ expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
+ expect(within(dialog()).getByLabelText("Заметка владельца")).toHaveValue("черновик");
+ });
+
+ it("T4.1: отказ от закрытия («Остаться») сбрасывает ожидание закрытия: «назад» на запись прямого адреса не уводит в список", async () => {
+ const router = at(["/app/issues/AURORA-9001"]);
+ render( );
+ await userEvent.click(await within(await screen.findByRole("dialog")).findByRole("link", { name: "AURORA-9002" }));
+ await within(dialog()).findByRole("heading", { name: /AURORA-9002/ });
+ await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));
+ await userEvent.type(await within(dialog()).findByLabelText("Заметка владельца"), "черновик");
+ fireEvent(dialog(), new Event("cancel", { cancelable: true }));
+ await userEvent.click(await within(dialog()).findByRole("button", { name: "Остаться" }));
+ await userEvent.click(within(dialog()).getByRole("button", { name: "Отмена" }));
+ await userEvent.click(await within(dialog()).findByRole("button", { name: "Отменить правки" }));
+ await act(async () => { await router.navigate(-1); });
+ expect(await within(dialog()).findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
+ expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001");
+ });
+
+ it("T4.4: у окна есть имя в состоянии загрузки и 404", async () => {
+ mockFetch((_m, url) => {
+ if (url.pathname === "/settings") return json(200, settings);
+ if (url.pathname === "/issues/AURORA-1") return new Promise(() => {});
+ if (url.pathname === "/issues/AURORA-2") return json(404, { detail: "нет" });
+ return json(200, {});
+ });
+ const r1 = render( );
+ expect(await screen.findByRole("dialog", { name: "AURORA-1" })).toBeInTheDocument();
+ r1.unmount();
+ render( );
+ expect(await screen.findByRole("dialog", { name: "AURORA-2" })).toBeInTheDocument();
+ });
+});
diff --git a/web/src/issues/IssueModal.tsx b/web/src/issues/IssueModal.tsx
new file mode 100644
index 0000000..a4f7134
--- /dev/null
+++ b/web/src/issues/IssueModal.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useRef } from "react";
+import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
+import { IssueCard } from "./IssueCard.tsx";
+
+// имя окна — заголовок карточки; пока его нет (загрузка, 404, ошибка), запасное имя — ключ задачи
+// окно карточки (design 13): держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
+// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
+// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
+export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
+ const ref = useRef(null);
+ // I-2: закрывать щелчком по фону, только если и нажатие было на фоне — выделение текста, отпущенное за краем окна,
+ // даёт click на самом , но нажатие было внутри содержимого
+ const downOnBackdrop = useRef(false);
+ useEffect(() => {
+ const d = ref.current;
+ if (d && !d.open) d.showModal();
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden"; // страница под окном не прокручивается
+ return () => { document.body.style.overflow = prev; };
+ }, []);
+ return (
+ { if (e.cancelable) { e.preventDefault(); onClose(); } }}
+ onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
+ onPointerDown={(e) => { downOnBackdrop.current = e.target === e.currentTarget; }}
+ onClick={(e) => {
+ const fromBackdrop = downOnBackdrop.current;
+ downOnBackdrop.current = false;
+ if (e.target === e.currentTarget && fromBackdrop) onClose();
+ }}>
+
+
+ );
+}
diff --git a/web/src/issues/IssuePage.test.tsx b/web/src/issues/IssuePage.test.tsx
deleted file mode 100644
index 801f37e..0000000
--- a/web/src/issues/IssuePage.test.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { act, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { QueryClientProvider } from "@tanstack/react-query";
-import { createMemoryRouter, RouterProvider } from "react-router";
-import { beforeEach, describe, expect, it } from "vitest";
-import { qk } from "../api/queries.ts";
-import { makeQueryClient } from "../api/queryClient.ts";
-import { setToken } from "../auth/token.ts";
-import settings from "../board/__fixtures__/settings.json";
-import { json, mockFetch } from "../test/fetchMock.ts";
-import { IssuePage } from "./IssuePage.tsx";
-
-const note = (over = {}) => ({ key: "AURORA-9001", fact_status: "В работе", work_kind: "разработка", done_text: "схема",
- rest_text: "API", owner_note: null, updated_at: "2026-10-14T09:00:00+00:00", updated_by: "chat", ...over });
-const issue = (key: string, over = {}) => ({
- key, id: "1", issue_type: "Задача", summary: `Тема ${key}`, description: "строка 1\nстрока 2", status: "В работе",
- priority: "Высокий", assignee: "Иванов Иван Иванович", reporter: "me", duedate: null, planned_start: null,
- planned_end: "2026-10-20", epic_key: "AURORA-9100", labels: ["kind-dev"], sprint: "AWPCA_Спринт 2", created: null,
- jira_updated: null, fetched_at: "", neighbor: false,
- links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала" }],
- links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate" }],
- note: note({ key }), commands: [{ id: 5, batch_id: 2, type: "ensure_fields", status: "done", finished_at: "2026-10-14T09:00:00+00:00", error: null }],
- ...over,
-});
-const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; };
-
-function open(path: string, client = makeQueryClient()) {
- const router = createMemoryRouter([
- { path: "/issues/:key", element: },
- { path: "/board", element: доска
},
- ], { initialEntries: [path] });
- render( );
- return router;
-}
-
-describe("карточка задачи", () => {
- beforeEach(() => setToken("tok"));
-
- it("поля, описание с переносами, связи с подписями, команды", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
- open("/issues/AURORA-9001");
- expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
- expect(screen.getByText("строка 1\nстрока 2", { normalizer: (s) => s })).toHaveClass("description");
- expect(await screen.findByText("блокирует начало")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "AURORA-9002" })).toHaveAttribute("href", "/issues/AURORA-9002");
- expect(screen.getByText("Duplicate")).toBeInTheDocument(); // неизвестный тип — имя Jira
- expect(screen.getByRole("cell", { name: "ensure_fields" })).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Открыть в Jira" })).toHaveAttribute("href", "https://sdlc.go.rshbank.ru/jira/browse/AURORA-9001");
- });
-
- it("заголовок страницы — h1 (бренд в оболочке — span)", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
- open("/issues/AURORA-9001");
- expect(await screen.findByRole("heading", { level: 1, name: /AURORA-9001/ })).toBeInTheDocument();
- });
-
- it("задачи нет в зеркале — сообщение и ссылка на список", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
- open("/issues/AURORA-1");
- expect(await screen.findByText(/Задачи AURORA-1 нет в зеркале/)).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "к списку задач" })).toHaveAttribute("href", "/issues");
- });
-
- it("сохраняются только изменённые поля, очищенное — null", async () => {
- let put: unknown = null;
- mockFetch((m, url, body) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (m === "PUT") { put = body; return json(200, note({ rest_text: "тесты", done_text: null, updated_by: "web" })); }
- return json(200, issue("AURORA-9001"));
- });
- open("/issues/AURORA-9001");
- const rest = await screen.findByLabelText("Осталось");
- await userEvent.clear(rest); await userEvent.type(rest, "тесты");
- await userEvent.clear(screen.getByLabelText("Сделано"));
- await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
- await waitFor(() => expect(put).toEqual({ rest_text: "тесты", done_text: null, updated_by: "web" }));
- expect(await screen.findByText(/сохранено .*web/)).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
- });
-
- it("422 — текст ошибки у формы, введённое на месте", async () => {
- mockFetch((m, url) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (m === "PUT") return json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" });
- return json(200, issue("AURORA-9001"));
- });
- open("/issues/AURORA-9001");
- const owner = await screen.findByLabelText("Заметка владельца");
- await userEvent.type(owner, "проверить");
- await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
- expect(await screen.findByRole("alert")).toHaveTextContent("неизвестные поля заметки: x");
- expect(owner).toHaveValue("проверить");
- });
-
- it("уход с несохранёнными правками требует подтверждения", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
- const router = open("/issues/AURORA-9001");
- await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
- await act(async () => { await router.navigate("/board"); });
- expect(await screen.findByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeInTheDocument();
- await userEvent.click(screen.getByRole("button", { name: "Остаться" }));
- expect(router.state.location.pathname).toBe("/issues/AURORA-9001");
- expect(screen.getByLabelText("Заметка владельца")).toHaveValue("черновик");
- });
-
- it("поздний ответ прежней карточки не подменяет новую", async () => {
- const client = makeQueryClient();
- const slow = deferred();
- mockFetch((_m, url) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (url.pathname === "/issues/AURORA-9001") return slow.promise;
- return json(200, issue("AURORA-9002"));
- });
- const router = open("/issues/AURORA-9001", client);
- await act(async () => { await router.navigate("/issues/AURORA-9002"); });
- expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
- slow.resolve(json(200, issue("AURORA-9001")));
- await waitFor(() => expect(client.getQueryData(qk.issue("AURORA-9001"))).toBeDefined());
- expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
- expect(screen.queryByRole("heading", { name: /AURORA-9001/ })).toBeNull();
- });
-
- it("ответ «Сохранить» после ухода в другую карточку не пишет её заметку в форму новой", async () => {
- const slowPut = deferred();
- mockFetch((m, url) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (m === "PUT") return slowPut.promise;
- if (url.pathname === "/issues/AURORA-9002") return json(200, issue("AURORA-9002"));
- return json(200, issue("AURORA-9001"));
- });
- // 9002 уже в кэше: форма не размонтируется при переходе, её состояние переживает навигацию
- const client = makeQueryClient();
- client.setQueryData(qk.issue("AURORA-9002"), issue("AURORA-9002"));
- const router = open("/issues/AURORA-9001", client);
- await userEvent.type(await screen.findByLabelText("Заметка владельца"), "черновик");
- await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
- await act(async () => { await router.navigate("/issues/AURORA-9002"); });
- await userEvent.click(await screen.findByRole("button", { name: "Уйти" }));
- expect(await screen.findByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
- slowPut.resolve(json(200, note({ owner_note: "черновик", updated_by: "web" })));
- await waitFor(() => expect(client.isMutating()).toBe(0));
- expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
- expect(screen.getByLabelText("Заметка владельца")).toHaveValue("");
- expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled();
- });
-
- it("T14.1: поля заметки заблокированы на время сохранения, ответ не затирает ввод", async () => {
- const slowPut = deferred();
- mockFetch((m, url) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (m === "PUT") return slowPut.promise;
- return json(200, issue("AURORA-9001"));
- });
- open("/issues/AURORA-9001");
- await userEvent.type(await screen.findByLabelText("Заметка владельца"), "раз");
- await userEvent.click(screen.getByRole("button", { name: "Сохранить" }));
- const box = screen.getByLabelText("Заметка владельца");
- await waitFor(() => expect(box).toBeDisabled());
- slowPut.resolve(json(200, note({ owner_note: "раз", updated_by: "web" })));
- await waitFor(() => expect(box).toBeEnabled());
- expect(box).toHaveValue("раз");
- });
-
- it("M-2: ошибка повторной загрузки при уже имеющихся данных — данные остаются, строка с «Повторить»", async () => {
- const client = makeQueryClient();
- client.setQueryData(qk.issue("AURORA-9001"), issue("AURORA-9001"));
- let down = true;
- mockFetch((_m, url) => {
- if (url.pathname === "/settings") return json(200, settings);
- if (down) throw new TypeError("Failed to fetch");
- return json(200, issue("AURORA-9001"));
- });
- open("/issues/AURORA-9001", client);
- expect(await screen.findByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
- await act(async () => { await client.invalidateQueries({ queryKey: qk.issue("AURORA-9001") }); }); // повторная загрузка при данных в кэше
- expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает");
- expect(screen.getByRole("heading", { name: /AURORA-9001/ })).toBeInTheDocument();
- down = false;
- await userEvent.click(screen.getByRole("button", { name: "Повторить" }));
- await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
- });
-
- it("O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(404, { error: "not_found", detail: "issue" }));
- open("/issues/AURORA-1");
- const link = await screen.findByRole("link", { name: "к списку задач" });
- expect(link.closest(".issue")).not.toBeNull();
- });
-
- it("O-11: задача без команд — текст вместо пустой таблицы", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001", { commands: [] })));
- open("/issues/AURORA-9001");
- expect(await screen.findByText("команд по задаче не было")).toBeInTheDocument();
- expect(screen.queryByRole("columnheader", { name: "Команда" })).toBeNull();
- });
-
- it("T14.3: id подсказок «статуса по факту» уникален и связан с полем", async () => {
- mockFetch((_m, url) => url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-9001")));
- open("/issues/AURORA-9001");
- const input = await screen.findByLabelText("Статус по факту");
- const id = input.getAttribute("list")!;
- expect(id).not.toBe("fact-statuses");
- expect(document.getElementById(id)?.tagName).toBe("DATALIST");
- });
-});
diff --git a/web/src/issues/IssuePage.tsx b/web/src/issues/IssuePage.tsx
deleted file mode 100644
index 9a8b93c..0000000
--- a/web/src/issues/IssuePage.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Link, useParams } from "react-router";
-import { ApiError } from "../api/client.ts";
-import { useIssue, useSettings } from "../api/queries.ts";
-import { JIRA_URL } from "../board/constants.ts";
-import { fmtStamp } from "../board/model/util.ts";
-import { NoteForm } from "./NoteForm.tsx";
-import { dueOf } from "./options.ts";
-import "./issues.css";
-
-export function IssuePage() {
- const { key = "" } = useParams();
- const issue = useIssue(key);
- const settings = useSettings();
- // подпись типа связи: логический тип из link_types настроек, иначе имя типа Jira
- const typeName = (jira: string) =>
- Object.entries(settings.data?.link_types ?? {}).find(([, j]) => j === jira)?.[0] ?? jira;
-
- if (issue.isPending) return загрузка {key}…
;
- if (issue.error instanceof ApiError && issue.error.status === 404) {
- return Задачи {key} нет в зеркале хаба. к списку задач
;
- }
- if (issue.isError && !issue.data) {
- return (
- {issue.error.message}{" "}
- void issue.refetch()}>Повторить
- );
- }
- const i = issue.data!;
- const fields: [string, string | null][] = [
- ["Тип", i.issue_type], ["Статус Jira", i.status], ["Приоритет", i.priority], ["Исполнитель", i.assignee],
- ["Автор", i.reporter], ["Спринт", i.sprint], ["Срок", dueOf(i) || null], ["Плановое начало", i.planned_start],
- ["Эпик", i.epic_key], ["Метки", i.labels.join(", ") || null], ["Сосед по связям", i.neighbor ? "да" : null],
- ["Обновлена в Jira", i.jira_updated ? fmtStamp(i.jira_updated) : null],
- ];
- return (
-
- {issue.isError && (
-
{issue.error.message}{" "}
- void issue.refetch()}>Повторить
- )}
-
← задачи
-
{i.key} · {i.summary}
-
Открыть в Jira
-
-
-
{fields.filter(([, v]) => v).map(([k, v]) =>
{k} {v} )}
- {i.description &&
{i.description} }
-
Связи
-
- {i.links_out.map((l) => {typeName(l.link_type)} → {l.target_key} )}
- {i.links_in.map((l) => {l.source_key} → {typeName(l.link_type)} → эта задача )}
- {!i.links_out.length && !i.links_in.length && связей нет }
-
-
Последние команды
- {i.commands.length ? (
-
- Команда Пакет Тип Статус Завершена Ошибка
- {i.commands.map((c) => (
- #{c.id} #{c.batch_id} {c.type} {c.status}
- {c.finished_at ? fmtStamp(c.finished_at) : ""} {c.error}
- ))}
-
- ) :
команд по задаче не было
}
-
-
-
-
- );
-}
diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx
index 2015915..44da18b 100644
--- a/web/src/issues/IssuesPage.tsx
+++ b/web/src/issues/IssuesPage.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
-import { Link, useSearchParams } from "react-router";
+import { useSearchParams } from "react-router";
import { ISSUE_FILTER_NAMES, useBoard, useIssues, useSettings, type IssueFilters } from "../api/queries.ts";
+import { IssueLink } from "./IssueLink.tsx";
import { dueOf, epicNames, filterOptions, withCurrent } from "./options.ts";
import "./issues.css";
@@ -63,7 +64,7 @@ export function IssuesPage() {
{rows.map((i) => (
- {i.key}{i.summary} {i.issue_type} {i.status}
+ {i.summary} {i.issue_type} {i.status}
{i.assignee} {i.sprint} {dueOf(i)} {i.epic_key ? names[i.epic_key] ?? i.epic_key : ""}
{i.work_kind} {i.fact_status} {i.neighbor ? "да" : ""}
diff --git a/web/src/issues/NoteForm.tsx b/web/src/issues/NoteForm.tsx
index 833deb4..d5fa280 100644
--- a/web/src/issues/NoteForm.tsx
+++ b/web/src/issues/NoteForm.tsx
@@ -1,9 +1,9 @@
-import { useEffect, useId, useMemo, useState } from "react";
+import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
+import { useCloseAbort } from "./closeAbort.ts";
import { ApiError } from "../api/client.ts";
import { useSaveNote, useSettings } from "../api/queries.ts";
import { NOTE_FIELDS, type IssueDetail, type NoteField, type NoteFields } from "../api/types.ts";
-import { fmtStamp } from "../board/model/util.ts";
type Values = Record;
const valuesOf = (n: IssueDetail["note"]): Values =>
@@ -18,14 +18,17 @@ export function changedFields(initial: Values, values: Values): NoteFields {
const TEXTS: [NoteField, string][] = [["done_text", "Сделано"], ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];
-export function NoteForm({ issue }: { issue: IssueDetail }) {
+export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {
const settings = useSettings();
const save = useSaveNote(issue.key);
+ const [askCancel, setAskCancel] = useState(false);
+ const confirmRef = useRef(null);
const [initial, setInitial] = useState(() => valuesOf(issue.note));
const [values, setValues] = useState(initial);
const changed = useMemo(() => changedFields(initial, values), [initial, values]);
const dirty = Object.keys(changed).length > 0;
+ const abortClose = useCloseAbort();
const blocker = useBlocker(({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname);
useEffect(() => {
if (!dirty) return;
@@ -33,6 +36,8 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
+ // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
+ useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
const kinds = Object.keys(settings.data?.kind_labels ?? {});
const kindOptions = values.work_kind && !kinds.includes(values.work_kind) ? [values.work_kind, ...kinds] : kinds;
@@ -41,14 +46,13 @@ export function NoteForm({ issue }: { issue: IssueDetail }) {
const set = (f: NoteField) => (e: { target: { value: string } }) => setValues((v) => ({ ...v, [f]: e.target.value }));
const submit = () => save.mutate(changed, {
- onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); },
+ onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); },
});
const err = save.error instanceof ApiError ? save.error.detail ?? save.error.message : save.error?.message;
- const saved = save.data ?? issue.note;
return (
-