From 38c0ccc024cdad080d723400cb764a799e0bc1bd Mon Sep 17 00:00:00 2001 From: GeneAI Date: Thu, 14 May 2026 09:07:35 -0400 Subject: [PATCH] feat(ui): group federated specs by project on the Specs page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Specs page rendered ~28 federated specs (attune-ai + attune-gui) as one flat table — the `project` field plumbed by #30 was invisible to the user. Wrap rows in per-project
sections so federation becomes a visible structural signal instead of noise mixed by slug. The section matching the active workspace expands by default; others collapse. Drops the single specs_root meta-line in favour of a small "Reading from N root(s)" hint. Approved proposal: attune-ai/docs/specs/spec-viewer-ia/proposal.md Co-Authored-By: Claude Opus 4.7 --- sidecar/attune_gui/routes/cowork_pages.py | 37 ++++++- sidecar/attune_gui/static_cw/style.css | 3 + sidecar/attune_gui/templates/specs.html | 121 +++++++++++----------- sidecar/tests/test_cowork_pages.py | 25 +++++ 4 files changed, 125 insertions(+), 61 deletions(-) diff --git a/sidecar/attune_gui/routes/cowork_pages.py b/sidecar/attune_gui/routes/cowork_pages.py index 4501365..bd4265e 100644 --- a/sidecar/attune_gui/routes/cowork_pages.py +++ b/sidecar/attune_gui/routes/cowork_pages.py @@ -185,14 +185,45 @@ async def page_templates(request: Request, filter: str = "all") -> HTMLResponse: @router.get("/dashboard/specs", response_class=HTMLResponse, include_in_schema=False) async def page_specs(request: Request) -> HTMLResponse: - """Render the Specs page — feature specs grouped by phase + status.""" + """Render the Specs page — feature specs grouped by project.""" from attune_gui.routes import cowork_specs + from attune_gui.workspace import get_workspace + + data = await _safe_call(cowork_specs.list_specs()) or { + "specs": [], + "specs_root": None, + "specs_roots": [], + } + specs = data["specs"] + + # Project order = first-seen order in specs (preserves backend root priority). + projects_in_order: list[str] = [] + for s in specs: + p = s.get("project") or "(unknown)" + if p not in projects_in_order: + projects_in_order.append(p) + + ws = get_workspace() + ws_name = ws.name if ws is not None else None + if ws_name and ws_name in projects_in_order: + active_project = ws_name + elif projects_in_order: + active_project = projects_in_order[0] + else: + active_project = None + + roots_count = len(data.get("specs_roots") or []) or (1 if data.get("specs_root") else 0) - data = await _safe_call(cowork_specs.list_specs()) or {"specs": [], "specs_root": None} return templates.TemplateResponse( request, "specs.html", - _ctx(request, "specs", specs=data["specs"], specs_root=data["specs_root"]), + _ctx( + request, + "specs", + specs=specs, + active_project=active_project, + roots_count=roots_count, + ), ) diff --git a/sidecar/attune_gui/static_cw/style.css b/sidecar/attune_gui/static_cw/style.css index 57565dd..e2efd37 100644 --- a/sidecar/attune_gui/static_cw/style.css +++ b/sidecar/attune_gui/static_cw/style.css @@ -154,6 +154,9 @@ code, .mono { margin: -8px 0 16px; } +.spec-project-group { margin: 0 0 18px; } +.spec-project-group > summary { cursor: pointer; padding: 0.5rem 0; } + .empty { color: var(--ink-mute); padding: 32px 0; diff --git a/sidecar/attune_gui/templates/specs.html b/sidecar/attune_gui/templates/specs.html index 07747a2..d4f7237 100644 --- a/sidecar/attune_gui/templates/specs.html +++ b/sidecar/attune_gui/templates/specs.html @@ -7,9 +7,9 @@ {% endblock %} {% block content %} - {% if specs_root %} + {% if roots_count and roots_count > 0 %}

- Reading from {{ specs_root }} + Reading from {{ roots_count }} root{{ 's' if roots_count != 1 else '' }}

{% endif %} @@ -29,62 +29,67 @@ {% if specs %} -
- - - - - - - - - - - - {% for s in specs %} - - - - - - - - {% endfor %} - -
FeaturePhaseStatusFiles
- {% if s.phase %} - {{ s.feature }} - {% else %} - {{ s.feature }} - {% endif %} - - {% if s.phase_label %}{{ s.phase_label }} - {% else %}{% endif %} - - {% if s.status %} - {% set st = s.status|lower %} - {% if st in ('approved','complete','completed','done') %} - {{ s.status }} - {% elif st in ('in-review','in_review','review') %} - {{ s.status }} - {% elif st == 'draft' %} - {{ s.status }} - {% else %} - {{ s.status }} - {% endif %} - {% else %}{% endif %} - - {% for f in s.files %} - {{ f }} - {% endfor %} - - {% if s.phase == 'requirements.md' %} - - {% elif s.phase == 'design.md' %} - - {% endif %} -
-
+ {% for project, group in specs|groupby('project') %} +
+ {{ project }} ({{ group|length }}) +
+ + + + + + + + + + + + {% for s in group %} + + + + + + + + {% endfor %} + +
FeaturePhaseStatusFiles
+ {% if s.phase %} + {{ s.feature }} + {% else %} + {{ s.feature }} + {% endif %} + + {% if s.phase_label %}{{ s.phase_label }} + {% else %}{% endif %} + + {% if s.status %} + {% set st = s.status|lower %} + {% if st in ('approved','complete','completed','done') %} + {{ s.status }} + {% elif st in ('in-review','in_review','review') %} + {{ s.status }} + {% elif st == 'draft' %} + {{ s.status }} + {% else %} + {{ s.status }} + {% endif %} + {% else %}{% endif %} + + {% for f in s.files %} + {{ f }} + {% endfor %} + + {% if s.phase == 'requirements.md' %} + + {% elif s.phase == 'design.md' %} + + {% endif %} +
+
+
+ {% endfor %} {% else %}

No specs found. Click + New spec to start one.

{% endif %} diff --git a/sidecar/tests/test_cowork_pages.py b/sidecar/tests/test_cowork_pages.py index 6c0caf6..704a2f8 100644 --- a/sidecar/tests/test_cowork_pages.py +++ b/sidecar/tests/test_cowork_pages.py @@ -114,6 +114,31 @@ def test_specs_page_lists_seeded_features( assert "approved" in r.text +def test_specs_page_groups_by_project( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Federated specs from two projects render as two
blocks.""" + from attune_gui.routes import cowork_specs + + ai_root = tmp_path / "attune-ai" / "specs" + gui_root = tmp_path / "attune-gui" / "specs" + (ai_root / "alpha").mkdir(parents=True) + (ai_root / "alpha" / "requirements.md").write_text("# a\n\n**Status**: draft\n") + (gui_root / "beta").mkdir(parents=True) + (gui_root / "beta" / "requirements.md").write_text("# b\n\n**Status**: draft\n") + + monkeypatch.setattr(cowork_specs, "_specs_roots", lambda: [ai_root, gui_root]) + + r = client.get("/dashboard/specs", headers=HDR) + assert r.status_code == 200 + # Two project sections, each as its own
. + assert r.text.count('class="spec-project-group"') == 2 + assert "attune-ai" in r.text + assert "attune-gui" in r.text + assert "alpha" in r.text + assert "beta" in r.text + + # --------------------------------------------------------------------------- # Templates page renders seeded data + manual flag # ---------------------------------------------------------------------------