Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions sidecar/attune_gui/routes/cowork_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)


Expand Down
3 changes: 3 additions & 0 deletions sidecar/attune_gui/static_cw/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
121 changes: 63 additions & 58 deletions sidecar/attune_gui/templates/specs.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
{% endblock %}

{% block content %}
{% if specs_root %}
{% if roots_count and roots_count > 0 %}
<p class="meta-line">
Reading from <code class="mono">{{ specs_root }}</code>
Reading from {{ roots_count }} root{{ 's' if roots_count != 1 else '' }}
</p>
{% endif %}

Expand All @@ -29,62 +29,67 @@
</div>

{% if specs %}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Feature</th>
<th>Phase</th>
<th>Status</th>
<th>Files</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in specs %}
<tr data-feature="{{ s.feature }}" data-phase="{{ s.phase or '' }}">
<td>
{% if s.phase %}
<a href="/dashboard/preview?root=specs&path={{ (s.feature ~ '/' ~ s.phase)|urlencode }}" class="link">{{ s.feature }}</a>
{% else %}
<span>{{ s.feature }}</span>
{% endif %}
</td>
<td>
{% if s.phase_label %}<span class="badge accent-soft">{{ s.phase_label }}</span>
{% else %}<span class="dim">—</span>{% endif %}
</td>
<td>
{% if s.status %}
{% set st = s.status|lower %}
{% if st in ('approved','complete','completed','done') %}
<span class="badge ok">{{ s.status }}</span>
{% elif st in ('in-review','in_review','review') %}
<span class="badge warn">{{ s.status }}</span>
{% elif st == 'draft' %}
<span class="badge dim">{{ s.status }}</span>
{% else %}
<span class="badge accent-soft">{{ s.status }}</span>
{% endif %}
{% else %}<span class="dim">—</span>{% endif %}
</td>
<td class="row-actions">
{% for f in s.files %}
<a href="/dashboard/preview?root=specs&path={{ (s.feature ~ '/' ~ f)|urlencode }}" class="row-action">{{ f }}</a>
{% endfor %}
</td>
<td class="row-actions">
{% if s.phase == 'requirements.md' %}
<button class="btn ghost btn-add-phase" data-feature="{{ s.feature }}" data-phase="design">+ Design</button>
{% elif s.phase == 'design.md' %}
<button class="btn ghost btn-add-phase" data-feature="{{ s.feature }}" data-phase="tasks">+ Tasks</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% for project, group in specs|groupby('project') %}
<details class="spec-project-group" {% if project == active_project %}open{% endif %}>
<summary><strong>{{ project }}</strong> <span class="dim small">({{ group|length }})</span></summary>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Feature</th>
<th>Phase</th>
<th>Status</th>
<th>Files</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in group %}
<tr data-feature="{{ s.feature }}" data-phase="{{ s.phase or '' }}">
<td>
{% if s.phase %}
<a href="/dashboard/preview?root=specs&path={{ (s.feature ~ '/' ~ s.phase)|urlencode }}" class="link">{{ s.feature }}</a>
{% else %}
<span>{{ s.feature }}</span>
{% endif %}
</td>
<td>
{% if s.phase_label %}<span class="badge accent-soft">{{ s.phase_label }}</span>
{% else %}<span class="dim">—</span>{% endif %}
</td>
<td>
{% if s.status %}
{% set st = s.status|lower %}
{% if st in ('approved','complete','completed','done') %}
<span class="badge ok">{{ s.status }}</span>
{% elif st in ('in-review','in_review','review') %}
<span class="badge warn">{{ s.status }}</span>
{% elif st == 'draft' %}
<span class="badge dim">{{ s.status }}</span>
{% else %}
<span class="badge accent-soft">{{ s.status }}</span>
{% endif %}
{% else %}<span class="dim">—</span>{% endif %}
</td>
<td class="row-actions">
{% for f in s.files %}
<a href="/dashboard/preview?root=specs&path={{ (s.feature ~ '/' ~ f)|urlencode }}" class="row-action">{{ f }}</a>
{% endfor %}
</td>
<td class="row-actions">
{% if s.phase == 'requirements.md' %}
<button class="btn ghost btn-add-phase" data-feature="{{ s.feature }}" data-phase="design">+ Design</button>
{% elif s.phase == 'design.md' %}
<button class="btn ghost btn-add-phase" data-feature="{{ s.feature }}" data-phase="tasks">+ Tasks</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}
{% else %}
<p class="empty">No specs found. Click <strong>+ New spec</strong> to start one.</p>
{% endif %}
Expand Down
25 changes: 25 additions & 0 deletions sidecar/tests/test_cowork_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <details> 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 <details>.
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
# ---------------------------------------------------------------------------
Expand Down
Loading