---
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 `