sdlc-hub/docs/superpowers/plans/2026-10-15-issue-card-modal.md
Andrey Epifancev 9484d10d0b docs: план issue-card-modal (#37); уточнения design 5, 10–13, 25
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:52:09 +04:00

114 KiB
Raw Permalink Blame History

issue-card-modal — карточка задачи окном, комментарии из Jira — план хаба

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: карточка задачи веб-приложения открывается модальным окном поверх списка «Задачи» и доски, с понятной раскладкой (состояние задачи в шапке, связи по смыслу, заметки в режиме чтения, комментарии из Jira со скрытием); хаб хранит комментарии, присланные агентом v10, и отдаёт их вместе с темами и статусами связанных задач.

Architecture: хаб — таблица issue_comment (схема v3), запись в mirror.apply_state, проверка формы в h_agent_state, расширенный mirror.get_issue, комментарии в MCP issue. Веб — маршрут верхнего уровня {path: "*", element: <Layout/>}; Layout рисует страницы через useRoutes(PAGE_ROUTES, pageLocation) с явным адресом страницы под окном (location.state.background) и окно <dialog> (IssueModal → IssueCard) при адресе /issues/:key. Логика без React — в issues/modal.ts (состояние истории) и issues/card.ts (группы связей, просрочка).

Tech Stack: Python 3.13 (uv), stdlib sqlite3/http.server, pytest; React 19 + TypeScript, React Router 8.4, TanStack Query 5, Vitest 5 + Testing Library (jsdom 29), Playwright.

Spec: openspec/changes/issue-card-modal/ — proposal.md, design.md (решения 5–26, R-1…R-5), specs/ (data-model, hub-api, mcp, web-app; agent — для jira-agent, план ../jira-agent/.wt/agent-v10/docs/superpowers/plans/2026-10-15-agent-v10-comments.md, исполняется отдельно и раньше).

Worktree: /Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/issue-modal, ветка feat/37-issue-card-modal. Все команды — из корня worktree (фронт — из web/). Живой хаб :8790 и ~/.local/share/jira-hub не трогать; ручные проверки — только тестовый хаб bash web/scripts/test-hub.sh (:8791).

Global Constraints

  • Схема базы — v3 (db.SCHEMA_VERSION = 3); таблица issue_comment (key, comment_id, author, created, updated, body), PRIMARY KEY (key, comment_id).
  • Комментарии задачи заменяются присланными, только если у задачи в состоянии есть поле comments (список); без поля — не трогаются.
  • Неверная форма comments в POST /agent/state — 422 bad_state, текст называет ключ задачи и поле; пачка не применяется целиком.
  • GET /issues/{key}: comments новые сверху (created убыв., затем comment_id как число убыв.), epic_summary, у связей summary и status связанной задачи или null. Прежние поля не меняются.
  • MCP issue: «комментарии (N):», 5 последних, текст до 300 символов с «…», «ещё M»; без комментариев — «комментариев нет».
  • Тексты интерфейса — дословно как в спеке: «Зависит от», «Блокирует», «Связана с», «Прочие», «начало», «завершение», «нет в зеркале», «связей нет», «развернуть»/«свернуть», «Комментарии (N)», «Скрыть»/«Показать», «комментариев нет», «Править», «Отмена», «Отменить правки заметки?», «Прочие поля», «Команды моста (N)», «команд по задаче не было», «просрочено», «Открыть в Jira ↗», «Закрыть», «Задачи нет в зеркале хаба.», «Есть несохранённые правки заметки. Уйти без сохранения?».
  • Описание сворачивается свыше 8 строк; выбор «скрыто» у комментариев — localStorage["jira-hub-comments-hidden"] = "1".
  • Окно: до 1000 px шириной, до 90vh высотой; уже 700 px — на всю ширину; уже 900 px — одна колонка (заметки, связи, описание, комментарии, прочие поля, команды).
  • Новых npm-зависимостей нет.
  • Тесты: uv run pytest и cd web && npm test — зелёными считается только весь набор; перед коммитом каждой задачи фронта — cd web && npm run lint && npx tsc -b.
  • Коммиты на русском, трейлер Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Фоновая страница не пересоздаётся при открытии и закрытии окна (фильтры, догруженные порции, прокрутка) — Task 4, тест «фон не пересоздаётся».
  2. Перезагрузка страницы с открытым окном: под окном та же страница, закрытие возвращает на неё — Task 6, e2e «прямой адрес» и первый тест issues.spec.ts (reload).
  3. Esc при несохранённой заметке не закрывает окно молча — Task 4, тест «Esc с несохранённой заметкой спрашивает».
  4. Комментарий с пустым текстом или без автора не ломает карточку — Task 5, тест «комментарий без автора и текста».
  5. Связь на задачу вне зеркала и тип связи вне link_types — Task 3 (groupLinks) и Task 5 (отрисовка «нет в зеркале»).

Task 1: Комментарии в зеркале и расширенная карточка в хабе

Модель исполнителя: haiku (готовый код).

Files:

  • Modify: src/jira_hub/db.py (SCHEMA_VERSION, SCHEMA, MIGRATIONS)
  • Modify: src/jira_hub/mirror.py (apply_state, get_issue, новая check_comments)
  • Test: tests/test_db.py, tests/test_mirror.py, tests/test_e2e_hub.py:78

Interfaces:

  • Produces: mirror.check_comments(issues: list[dict]) -> None (бросает ValueError("<ключ>: comments[<i>].<поле> — …")); mirror.get_issue → дополнительно epic_summary: str | None, comments: list[{comment_id, author, created, updated, body}], у links_out/links_in — summary, status.

  • Step 1: Падающие тесты схемы

В tests/test_db.py в test_connect_creates_schema заменить множество таблиц и версию:

    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"

Во всём файле tests/test_db.py строки ... .fetchone()["value"] == "2" (миграция v1 и два соединения) заменить на == "3". После test_migrates_v1_with_backup добавить:

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()
  • Step 2: Падающие тесты зеркала

В tests/test_mirror.py заменить ожидания связей (строки 30, 31, 38):

    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": "Ожидание разработки"}]

и в test_links_from_other_source_survive (строка 38):

    assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные",
                                                          "summary": "Тема A-2", "status": "Ожидание разработки"}]

В конец файла добавить:

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="")])])

В tests/test_e2e_hub.py строку 78 заменить на:

    links_in = client.get("/issues/A-1")["links_in"]
    assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")]
  • Step 3: Тесты падают

