--- 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 setActiveId(String(active.id))} onDragEnd={handleDragEnd} onDragCancel={() => setActiveId(null)} > {entries.map((entry, index) => ( ))} ``` - `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 `