Files
kandev/docs/plans/message-queue-reorder/plan.md
T
2026-08-09 12:00:21 +01:00

15 KiB
Raw Blame History

spec, created, status
spec created status
docs/specs/ui/message-queue-reorder.md 2026-08-07 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:

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

// 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:

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

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):

<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 arrayMoved 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.)