Run: uv run pytest tests/test_db.py tests/test_mirror.py -q Expected: FAIL — issue_comment нет в таблицах, версия "2", KeyError: 'comments', AttributeError: module 'jira_hub.mirror' has no attribute 'check_comments'.

  • Step 4: Схема v3

В src/jira_hub/db.py: SCHEMA_VERSION = 3. В SCHEMA после индекса ix_issue_link_target добавить:

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)
);

В MIGRATIONS добавить шаг (таблицу создаёт SCHEMA через CREATE TABLE IF NOT EXISTS до миграции; шаг нужен, чтобы цикл миграции дошёл до версии 3):

    2: [],   # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
  • Step 5: Запись, проверка и чтение комментариев

В src/jira_hub/mirror.py заменить строку документации модуля на:

"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""

После NOTE_FIELDS добавить:

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")

В apply_state, внутри цикла for it in issues: после 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"]])

В get_issue заменить блок от d["links_out"] = ... до d["note"] = get_note(conn, key) на:

    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 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 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)
  • Step 6: Тесты проходят, весь набор зелёный

Run: uv run pytest tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py -q Expected: PASS.

Run: uv run pytest -q Expected: все тесты зелёные, кроме tests/test_api.py строки 75 (ожидание links_out без summary) — его правит Task 2. Если упало что-то ещё — чинить в этой задаче.

  • Step 7: Commit
git add src/jira_hub/db.py src/jira_hub/mirror.py tests/test_db.py tests/test_mirror.py tests/test_e2e_hub.py
git commit -m "feat(hub): комментарии задач в зеркале, схема v3; темы и статусы связанных в карточке (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: Проверка состояния от агента и комментарии в MCP

Модель исполнителя: haiku (готовый код).

Files:

  • Modify: src/jira_hub/api.py (h_agent_state)
  • Modify: src/jira_hub/mcp_server.py (fmt_issue)
  • Test: tests/test_api.py, tests/test_mcp.py

Interfaces:

  • Consumes: Task 1 — mirror.check_comments, comments/epic_summary/summary/status в get_issue.

  • Produces: 422 bad_state у POST /agent/state; строки комментариев в fmt_issue.

  • Step 1: Падающие тесты API

В tests/test_api.py строку 75 заменить на:

    assert issue["links_out"] == [{"target_key": "B-1", "link_type": "ASUPR_Связанные", "summary": "Сосед", "status": None}]

В конец файла добавить:

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"] == "Эпик"
  • Step 2: Падающий тест MCP

В tests/test_mcp.py в конец файла добавить:

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
  • Step 3: Тесты падают

Run: uv run pytest tests/test_api.py tests/test_mcp.py -q Expected: FAIL — 422 не возвращается (пачка применяется), в fmt_issue нет «комментариев нет».

  • Step 4: Реализация

В src/jira_hub/api.py заменить h_agent_state целиком:

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, 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"])
    return 200, out

В src/jira_hub/mcp_server.py в fmt_issue заменить строку return "\n".join(head + ... (две строки до конца функции) на:

    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 "—"])
  • Step 5: Весь набор зелёный

Run: uv run pytest -q Expected: все тесты зелёные.

  • Step 6: Commit
git add src/jira_hub/api.py src/jira_hub/mcp_server.py tests/test_api.py tests/test_mcp.py
git commit -m "feat(hub): проверка комментариев в POST /agent/state (422 bad_state), комментарии в MCP issue (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: Данные карточки во фронте: типы, группы связей, просрочка, состояние окна

Модель исполнителя: haiku (готовый код).

Files:

  • Modify: web/src/api/types.ts (IssueDetail, новые IssueLinkOut, IssueLinkIn, IssueComment)
  • Create: web/src/issues/card.ts, web/src/issues/card.test.ts
  • Create: web/src/issues/modal.ts, web/src/issues/modal.test.ts
  • Create: web/src/issues/commentsPref.ts
  • Create: web/src/test/issueFixtures.ts
  • Modify: web/src/test/setup.ts (замена showModal/close для jsdom)
  • Modify: web/src/issues/IssuePage.test.tsx (фабрика из issueFixtures.ts)

Interfaces:

  • Produces (card.ts): type LinkGroupId = "depends" | "blocks" | "related" | "other"; interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }; groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][]; factClass(fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null; isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean; todayISO(d?: Date): string.

  • Produces (modal.ts): interface Background { pathname: string; search: string }; interface CardState { background: Background; depth: number; direct: boolean }; LIST_BACKGROUND; BOARD_CARD_STATE: CardState; cardState(s: unknown): CardState | null; openCardState(loc: { pathname: string; search: string; state: unknown }): CardState.

  • Produces (commentsPref.ts): useCommentsHidden(): [boolean, (hidden: boolean) => void], ключ COMMENTS_HIDDEN_KEY.

  • Produces (test/issueFixtures.ts): note(over?), issue(key, over?) — IssueDetail для тестов.

  • Step 1: Типы

В web/src/api/types.ts заменить IssueDetail на:

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 {
  epic_summary: string | null;
  links_out: IssueLinkOut[];
  links_in: IssueLinkIn[];
  note: Note | null;
  commands: IssueCommand[];
  comments: IssueComment[];
}
  • Step 2: Общие тестовые данные и <dialog> в jsdom

Создать web/src/test/issueFixtures.ts:

import type { IssueDetail, Note } from "../api/types.ts";

export const note = (over: Partial<Note> = {}): Note => ({
  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,
});

export const issue = (key: string, over: Partial<IssueDetail> = {}): IssueDetail => ({
  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", epic_summary: "Тестовый эпик", labels: ["kind-dev"],
  sprint: "AWPCA_Спринт 2", created: null, jira_updated: null, fetched_at: "", neighbor: false,
  links_out: [{ target_key: "AURORA-9002", link_type: "ASUPR_Блокер_Начала", summary: "API свода", status: "Новая" }],
  links_in: [{ source_key: "AURORA-9003", link_type: "Duplicate", summary: "Макет экрана", status: "Готово" }],
  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 }],
  comments: [],
  ...over,
});

В web/src/issues/IssuePage.test.tsx удалить локальные const note = … и const issue = … (строки 13–25) и добавить импорт import { issue, note } from "../test/issueFixtures.ts";.

В web/src/test/setup.ts после импортов добавить:

