Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ uv run --all-extras python scripts/dump_schemas.py \

**Code-review detector output** — the `cr-*` detectors defer their `{"findings":[...]}` to `/workspace/tmp/subagent-output/<name>-<hash>.json` (via `DeferredOutputMiddleware`, added in `_build_detector_middleware`); the review orchestrator passes those paths to `scripts/findings.py merge` instead of re-typing the JSON. The detector charters are unaware of this — they still just return the structured object. (A detector with no structured response — e.g. one the `LoopBreakerMiddleware` stopped — defers a `.txt` error file instead, which `findings.py merge` counts as a `skipped`/failed detector, never as empty findings.)

**Template comments** — Django's `{# … #}` comment is **single-line only** (the lexer's `tag_re` has no `DOTALL` flag). A `{# … #}` that spans a newline is NOT recognised as a comment: the text renders literally and any `{{ … }}` / `{% … %}` inside it gets evaluated. Use `{% comment %} … {% endcomment %}` for anything multi-line; reserve `{# … #}` for one-liners.

**Icons in templates** — never hand-roll an inline `<svg>` for a UI icon. Use `{% load icon_tags %}{% icon "name" "css-classes" %}`; see `DESIGN.md` §Icon System for the mechanism and the icon directory. Exceptions (keep inline): animated spinners, SVGs that need `<title>`/Alpine `:class` on the element itself, and brand/logo `<img>` tags.

**Views split by content type** — server-rendered HTML (dashboard pages, forms) lives in `daiv/<app>/views.py` as **CBVs** subclassing `View` / `TemplateView` / `ListView` / `UpdateView` with `LoginRequiredMixin` / `AdminRequiredMixin`. JSON endpoints (including those consumed by dashboard JS like autocompletes and the agent-picker catalog) live in `daiv/<app>/api/views.py` (or `api/router.py` — both names exist) as a **django-ninja `Router`** with `auth=django_auth` for session callers, registered on the central `NinjaAPI` in `daiv/daiv/api.py` (`api.add_router("/<app>", <app>_router)`). Set `url_name="..."` on each route and reverse via `{% url 'api:<route_name>' %}` from templates (or pass the URL into JS as an init prop instead of hardcoding `/api/...` paths); see `daiv/automation/api/views.py` + `_agent_picker.html` for the reference pair.
Expand Down
42 changes: 24 additions & 18 deletions daiv/accounts/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,24 @@ def running_jobs_count(request, user) -> int:


def feed_unread_attention_count(user) -> int:
"""Count the user's unread Feed rows that still NEED ATTENTION (Story 2.4, Option 1).

Envelope-aware, unlike the bell's flat unread count: a row counts only when its run still
exists AND its envelope is not ``all-clear``. A ``classifying`` run (terminal but no envelope
yet) counts — it needs attention until it resolves; an unread ``all-clear`` run does not (quiet
by design, so the badge never contradicts the "nothing needs you." seal); a row whose ``Run``
was deleted does not. Three small queries, all scoped to the user's unread ``RUN_FEED`` rows
(per-user, low volume) — never an unscoped notification-table scan.
"""Count the user's unread Feed rows that are still ACTIONABLE (Story 2.4 / Story 3.3).

Routes the liveness decision through the single shared ``still_actionable`` predicate (AC2), so
the badge can never diverge from the Feed or the (future) Queue. A row counts only when its run
still exists AND ``still_actionable`` holds: an ``all-clear`` envelope is quiet (not counted, so
the badge never contradicts the "nothing needs you." seal); a ``classifying`` run (terminal, no
envelope yet) counts until it resolves; needs-attention / found-issues / failed count; a row
whose ``Run`` was deleted does not; and — new in Story 3.3 — a run whose MR was merged/closed
externally no longer counts (AC4/AC5), while a read failure keeps it counted (AC6, fail-safe).

