Files
kandev/docs/plans/message-queue-reorder/plan.md
T
2026-08-24 11:09:03 +00:00

340 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
spec: docs/specs/ui/requirements/message-queue-reorder.md
created: 2026-08-07
status: draft
---
# Implementation Plan: Reorder Queued Messages
## Overview
Add a durable, server-persisted reorder operation for the per-session message
queue. The backend gains `message.queue.reorder` (full-order submission,
atomically validated against the visible pending set, positions rewritten in
one transaction). The frontend wires a new `reorderEntries` hook action
(optimistic reorder + reconcile) and converts the queue panel rows to
`@dnd-kit/sortable` with a dotted grab handle that floats over the row's left
edge on hover (always visible on touch). E2E covers desktop pointer and
mobile touch drags; two shipped specs' out-of-scope lines are updated.
Order: repository → service/handler → frontend API/hook → frontend UI →
E2E → docs.
---
## Backend
### Repository contract — `apps/backend/internal/orchestrator/messagequeue/repository.go`
Add to the `Repository` interface:
```go
// ReorderEntries atomically rewrites the FIFO positions of the session's
// pending entries to match orderedIDs. The submitted list must be an exact
// permutation of the currently visible pending (not reserved-in-flight)
// entry ids ordered as the caller wants them; any drift — missing, extra,
// or duplicate ids, or an id belonging to a reserved in-flight row —
// returns ErrQueueChanged and leaves the queue untouched. Reserved
// in-flight rows keep their place in the sequence; visible rows are
// interleaved in the submitted order and all positions are compacted to
// 1..N in one transaction.
ReorderEntries(ctx context.Context, sessionID string, orderedIDs []string) error
```
Add `ErrQueueChanged = errors.New("queue changed during reorder")` beside the
other package errors in `types.go`.
### SQLite — `apps/backend/internal/orchestrator/messagequeue/repository_sqlite.go`
`ReorderEntries`: take `r.withSessionLock(sessionID)`, begin a tx (pattern:
`MergeIntoAbove`, `applyMergeWrites`). Inside the tx:
1. Read all rows ordered by position (`SELECT id, ... ORDER BY position ASC`).
2. Split into `visible` (not `IsReservedInFlight()`) and `reserved`.
3. Validate `orderedIDs` is a permutation of the visible ids (same set, no
duplicates) → else `ErrQueueChanged`.
4. Build the merged sequence: walk the original order; reserved rows emit as
themselves, visible rows emit from `orderedIDs` in order.
5. Rewrite every row's position to `1..N` (`UPDATE queued_messages SET
position = ? WHERE id = ? AND session_id = ?`; check `RowsAffected` per
row — a vanished row during the tx means a drain raced the lock and
`ErrQueueChanged` rolls back).
6. Commit. Defer `tx.Rollback()` as in the merge path.
Do not reuse `ListBySession` inside the tx (it reads from the read-only
handle); select within the tx so validation and writes see one snapshot under
the session lock.
### Memory — `apps/backend/internal/orchestrator/messagequeue/repository_memory.go`
Mirror under `r.mu.Lock()`: same visible/reserved split and permutation
validation against the stored slice (slice is not guaranteed position-sorted
after `ReplaceSession` — sort a copy by position first), then rewrite each
entry's `Position` to `1..N` in the merged sequence and bump
`r.nextPosition[sessionID]` to at least `N`.
### Tests — `apps/backend/internal/orchestrator/messagequeue/repository_reorder_test.go`
Table-driven over both repos (pattern: `repository_send_now_test.go` builds a
table of `new func(*testing.T) Repository`). Cases:
- reorder reverses a 3-entry queue; positions become 1..3, content/metadata/
queued_by untouched (compare full entries).
- no-op order (same as current) succeeds and leaves positions unchanged.
- drift: missing id, extra id, duplicate id, empty list → `ErrQueueChanged`,
queue unchanged (assert full snapshot identical).
- a reserved in-flight lifecycle row (insert via metadata
`lifecycle_reserved_in_flight: true`) is not in the visible set: submitting
only visible ids succeeds, the reserved row keeps its position, visible
rows interleave around it; submitting the reserved id → `ErrQueueChanged`.
- concurrent drain during reorder: take head after building the submitted
set, then reorder → `ErrQueueChanged` (validates the atomic read).
---
### Service — `apps/backend/internal/orchestrator/messagequeue/service.go`
```go
// ReorderEntries rewrites the pending FIFO order of a session's queue to
// match orderedIDs. Returns ErrQueueChanged when the submitted order does
// not match the current visible pending set (a drain/remove/merge raced
// the request); callers refetch the authoritative queue.
func (s *Service) ReorderEntries(ctx context.Context, sessionID string, orderedIDs []string) error
```
Validation before the repo call: non-empty, no duplicate ids → `ErrQueueChanged`
or a dedicated validation error (handler maps both to `queue_changed`).
Delegate to `s.repo.ReorderEntries`; wrap only unexpected errors.
### WebSocket action — `apps/backend/pkg/websocket/actions.go`
Add next to the queue actions:
```go
ActionMessageQueueReorder = "message.queue.reorder" // Rewrite the visible pending order for a session
```
### Handler — `apps/backend/internal/orchestrator/handlers/queue_handlers.go`
- Add `ReorderEntries(ctx context.Context, sessionID string, orderedIDs []string) error`
to the `QueueService` interface (real impl is `messagequeue.Service` via
`orchestratorSvc.GetMessageQueue()` — `apps/backend/internal/backendapp/gateway.go`).
- Register `d.RegisterFunc(ws.ActionMessageQueueReorder, h.wsReorder)`.
- `wsReorder`: parse `{session_id, ordered_ids}`; validation errors for
missing session, empty/duplicate `ordered_ids`; `authorizeSession`; call
`ReorderEntries`; map `ErrQueueChanged` (and the service-level validation
error) to error code `queue_changed`; other errors → `500`-style WS error.
On success respond `{session_id, reordered: N}` and call `publishStatus`.
- Add `queueErrorCodeQueueChanged = "queue_changed"` constant (leave the
existing send-now constant untouched; wire value is identical).
### Tests — handler + service
- `apps/backend/internal/orchestrator/handlers/queue_handlers_reorder_test.go`:
happy path (mock `QueueService` + `mockEventBus` asserts `publishStatus`
ran), validation errors (missing session, empty ids, duplicate ids),
access denied, `ErrQueueChanged` → `queue_changed` error code, and a real
`messagequeue.NewServiceMemory` round-trip (queue 2 entries via
`QueueMessageWithMetadata`, reorder reversed, assert response + status).
Add the action to the table in `queue_handlers_test.go` (mirror the
`merge` row).
- Service-level validation covered in the repository tests plus a small
`service_test.go` case for duplicate/empty input.
---
## Frontend
### API client — `apps/web/lib/api/domains/queue-api.ts`
```ts
export class QueueReorderError extends Error {
readonly code = "queue_changed" as const;
constructor(message?: string) { ... this.name = "QueueReorderError"; }
}
export async function reorderQueuedEntries(params: {
session_id: string;
ordered_ids: string[];
}): Promise<{ session_id: string; reordered: number }>
```
`reorderQueuedEntries` sends `{ action: "message.queue.reorder", payload }`
via `getWebSocketClient()`. Because `rethrowQueueError` currently maps
`queue_changed` to `QueueSendNowError`, check `asWSError(err)?.code ===
"queue_changed"` in `reorderQueuedEntries` and throw `QueueReorderError`
before falling through to `rethrowQueueError`.
### Hook — `apps/web/hooks/domains/session/use-queue.ts`
Add `reorderEntries(orderedIds: string[])`:
1. Optimistic: reorder the current `entries` by id using `setQueueEntries`
(keep the same `meta`).
2. `await reorderQueuedEntries({ session_id, ordered_ids })`; on
`QueueReorderError` refetch and swallow (self-recovering race, mirrors
`mergeEntry`'s `QueueEntryNotFoundError` handling); on any other error
refetch and rethrow.
3. On success refetch to reconcile (authoritative order + positions).
Expose `reorderEntries` from `useQueue` and from
`useQueuePanelHandlers`/`QueueAffordance` as `handleReorder(orderedIds)` that
toasts `chat:failedToReorderQueuedMessages` for non-race errors.
### State slice
No changes: `setQueueEntries` and `metaBySessionId` already exist in
`apps/web/lib/state/slices/session/types.ts` / the session store.
### i18n — `apps/web/src/locales/en/chat.json` (+ regenerate pseudo)
- `"reorderQueuedMessage": "Reorder queued message"` — handle aria-label
- `"sortable": "sortable"` — handle `aria-roledescription`
- `"failedToReorderQueuedMessages": "Failed to reorder queued messages."` —
error toast
Run `pnpm i18n:pseudo` to regenerate `src/locales/pseudo/chat.json`, then
`pnpm i18n:check` and `pnpm i18n:ratchet`. No new copy may be a hardcoded
literal in JSX.
---
### Queue panel — `apps/web/components/task/chat/queued-ghost-list.tsx`
In `QueuePanel`, wrap the `queue-scroll-region` list with dnd-kit (pattern:
`apps/web/components/kanban/swimlane-container.tsx`):
```tsx
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={({ active }) => setActiveId(String(active.id))}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{entries.map((entry, index) => (
<QueuedGhostMessage key={entry.id} ... canDrag={!isLoading && !cancellationPending} isDragging={activeId === entry.id} />
))}
</SortableContext>
</DndContext>
```
- `sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }))`.
- `handleDragEnd`: no-op when `over` is null or ids match; otherwise
`arrayMove(ids, oldIndex, newIndex)` → `onReorder(newIds)`.
- `onReorder` = `handleReorder` from `QueueAffordance` (threaded through
`QueuePanelDisclosure` + `QueuePanel` props, like `onMerge`).
### Row — `apps/web/components/task/chat/queued-ghost-message.tsx`
- `useSortable({ id: entry.id, disabled: editing || !canDrag })` on the row
wrapper; apply `setNodeRef`, `style={{ transform: CSS.Transform.toString(transform), transition }}`,
add `group relative` + `z-10 opacity-40` while `isDragging`.
- Add `canDrag` + `isDragging` props; `canDrag` defaults `true` for
standalone renders/tests.
- New `QueueGrabHandle` component rendered when `!editing`: an absolutely
positioned `<button type="button">` at `left-0`/`-left-1`, `top-1/2
-translate-y-1/2`, `z-10`, `cursor-grab active:cursor-grabbing touch-none`,
containing a 2×3 dot grid (`grid grid-cols-2`, 3px dots) plus
`{...attributes} {...listeners}` from `useSortable`.
- Visibility: `opacity-0 group-hover:opacity-100 group-focus-within:opacity-100
transition-opacity` on fine pointers;
`[@media(pointer:coarse)]:opacity-100` always visible with
`[@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11` hit area
and a translucent surface (`bg-background/80`) so the `#N` label stays
faintly legible.
- `aria-label={t("chat:reorderQueuedMessage")}`,
`aria-roledescription={t("chat:sortable")}`.
- Disabled state (`!canDrag`): no listeners, `cursor-default opacity-30`.
- The row body keeps its existing handlers; the handle is the only draggable
surface (`listeners` only on the handle).
### Tests — `apps/web/components/task/chat/queued-ghost-list.test.tsx` (+ new)
- Handle visibility: hidden without hover on a fine pointer (opacity class),
revealed by `mouseenter` on the row, hidden while the row is editing.
- Drag end wiring: simulate `DndContext` drag via pointer events on the
handle (or call the panel's `handleDragEnd` with crafted `DragEndEvent`
objects) and assert `reorderEntries` receives the `arrayMove`d id list.
- Disabled while `isLoading`/`cancellationPending`; handle absent/disabled
during edit.
- `use-queue.test.ts`: `reorderEntries` optimistically updates the store
order, refetches on success, refetches + rethrows on failure, swallows
`QueueReorderError` after refetch.
- `queue-api.test.ts`: `reorderQueuedEntries` sends the right action/payload,
maps `queue_changed` → `QueueReorderError`.
---
## Tests
- **Backend repo (unit, both impls):** `repository_reorder_test.go` —
reversal, no-op, every drift case, reserved-row interleaving, atomic
rollback on drift, concurrent-drain race.
- **Backend handler/service:** `queue_handlers_reorder_test.go` — validation,
authorization, `queue_changed` mapping, publish-on-success, real
in-memory round-trip.
- **Frontend unit:** `queue-api.test.ts`, `use-queue.test.ts`,
`queued-ghost-list.test.tsx` — action payload, error mapping, optimistic
order + reconcile, handle visibility/disabled states, drag-end wiring.
- Commands (run from `apps/backend` / `apps/web` as appropriate):
- `cd apps/backend && go test ./internal/orchestrator/messagequeue/...`
- `cd apps/backend && go test ./internal/orchestrator/handlers/...`
- `cd apps && pnpm --filter @kandev/web test -- lib/api/domains/queue-api.test.ts hooks/domains/session/use-queue.test.ts components/task/chat/queued-ghost-list.test.tsx`
- `cd apps/web && pnpm run typecheck`
- `cd apps/web && pnpm run lint -- components/task/chat` (plus `i18n:ratchet`/`i18n:check`)
## E2E Tests
- **Scenario:** drag handle of `#3` above `#1` → order persists across
reload; handle hidden until hover on desktop; touch drag on mobile.
- **File:** `apps/web/e2e/tests/chat/message-queue-reorder.spec.ts` and
`apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts`
(mobile file name is required for the `mobile-chrome` project).
- **What to verify:** queue 3 messages while the agent is busy (reuse the
quick-chat + `typeWhileBusy` pattern from `message-queue.spec.ts`), open
the panel, assert `#1/#2/#3` labels, `dragTo` the handle of the last row
onto the first, assert the labels swapped and the order survives a reload
(server-persisted). Desktop also asserts the handle is not visible before
hover. Mobile asserts the handle is visible without hover and touch drag
reorders. Keyboard: focus handle, Space, Arrow Up, Space, assert move.
If `dragTo` proves flaky against dnd-kit pointer sensors, dispatch
pointerdown/move/up manually (pattern: `file-tree-drag-drop.spec.ts`).
- Build note (from AGENTS.md): E2E runs against the production build — run
`make build-web` (or `make test-e2e`) after frontend changes.
## Verification Results
Pending. On completion, synchronize with each task's `## Results`.
## Implementation Waves And Parallel Candidates
```
Wave 1 (sequential):
- [x] [task-01-backend-repository-reorder](task-01-backend-repository-reorder.md)
Wave 2 (sequential):
- [x] [task-02-backend-service-handler-reorder](task-02-backend-service-handler-reorder.md)
Wave 3 (sequential):
- [x] [task-03-frontend-api-hook-reorder](task-03-frontend-api-hook-reorder.md)
Wave 4 (sequential):
- [x] [task-04-frontend-dnd-ui](task-04-frontend-dnd-ui.md)
Wave 5 (sequential):
- [x] [task-05-e2e-reorder](task-05-e2e-reorder.md)
Wave 6 (sequential):
- [x] [task-06-docs-out-of-scope-updates](task-06-docs-out-of-scope-updates.md)
```
All tasks are `sequential` by default. `task-06` touches only docs and is
independent; it is listed last so the two shipped specs are updated after the
feature is proven, but it may be executed any time after the spec lands.
## Open Questions
(Delete when empty.)