// jsdom 29 не умеет showModal/close у <dialog>: открытость — атрибут open, как в браузере
if (typeof HTMLDialogElement !== "undefined" && !HTMLDialogElement.prototype.showModal) {
  HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { this.setAttribute("open", ""); };
  HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { this.removeAttribute("open"); };
}
  • Step 3: Падающие тесты логики

Создать web/src/issues/card.test.ts:

import { describe, expect, it } from "vitest";
import settings from "../board/__fixtures__/settings.json";
import { factClass, groupLinks, isOverdue, todayISO } from "./card.ts";

const types = settings.link_types as Record<string, string>;
const labels = { ...(settings.status_labels as Record<string, string>), "Снято": "fact-cancelled" };
const out = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ target_key: k, link_type: t, summary: s, status: st });
const inn = (k: string, t: string, s: string | null = `Тема ${k}`, st: string | null = "В работе") => ({ source_key: k, link_type: t, summary: s, status: st });

describe("groupLinks", () => {
  it("блокеры по направлению, связанные без повторов, прочие с направлением; порядок групп и ключей", () => {
    const g = groupLinks({
      links_out: [out("A-10", "ASUPR_Блокер_Завершения"), out("A-2", "ASUPR_Блокер_Начала"), out("A-5", "ASUPR_Связанные"),
        out("A-7", "Duplicate")],
      links_in: [inn("A-3", "ASUPR_Блокер_Начала"), inn("A-5", "ASUPR_Связанные"), inn("A-8", "Duplicate", null, null)],
    }, types);
    expect(g.map(([id, title]) => [id, title])).toEqual([["depends", "Зависит от"], ["blocks", "Блокирует"],
      ["related", "Связана с"], ["other", "Прочие"]]);
    expect(g[0][2]).toEqual([{ key: "A-3", summary: "Тема A-3", status: "В работе", tag: "начало" }]);
    expect(g[1][2].map((l) => [l.key, l.tag])).toEqual([["A-2", "начало"], ["A-10", "завершение"]]);   // ключи как числа
    expect(g[2][2].map((l) => l.key)).toEqual(["A-5"]);
    expect(g[3][2]).toEqual([{ key: "A-7", summary: "Тема A-7", status: "В работе", tag: "→ Duplicate" },
      { key: "A-8", summary: null, status: null, tag: "← Duplicate" }]);
  });

  it("пустые группы не возвращаются; без link_types всё — «Прочие» с именем Jira", () => {
    expect(groupLinks({ links_out: [], links_in: [] }, types)).toEqual([]);
    const g = groupLinks({ links_out: [out("A-2", "ASUPR_Блокер_Начала")], links_in: [] }, {});
    expect(g).toEqual([["other", "Прочие", [{ key: "A-2", summary: "Тема A-2", status: "В работе", tag: "→ ASUPR_Блокер_Начала" }]]]);
  });

  it("логический тип из настроек вне трёх известных — «Прочие» с логическим именем", () => {
    const g = groupLinks({ links_out: [out("A-2", "X_Дубль")], links_in: [] }, { ...types, "дублирует": "X_Дубль" });
    expect(g[0][2][0].tag).toBe("→ дублирует");
  });
});

describe("просрочка и класс статуса по факту", () => {
  it("класс из status_labels", () => {
    expect(factClass("Готово — закрыть", labels)).toBe("fact-done");
    expect(factClass("свой статус", labels)).toBeNull();
    expect(factClass(null, labels)).toBeNull();
  });
  it("срок раньше сегодня и не закрыта по факту — просрочено (R-2)", () => {
    expect(isOverdue("2026-10-14", "В работе", labels, "2026-10-15")).toBe(true);
    expect(isOverdue("2026-10-14", null, labels, "2026-10-15")).toBe(true);
    expect(isOverdue("2026-10-15", "В работе", labels, "2026-10-15")).toBe(false);
    expect(isOverdue("2026-10-14", "Готово — закрыть", labels, "2026-10-15")).toBe(false);
    expect(isOverdue("2026-10-14", "Снято", labels, "2026-10-15")).toBe(false);
    expect(isOverdue("", "В работе", labels, "2026-10-15")).toBe(false);
  });
  it("todayISO — местная дата YYYY-MM-DD", () => {
    expect(todayISO(new Date(2026, 0, 5, 23, 30))).toBe("2026-01-05");
  });
});

Создать web/src/issues/modal.test.ts:

import { describe, expect, it } from "vitest";
import { BOARD_CARD_STATE, cardState, LIST_BACKGROUND, openCardState } from "./modal.ts";

describe("состояние окна в истории", () => {
  it("со страницы — фон = адрес страницы, глубина 1", () => {
    expect(openCardState({ pathname: "/issues", search: "?sprint=S", state: null }))
      .toEqual({ background: { pathname: "/issues", search: "?sprint=S" }, depth: 1, direct: false });
  });
  it("из окна — тот же фон, глубина + 1, признак прямого адреса сохраняется", () => {
    const s = { background: LIST_BACKGROUND, depth: 0, direct: true };
    expect(openCardState({ pathname: "/issues/A-1", search: "", state: s })).toEqual({ background: LIST_BACKGROUND, depth: 1, direct: true });
    expect(openCardState({ pathname: "/issues/A-2", search: "", state: BOARD_CARD_STATE }))
      .toEqual({ background: { pathname: "/board", search: "" }, depth: 2, direct: false });
  });
  it("чужое или битое state — не состояние окна", () => {
    expect(cardState(null)).toBeNull();
    expect(cardState({ background: "/issues", depth: 1 })).toBeNull();
    expect(cardState({ background: { pathname: "/issues", search: "" }, depth: "1" })).toBeNull();
    expect(cardState({ background: { pathname: "/issues", search: "" }, depth: 1 }))
      .toEqual({ background: { pathname: "/issues", search: "" }, depth: 1, direct: false });
  });
});

Run: cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts Expected: FAIL — Failed to resolve import "./card.ts" и "./modal.ts".

  • Step 4: Реализация логики

Создать web/src/issues/card.ts:

import type { IssueDetail } from "../api/types.ts";

export type LinkGroupId = "depends" | "blocks" | "related" | "other";
export interface CardLink { key: string; summary: string | null; status: string | null; tag: string | null }