All queries are scoped to the user's unread ``RUN_FEED`` rows (per-user, low volume) — never an
unscoped notification-table scan. Runs + envelopes are batched (no N+1); only MR-referencing
runs trigger a live read, and cache-warm reads are free.
"""
from notifications.choices import EventType
from notifications.models import Notification
from sessions.models import EnvelopeStatus, Run, RunEnvelope
from sessions.models import Run, RunEnvelope
from sessions.reconcile import still_actionable

source_ids = list(
Notification.objects.filter(recipient=user, event_type=EventType.RUN_FEED, read_at__isnull=True).values_list(
Expand All @@ -123,15 +129,15 @@ def feed_unread_attention_count(user) -> int:
)
if not source_ids:
return 0
existing = {str(pk) for pk in Run.objects.filter(pk__in=source_ids).values_list("pk", flat=True)}
all_clear = {
str(run_id)
for run_id in RunEnvelope.objects.filter(run_id__in=source_ids, status=EnvelopeStatus.ALL_CLEAR).values_list(
"run_id", flat=True
)
}
# classifying (run exists, no envelope) is not in ``all_clear`` → counts.
return sum(1 for source_id in source_ids if source_id in existing and source_id not in all_clear)
runs = {str(run.pk): run for run in Run.objects.filter(pk__in=source_ids)}
# Batch the envelopes once; a missing entry (``None``) is the classifying state passed straight
# into ``still_actionable`` — no per-run re-query.
envelopes = {str(env.run_id): env for env in RunEnvelope.objects.filter(run_id__in=source_ids)}
return sum(
1
for source_id in source_ids
if (run := runs.get(str(source_id))) is not None and still_actionable(run, envelopes.get(str(source_id)))
)


def feed_unread_count(request) -> dict[str, Any]:
Expand Down
103 changes: 100 additions & 3 deletions daiv/accounts/locale/pt/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-19 13:16+0100\n"
"POT-Creation-Date: 2026-07-20 05:23-0500\n"
"PO-Revision-Date: 2026-04-04 22:34+0100\n"
"Last-Translator: \n"
"Language-Team: Portuguese <pt@li.org>\n"
Expand Down Expand Up @@ -83,8 +83,24 @@ msgstr "Introduza este código na janela do navegador para concluir a autentica
msgid "Your DAIV Sign-In Code"
msgstr "O Seu Código de Autenticação DAIV"

msgid "Today"
msgstr "Hoje"
msgid "What this counts"
msgstr "O que isto conta"

msgid "Changes shipped = merged MRs opened by DAIV, counted from GitLab merges and attributed to you because each one links to one of your runs."
msgstr "Alterações entregues = MRs integrados abertos pelo DAIV, contados a partir das integrações do GitLab e atribuídos a ti porque cada um está ligado a uma das tuas execuções."

#, python-format
msgid "open MR !%(iid)s on GitLab"
msgstr "abrir MR !%(iid)s no GitLab"

msgid "view run"
msgstr "ver execução"

msgid "No changes shipped in this range."
msgstr "Sem alterações entregues neste intervalo."

msgid "Throughput"
msgstr "Entregas"

msgid "Needs-me queue"
msgstr "Fila de pendências"
Expand Down Expand Up @@ -154,9 +170,87 @@ msgstr[1] "%(counter)s execuções sem problemas"
msgid "next sweep %(sweep)s"
msgstr "próxima verificação %(sweep)s"

#, python-format
msgid "last checked %(checked)s"
msgstr "última verificação %(checked)s"

msgid "No scheduled runs yet."
msgstr "Ainda não há execuções agendadas."

msgid "today"
msgstr "hoje"

msgid "vs yesterday"
msgstr "vs ontem"

msgid "this week"
msgstr "esta semana"

msgid "vs last week"
msgstr "vs semana passada"

msgid "in the last 30 days"
msgstr "nos últimos 30 dias"

msgid "vs last 30 days"
msgstr "vs últimos 30 dias"

msgid "in the last 90 days"
msgstr "nos últimos 90 dias"

msgid "vs last 90 days"
msgstr "vs últimos 90 dias"

msgid "all time"
msgstr "desde sempre"

#, python-format
msgid "<span class=\"text-text-strong\">%(n)s</span> change shipped %(range_label)s"
msgid_plural "<span class=\"text-text-strong\">%(n)s</span> changes shipped %(range_label)s"
msgstr[0] "<span class=\"text-text-strong\">%(n)s</span> alteração entregue %(range_label)s"
msgstr[1] "<span class=\"text-text-strong\">%(n)s</span> alterações entregues %(range_label)s"

msgid "counted — every merged change"
msgstr "contado — cada alteração integrada"

msgid "how this is computed"
msgstr "como isto é calculado"

msgid "shipped since day one"
msgstr "entregues desde o início"

#, python-format
msgid "~%(n)s dev-hours saved"
msgstr "~%(n)s horas de dev poupadas"

msgid "est."
msgstr "est."

#, python-format
msgid "DAIV shipped %(n)s changes %(range)s (%(total)s all-time)"
msgstr "A DAIV entregou %(n)s alterações %(range)s (%(total)s no total)"

msgid "Copy"
msgstr "Copiar"

msgid "Copied to clipboard"
msgstr "Copiado para a área de transferência"

msgid "Copy failed"
msgstr "Falha ao copiar"

msgid "Copy Slack summary"
msgstr "Copiar resumo para o Slack"

msgid "Copied"
msgstr "Copiado"

msgid "No changes shipped yet"
msgstr "Ainda sem alterações entregues"

msgid "Your throughput appears here once DAIV merges its first change."
msgstr "As tuas entregas aparecem aqui assim que o DAIV fizer o primeiro merge."

msgid "Org impact"
msgstr "Impacto da organização"

Expand Down Expand Up @@ -282,6 +376,9 @@ msgstr "Nenhum utilizador encontrado."
msgid "Time range"
msgstr "Intervalo de tempo"

msgid "Today"
msgstr "Hoje"

msgid "7 days"
msgstr "7 dias"

Expand Down
64 changes: 64 additions & 0 deletions daiv/accounts/templates/accounts/_clickthrough.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{% load i18n icon_tags %}
{% comment %}
Generic click-through breakdown panel (Story 3.2) — reused by Epic 4's Queue count (AC9).
HERO-AGNOSTIC: reads ONLY its include params, never a ``hero.``-prefixed context var, so Epic 4
wires Queue rows in with no rework.

{% include "accounts/_clickthrough.html" with rows=… number=… how_computed=… panel_id=… %}

Params:
rows — iterable of row objects. Row contract (each item exposes, as attr or dict key):
· repo_id — repo tag (raw, mono chip)
· merge_request_iid — the MR/PR number (rendered ``!{iid}``, mono)
· title — human title (Geist Sans, truncate-tolerant)
· merged_at — merged timestamp (mono, tabular)
· web_url — best-effort out-link URL; blank → NO out-link, never ``#`` (AC11)
· thread_id — originating session for the same-tab drill-through
number — the count/label this panel is behind (contract param; not force-rendered).
how_computed — truthy renders the "how computed" AD-10 formula disclosure (AC2).
panel_id — the unique id the trigger's ``aria-controls`` points at (a11y, AC10).

Flat INLINE surface (surface-2 + hairline) — NOT a floating popover, so no overlay shadow. Links
are teal ``accent`` NAVIGATION — never a ``button-*`` action, never a status colour. Icons via
``{% templatetag openblock %} icon {% templatetag closeblock %}`` only (no inline ``<svg>``).
{% endcomment %}
<div id="{{ panel_id }}" data-testid="hero-breakdown"
class="rounded-md border border-border bg-surface-2 p-3">
{% if how_computed %}
<div data-testid="hero-howcomputed" class="mb-3 border-b border-border pb-3">
<h3 class="font-mono text-[11px] font-medium uppercase tracking-[0.12em] text-text-faint">
{% translate "What this counts" %}
</h3>
<p class="mt-1.5 text-[13px] leading-relaxed text-text-muted">
{% blocktranslate %}Changes shipped = merged MRs opened by DAIV, counted from GitLab merges and attributed to you because each one links to one of your runs.{% endblocktranslate %}
</p>
</div>
{% endif %}
<div class="overflow-x-auto">
<ul role="list" class="flex min-w-0 flex-col divide-y divide-border">
{% for row in rows %}
<li data-testid="breakdown-row" class="flex flex-wrap items-center gap-x-3 gap-y-1 py-2">
<span class="inline-flex shrink-0 items-center rounded bg-ground px-1.5 py-0.5 font-mono text-[11px] text-text-muted">&rsaquo; {{ row.repo_id }}</span>
<span class="shrink-0 font-mono text-[12px] tabular-nums text-text-muted">MR !{{ row.merge_request_iid }}</span>
<span class="min-w-0 flex-1 truncate text-[13px] text-text">{{ row.title }}</span>
<time class="shrink-0 font-mono text-[11px] tabular-nums text-text-faint">{{ row.merged_at|date:"Y-m-d H:i" }}</time>
<span class="ml-auto flex shrink-0 items-center gap-3">
{% if row.web_url %}
<a href="{{ row.web_url }}" target="_blank" rel="noopener"
aria-label="{% blocktranslate with iid=row.merge_request_iid %}open MR !{{ iid }} on GitLab{% endblocktranslate %}"
class="inline-flex min-h-[44px] items-center gap-1 text-[12px] text-accent transition-colors hover:text-accent-bright md:min-h-0">
<span>MR</span>{% icon "arrow-right" "h-3.5 w-3.5" %}
</a>
{% endif %}
<a href="{% url 'session_detail' thread_id=row.thread_id %}"
class="inline-flex min-h-[44px] items-center gap-1 text-[12px] text-accent transition-colors hover:text-accent-bright md:min-h-0">
{% translate "view run" %}
</a>
</span>
</li>
{% empty %}
<li class="py-2 text-[13px] text-text-faint">{% translate "No changes shipped in this range." %}</li>
{% endfor %}
</ul>
</div>
</div>
17 changes: 8 additions & 9 deletions daiv/accounts/templates/accounts/_console_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,19 @@
shimmer skeletons are aria-hidden so assistive tech is not read empty rows.
{% endcomment %}
<div class="flex flex-col gap-5">
{# ── Hero ─────────────────────────────────────────────────────────────── #}
<section data-testid="console-hero" aria-labelledby="console-hero-heading" aria-busy="true"
{# ── Hero (Story 3.1) ─────────────────────────────────────────────────── #}
{% comment %} Range-agnostic section heading: the active range is carried honestly by the Hero headline
itself ("… this week"), so this label can never contradict the selected range. {% endcomment %}
<section data-testid="console-hero" aria-labelledby="console-hero-heading"
class="rounded-xl border border-border bg-surface-2 p-[22px]">
<header class="mb-4 flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-status-clear"></span>
<h2 id="console-hero-heading"
class="font-mono text-[11px] font-medium uppercase tracking-[0.14em] text-text-faint">
{% translate "Today" %}
{% translate "Throughput" %}
</h2>
</header>
<div class="space-y-3" aria-hidden="true">
<div class="skeleton h-9 w-2/3 rounded-md"></div>
<div class="skeleton h-4 w-2/5 rounded-md"></div>
</div>
{% include "accounts/_hero.html" %}
</section>

{# ── Needs-me queue ───────────────────────────────────────────────────── #}
Expand Down Expand Up @@ -50,8 +49,8 @@ <h2 id="console-feed-heading" class="text-[14px] font-semibold tracking-tight te
<span class="font-mono text-[11px] uppercase tracking-[0.14em] text-text-faint">
{% translate "what happened" %}
</span>
{# Persistent aria-live container: only its inner fragment swaps on ``feed:seen`` so the live
region survives and the change (including to 0) is announced. #}
{% comment %} Persistent aria-live container: only its inner fragment swaps on ``feed:seen`` so the live
region survives and the change (including to 0) is announced. {% endcomment %}
<span id="feed-unread" role="status" aria-live="polite" class="ml-auto"
hx-get="{% url 'feed_unread_badge' %}" hx-trigger="feed:seen from:body"
hx-target="this" hx-swap="innerHTML">{% include "accounts/_feed_unread_badge.html" %}</span>
Expand Down
10 changes: 10 additions & 0 deletions daiv/accounts/templates/accounts/_feed.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
{% include "accounts/_feed_item.html" with item=item %}
{% endfor %}
</div>
{% comment %} Reconcile freshness (Story 3.3, AC8): rendered ONLY alongside the attention list and
ONLY when a live MR-state read actually happened this render (``reconciled_at`` is gated in the
view — review P2 / NFR1). This avoids both duplicating the zero-state's own "last checked" meta
(_feed_zero_state.html) and claiming a source-of-truth check that never occurred. Presentation-only.
{% endcomment %}
{% if reconciled_at %}
<p data-testid="feed-reconciled-meta" class="mt-3 font-mono text-[12px] tabular-nums text-text-faint">
{% blocktranslate with checked=reconciled_at|date:"H:i" %}last checked {{ checked }}{% endblocktranslate %}
</p>
{% endif %}
{% else %}
{% include "accounts/_feed_zero_state.html" with zero=feed_zero %}
{% endif %}
Expand Down
8 changes: 4 additions & 4 deletions daiv/accounts/templates/accounts/_feed_item.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
{% if item.status_slug == "classifying" %}aria-live="polite" aria-busy="true"{% endif %}
class="flex items-stretch rounded-md border border-l-2 border-border bg-surface-2{% if item.status_slug == 'classifying' %} border-dashed{% endif %}{% if not item.read_at %} bg-white/[0.02]{% endif %}"
style="{% if item.status_slug == 'all-clear' %}opacity: 0.92; border-left-color: var(--color-status-clear);{% else %}{% if item.read_at %}opacity: 0.7;{% endif %}{% if item.accent_var %}border-left-color: var({{ item.accent_var }});{% endif %}{% endif %}">
{# Only an unmodified left-click marks-seen-then-navigates via htmx; ctrl/cmd/shift/middle-click
{% comment %} Only an unmodified left-click marks-seen-then-navigates via htmx; ctrl/cmd/shift/middle-click
fall through to the native ``href`` (open in a new tab) WITHOUT marking seen — restoring 2.3's
plain-<a> behaviour that the bare ``hx-post`` (fires on every click) regressed. #}
plain-<a> behaviour that the bare ``hx-post`` (fires on every click) regressed. {% endcomment %}
<a href="{{ item.link_url }}"
hx-post="{% url 'feed_item_seen' item.run.id %}"
hx-trigger="click[button==0&&!ctrlKey&&!metaKey&&!shiftKey]"
Expand Down Expand Up @@ -75,12 +75,12 @@

<span class="mt-0.5 shrink-0 text-text-faint" aria-hidden="true">{% icon "chevron-right" "h-4 w-4" %}</span>
</a>
{# Dismiss (mark-seen) — resolved-only, unread-only. A seen-state control, NOT an Epic 5 action.
{% comment %} Dismiss (mark-seen) — resolved-only, unread-only. A seen-state control, NOT an Epic 5 action.
Single accessible name (aria-label); 44px touch target; inherits the global teal focus ring.
The swap replaces this <article> with its seen version (which has NO dismiss button), so focus
would fall to <body>; after the swap, move focus back onto the swapped-in article (same
``#feed-item-{id}``, made focusable by ``tabindex="-1"``) so keyboard/AT users keep their place
(WCAG 2.4.3). The global ``:focus-visible`` ring supplies the visible indicator. #}
(WCAG 2.4.3). The global ``:focus-visible`` ring supplies the visible indicator. {% endcomment %}
{% if not item.read_at and item.status_slug != "classifying" %}
<button type="button"
data-testid="feed-item-dismiss"
Expand Down
Loading