feat: show queued prompt count badge in the task sidebar (#2354)
This commit is contained in:
@@ -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`.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user