const GROUPS: [LinkGroupId, string][] = [["depends", "Зависит от"], ["blocks", "Блокирует"], ["related", "Связана с"], ["other", "Прочие"]];
const BLOCK_TAGS: Record<string, string> = { "блокирует начало": "начало", "блокирует завершение": "завершение" };
const byKey = (a: CardLink, b: CardLink) =>
  a.key.localeCompare(b.key, "ru", { numeric: true }) || (a.tag ?? "").localeCompare(b.tag ?? "", "ru");

// связи карточки по смыслу (design 17): логическое имя типа — из link_types настроек (имя типа Jira → логическое)
export function groupLinks(d: Pick<IssueDetail, "links_out" | "links_in">, linkTypes: Record<string, string>): [LinkGroupId, string, CardLink[]][] {
  const logical = new Map(Object.entries(linkTypes).map(([name, jira]) => [jira, name]));
  const groups: Record<LinkGroupId, CardLink[]> = { depends: [], blocks: [], related: [], other: [] };
  const add = (dir: "out" | "in", key: string, jiraType: string, summary: string | null, status: string | null) => {
    const name = logical.get(jiraType);
    if (name && BLOCK_TAGS[name]) groups[dir === "in" ? "depends" : "blocks"].push({ key, summary, status, tag: BLOCK_TAGS[name] });
    else if (name === "связанные") { if (!groups.related.some((l) => l.key === key)) groups.related.push({ key, summary, status, tag: null }); }
    else groups.other.push({ key, summary, status, tag: `${dir === "out" ? "→" : "←"} ${name ?? jiraType}` });
  };
  for (const l of d.links_out) add("out", l.target_key, l.link_type, l.summary, l.status);
  for (const l of d.links_in) add("in", l.source_key, l.link_type, l.summary, l.status);
  return GROUPS.filter(([id]) => groups[id].length).map(([id, title]) => [id, title, groups[id].sort(byKey)]);
}

const CLOSED_FACT = new Set(["fact-done", "fact-cancelled"]);
export const factClass = (fact: string | null | undefined, statusLabels: Record<string, string> | undefined): string | null =>
  (fact && statusLabels?.[fact]) || null;

// R-2: просрочено — срок раньше сегодня, а статус по факту не «закрыт» (классы fact-done, fact-cancelled)
export function isOverdue(due: string, fact: string | null | undefined, statusLabels: Record<string, string> | undefined, today: string): boolean {
  if (!due || due >= today) return false;
  const cls = factClass(fact, statusLabels);
  return !(cls && CLOSED_FACT.has(cls));
}

export const todayISO = (d = new Date()): string =>
  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

Создать web/src/issues/modal.ts:

// состояние окна карточки в записи истории (design 10–12): страница под окном, сколько записей заняли карточки
// поверх неё, открыто ли окно прямым адресом
export interface Background { pathname: string; search: string }
export interface CardState { background: Background; depth: number; direct: boolean }

export const LIST_BACKGROUND: Background = { pathname: "/issues", search: "" };
// доска не хранит состояние в адресе: «ⓘ» всегда открывает окно поверх /board
export const BOARD_CARD_STATE: CardState = { background: { pathname: "/board", search: "" }, depth: 1, direct: false };

export function cardState(s: unknown): CardState | null {
  const c = s as { background?: { pathname?: unknown; search?: unknown }; depth?: unknown; direct?: unknown } | null;
  if (!c || typeof c !== "object" || !c.background || typeof c.background !== "object") return null;
  const { pathname, search } = c.background;
  if (typeof pathname !== "string" || typeof search !== "string" || typeof c.depth !== "number") return null;
  return { background: { pathname, search }, depth: c.depth, direct: c.direct === true };
}

// state ссылки на карточку: со страницы — фон = её адрес, глубина 1; из окна — тот же фон, глубина + 1
export function openCardState(loc: { pathname: string; search: string; state: unknown }): CardState {
  const cur = cardState(loc.state);
  return cur ? { ...cur, depth: cur.depth + 1 } : { background: { pathname: loc.pathname, search: loc.search }, depth: 1, direct: false };
}

Создать web/src/issues/commentsPref.ts:

import { useState } from "react";

// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны
export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden";
const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } };

export function useCommentsHidden(): [boolean, (hidden: boolean) => void] {
  const [hidden, setHidden] = useState(read);
  const set = (v: boolean) => {
    setHidden(v);
    try { if (v) localStorage.setItem(COMMENTS_HIDDEN_KEY, "1"); else localStorage.removeItem(COMMENTS_HIDDEN_KEY); }
    catch { /* хранилище недоступно: выбор живёт до закрытия карточки */ }
  };
  return [hidden, set];
}
  • Step 5: Тесты проходят, весь набор зелёный

Run: cd web && npx vitest run src/issues/card.test.ts src/issues/modal.test.ts Expected: PASS.

Run: cd web && npm test && npm run lint && npx tsc -b Expected: все тесты зелёные (в том числе IssuePage.test.tsx с фабрикой из issueFixtures.ts), lint и tsc без ошибок.

  • Step 6: Commit
git add web/src/api/types.ts web/src/issues/card.ts web/src/issues/card.test.ts web/src/issues/modal.ts \
  web/src/issues/modal.test.ts web/src/issues/commentsPref.ts web/src/test/issueFixtures.ts web/src/test/setup.ts \
  web/src/issues/IssuePage.test.tsx
git commit -m "feat(web): данные карточки — комментарии, группы связей, просрочка, состояние окна (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Карточка окном поверх страницы

Модель исполнителя: sonnet (интеграция с маршрутизатором).

Files:

  • Create: web/src/shell/pages.tsx (PAGE_ROUTES)
  • Modify: web/src/routes.tsx
  • Modify: web/src/shell/Layout.tsx
  • Create: web/src/issues/IssueModal.tsx, web/src/issues/IssueLink.tsx
  • Rename: web/src/issues/IssuePage.tsx → web/src/issues/IssueCard.tsx; web/src/issues/IssuePage.test.tsx → web/src/issues/IssueCard.test.tsx
  • Modify: web/src/issues/IssuesPage.tsx:66 (ключ — IssueLink), web/src/board/components/Card.tsx:34-35 (ⓘ — BOARD_CARD_STATE)
  • Modify: web/src/issues/issues.css (окно)
  • Test: web/src/issues/IssueModal.test.tsx, web/src/App.test.tsx, web/src/board/components/BoardCanvas.test.tsx:53 (если проверяет только href — без изменений)

