feat: show queued prompt count badge in the task sidebar (#2354)

This commit is contained in:
Clément Fiere
2026-08-07 16:34:39 +02:00
committed by GitHub
parent 4d80edacf2
commit 084ecc8b54
51 changed files with 2262 additions and 64 deletions
@@ -0,0 +1,182 @@
---
spec: docs/specs/ui/sidebar-queued-prompt-count.md
created: 2026-08-04
status: implemented
---
# Implementation Plan: Sidebar Queued Prompt Count Badge
## Overview
Show, on every task and subtask row in the left task view, a mail-icon badge
with the number of prompts currently en-queued for that task (all sessions,
pending semantics identical to `message.queue.get`). The count rides the
existing per-task status summary: computed fresh at list/snapshot assembly for
initial load, and refreshed live by the status-summary projector subscribing
to the internal queue-status event and reusing the workspace-wide
`task.status_summary.updated` broadcast. Zero renders nothing.
## Confirmed Current Behavior
- The queue is per-session in `queued_messages` (SQLite table created in
`internal/orchestrator/messagequeue/repository_sqlite.go:initSchema`); each
row carries a denormalized `task_id` written at insert. Pending = any row
except durable lifecycle rows marked `lifecycle_reserved_in_flight`
(`Service.GetStatus` filters in memory via `QueuedMessage.IsReservedInFlight`).
- Queue status reaches clients only as the session-scoped WS event
`message.queue.status_changed` (`{session_id, entries, count, max}`), routed
by `TaskEventBroadcaster` via `BroadcastToSession`. The payload has no
`task_id`, and the event never reaches task-list clients for non-focused
sessions.
- The sidebar (`TaskSessionSidebar` → `TaskSwitcher` → `TaskItem` →
`TaskItemStatsRow`) consumes `KanbanTask.statusSummary` (type
`TaskStatusSummary`, snake_case JSON), populated from the HTTP workflow
snapshot and kept live by `task.status_summary.updated` (workspace-wide).
- No per-task queued count exists anywhere. There is no index on
`queued_messages.task_id`; the table is per-install small (bounded by
`max_per_session` per session), so a batched scan per list assembly is
acceptable.
## Behavioral Contract
- Badge = mail icon + pending prompt count on the row metadata line; hidden at
0; same treatment for subtasks and tasks.
- Count = pending `queued_messages` rows with `task_id` = task (excluding
reserved-in-flight), across all of the task's sessions.
- Initial list/snapshot loads are authoritative (fresh count at assembly).
- Live updates flow through the existing status-summary broadcast; no new WS
action, no workspace-wide leak of queue contents.
- `message.queue.status_changed` keeps its session-scoped routing; its payload
gains an informational `task_id`.
## Backend Design
### Pending count query
- Add `CountPendingByTaskIDs(ctx, taskIDs []string) (map[string]int, error)`
to `messagequeue.Repository` (interface, SQLite, memory). SQLite: one
`SELECT task_id, metadata_json FROM queued_messages WHERE task_id IN (...)` +
in-memory reserved filter (reuse `scanQueuedRow`/`IsReservedInFlight`
semantics; do not filter JSON in SQL). Memory: iterate per-session lists.
- Add `Service.CountPendingByTaskIDs` and single-task
`Service.CountPendingByTask(ctx, taskID) (int, error)` delegating to it.
Export a narrow `QueuedPromptCounter`-style adapter at the composition root
(backendapp) so neither the task service nor the status-summary projector
imports `messagequeue`.
### Status summary live projection
- `TaskStatusSummary` gains `QueuedPromptCount int \`json:"queued_prompt_count,omitempty"\``;
`Validate` rejects negatives; `SemanticJSON`/`semanticPayload` include it
(persistence round-trip); `SemanticEqual` picks it up via `reflect.DeepEqual`.
- Projector: add `CountQueuedPrompts func(ctx, taskID) (int, error)` to
`ProjectorConfig`; subscribe to `events.MessageQueueStatusChanged`; add
`queuedCount` to `projectionState`; new `applyQueueStatusEventLocked` case
that queries the count and returns changed only on delta; restore the count
in `restorePersistedState`; include it in `deriveSummary`.
- Publish sites add `task_id` to the internal event payload:
- `orchestrator/event_handlers_agent.go:publishQueueStatusEvent` — resolve
session→task via a new small `SessionTaskID(ctx, sessionID)` helper on the
orchestrator service (its repo owns sessions).
- `orchestrator/handlers/queue_handlers.go:publishStatus` — resolve via an
injected `SessionTaskResolver` (wire `orchestratorSvc.SessionTaskID` at
`backendapp/gateway.go:155`). On resolution failure, log and publish
without `task_id` (projector skips; next event or list load reconciles).
### List/snapshot assembly
- Task service: add optional `QueuedPromptCounter` interface + `SetQueuedPromptCounter`
(pattern of `ForegroundActivityProvider`); batch method
`CountPendingQueuedByTaskIDs`.
- `buildTaskDTOsWithSessionInfo` (all list/snapshot paths funnel through it:
HTTP list, workspace list, single task, WS list, workflow/workspace
snapshot): load counts for the listed tasks and stamp
`taskDTO.StatusSummary.QueuedPromptCount` (only when a summary exists — never
fabricate a summary, because `sidebarSessionStatus` treats a non-nil summary
as authoritative for other fields).
- `HydrateMissingTaskStatusSummaries`: include the count in the rebuilt
summaries for tasks it repairs (extend `statussummary.RebuildInput`).
- backendapp wires the adapter into the task service and the projector config.
## Frontend Design
- `lib/types/task-status-summary.ts`: add `queued_prompt_count?: number`.
(`toKanbanTask` already carries `status_summary` wholesale; WS summary
updates replace it wholesale, so no mapper change is needed.)
- `buildSidebarItem`: `queuedCount: summary?.queued_prompt_count` on the item.
- `TaskSwitcherItem`: new `queuedCount?: number`; `TaskRow` forwards it.
- `TaskItem`/`TaskItemStatsRow`: render
`IconMail` + count when `queuedCount > 0`; include it in the row's
early-return condition; `data-testid="sidebar-task-queued-count"`; accessible
name via `t("sidebar:queuedPromptCount", { count })`
(`_one`/`_other` plurals in `apps/web/src/locales/en/sidebar.json` +
pseudo-locale). Badge is inline-flex, shrink-safe, non-interactive.
- i18n ratchet: new copy uses `t()` (sidebar namespace); do not migrate
untouched literals in the file.
## Responsive and Mobile Contract
The badge is a static inline-flex text+icon pair inside the existing metadata
row. No hover dependency, no hit target, no new scroll owner; row overflow
behavior is unchanged (title truncates, badge does not wrap). Desktop and
mobile render identically because both use `TaskItem`. Mobile E2E asserts the
badge through the mobile sidebar flow.
## TDD and Verification Strategy
1. Backend repo/service tests for the count query (multi-session accumulation,
reserved-in-flight exclusion, empty, batch). RED → implement → GREEN.
`cd apps/backend && go test ./internal/orchestrator/messagequeue/...`
2. Model + projector tests: field round-trip, negative validation, queue event
updates count, no-change suppresses publish, restore keeps count. RED →
implement → GREEN.
`cd apps/backend && go test ./internal/task/statussummary/...`
3. DTO assembly + hydration tests (count stamped on existing summaries,
included in repaired summaries, absent when no summary). RED → implement →
GREEN. `cd apps/backend && go test ./internal/task/...`
4. Frontend unit tests: `buildSidebarItem` mapping, `TaskItem` badge
render/hide, plural/aria. RED → implement → GREEN.
`cd apps && pnpm --filter @kandev/web exec vitest run components/task/task-item.test.tsx components/task/task-session-sidebar-item.test.ts`
5. Full gates: `make -C apps/backend test lint build`, then from `apps`:
web typecheck, lint, `i18n:check`, `i18n:ratchet`.
6. E2E (desktop + mobile) as Task 05:
`cd apps/web && pnpm e2e -- tests/task/sidebar-queued-count.spec.ts tests/task/mobile-sidebar-queued-count.spec.ts`,
preserving/restoring queue state in teardown.
## Implementation Waves
- [x] [Task 01: Add pending prompt count queries to the message queue](task-01-pending-prompt-count-queries.md) — wave 1
- [x] [Task 02: Add queued_prompt_count to the live status summary](task-02-status-summary-queued-count.md) — wave 2, after Task 01
- [x] [Task 03: Attach the count to task list/snapshot payloads](task-03-attach-count-to-task-payloads.md) — wave 3, after Task 02
- [x] [Task 04: Render the badge in the task sidebar](task-04-sidebar-badge-ui.md) — wave 3, after Task 02 (file-disjoint from Task 03; parallel-safe)
- [x] [Task 05: E2E coverage and verification](task-05-e2e-and-verification.md) — wave 4, after Tasks 03 and 04
This package does not authorize subagents; implementation remains in the
user-controlled primary session.
## Results
All five tasks implemented and verified: backend package tests, vet and golangci-lint on touched packages
green; 50 frontend unit tests plus typecheck/lint/i18n gates green; E2E desktop + mobile 4/4 on the built
artifact (badge visible, live clear, hidden at 0, subtasks). Full per-task command results are recorded in
each task file's Results section. Pre-existing VM-environment test failures (launcher/agentctl/local-repo)
reproduce at HEAD and are unrelated.
## Environment Prerequisite
If `apps/node_modules` is absent, run `pnpm install --frozen-lockfile` from
`apps/` before frontend RED tests. Do not change the lockfile.
## Risks and Boundaries
- Fabricating a status summary for a task that has none would flip
`sidebarSessionStatus` to summary-authoritative and hide the relative time;
assembly must stamp only existing summaries and let hydration create the
rest.
- JSON-in-SQL filtering of the reserved marker is fragile; keep the reserved
exclusion in Go (mirrors `GetStatus`).
- `task_id` has no index; list assembly does one batched scan of a bounded
table. If queue volumes ever grow, add a replayable migration for an index.
- WIP overflow queues (`queued_for_step_id`) are unrelated and out of scope.
- The projector may briefly lag on startup; fresh assembly at list time is the
correctness backstop, and the next queue event repairs the live path.
@@ -0,0 +1,67 @@
---
id: "01-pending-prompt-count-queries"
title: "Add pending prompt count queries to the message queue"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/ui/sidebar-queued-prompt-count.md"
---
# Task 01: Add Pending Prompt Count Queries
## Acceptance
- `messagequeue.Repository` (interface, SQLite, memory) exposes
`CountPendingByTaskIDs(ctx, taskIDs []string) (map[string]int, error)`
returning pending rows grouped by `task_id`. Pending excludes rows whose
metadata marks `lifecycle_reserved_in_flight` (same semantics as
`Service.GetStatus`); durable lifecycle rows not yet reserved still count.
The reserved exclusion happens in Go, never via JSON matching in SQL.
- `messagequeue.Service` exposes `CountPendingByTaskIDs` and
`CountPendingByTask(ctx, taskID string) (int, error)`.
- Unit tests prove: multi-session accumulation for one task, reserved-in-flight
exclusion, empty result maps to zero, batch with mixed found/missing task
IDs, and cross-task isolation. Memory and SQLite repositories both covered.
## TDD Sequence
1. Write repository tests (SQLite + memory) and service tests asserting the
behavior above. Run RED.
2. Implement `CountPendingByTaskIDs` on the interface, `sqliteRepository`, and
`memoryRepository`, plus the two service methods.
3. Run the package tests GREEN and refactor.
## Verification
```bash
cd apps/backend
go test ./internal/orchestrator/messagequeue/...
```
## Files Likely Touched
- `apps/backend/internal/orchestrator/messagequeue/repository.go`
- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go`
- `apps/backend/internal/orchestrator/messagequeue/repository_memory.go`
- `apps/backend/internal/orchestrator/messagequeue/service.go`
- `apps/backend/internal/orchestrator/messagequeue/repository_sqlite_test.go`
- `apps/backend/internal/orchestrator/messagequeue/repository_memory_test.go`
- `apps/backend/internal/orchestrator/messagequeue/service_test.go`
## Dependencies
None.
## Output Contract
Report RED/GREEN evidence, the final method signatures, and changed files.
Update this task and `plan.md` status in the same implementation conversation.
## Results
- RED: `TestCountPendingByTaskIDs*` and `TestServiceCountPendingByTask` failed to compile (methods absent).
- GREEN: `go test -tags fts5 ./internal/orchestrator/messagequeue/` passes — SQLite and memory both cover
multi-session accumulation, reserved-in-flight exclusion, empty/missing task IDs, and cross-task isolation.
- Final signatures: `Repository.CountPendingByTaskIDs(ctx, taskIDs []string) (map[string]int, error)`
(SQLite + memory), `Service.CountPendingByTaskIDs`, `Service.CountPendingByTask(ctx, taskID) (int, error)`.
@@ -0,0 +1,94 @@
---
id: "02-status-summary-queued-count"
title: "Add queued_prompt_count to the live status summary"
status: done
wave: 2
depends_on: ["01-pending-prompt-count-queries"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-queued-prompt-count.md"
---
# Task 02: Add `queued_prompt_count` to the Live Status Summary
## Acceptance
- `statussummary.TaskStatusSummary` gains a new field; `Validate`
rejects negative values; `SemanticJSON`/`semanticPayload` include the field
so the persisted `task_status_summaries` payload round-trips it; old stored
rows decode with the field absent (0).
```go
QueuedPromptCount int `json:"queued_prompt_count,omitempty"`
```
- The projector (`internal/task/statussummary/projector.go`) subscribes to
`events.MessageQueueStatusChanged` and refreshes the affected task's count
through a new `CountQueuedPrompts func(ctx, taskID string) (int, error)` in
`ProjectorConfig`. A queue event whose recomputed count equals the current
value does not bump the revision or republish. The count is restored from
the persisted summary in `restorePersistedState` and included in
`deriveSummary`.
- The internal `message.queue.status_changed` event payload gains `task_id` at
both publish sites:
- `orchestrator.Service.publishQueueStatusEvent` resolves session→task via a
new `SessionTaskID(ctx, sessionID)` helper (orchestrator repo owns
sessions); missing/unknown session → log and publish without `task_id`.
- `handlers.QueueHandlers.publishStatus` resolves via an injected
`SessionTaskResolver` field (wired to `orchestratorSvc.SessionTaskID` in
`backendapp/gateway.go`); resolution failure → log, publish without
`task_id`.
The WS-facing `message.queue.status_changed` payload therefore also carries
`task_id` (informational); its session-scoped routing is unchanged.
- The projector is wired in `backendapp/gateway.go` with the count adapter from
`orchestratorSvc.GetMessageQueue()`.
- Unit tests prove: model round-trip + negative validation; projector updates
the summary on a queue event, suppresses republish on unchanged count,
restores the count from persisted state, and ignores events without
`task_id`; handlers publish `task_id` when resolvable and skip it when not.
## TDD Sequence
1. Model tests (round-trip, validation) + projector tests (event → count
update, no-change suppression, restore, missing task_id) + publish-site
tests. Run RED.
2. Implement the field, projector subscription/state/derive/restore, the
orchestrator `SessionTaskID` helper, the handler resolver, and gateway
wiring.
3. Run package tests GREEN, then refactor.
## Verification
```bash
cd apps/backend
go test ./internal/task/statussummary/... ./internal/orchestrator/... ./internal/backendapp/...
```
## Files Likely Touched
- `apps/backend/internal/task/statussummary/model.go`
- `apps/backend/internal/task/statussummary/model_test.go`
- `apps/backend/internal/task/statussummary/projector.go`
- `apps/backend/internal/task/statussummary/projector_test.go`
- `apps/backend/internal/orchestrator/event_handlers_agent.go` (publishQueueStatusEvent)
- `apps/backend/internal/orchestrator/service.go` (SessionTaskID helper)
- `apps/backend/internal/orchestrator/handlers/queue_handlers.go` (publishStatus + resolver field)
- `apps/backend/internal/orchestrator/handlers/queue_handlers_test.go`
- `apps/backend/internal/backendapp/gateway.go`
## Dependencies
Task 01 supplies `messagequeue.Service.CountPendingByTask`.
## Output Contract
Report RED/GREEN evidence, the new event payload shape, and changed files.
Update this task and `plan.md` status in the same implementation conversation.
## Results
- RED: model/projector/publish-site tests failed against the unimplemented field and payload.
- GREEN: `go test -tags fts5 ./internal/task/statussummary/ ./internal/orchestrator/... ./internal/backendapp/...` —
round-trip + negative validation, queue-event count updates, no-change suppression, restore, missing-task_id ignore.
- The internal and WS `message.queue.status_changed` payload now carries `task_id` alongside
`{session_id, entries, count, max}`; session-scoped routing unchanged.
- Follow-up: `TestProjectorQueueEventRetriesAfterRejectedWrite` — competing writer lands a higher revision with
the stale count; the retried write wins (final stored count 3, revision 8, exactly one publish).
@@ -0,0 +1,95 @@
---
id: "03-attach-count-to-task-payloads"
title: "Attach the count to task list/snapshot payloads"
status: done
wave: 3
depends_on: ["02-status-summary-queued-count"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-queued-prompt-count.md"
---
# Task 03: Attach the Count to Task List/Snapshot Payloads
## Acceptance
- The task service gains an optional `QueuedPromptCounter` interface (batched:
`CountPendingByTaskIDs(ctx, taskIDs []string) (map[string]int, error)`) plus
`SetQueuedPromptCounter` (pattern of `ForegroundActivityProvider`), and a
batch helper `CountPendingQueuedByTaskIDs`. The task service must not import
`internal/orchestrator/messagequeue`.
- `buildTaskDTOsWithSessionInfo` (the single assembly used by HTTP list,
workspace list, single task, WS list, and workflow/workspace snapshot)
loads fresh counts for the listed tasks and stamps
`StatusSummary.QueuedPromptCount` onto each task's summary. It never
fabricates a summary for a task that has none — a synthetic summary would
flip `sidebarSessionStatus` to summary-authoritative and hide the relative
time (see Risks). Tasks without a summary keep no count field.
- `HydrateMissingTaskStatusSummaries` includes the count in summaries it
repairs: `statussummary.RebuildInput` gains the count and the hydrate path
fills it from the counter. Repaired rows therefore carry the badge on the
next load even before the projector touches them.
- backendapp wires the messagequeue-backed adapter into both the task service
and (already done by Task 02) the projector.
- Tests prove: assembly stamps fresh counts on tasks with summaries; repaired
summaries include counts; tasks without summaries omit the field; counter
failure degrades to no badge without failing the list; the JSON payload of a
list/snapshot response carries `status_summary.queued_prompt_count`.
- Counter-failure DTO contract: when the counter errors, the response DTO
clears `queued_prompt_count` to `0` for that request only — never persisted.
When the counter is unwired (provider unavailable), the DTO preserves the
projected count instead of stamping a zero. Regression tests cover both: an
errored counter yields a non-nil summary with count `0`; an unwired counter
preserves a positive projected count (`TestTaskDTOBuilderPreservesProjectedCountWhenCounterUnwired`).
## TDD Sequence
1. Write service/assembly/hydration tests for the acceptance cases. Run RED.
2. Implement the counter interface + setter, the batch helper, the
`RebuildInput` extension, the assembly stamping, and the gateway wiring.
3. Run the task-package and backendapp tests GREEN, then refactor.
## Verification
```bash
cd apps/backend
go test ./internal/task/... ./internal/backendapp/...
```
## Files Likely Touched
- `apps/backend/internal/task/service/service.go`
- `apps/backend/internal/task/service/service_status_summary_rebuild.go`
- `apps/backend/internal/task/service/service_status_summary_rebuild_test.go`
- `apps/backend/internal/task/statussummary/rebuild.go` (RebuildInput)
- `apps/backend/internal/task/handlers/task_http_handlers.go`
- `apps/backend/internal/task/handlers/task_http_handlers_test.go`
- `apps/backend/internal/backendapp/gateway.go`
## Dependencies
Task 02 defines the summary field this task stamps; Task 01 supplies the
underlying count query.
## Risks
- Never construct a `TaskStatusSummary` solely to carry the count; the
frontend treats a non-nil summary as authoritative for session/updatedAt
fields. Stamp only existing summaries; hydration is the only creator.
## Output Contract
Report RED/GREEN evidence, the final DTO JSON for a task with and without a
summary, and changed files. Update this task and `plan.md` status in the same
implementation conversation.
## Results
- RED: assembly/hydration/rebuild tests failed against the missing count plumbing.
- GREEN: `go test -tags fts5 ./internal/task/... ./internal/backendapp/...` — assembly stamps fresh counts on
existing summaries, hydration includes counts in repaired rows, tasks without summaries omit the field,
and a counter failure clears the response count without persisting.
- `TestBootKanbanSnapshotStampsQueuedPromptCount` proves the boot kanban snapshot carries `queued_prompt_count`;
`TestTaskDTOBuilderPreservesProjectedCountWhenCounterUnwired` proves an unwired counter leaves a projected
positive count intact.
- DTO JSON for a queued task carries `"queued_prompt_count":3` inside `status_summary`; a task with no summary
carries no count field.
@@ -0,0 +1,86 @@
---
id: "04-sidebar-badge-ui"
title: "Render the queued prompt count badge in the task sidebar"
status: done
wave: 3
depends_on: ["02-status-summary-queued-count"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-queued-prompt-count.md"
---
# Task 04: Render the Badge in the Task Sidebar
## Acceptance
- `TaskStatusSummary` (web type) gains `queued_prompt_count?: number`.
`toKanbanTask` and the WS `task.status_summary.updated` handler already move
the summary wholesale, so no mapper change is required.
- `buildSidebarItem` maps `summary.queued_prompt_count` to a new
`queuedCount?: number` on `TaskSwitcherItem`; `TaskRow` forwards it to
`TaskItem`.
- `TaskItemStatsRow` renders, on the metadata line, an inline-flex badge with
`IconMail` (from `@tabler/icons-react`) followed by the count whenever
`queuedCount > 0`. Count 0 (or undefined) renders nothing, and the row's
early-return condition includes the badge so a row with only a queue badge
still renders. The badge is `data-testid="sidebar-task-queued-count"`,
non-interactive, shrink-safe, and must not wrap or overflow the row.
- Accessible name is localized via `useTranslation`:
`sidebar:queuedPromptCount_one` (`{{count}} queued prompt`) /
`sidebar:queuedPromptCount_other` (`{{count}} queued prompts`) added to
`apps/web/src/locales/en/sidebar.json`, with the pseudo-locale regenerated.
No other literals in the file are migrated (i18n ratchet judges only added
lines).
- Subtasks and tasks share `TaskItem`, so both get identical treatment.
- Unit tests prove: `buildSidebarItem` carries the count; the badge renders
for counts > 0 on task and subtask rows, is absent at 0/undefined, carries
the pluralized accessible name, and does not render when the row would
otherwise be empty.
## TDD Sequence
1. Write `buildSidebarItem` and `TaskItem` tests for the cases above. Run RED.
2. Implement the type field, item plumbing, row rendering, and i18n keys.
3. Run focused tests GREEN, then typecheck, lint, `i18n:check`, and
`i18n:ratchet` (see Verification).
## Verification
```bash
cd apps
pnpm --filter @kandev/web exec vitest run \
components/task/task-item.test.tsx \
components/task/task-session-sidebar-item.test.ts
pnpm --filter @kandev/web run typecheck
pnpm --filter @kandev/web run lint
pnpm --filter @kandev/web run i18n:check
pnpm --filter @kandev/web run i18n:ratchet
```
## Files Likely Touched
- `apps/web/lib/types/task-status-summary.ts`
- `apps/web/components/task/task-session-sidebar-item.ts`
- `apps/web/components/task/task-switcher.tsx`
- `apps/web/components/task/task-item.tsx`
- `apps/web/components/task/task-item.test.tsx`
- `apps/web/components/task/task-session-sidebar-item.test.ts`
- `apps/web/src/locales/en/sidebar.json`
- generated pseudo-locale catalog
## Dependencies
Task 02 fixes the summary JSON shape (`queued_prompt_count`) this task reads.
## Output Contract
Report RED/GREEN unit evidence, the badge markup, i18n status, typecheck/lint
status, and changed files. Update this task and `plan.md` status in the same
implementation conversation.
## Results
- RED: buildSidebarItem/TaskItem badge tests failed against the missing prop and rendering.
- GREEN: 50 focused unit tests pass (task-item, task-session-sidebar-item, mobile sheet-hooks, task-switcher);
web typecheck, lint, `i18n:check`, and `i18n:ratchet` all pass.
- The badge renders `IconMail` + count on the metadata line when > 0 (desktop and mobile sheet share
`TaskItem`), with localized `sidebar:queuedPromptCount_one/_other` aria; count 0 renders nothing.
@@ -0,0 +1,81 @@
---
id: "05-e2e-and-verification"
title: "E2E coverage and full verification"
status: done
wave: 4
depends_on: ["03-attach-count-to-task-payloads", "04-sidebar-badge-ui"]
plan: "plan.md"
spec: "../../specs/ui/sidebar-queued-prompt-count.md"
---
# Task 05: E2E Coverage and Full Verification
## Acceptance
- Desktop Playwright spec (`apps/web/e2e/tests/task/sidebar-queued-count.spec.ts`):
- Create a task, seed an IDLE session, queue 3 prompts via the existing
`apiClient.queueMessage` helper, open the workspace, and assert the task
row in `SidebarTasksPage` shows the badge (`sidebar-task-queued-count`)
with text `3`.
- Clear the queue (add an `apiClient.clearQueue(sessionId)` helper backed by
`message.queue.cancel` if none exists) and assert the badge disappears
without a reload (live path through the status-summary broadcast).
- Assert a task with an empty queue never shows the badge.
- Teardown restores queue state so parallel specs cannot inherit counts.
- Mobile flow: same enqueue assertion driven through the mobile sidebar (per
`mobile-parity`), confirming no hover dependency and no horizontal overflow.
- Backend Go tests, frontend unit tests, typecheck, lint, and both i18n gates
all GREEN (commands below).
## TDD Sequence
1. Write the desktop spec (badge visible → live clear → hidden) and run it
RED against the pre-feature build expectations if feasible, else after the
unit work with a deliberately failing selector first.
2. Implement any missing E2E helper (`clearQueue`) and finish the spec.
3. Run the specs GREEN; run the full verification set.
## Verification
Every command below runs from the repository root (repo root is the parent of
`apps/`); subshells keep each block independent.
```bash
# Desktop + mobile E2E (exact specs; both run in one Playwright invocation)
( cd apps/web && pnpm e2e -- tests/task/sidebar-queued-count.spec.ts tests/task/mobile-sidebar-queued-count.spec.ts )
# Backend gates
( cd apps/backend && make test lint build )
# Frontend gates
( cd apps && pnpm --filter @kandev/web run typecheck && pnpm --filter @kandev/web run lint && pnpm --filter @kandev/web run i18n:check && pnpm --filter @kandev/web run i18n:ratchet )
```
## Files Likely Touched
- `apps/web/e2e/tests/task/sidebar-queued-count.spec.ts`
- `apps/web/e2e/tests/task/mobile-sidebar-queued-count.spec.ts` (or a shared
helper reusing the `SidebarTasksPage` page object)
- `apps/web/e2e/helpers/api-client.ts` (clearQueue helper if absent)
## Dependencies
Tasks 03 and 04 deliver the payload and the badge; this task proves them end
to end on desktop and mobile.
## Output Contract
Report the E2E run results (badge visible, live clear, hidden at 0), the full
verification command results, and changed files. Update this task and
`plan.md` status in the same implementation conversation.
## Results
- E2E: `sidebar-queued-count.spec.ts` (3 tests) + `mobile-sidebar-queued-count.spec.ts` (1 test) — 4/4 pass on
the built artifact: badge shows the queued count with the mail icon, clears live with no reload, is absent at 0,
and covers subtask rows; mobile asserts the badge in the task-switcher sheet.
- Full gates: backend `go build ./...`, package tests, vet and golangci-lint on touched packages green;
web typecheck, lint, `i18n:check`, `i18n:ratchet` green. (Pre-existing VM-environment failures in
launcher/agentctl/local-repo packages reproduce at HEAD and are unrelated.)
- Manual: secondary dev instance on the LAN — badge rendered `📧 3` after enqueuing prompts and disappeared
without reload after `message.queue.cancel`.
+1
View File
@@ -154,6 +154,7 @@ Subscription quota tracking and per-agent cheap-model profile routing.
| [sidebar-view-creation](ui/sidebar-view-creation.md) | shipped |
| [command-panel sidebar task reveal](ui/command-panel-sidebar-task-reveal.md) | draft |
| [sidebar-task-completion-icons](ui/sidebar-task-completion-icons.md) | shipped |
| [sidebar-queued-prompt-count](ui/sidebar-queued-prompt-count.md) | shipped |
| [session-tab-delete-feedback](ui/session-tab-delete-feedback.md) | shipped |
| [slash-command-composer](ui/slash-command-composer.md) | shipped |
| [subagent-observability](ui/subagent-observability.md) | building |
@@ -0,0 +1,149 @@
---
status: shipped
created: 2026-08-04
owner: kandev
---
# Sidebar Queued Prompt Count Badge
## Why
A task with en-queued prompts looks identical to an idle one in the left
task view: nothing distinguishes "the agent is busy and three follow-ups are
waiting" from "nothing is waiting". Users bounce between the sidebar and each
task's queue panel to find where their typed work is stuck. A compact per-row
count on the metadata line (the same line that already carries the last
update time and PR number) makes queued work visible at a glance, for tasks
and subtasks alike.
## What
### The badge
- Every task row and subtask row in the left task view (`TaskSessionSidebar`)
shows, on its metadata line, a badge with a mail icon followed by the number
of prompts currently en-queued for that task.
- The count covers all of the task's sessions. It counts persisted queue
entries whose `task_id` is the task, excluding rows already reserved for
in-flight delivery (the same pending semantics as `message.queue.get`).
- A count of `0` renders no badge: the metadata line is unchanged from today
for tasks with nothing queued.
- The badge is display-only: it is not a button, has no hover dependency, and
never pushes the row to a second line.
### Data path
- **Initial load:** every task list/snapshot endpoint (HTTP list, workspace
list, workflow/workspace snapshot, WS list, single task) carries the count in
the task's status summary (`status_summary.queued_prompt_count`). The count
is computed fresh from the queue table at assembly time so the sidebar is
correct on first paint regardless of projector state.
- **Live updates:** the task status summary projector subscribes to the
internal `message.queue.status_changed` event, recomputes the affected
task's pending count, and publishes the existing workspace-wide
`task.status_summary.updated` broadcast. The sidebar therefore updates for
every task in the workspace without any new per-session subscription.
- **Queue panel payload:** the WebSocket `message.queue.status_changed` event
(session-scoped, unchanged routing) gains an informational `task_id` field.
Its `entries` content remains session-scoped; it is never broadcast
workspace-wide.
## API Surface
No new actions or endpoints. Payload additions only:
```text
task status summary (HTTP DTO, WS task.status_summary.updated)
status_summary.queued_prompt_count: int // omitted when 0
message.queue.status_changed (WebSocket, session-scoped as today)
task_id: string // informational addition
```
## Data Model
No table migration. The count derives from the existing `queued_messages`
table (`task_id` column, written at insert from the session's owning task).
Pending semantics match `messagequeue.Service.GetStatus`: a row carrying
`lifecycle_reserved_in_flight` metadata is excluded; durable lifecycle rows
not yet reserved remain pending and count. The persisted
`task_status_summaries` payload gains `queued_prompt_count` through the
existing `SemanticJSON` envelope; old rows decode with the field absent
(0).
## State and Concurrency
- The projector refreshes the count only when the queue event's computed count
differs from its current value; unchanged counts do not bump the summary
revision or republish.
- The projector restores the last persisted count on restart and corrects it on
the next queue event. List/snapshot assembly always reads the count fresh,
so a missed event or projector startup ordering cannot leave a stale badge on
initial load.
- A queue mutation and a list assembly racing read the same queue table; the
list response may carry a count from either side of the mutation, both
authoritative snapshots.
- Reserved-in-flight rows are excluded exactly as in `GetStatus`; a prompt
already handed to a dispatch attempt is not "queued".
## Responsive and Mobile Behavior
- The badge renders inside the existing metadata row on both desktop and
mobile sidebar layouts; it is a static text+icon pair, so it adds no hit
target, no hover behavior, and no scroll owner.
- The row's ellipsis/overflow behavior is unchanged: the badge is inline-flex
and shrink-safe, so narrow viewports truncate the title rather than wrap the
badge.
- The accessible name is localized; the visible text is only the count.
## Failure Modes
- **Queue event missed (startup ordering, projector down):** initial list
loads still show the correct count (fresh assembly); the next queue event
repairs the live path.
- **Session→task resolution fails at a publish site:** the event is published
without `task_id`; the projector skips it and the next successful event (or
the next list load) reconciles.
- **Summary row missing and count is 0:** nothing to show; the row behaves as
today.
- **Summary row missing and count > 0 (hydration failed):** the badge is
absent on that row until a summary exists; the queue panel remains the
fallback surface. Same degradation class as other summary-only fields.
- **Count query failure at assembly:** the list endpoint logs and serves the
rest of the row without the badge: the response DTO clears any existing
`queued_prompt_count` to `0` for that request only. The cleared value is
never persisted back to the summary store, so a later successful count or
projector event restores the badge. Matching the existing status-summary
load behavior.
- **Queued-counter provider unavailable (unwired):** the DTO leaves the
projected `queued_prompt_count` untouched instead of stamping a zero over
it, so a previously projected positive count survives list loads.
## Scenarios
- **GIVEN** a task with three prompts queued across its sessions, **WHEN** the
sidebar loads, **THEN** its row shows a mail icon and `3` on the metadata
line.
- **GIVEN** a task with an empty queue, **WHEN** the sidebar loads, **THEN**
its row shows no mail badge and the metadata line is unchanged.
- **GIVEN** a task with two queued prompts, **WHEN** one is drained, **THEN**
the badge updates to `1` without a reload, and to nothing when the last one
drains.
- **GIVEN** a prompt reserved for in-flight delivery while a later prompt
remains pending, **WHEN** the sidebar renders, **THEN** only the pending
prompt counts.
- **GIVEN** a subtask with one queued prompt, **WHEN** the sidebar renders the
subtree, **THEN** the subtask row shows the same badge treatment as a
top-level task.
- **GIVEN** a phone viewport, **WHEN** the sidebar renders a row with queued
prompts, **THEN** the badge is visible without hover and the row does not
overflow horizontally.
## Out of Scope
- Clicking the badge to open the queue panel (the row click still navigates).
- Per-session breakdown of the count.
- WIP overflow queues (`queued_for_step_id`) — a different queue concept with
its own UI.
- Queue capacity display, queue contents, or provenance in the sidebar.
- Changing the session-scoped routing of `message.queue.status_changed`.