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

211 lines
9.9 KiB
Markdown

---
status: building
created: 2026-08-07
owner: kandev
---
# Reorder Queued Messages
## Why
Pending messages run in strict FIFO order, so a later, more urgent prompt
waits behind everything queued before it. Today the only workarounds are
remove-and-requeue (destructive) or Send Now (interrupts the active turn).
Users need to rearrange pending messages so execution order matches priority
without losing content.
## What
- Every visible pending message in the queue panel offers a **grab handle**
on its left edge. The handle is a small dotted grip (2-column dot grid).
- The handle is a **floating overlay** anchored to the row's left edge,
vertically centered. It does not participate in layout: no row or panel
content moves to make room for it. While shown it may cover the row's
`#N` position label; the label returns when the handle hides.
- On a fine pointer the handle is hidden by default and appears when the
pointer hovers the row or keyboard focus reaches it. On a coarse pointer
(touch) it is always visible, attached flush to the message box's left
edge, and painted **behind** the row content — no chip surface, so the
dotted grip reads as part of the box edge. The position label and sender
icon stay painted above it (and pass touches through), and the interactive
hit area is at least 44 by 44 CSS pixels.
- Dragging starts **only** from the handle. Pointer interaction with the row
body (text selection, expand toggle, edit/merge/remove/send-now actions)
is unchanged. Dragging requires pointer movement (activation distance),
so a click on the handle does nothing by itself.
- Dropping the handle onto another row commits a new order. The queue panel
updates immediately (optimistic) and reconciles to the backend result.
- The new order is **persisted server-side** and survives reload, session
switch-back, and restart. All connected views reconcile to it.
- Displayed positions remain compact one-based ordinals (`#1` through
`#N`) derived from the visible order, exactly as after remove/merge.
- Reordering applies to every visible pending row **regardless of
provenance** (user, agent, workflow, system), matching the Remove action.
Server-reserved rows already in flight stay hidden and are never
reordered.
- The handle is disabled while a queue mutation or backend cancellation for
the session is pending (the same `isLoading`/`cancellationPending` gate as
Send Now), and is hidden while the row is being edited.
- Keyboard reordering is supported: focusing the handle and using
Space/Enter plus the arrow keys moves the row, matching the platform's
sortable-list convention.
- Reordering changes nothing else: merge, edit, Remove, Send Now, FIFO
auto-drain, and capacity semantics are unchanged. Send Now **all** and
FIFO drain dispatch in the current (reordered) order.
## API Surface
New WebSocket action:
```text
message.queue.reorder
```
Request:
```json
{
"session_id": "session-id",
"ordered_ids": ["entry-b", "entry-a", "entry-c"]
}
```
`session_id` is required. `ordered_ids` is required, non-empty, and must
contain each visible pending entry id of the session exactly once.
Successful response:
```json
{
"session_id": "session-id",
"reordered": 3
}
```
`reordered` is the number of entries in the submitted order — equal to the
visible pending count on success. A no-op reorder (submitting the current
order) reports the same count, since positions are rewritten to the compacted
`1..N` sequence.
Errors:
- `validation`: `session_id` missing, `ordered_ids` missing/empty, or
duplicate ids in `ordered_ids`.
- `queue_changed`: the submitted id set does not match the current visible
pending set — an entry was drained, removed, merged, or newly queued
since the client's snapshot. The request is rejected atomically with no
partial reorder; the client refetches the authoritative queue.
- Session access is required; the existing non-enumerating session-not-found
response is used before any queue read or mutation.
On success the handler publishes the existing `message.queue.status_changed`
event so every connected view reconciles.
## Data Model
No schema change. Order lives in the existing `queued_messages.position`
integer column (lower = head). A successful reorder rewrites positions to a
strictly increasing sequence: reserved in-flight rows keep their place in the
sequence, and the visible rows appear in the submitted order. Positions are
compacted (`1..N`) by the same operation, so subsequent inserts take
`MAX(position) + 1` as today.
## Permissions
- Any caller with access to the session may reorder its visible pending
entries, regardless of entry provenance (same rule as Remove and Send Now).
- The action accepts no caller-supplied content, `user_id`, `task_id`, or
identity. Only `session_id` and the entry ids are taken from the caller,
and the ids are validated against the persisted visible set.
## Failure Modes
- **Drain/remove/merge races the drag:** the submitted set no longer matches
the visible set; the backend rejects the whole request with `queue_changed`
and the client refetches silently (self-recovering, no error toast).
- **Backend mutation fails for another reason:** the client refetches to
restore the authoritative order and shows a localized error toast.
- **Concurrent clients:** the last successful reorder wins. A client with a
stale snapshot receives `queue_changed` and reconciles on its next refetch.
- **Reserved in-flight rows:** they are invisible to the client and excluded
from validation; they are never reordered and keep their delivery
reservation.
- **Rapid repeated drags:** while a reorder request is in flight the handles
are disabled, so overlapping reorder requests cannot be issued from one
client.
## Persistence Guarantees
The reordered sequence persists in `queued_messages.position` in SQLite and
survives a Kandev restart. It is queue state, not a client preference, and is
shared by every view of the session. A process crash mid-reorder leaves the
transaction uncommitted and the previous order intact.
## Responsive and Mobile Behavior
- The existing inline queue panel remains the desktop and phone composition;
the queue list stays the single internal scroll owner and the composer
stays visible.
- Desktop: the handle is a hover/focus-revealed floating overlay on the
row's left edge; nothing shifts in layout.
- Phone/coarse-pointer: the handle is always visible, attached flush to the
box's left edge, and painted behind the row content (background, no chip
surface). The row's position label / sender icon render above it and are
`pointer-events: none`, so touches reach the handle's 44 by 44 CSS-pixel
target while the label stays readable. `touch-action: none` on the handle
keeps a touch-drag reordering instead of scrolling; vertical scrolling of
the queue list is unaffected elsewhere.
- The queue hook, backend action, optimistic order, and reconciliation are
shared across viewports; only the handle's visibility and size differ.
- Mobile Playwright coverage proves the same reorder result with touch input.
## Scenarios
- **GIVEN** three messages are queued in order A, B, C, **WHEN** the user
drags C's handle above A, **THEN** the panel shows `#1` C, `#2` A, `#3` B
and reloading the page keeps that order.
- **GIVEN** a fine-pointer desktop with a queued message, **WHEN** the
pointer is not over the row, **THEN** no handle is visible, and the row
box has the same width and content position as without the feature.
- **GIVEN** the pointer hovers a queued message row, **WHEN** the pointer
moves onto the left edge, **THEN** the dotted grab handle appears as a
floating overlay and the row content does not shift.
- **GIVEN** a queued message row, **WHEN** the user presses and drags on the
row body (not the handle), **THEN** no drag starts; text selection and row
actions behave as before.
- **GIVEN** a row is being edited, **WHEN** the user looks at it, **THEN**
no grab handle is shown for that row.
- **GIVEN** a queue mutation or cancellation is in flight, **WHEN** the user
looks at any row, **THEN** the handles are visible but disabled.
- **GIVEN** the user starts dragging entry A and another client drains A
before the drop, **WHEN** the drop commits, **THEN** the backend returns
`queue_changed`, the panel silently refetches, and no error toast appears.
- **GIVEN** a queue whose head is a hidden reserved in-flight lifecycle row,
**WHEN** the user reorders the visible entries, **THEN** the visible
entries assume the submitted order around the reserved row and its
delivery/acknowledgement is unaffected.
- **GIVEN** the backend reorder fails for a non-race reason, **WHEN** the
drop commits, **THEN** the panel refetches the authoritative order and
shows the localized "failed to reorder" toast.
- **GIVEN** a queue containing user, agent, workflow, and system rows, **WHEN**
the user drags any of them, **THEN** the row moves to the dropped position.
- **GIVEN** a reordered queue and the user clicks Send Now **all**, **THEN**
the bulk prompt concatenates the bodies in the reordered FIFO order.
- **GIVEN** a phone viewport with queued messages, **WHEN** the user opens
the queue panel, **THEN** every row shows an always-visible touch-sized
handle attached flush to the box's left edge and painted behind the row
content (position labels stay readable), and a touch drag reorders the
queue with the same persisted result.
- **GIVEN** the user focuses a row's handle with the keyboard, **WHEN** they
press Space, Arrow Up, then Space, **THEN** the row moves one position up
and the order is persisted.
## Out of Scope
- Bulk selection or multi-row drag.
- Dedicated up/down move buttons as an alternative to dragging (keyboard
reordering is covered by the sortable keyboard sensor).
- Reordering hidden reserved in-flight rows.
- Cross-session or cross-task reordering.
- Changing merge, edit, Remove, Send Now, FIFO drain, or capacity semantics.