Interfaces:

  • Consumes: Task 3 — cardState, openCardState, LIST_BACKGROUND, BOARD_CARD_STATE, issue()/note().

  • Produces: IssueCard({ issueKey, onClose }: { issueKey: string; onClose: () => void }) (Task 5 переписывает его содержимое, сигнатура та же); IssueModal({ issueKey, onClose }); IssueLink({ issueKey, className?, children? }); PAGE_ROUTES: RouteObject[]; заголовок карточки — <h2 id="issue-modal-title">.

  • Step 1: Перенос страницы карточки в компонент окна

git mv web/src/issues/IssuePage.tsx web/src/issues/IssueCard.tsx
git mv web/src/issues/IssuePage.test.tsx web/src/issues/IssueCard.test.tsx

В web/src/issues/IssueCard.tsx:

  • заменить import { Link, useParams } from "react-router"; на import { IssueLink } from "./IssueLink.tsx";;
  • заменить export function IssuePage() { и строку const { key = "" } = useParams(); на export function IssueCard({ issueKey: key, onClose }: { issueKey: string; onClose: () => void }) {;
  • data-testid="page-issue" везде заменить на data-testid="issue-card";
  • ветку 404 заменить на return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;;
  • строку <p><Link to="/issues">← задачи</Link></p> заменить на <p><button className="btn sm" type="button" aria-label="Закрыть" onClick={onClose}>✕</button></p>;
  • <h1>{i.key} · {i.summary}</h1> заменить на <h2 id="issue-modal-title">{i.key} · {i.summary}</h2>;
  • в связях <Link to={/issues/${l.target_key}}>{l.target_key}</Link> → <IssueLink issueKey={l.target_key} />, <Link to={/issues/${l.source_key}}>{l.source_key}</Link> → <IssueLink issueKey={l.source_key} />.

В web/src/issues/NoteForm.tsx у блока подтверждения ухода <div className="confirm" role="dialog"> заменить role="dialog" на role="alertdialog": внутри окна <dialog> второй role="dialog" делает поиск окна (getByRole("dialog")) неоднозначным.

Создать web/src/issues/IssueLink.tsx:

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 <Link className={className} to={`/issues/${encodeURIComponent(issueKey)}`} state={openCardState(loc)}>{children ?? issueKey}</Link>;
}

Создать web/src/issues/IssueModal.tsx:

import { useEffect, useRef } from "react";
import { ErrorBoundary } from "../shell/ErrorBoundary.tsx";
import { IssueCard } from "./IssueCard.tsx";

// окно карточки (design 13): <dialog> держит фокус и делает фон недоступным; Esc и щелчок по фону — onClose.
// Сами окно не закрываем: оно исчезает вместе с адресом карточки. Если браузер закрыл его сам (Esc без активации
// пользователя — cancel не отменяемый), открываем снова и просим закрытия по общему пути (блокировщик заметок).
export function IssueModal({ issueKey, onClose }: { issueKey: string; onClose: () => void }) {
  const ref = useRef<HTMLDialogElement>(null);
  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 (
    <dialog ref={ref} className="issue-modal" aria-labelledby="issue-modal-title"
      onCancel={(e) => { if (e.cancelable) { e.preventDefault(); onClose(); } }}
      onClose={() => { const d = ref.current; if (d?.isConnected) { d.showModal(); onClose(); } }}
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <ErrorBoundary key={issueKey}><IssueCard issueKey={issueKey} onClose={onClose} /></ErrorBoundary>
    </dialog>
  );
}
  • Step 2: Маршруты и оболочка

Создать web/src/shell/pages.tsx:

import { Navigate, type RouteObject } from "react-router";
import { BoardPage } from "../board/BoardPage.tsx";
import { IssuesPage } from "../issues/IssuesPage.tsx";
import { NotFound } from "./NotFound.tsx";

// страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10)
export const PAGE_ROUTES: RouteObject[] = [
  { path: "/", element: <Navigate to="/board" replace /> },
  { path: "/board", element: <BoardPage /> },
  { path: "/issues", element: <IssuesPage /> },
  { path: "*", element: <NotFound /> },
];

Заменить web/src/routes.tsx целиком:

import type { RouteObject } from "react-router";
import { Layout } from "./shell/Layout.tsx";

// адреса относительно basename "/app": страницы и окно карточки рисует Layout (shell/pages.tsx, design 10)
export const routes: RouteObject[] = [{ path: "*", element: <Layout /> }];

Заменить web/src/shell/Layout.tsx целиком:

import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router";
import { clearToken } from "../auth/token.ts";
import { IssueModal } from "../issues/IssueModal.tsx";
import { cardState, LIST_BACKGROUND } from "../issues/modal.ts";
import { ErrorBoundary } from "./ErrorBoundary.tsx";
import { PAGE_ROUTES } from "./pages.tsx";
import "./shell.css";

export function Layout() {
  const qc = useQueryClient();
  const location = useLocation();
  const navigate = useNavigate();
  const issueKey = matchPath("/issues/:key", location.pathname)?.params.key ?? null;
  const card = issueKey ? cardState(location.state) : null;
  const bgPath = issueKey ? (card?.background ?? LIST_BACKGROUND).pathname : null;
  const bgSearch = issueKey ? (card?.background ?? LIST_BACKGROUND).search : "";
  // адрес страницы под окном; передаётся в useRoutes всегда (и без окна), чтобы дерево страницы не менялось при
  // открытии окна и useSearchParams внутри неё видел её собственный адрес (design 10)
  const bgLocation = useMemo(() => bgPath === null ? null : { pathname: bgPath, search: bgSearch, hash: "", state: null, key: "card-background" },
    [bgPath, bgSearch]);
  const pageLocation = bgLocation ?? location;
  const page = useRoutes(PAGE_ROUTES, pageLocation);

  // прямой адрес карточки: запись истории получает state окна поверх списка задач (design 11)
  useEffect(() => {
    if (issueKey && !card) {
      void navigate(location.pathname + location.search, { replace: true, state: { background: LIST_BACKGROUND, depth: 0, direct: true } });
    }
  }, [issueKey, card, location.pathname, location.search, navigate]);

  // закрытие (design 12): назад на depth записей; запись прямого адреса, на которую вернулись, заменяется списком
  const closingDirect = useRef(false);
  useEffect(() => {
    if (closingDirect.current && card?.direct && card.depth === 0) {
      closingDirect.current = false;
      void navigate("/issues", { replace: true });
    }
  }, [card, navigate]);
  const close = useCallback(() => {
    if (!card || card.depth === 0) { void navigate("/issues", { replace: true }); return; }
    closingDirect.current = card.direct;
    void navigate(-card.depth);
  }, [card, navigate]);

  const at = pageLocation.pathname;
  const tab = (to: string, label: string) => {
    const active = at === to || at.startsWith(to + "/");
    return <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
  };
  return (
    <>
      <header className="appbar">
        <span className="brand">Хаб Jira</span>
        <nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
        <span className="spacer" />
        <button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
      </header>
      <main className="page">
        <ErrorBoundary key={at}>{page}</ErrorBoundary>
      </main>
      {issueKey && <IssueModal issueKey={issueKey} onClose={close} />}
    </>
  );
}

В web/src/issues/IssuesPage.tsx: импорт Link из react-router убрать (оставить useSearchParams), добавить import { IssueLink } from "./IssueLink.tsx";, в строке таблицы <Link to={/issues/${i.key}}>{i.key}</Link> заменить на <IssueLink issueKey={i.key} />.

В web/src/board/components/Card.tsx добавить импорт import { BOARD_CARD_STATE } from "../../issues/modal.ts"; и у ссылки «ⓘ» добавить атрибут state={BOARD_CARD_STATE} (после to={/issues/${n.key}}).

В web/src/issues/issues.css добавить в конец:

/* окно карточки задачи (design 13) */
.issue-modal{width:min(1000px,calc(100vw - 32px));max-width:none;max-height:90vh;padding:0;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--fg);overflow:auto}
.issue-modal::backdrop{background:rgb(0 0 0 / .45)}
.issue-modal > .issue{padding:0 20px 20px}
@media (max-width:700px){.issue-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;margin:0;border-radius:0}}
  • Step 3: Падающие тесты окна

Создать web/src/issues/IssueModal.test.tsx:

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(<App router={router} />);
    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(screen.getByRole("table")).toBe(table);                         // фон не пересоздаётся
    expect(screen.getByLabelText("Спринт")).toHaveValue("AWPCA_Спринт 2");  // фильтр фона — из его адреса
    expect(screen.getByRole("link", { name: "Задачи", exact: true })).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(screen.getByRole("table")).toBe(table);
    await act(async () => { await router.navigate(-1); });
    expect(router.state.location.pathname).toBe("/app/board");
  });

  it("переход по связи меняет окно, «назад» возвращает прежнюю карточку, Esc закрывает всё", async () => {
    const router = at(["/app/issues"]);
    render(<App router={router} />);
    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(<App router={router} />);
    await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
    await within(dialog()).findByRole("heading", { name: /AURORA-9001/ });
    await userEvent.click(dialog());                                       // цель — сам <dialog>, не содержимое
    await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
    expect(router.state.location.pathname).toBe("/app/issues");
  });

  it("с доски: окно поверх доски, вкладка «Доска» активна, после закрытия доска та же", async () => {
    const router = at(["/app/board"]);
    render(<App router={router} />);
    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: "Доска", exact: true })).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(<App router={router} />);
    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(<App router={router} />);
    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(<App router={router} />);
    await userEvent.click(await screen.findByRole("link", { name: "AURORA-9001" }));
    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("черновик");
  });
});

В web/src/issues/IssueCard.test.tsx:

  • импорт import { IssuePage } from "./IssuePage.tsx"; заменить на import { IssueCard } from "./IssueCard.tsx"; и добавить useNavigate, useParams в импорт из react-router;
  • в open() маршрут { path: "/issues/:key", element: <IssuePage /> } заменить на { path: "/issues/:key", element: <CardAt /> } и перед open() добавить:
function CardAt() {
  const { key = "" } = useParams();
  const navigate = useNavigate();
  return <IssueCard issueKey={key} onClose={() => void navigate("/board")} />;
}
  • тест «заголовок страницы — h1 (бренд в оболочке — span)» заменить на:
  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");
  });
  • тест «O-10: ссылка в ветке «задачи нет в зеркале» стоит внутри .issue (цвет темы)» удалить (ссылки в ветке 404 больше нет; цвет ссылок .issue a проверяет Task 5).

В web/src/App.test.tsx в тесте «неизвестный путь — «не найдено» со ссылками» ожидания не меняются (NotFound — в PAGE_ROUTES); если тест обращается к Layout напрямую — оставить импорт (Layout экспортируется по-прежнему).

Run: cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx Expected до Step 2 — FAIL (нет IssueModal, routes без окна); после Step 1–2 — проверить следующим шагом.

  • Step 4: Тесты проходят, весь набор зелёный

Run: cd web && npx vitest run src/issues/IssueModal.test.tsx src/issues/IssueCard.test.tsx src/App.test.tsx Expected: PASS.

Run: cd web && npm test && npm run lint && npx tsc -b Expected: всё зелёное. BoardCanvas.test.tsx проверяет href="/issues/AURORA-9001" у «ⓘ» — state на href не влияет.

Если useRoutes с явным адресом пересоздаёт страницу при открытии окна (тест «фон не пересоздаётся» падает на toBe(table)), это нарушение design 10: не обходить ключами key, а остановиться и описать наблюдение в отчёте.

  • Step 5: Commit
git add web/src/routes.tsx web/src/shell/Layout.tsx web/src/shell/pages.tsx web/src/issues web/src/board/components/Card.tsx \
  web/src/App.test.tsx
git commit -m "feat(web): карточка задачи окном поверх списка и доски (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: Раскладка карточки и заметки в режиме чтения

Модель исполнителя: sonnet (вёрстка и перенос тестов).

Files:

  • Modify: web/src/issues/IssueCard.tsx (полностью), web/src/issues/NoteForm.tsx (onDone, «Отмена»)
  • Create: web/src/issues/Notes.tsx
  • Modify: web/src/issues/issues.css (раскладка карточки)
  • Test: web/src/issues/IssueCard.test.tsx, web/src/issues/IssueModal.test.tsx

Interfaces:

  • Consumes: Task 3 — groupLinks, factClass, isOverdue, todayISO, useCommentsHidden, IssueComment; Task 4 — IssueCard({ issueKey, onClose }), IssueLink, <h2 id="issue-modal-title">.

  • Produces: Notes({ issue }: { issue: IssueDetail }); NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }).

  • Step 1: Падающие тесты раскладки

В web/src/issues/IssueCard.test.tsx:

  1. Тест «поля, описание с переносами, связи с подписями, команды» заменить на:
  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");
    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();
  });

(в импорт из @testing-library/react добавить cleanup и within).

  1. Тест «O-11: задача без команд — текст вместо пустой таблицы» оставить, в нём ничего не менять.

  2. Тесты заметок — перед первым обращением к полю формы добавить щелчок «Править»: await userEvent.click(await screen.findByRole("button", { name: "Править" })); — в тестах «сохраняются только изменённые поля…», «422 — текст ошибки…», «уход с несохранёнными правками…», «ответ «Сохранить» после ухода…», «T14.1…», «T14.3…». Дальше findByLabelText(...) остаётся. Ожидания после успешного сохранения в тесте «сохраняются только изменённые поля» заменить на:

    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();

В тесте «ответ «Сохранить» после ухода в другую карточку…» после перехода на AURORA-9002 последние три ожидания заменить на:

    expect(screen.getByRole("heading", { name: /AURORA-9002/ })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Править" })).toBeInTheDocument();      // новая карточка — в режиме чтения
    expect(screen.queryByText("черновик")).toBeNull();

В тесте «T14.1…» после slowPut.resolve(...) ожидания заменить на:

    expect(await screen.findByText("раз")).toBeInTheDocument();                         // режим чтения с сохранённым
  1. В web/src/issues/IssueModal.test.tsx, тест «Esc с несохранённой заметкой спрашивает…», перед строкой const box = await within(dialog()).findByLabelText("Заметка владельца"); добавить await userEvent.click(await within(dialog()).findByRole("button", { name: "Править" }));.

  2. Добавить тесты режима чтения и «Отмены»:

  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();
  });

Run: cd web && npx vitest run src/issues/IssueCard.test.tsx Expected: FAIL — нет issue-head, групп связей, раздела «Комментарии», кнопки «Править».

  • Step 2: Заметки: чтение и правка

Создать web/src/issues/Notes.tsx:

import { useState } from "react";
import type { IssueDetail, NoteField } from "../api/types.ts";
import { fmtStamp } from "../board/model/util.ts";
import { NoteForm } from "./NoteForm.tsx";

const ROWS: [NoteField, string][] = [["work_kind", "Вид работы"], ["fact_status", "Статус по факту"], ["done_text", "Сделано"],
  ["rest_text", "Осталось"], ["owner_note", "Заметка владельца"]];

// служебные заметки (design 20): по умолчанию чтение, «Править» — форма; после сохранения или отмены — снова чтение
export function Notes({ issue }: { issue: IssueDetail }) {
  const [editing, setEditing] = useState(false);
  if (editing) return <NoteForm issue={issue} onDone={() => setEditing(false)} />;
  const n = issue.note;
  return (
    <section className="note view" aria-labelledby="notes-title">
      <div className="sec-head">
        <h3 id="notes-title">Служебные заметки</h3>
        <button className="btn sm" type="button" onClick={() => setEditing(true)}>Править</button>
      </div>
      <dl className="fields">
        {ROWS.map(([f, label]) => <div key={f}><dt>{label}</dt><dd>{n?.[f] || "—"}</dd></div>)}
      </dl>
      {n && <p className="hint">изменено {fmtStamp(n.updated_at)}, {n.updated_by}</p>}
    </section>
  );
}

В web/src/issues/NoteForm.tsx:

  • в импорт из react добавить useRef;
  • сигнатуру заменить на export function NoteForm({ issue, onDone }: { issue: IssueDetail; onDone: () => void }) {;
  • после const save = useSaveNote(issue.key); добавить const [askCancel, setAskCancel] = useState(false); и const confirmRef = useRef<HTMLDivElement>(null);;
  • после эффекта beforeunload добавить:
  // подтверждение ухода может оказаться ниже видимой части окна — прокрутить к нему
  useEffect(() => { if (blocker.state === "blocked") confirmRef.current?.scrollIntoView?.({ block: "nearest" }); }, [blocker.state]);
  • submit заменить на:
  const submit = () => save.mutate(changed, {
    onSuccess: (note) => { const v = valuesOf(note); setInitial(v); setValues(v); onDone(); },
  });
  • строку const saved = save.data ?? issue.note; удалить;
  • <form className="note" …> заменить на <form className="note" aria-labelledby="notes-title" …>, а <h3>Служебные заметки</h3> — на <h3 id="notes-title">Служебные заметки</h3>;
  • блок <div className="row">…</div> (кнопка «Сохранить» и строка «сохранено …») заменить на:
      <div className="row">
        <button className="btn" type="submit" disabled={!dirty || save.isPending}>Сохранить</button>
        <button className="btn" type="button" disabled={save.isPending} onClick={() => (dirty ? setAskCancel(true) : onDone())}>Отмена</button>
      </div>
      {askCancel && (
        <div className="confirm" role="alertdialog" aria-label="Отменить правки заметки?">
          <span>Отменить правки заметки?</span>
          <button className="btn" type="button" onClick={onDone}>Отменить правки</button>
          <button className="btn" type="button" onClick={() => setAskCancel(false)}>Продолжить правку</button>
        </div>
      )}
  • у блока подтверждения ухода <div className="confirm" role="alertdialog"> (роль поменял Task 4) добавить ref={confirmRef}.

  • Step 3: Карточка

Заменить web/src/issues/IssueCard.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 = <button className="close" type="button" aria-label="Закрыть" onClick={onClose}>✕</button>;
  if (issue.isPending) return <div className="issue" data-testid="issue-card"><div className="issue-head"><p className="hint">загрузка {key}…</p>{close}</div></div>;
  if (issue.error instanceof ApiError && issue.error.status === 404) {
    return <div className="issue" data-testid="issue-card"><p>Задачи {key} нет в зеркале хаба.</p><button className="btn" type="button" onClick={onClose}>Закрыть</button></div>;
  }
  if (issue.isError && !issue.data) {
    return (
      <div className="issue" data-testid="issue-card">
        <p className="warn" role="alert">{issue.error.message}{" "}
          <button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
        <button className="btn" type="button" onClick={onClose}>Закрыть</button>
      </div>
    );
  }
  const i = issue.data!;
  const labels = settings.data?.status_labels as Record<string, string> | undefined;
  const fact = i.note?.fact_status ?? null;
  const cls = factClass(fact, labels);
  const due = dueOf(i);
  const facts = [
    i.assignee && <span key="who">{i.assignee}</span>,
    due && <span key="due">срок {fmtDate(due)}{isOverdue(due, fact, labels, todayISO()) && <> <span className="overdue">просрочено</span></>}</span>,
    i.sprint && <span key="sprint">{i.sprint}</span>,
    i.epic_key && <span key="epic">эпик: {i.epic_summary ?? i.epic_key}</span>,
  ].filter(Boolean);
  return (
    <div className="issue" data-testid="issue-card">
      <header className="issue-head" data-testid="issue-head">
        <div className="row1">
          <span className="key">{i.key}</span>
          {i.status && <span className="badge status">{i.status}</span>}
          {fact && <span className={"badge fact" + (cls ? " " + cls : "")}>{fact}</span>}
          <span className="spacer" />
          <a href={`${JIRA_URL}/browse/${i.key}`} target="_blank" rel="noopener">Открыть в Jira ↗</a>
          {close}
        </div>
        <h2 id="issue-modal-title">{i.summary || i.key}</h2>
        {facts.length > 0 && <p className="facts" data-testid="issue-facts">{facts.flatMap((f, n) => (n ? [" · ", f] : [f]))}</p>}
        {issue.isError && (
          <p className="warn" role="alert">{issue.error.message}{" "}
            <button className="btn sm" type="button" onClick={() => void issue.refetch()}>Повторить</button></p>
        )}
      </header>
      <div className="cols">
        <div className="main">
          <Links issue={i} linkTypes={(settings.data?.link_types ?? {}) as Record<string, string>} />
          {i.description && <Description text={i.description} />}
          <Comments items={i.comments ?? []} />
        </div>
        <div className="side">
          <Notes key={i.key} issue={i} />
          <OtherFields issue={i} />
          <Commands issue={i} />
        </div>
      </div>
    </div>
  );
}

function Links({ issue, linkTypes }: { issue: IssueDetail; linkTypes: Record<string, string> }) {
  const groups = groupLinks(issue, linkTypes);
  return (
    <section className="links-sec">
      <h3>Связи</h3>
      {!groups.length && <p className="hint">связей нет</p>}
      {groups.map(([id, title, links]) => (
        <div key={id} className="group" role="group" aria-labelledby={`links-${id}`}>
          <h4 id={`links-${id}`}>{title}</h4>
          <ul className="links">
            {links.map((l) => (
              <li key={id + l.key + (l.tag ?? "")}>
                {l.tag && <><span className="tag">{l.tag}</span>{" "}</>}
                <IssueLink issueKey={l.key} />{" "}
                {l.summary === null && l.status === null
                  ? <span className="sum absent">нет в зеркале</span>
                  : <><span className="sum">{l.summary}</span>{l.status && <>{" "}<span className="badge status">{l.status}</span></>}</>}
              </li>
            ))}
          </ul>
        </div>
      ))}
    </section>
  );
}

function Description({ text }: { text: string }) {
  const ref = useRef<HTMLPreElement>(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 (
    <section>
      <h3>Описание</h3>
      <pre ref={ref} className={"description" + (open ? "" : " clamped")}>{text}</pre>
      {(long || open) && <button className="link" type="button" onClick={() => setOpen(!open)}>{open ? "свернуть" : "развернуть"}</button>}
    </section>
  );
}

function Comments({ items }: { items: IssueComment[] }) {
  const [hidden, setHidden] = useCommentsHidden();
  return (
    <section className="comments" data-testid="comments">
      <div className="sec-head">
        <h3>Комментарии ({items.length})</h3>
        {items.length > 0 && <button className="link" type="button" onClick={() => setHidden(!hidden)}>{hidden ? "Показать" : "Скрыть"}</button>}
      </div>
      {!items.length && <p className="hint">комментариев нет</p>}
      {items.length > 0 && !hidden && (
        <ul>
          {items.map((c) => (
            <li key={c.comment_id}>
              <div className="who">{c.author ?? "—"}{c.created ? `, ${fmtStamp(c.created)}` : ""}</div>
              <pre className="body">{c.body}</pre>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}

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 (
    <section className="other" aria-labelledby="other-title">
      <h3 id="other-title">Прочие поля</h3>
      <dl className="fields">{rows.filter(([, v]) => v).map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}</dl>
    </section>
  );
}

function Commands({ issue: i }: { issue: IssueDetail }) {
  if (!i.commands.length) {
    return <section className="commands"><h3>Команды моста</h3><p className="hint">команд по задаче не было</p></section>;
  }
  return (
    <section className="commands">
      <details>
        <summary>Команды моста ({i.commands.length})</summary>
        <table className="grid">
          <thead><tr><th>Команда</th><th>Пакет</th><th>Тип</th><th>Статус</th><th>Завершена</th><th>Ошибка</th></tr></thead>
          <tbody>{i.commands.map((c) => (
            <tr key={c.id}><td>#{c.id}</td><td>#{c.batch_id}</td><td>{c.type}</td><td>{c.status}</td>
              <td>{c.finished_at ? fmtStamp(c.finished_at) : ""}</td><td>{c.error}</td></tr>
          ))}</tbody>
        </table>
      </details>
    </section>
  );
}

Тест O-11 ищет screen.queryByRole("columnheader", { name: "Команда" }) = null — при пустых командах таблицы нет.

  • Step 4: Стили карточки

В web/src/issues/issues.css заменить строки от .issue a{color:var(--accent)} до .issue .links{padding-left:18px} включительно на:

.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
git add web/src/issues
git commit -m "feat(web): раскладка карточки — шапка состояния, связи по смыслу, комментарии, заметки в режиме чтения (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

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:

{
  "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(...) добавить:
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 = [...] заменить на:
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(...) добавить:
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 целиком:

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: прокрутка доски после возврата из карточки задачи» на:

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<HTMLElement>(".board .card[data-key] a.open-card")]
      .find((e) => { const r = e.getBoundingClientRect(); return r.top > 200 && r.bottom < vh - 20; });
    return a?.closest<HTMLElement>(".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, щелчок по фону; прямой адрес открывает окно поверх списка задач)». В том же разделе, после абзаца про «Задачи», добавить абзац:

Карточка задачи: в шапке — статус 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
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 <noreply@anthropic.com>"

После плана

  • Финальное ревью ветки (opus); ручной проход сценариев в браузере на тестовом хабе :8791 со скриншотами (делает сессия, не контроллер).
  • PR в aep/jira-hub со ссылкой на #37 и на PR агента; merge — по команде владельца, после merge и публикации агента v10.