);
diff --git a/apps/web/components/task/chat/queued-ghost-message.test.tsx b/apps/web/components/task/chat/queued-ghost-message.test.tsx
index fa99e3d7ae..6b96e02076 100644
--- a/apps/web/components/task/chat/queued-ghost-message.test.tsx
+++ b/apps/web/components/task/chat/queued-ghost-message.test.tsx
@@ -81,6 +81,102 @@ function renderWithProviders(node: React.ReactNode) {
);
}
+describe("QueuedGhostMessage reorder handle", () => {
+ const HANDLE_TESTID = "queue-grab-handle";
+
+ function renderRow(
+ overrides: Partial = {},
+ props: Partial> = {},
+ ) {
+ return render(
+ ,
+ );
+ }
+
+ it("renders a dotted grab handle with localized labels", () => {
+ renderRow();
+ const handle = screen.getByTestId(HANDLE_TESTID);
+ expect(handle.getAttribute("aria-label")).toBe("Reorder queued message");
+ expect(handle.getAttribute("aria-roledescription")).toBe("sortable");
+ // 2x3 dot grid reads as a dotted grip.
+ expect(handle.querySelectorAll('[aria-hidden="true"] span').length).toBe(6);
+ });
+
+ it("shows the handle without hovering on a fine pointer", () => {
+ renderRow();
+ const handle = screen.getByTestId(HANDLE_TESTID);
+ // A mounted handle is an always-visible drag affordance on every pointer.
+ expect(handle.className).toContain("opacity-100");
+ expect(handle.className).not.toContain("opacity-0");
+ expect(handle.className).not.toContain("group-hover:opacity-100");
+ });
+
+ it("keeps the fine-pointer grip small and unobtrusive", () => {
+ renderRow();
+ const handle = screen.getByTestId(HANDLE_TESTID);
+ expect(handle.className).toContain("left-1");
+ expect(handle.className).toContain("h-3 w-3");
+ expect(handle.className).toContain("border-0");
+ expect(handle.className).toContain("bg-transparent");
+ expect(handle.className).toContain("shadow-none");
+ const grip = handle.querySelector('[aria-hidden="true"]')!;
+ expect(grip.className).toContain("gap-0.5");
+ const dot = handle.querySelector('[aria-hidden="true"] span')!;
+ expect(dot.className).toContain("h-px w-px");
+ });
+
+ it("attaches the coarse-pointer handle to the box edge in the background", () => {
+ renderRow();
+ const handle = screen.getByTestId(HANDLE_TESTID);
+ // Coarse pointers: flush at the left edge, behind the content (z-0, no
+ // chip surface) so the dots read as part of the box edge.
+ expect(handle.className).toContain("[@media(pointer:coarse)]:left-0");
+ expect(handle.className).toContain("[@media(pointer:coarse)]:z-0");
+ expect(handle.className).toContain("[@media(pointer:coarse)]:bg-transparent");
+ expect(handle.className).toContain("[@media(pointer:coarse)]:shadow-none");
+ expect(handle.className).toContain("[@media(pointer:coarse)]:h-11");
+ expect(handle.className).toContain("[@media(pointer:coarse)]:w-11");
+ });
+
+ it("keeps the position label painted above the handle and pointer-transparent", () => {
+ renderRow();
+ // The label/icon cluster sits above the background handle (relative) and
+ // passes touches through to it (pointer-events-none).
+ const label = screen.getByLabelText("Position #1");
+ const cluster = label.parentElement!;
+ expect(cluster.className).toContain("relative");
+ expect(cluster.className).toContain("pointer-events-none");
+ });
+
+ it("centers the position, sender icon, and message content", () => {
+ renderRow();
+ const row = screen.getByTestId("queue-entry");
+ expect(row.className).toContain("items-center");
+ expect(row.className).not.toContain("items-start");
+ const label = screen.getByLabelText("Position #1");
+ expect(label.parentElement!.className).not.toContain("mt-0.5");
+ });
+
+ it("disables the handle while reordering is unavailable", () => {
+ renderRow({}, { canDrag: false });
+ const handle = screen.getByTestId(HANDLE_TESTID) as HTMLButtonElement;
+ expect(handle.disabled).toBe(true);
+ expect(handle.className).toContain("cursor-not-allowed");
+ });
+
+ it("hides the handle while the row is being edited", () => {
+ renderRow({ queued_by: "user-1" }, { canEdit: true });
+ fireEvent.click(screen.getByTestId(EDIT_TESTID));
+ expect(screen.queryByTestId(HANDLE_TESTID)).toBeNull();
+ });
+});
+
describe("QueuedGhostMessage workflow badge", () => {
it("renders workflow metadata as a workflow step badge", () => {
render(
diff --git a/apps/web/components/task/chat/queued-ghost-message.tsx b/apps/web/components/task/chat/queued-ghost-message.tsx
index 5d57ebf650..37e6861672 100644
--- a/apps/web/components/task/chat/queued-ghost-message.tsx
+++ b/apps/web/components/task/chat/queued-ghost-message.tsx
@@ -9,8 +9,12 @@ import {
useRef,
useState,
} from "react";
+import type { ReactNode } from "react";
import { IconCheck, IconFile, IconInfoCircle, IconRobot, IconUser } from "@tabler/icons-react";
import ReactMarkdown from "react-markdown";
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
import { toast } from "@/lib/toast/sonner";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui";
@@ -335,8 +339,11 @@ function DisplayView({
[entityReferences],
);
return (
-
-
+
+ {/* Position label + sender icon are non-interactive; relative keeps them
+ * painted above the coarse-pointer touch target, and pointer-events-none
+ * lets touches reach the handle behind them. */}
+ void;
+};
+
+/**
+ * Dotted drag handle that floats over the row's left edge. It is absolutely
+ * positioned so nothing in the row shifts to make room. It is always visible
+ * when the queue has multiple entries, attached flush to the box's left edge
+ * and painted behind the row content on coarse pointers (no chip surface —
+ * the dots read as part of the box edge).
+ * Dragging starts only from this handle.
+ */
+function QueueGrabHandle({
+ canDrag,
+ attributes,
+ listeners,
+ setActivatorNodeRef,
+}: QueueGrabHandleProps) {
+ const { t } = useTranslation();
+ const dragProps = canDrag ? { ...attributes, ...listeners } : {};
+ return (
+
+ );
+}
+
+type SortableRowShellProps = {
+ id: string;
+ /** useSortable is disabled while the row is edited or reordering is off. */
+ disabled: boolean;
+ canDrag: boolean;
+ /** The grab handle is hidden while the row is being edited. */
+ showHandle: boolean;
+ isDragging: boolean;
+ children: ReactNode;
+};
+
+/** Sortable row wrapper: owns the dnd-kit node ref, transform, and the dotted
+ * grab handle overlay, keeping QueuedGhostMessage focused on content. */
+function SortableRowShell({
+ id,
+ disabled,
+ canDrag,
+ showHandle,
+ isDragging,
+ children,
+}: SortableRowShellProps) {
+ const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition } =
+ useSortable({
+ id,
+ disabled,
+ });
+ return (
+
+ {showHandle && (
+
+ )}
+ {children}
+
+ );
+}
+
type QueuedGhostMessageProps = {
entry: QueuedMessage;
/** Zero-based render position; combined with entry.position to label the row. */
@@ -399,6 +517,17 @@ type QueuedGhostMessageProps = {
* share a sender kind. Optional so standalone renders (tests) can omit it.
*/
canMerge?: boolean;
+ /**
+ * Reordering is enabled unless a queue mutation or backend cancellation is
+ * in flight; the row's own edit view also suppresses the handle. The queue
+ * panel hides it when there is only one entry. Optional so standalone renders
+ * default to showing the handle.
+ */
+ canDrag?: boolean;
+ /** Whether this row belongs to a queue with enough entries to reorder. */
+ showDragHandle?: boolean;
+ /** Set while this row is the active drag target, for dimming/z-index. */
+ isDragging?: boolean;
onSave: (content: string, entityReferences: EntityReference[]) => Promise;
onRemove: () => void | Promise;
/** Fold this entry into the one above it. */
@@ -479,6 +608,9 @@ export const QueuedGhostMessage = forwardRef
{editing ? (
)}
-
+
);
},
);
diff --git a/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts b/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts
new file mode 100644
index 0000000000..3f4832fde0
--- /dev/null
+++ b/apps/web/e2e/tests/chat/message-queue-reorder.spec.ts
@@ -0,0 +1,192 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+import { test } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { typeWhileBusy } from "../../helpers/type-while-busy";
+import { SessionPage } from "../../pages/session-page";
+
+/** Create a task whose agent finishes its intro turn and becomes idle. */
+async function seedIdleTask(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+): Promise {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 60_000 });
+ return session;
+}
+
+/** Keep the agent busy with a slow command, then queue one message per entry. */
+async function queueMessagesWhileBusy(
+ page: Page,
+ editor: Locator,
+ messages: string[],
+): Promise {
+ const submit = page.getByTestId("submit-message-button");
+ for (const message of messages) {
+ await typeWhileBusy(page, editor, message);
+ await expect(submit).toBeVisible({ timeout: 5_000 });
+ await submit.click();
+ }
+}
+
+async function openQueuePanel(session: SessionPage): Promise {
+ const chip = session.activeChat().getByTestId("queue-chip");
+ await expect(chip).toBeVisible({ timeout: 10_000 });
+ await chip.click();
+ const panel = session.activeChat().getByTestId("queued-ghost-list");
+ await expect(panel).toBeVisible({ timeout: 5_000 });
+ return panel;
+}
+
+/** Grab handle of the row whose visible text contains `text`. */
+function rowHandle(panel: Locator, text: string): Locator {
+ const row = panel.getByTestId("queue-entry").filter({ hasText: text });
+ return row.locator("xpath=..").getByTestId("queue-grab-handle");
+}
+
+/**
+ * Assert that `contents` occupy exactly the consecutive positions starting at
+ * the head after removing the `/slow` keep-busy command, and that the `/slow`
+ * row — when present — sits at the head. This pins the global order instead of
+ * normalizing it away: a foreign row interleaved between the expected contents
+ * or a `/slow` row anywhere but the head fails.
+ */
+async function expectRelativeOrder(panel: Locator, ...contents: string[]): Promise {
+ await expect
+ .poll(
+ async () => {
+ const texts = (await panel.getByTestId("queue-entry-text").allTextContents()).map((t) =>
+ t.replace(/\s+/g, " ").trim(),
+ );
+ const slowIndex = texts.findIndex((t) => t.startsWith("/slow"));
+ const withoutSlow = texts.filter((_, index) => index !== slowIndex);
+ return {
+ positions: contents.map((content) => withoutSlow.findIndex((t) => t.includes(content))),
+ slowHead: slowIndex === -1 || slowIndex === 0,
+ };
+ },
+ { timeout: 10_000 },
+ )
+ .toEqual({ positions: contents.map((_, index) => index), slowHead: true });
+}
+
+test.describe("Queue reorder", () => {
+ test.describe.configure({ retries: 1 });
+
+ test("drag-and-drop reorders queued messages and persists the order", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder drag");
+ await session.sendMessage("/slow 30s");
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+ await testPage.waitForTimeout(500);
+
+ const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
+ await queueMessagesWhileBusy(testPage, editor, [
+ "reorder first",
+ "reorder second",
+ "reorder third",
+ ]);
+
+ const panel = await openQueuePanel(session);
+ await expectRelativeOrder(panel, "reorder first", "reorder second", "reorder third");
+
+ // Multiple queued rows expose their dotted grab handles immediately on a
+ // fine pointer, so the drag affordance is discoverable without hover.
+ const firstHandle = rowHandle(panel, "reorder first");
+ await expect(firstHandle).toHaveCSS("opacity", "1", { timeout: 5_000 });
+ const firstContent = panel.getByTestId("queue-entry").filter({ hasText: "reorder first" });
+ const [handleBox, contentBox] = await Promise.all([
+ firstHandle.boundingBox(),
+ firstContent.boundingBox(),
+ ]);
+ expect(handleBox).not.toBeNull();
+ expect(contentBox).not.toBeNull();
+ expect(handleBox!.width).toBeLessThanOrEqual(12);
+ expect(contentBox!.x).toBeGreaterThanOrEqual(handleBox!.x + handleBox!.width + 4);
+
+ // Drag the third row's handle onto the first row. Playwright's dragTo
+ // fires a single mousemove, which only activates dnd-kit's PointerSensor
+ // (distance constraint) without dispatching DragMove — the drop would
+ // resolve no target. Stepped moves keep the sensor's move events flowing.
+ const sourceBox = (await rowHandle(panel, "reorder third").boundingBox())!;
+ const targetBox = (await firstHandle.boundingBox())!;
+ await testPage.mouse.move(
+ sourceBox.x + sourceBox.width / 2,
+ sourceBox.y + sourceBox.height / 2,
+ );
+ await testPage.mouse.down();
+ await testPage.mouse.move(
+ targetBox.x + targetBox.width / 2,
+ targetBox.y + targetBox.height / 2,
+ { steps: 12 },
+ );
+ await testPage.mouse.up();
+ await expectRelativeOrder(panel, "reorder third", "reorder first", "reorder second");
+
+ // The order is persisted server-side and survives a reload.
+ await testPage.reload();
+ await session.waitForLoad();
+ const panelAfter = await openQueuePanel(session);
+ await expectRelativeOrder(panelAfter, "reorder third", "reorder first", "reorder second");
+ });
+
+ test("keyboard reordering moves a queued message one position", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+
+ const session = await seedIdleTask(testPage, apiClient, seedData, "Queue reorder keyboard");
+ await session.sendMessage("/slow 30s");
+ await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
+ await testPage.waitForTimeout(500);
+
+ const editor = session.activeChat().locator(".tiptap.ProseMirror:visible").first();
+ await queueMessagesWhileBusy(testPage, editor, [
+ "reorder first",
+ "reorder second",
+ "reorder third",
+ ]);
+
+ const panel = await openQueuePanel(session);
+ await expectRelativeOrder(panel, "reorder first", "reorder second");
+
+ // dnd-kit's keyboard sensor: Space starts the drag, arrows move, Space
+ // drops. Wait for the drag to actually start (the row dims) before moving.
+ const handle = rowHandle(panel, "reorder first");
+ const row = handle.locator("xpath=..");
+ await handle.focus();
+ await testPage.keyboard.press("Space");
+ await expect(row).toHaveClass(/opacity-40/, { timeout: 5_000 });
+ // Droppable measuring happens asynchronously after the drag starts; an
+ // arrow press before it completes computes no target. Settle briefly.
+ await testPage.waitForTimeout(200);
+ await testPage.keyboard.press("ArrowDown");
+ await testPage.waitForTimeout(100);
+ await testPage.keyboard.press("Space");
+ await expect(row).not.toHaveClass(/opacity-40/, { timeout: 5_000 });
+
+ await expectRelativeOrder(panel, "reorder second", "reorder first");
+ });
+});
diff --git a/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts
new file mode 100644
index 0000000000..8ae65f9633
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-message-queue-reorder.spec.ts
@@ -0,0 +1,166 @@
+import { expect, type Locator, type Page } from "@playwright/test";
+import { test } from "../../fixtures/test-base";
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { typeWhileBusy } from "../../helpers/type-while-busy";
+import { SessionPage } from "../../pages/session-page";
+
+async function expectTouchTarget(locator: Locator): Promise {
+ await expect(locator).toBeVisible();
+ const box = await locator.boundingBox();
+ expect(box).not.toBeNull();
+ // Chromium can report a 44px CSS target a fraction below 44 after layout.
+ expect(box!.width).toBeGreaterThanOrEqual(43.5);
+ expect(box!.height).toBeGreaterThanOrEqual(43.5);
+}
+
+async function seedBusyQueueTask(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+): Promise {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ "Mobile queue reorder",
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 60_000 });
+ await session.sendMessageViaButton("/slow 30s");
+ await session.agentStatus().waitFor({ state: "visible", timeout: 15_000 });
+ await testPage.waitForTimeout(500);
+ return session;
+}
+
+/** Grab handle of the row whose visible text contains `text`. */
+function rowHandle(panel: Locator, text: string): Locator {
+ const row = panel.getByTestId("queue-entry").filter({ hasText: text });
+ return row.locator("xpath=..").getByTestId("queue-grab-handle");
+}
+
+/**
+ * Assert that `contents` occupy exactly the consecutive positions starting at
+ * the head after removing the `/slow` keep-busy command, and that the `/slow`
+ * row — when present — sits at the head. This pins the global order instead of
+ * normalizing it away: a foreign row interleaved between the expected contents
+ * or a `/slow` row anywhere but the head fails.
+ */
+async function expectRelativeOrder(panel: Locator, ...contents: string[]): Promise {
+ await expect
+ .poll(
+ async () => {
+ const texts = (await panel.getByTestId("queue-entry-text").allTextContents()).map((t) =>
+ t.replace(/\s+/g, " ").trim(),
+ );
+ const slowIndex = texts.findIndex((t) => t.startsWith("/slow"));
+ const withoutSlow = texts.filter((_, index) => index !== slowIndex);
+ return {
+ positions: contents.map((content) => withoutSlow.findIndex((t) => t.includes(content))),
+ slowHead: slowIndex === -1 || slowIndex === 0,
+ };
+ },
+ { timeout: 10_000 },
+ )
+ .toEqual({ positions: contents.map((_, index) => index), slowHead: true });
+}
+
+/**
+ * Touch-pointer drag for dnd-kit's PointerSensor: pointerdown on the handle,
+ * two pointermoves (the first only activates the drag, the second dispatches
+ * DragMove so collision detection resolves), then pointerup. isPrimary is set
+ * explicitly because Playwright's dispatchEvent constructs untrusted events.
+ */
+async function touchDragTo(
+ page: Page,
+ source: Locator,
+ targetBox: { x: number; y: number },
+): Promise {
+ const sourceBox = (await source.boundingBox())!;
+ const start = { x: sourceBox.x + sourceBox.width / 2, y: sourceBox.y + sourceBox.height / 2 };
+ const end = { x: targetBox.x, y: targetBox.y };
+ const pointerId = 5;
+ const down = {
+ pointerId,
+ pointerType: "touch",
+ isPrimary: true,
+ button: 0,
+ clientX: start.x,
+ clientY: start.y,
+ };
+ await source.dispatchEvent("pointerdown", down);
+ // Each event is a separate task; the gaps let React commit the drag start
+ // and dnd-kit finish droppable measuring before the move dispatches
+ // DragMove — otherwise the drop resolves no target.
+ await page.waitForTimeout(100);
+ await page.dispatchEvent("body", "pointermove", { ...down, clientX: end.x, clientY: end.y });
+ await page.waitForTimeout(100);
+ await page.dispatchEvent("body", "pointermove", {
+ ...down,
+ clientX: end.x,
+ clientY: end.y + 2,
+ });
+ await page.waitForTimeout(100);
+ await page.dispatchEvent("body", "pointerup", {
+ ...down,
+ clientX: end.x,
+ clientY: end.y + 2,
+ });
+}
+
+test.describe.configure({ retries: 1 });
+
+test("mobile touch drag reorders queued messages with an always-visible handle", async ({
+ testPage,
+ apiClient,
+ seedData,
+}) => {
+ test.setTimeout(120_000);
+
+ const session = await seedBusyQueueTask(testPage, apiClient, seedData);
+ const chat = session.activeChat();
+ const editor = chat.locator(".tiptap.ProseMirror:visible").first();
+ const submit = testPage.getByTestId("submit-message-button");
+ for (const message of ["reorder first", "reorder second", "reorder third"]) {
+ await typeWhileBusy(testPage, editor, message);
+ await expect(submit).toBeVisible({ timeout: 5_000 });
+ await submit.tap();
+ }
+
+ const chip = chat.getByTestId("queue-chip");
+ await expect(chip).toBeVisible({ timeout: 10_000 });
+ await chip.tap();
+ const panel = chat.getByTestId("queued-ghost-list");
+ await expect(panel).toBeVisible({ timeout: 5_000 });
+ await expectRelativeOrder(panel, "reorder first", "reorder second", "reorder third");
+
+ // No hover on touch: the handle is always visible and touch-sized.
+ const firstHandle = rowHandle(panel, "reorder first");
+ const lastHandle = rowHandle(panel, "reorder third");
+ await expect(firstHandle).toHaveCSS("opacity", "1");
+ await expectTouchTarget(lastHandle);
+
+ const firstBox = (await firstHandle.boundingBox())!;
+ await touchDragTo(testPage, lastHandle, {
+ x: firstBox.x + firstBox.width / 2,
+ y: firstBox.y + firstBox.height / 2,
+ });
+
+ await expectRelativeOrder(panel, "reorder third", "reorder first", "reorder second");
+
+ // The reorder is persisted server-side and visible after a reload.
+ await testPage.reload();
+ await session.waitForLoad();
+ await expect(chat.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 });
+ await chat.getByTestId("queue-chip").tap();
+ const panelAfter = chat.getByTestId("queued-ghost-list");
+ await expect(panelAfter).toBeVisible({ timeout: 5_000 });
+ await expectRelativeOrder(panelAfter, "reorder third", "reorder first", "reorder second");
+});
diff --git a/apps/web/hooks/domains/session/use-queue.test.ts b/apps/web/hooks/domains/session/use-queue.test.ts
index 241f378cfd..ac0b061147 100644
--- a/apps/web/hooks/domains/session/use-queue.test.ts
+++ b/apps/web/hooks/domains/session/use-queue.test.ts
@@ -6,9 +6,11 @@ import type { EntityReference } from "@/lib/types/entity-reference";
const queueApiMock = vi.hoisted(() => {
class QueueEntryNotFoundError extends Error {}
class QueueSendNowError extends Error {}
+ class QueueReorderError extends Error {}
return {
QueueEntryNotFoundError,
QueueSendNowError,
+ QueueReorderError,
queueMessage: vi.fn(),
clearQueue: vi.fn(),
drainQueuedMessage: vi.fn(),
@@ -16,6 +18,7 @@ const queueApiMock = vi.hoisted(() => {
updateQueuedMessage: vi.fn(),
removeQueuedEntry: vi.fn(),
mergeQueuedEntry: vi.fn(),
+ reorderQueuedEntries: vi.fn(),
sendQueuedNow: vi.fn(),
};
});
@@ -497,3 +500,94 @@ describe("useQueue removeEntry", () => {
});
});
});
+
+describe("useQueue reorderEntries", () => {
+ beforeEach(() => {
+ resetMockState();
+ setDocumentVisibility("visible");
+ queueApiMock.reorderQueuedEntries.mockReset();
+ queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 });
+ });
+
+ it("optimistically reorders the store and refetches the authoritative queue", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ const second = entry({ id: "q-2", content: "second" });
+ // The test harness's setQueueEntries is a no-op spy, so seed the store
+ // directly: the hook's optimistic reorder reads the current entries.
+ mockState.queue.bySessionId[SESSION_ID] = [first, second];
+ mockState.queue.metaBySessionId[SESSION_ID] = { count: 2, max: 10 };
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+ queueApiMock.reorderQueuedEntries.mockResolvedValue({
+ session_id: SESSION_ID,
+ reordered: 2,
+ });
+
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+
+ await act(async () => {
+ await result.current.reorderEntries([second.id, first.id]);
+ });
+
+ expect(queueApiMock.reorderQueuedEntries).toHaveBeenCalledWith({
+ session_id: SESSION_ID,
+ ordered_ids: [second.id, first.id],
+ });
+ expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [second, first], {
+ count: 2,
+ max: 10,
+ mergeEnabled: true,
+ });
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ });
+
+ it("refetches and rethrows when the reorder fails", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ const second = entry({ id: "q-2", content: "second" });
+ queueApiMock.getQueueStatus.mockResolvedValue({
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+ queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new Error("reorder failed"));
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+ queueApiMock.getQueueStatus.mockResolvedValueOnce({
+ entries: [first, second],
+ count: 2,
+ max: 10,
+ });
+
+ await act(async () => {
+ await expect(result.current.reorderEntries([second.id, first.id])).rejects.toThrow(
+ "reorder failed",
+ );
+ });
+
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ });
+
+ it("refetches and rethrows a QueueReorderError so the panel can swallow it", async () => {
+ const first = entry({ id: "q-1", content: "first" });
+ queueApiMock.getQueueStatus.mockResolvedValue({ entries: [first], count: 1, max: 10 });
+ queueApiMock.reorderQueuedEntries.mockRejectedValueOnce(new queueApiMock.QueueReorderError());
+
+ const { result } = renderHook(() => useQueue(SESSION_ID));
+ await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalled());
+ queueApiMock.getQueueStatus.mockClear();
+
+ await act(async () => {
+ await expect(result.current.reorderEntries([first.id])).rejects.toThrow(
+ queueApiMock.QueueReorderError,
+ );
+ });
+
+ expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID);
+ });
+});
diff --git a/apps/web/hooks/domains/session/use-queue.ts b/apps/web/hooks/domains/session/use-queue.ts
index ea345146b6..5ee2e39990 100644
--- a/apps/web/hooks/domains/session/use-queue.ts
+++ b/apps/web/hooks/domains/session/use-queue.ts
@@ -9,6 +9,7 @@ import {
updateQueuedMessage,
removeQueuedEntry,
mergeQueuedEntry,
+ reorderQueuedEntries,
QueueEntryNotFoundError,
sendQueuedNow,
} from "@/lib/api/domains/queue-api";
@@ -69,6 +70,7 @@ function useQueueState(sessionId: string | null) {
type QueueActionsArgs = {
sessionId: string | null;
+ entries: QueuedMessage[];
setQueueEntries: ReturnType["setQueueEntries"];
removeQueueEntry: ReturnType["removeQueueEntry"];
setQueueLoading: ReturnType["setQueueLoading"];
@@ -167,6 +169,7 @@ function useQueueRefetch(
/** Build an action set bound to the supplied session + slice setters. */
function useQueueActions({
sessionId,
+ entries,
setQueueEntries,
removeQueueEntry,
setQueueLoading,
@@ -209,7 +212,85 @@ function useQueueActions({
[sessionId, refetch, setQueueLoading],
);
- const clearAll = useCallback(async () => {
+ const clearAll = useClearAllAction({
+ sessionId,
+ setQueueEntries,
+ setQueueLoading,
+ metaMax,
+ metaMergeEnabled,
+ refetch,
+ invalidateRefetch,
+ });
+
+ const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch);
+
+ const sendNow = useSendNowAction(sessionId, setQueueLoading, refetch);
+
+ const sendEntryNow = useCallback((entryId: string) => sendNow("entry", entryId), [sendNow]);
+ const sendAllNow = useCallback(() => sendNow("all"), [sendNow]);
+
+ const { editEntry, removeEntry, mergeEntry } = useEntryMutations({
+ sessionId,
+ removeQueueEntry,
+ refetch,
+ });
+
+ const reorderEntries = useReorderEntriesAction({
+ sessionId,
+ entries,
+ setQueueEntries,
+ setQueueLoading,
+ metaMax,
+ metaMergeEnabled,
+ refetch,
+ });
+
+ return {
+ refetch,
+ queue,
+ clearAll,
+ drainNext,
+ sendEntryNow,
+ sendAllNow,
+ editEntry,
+ removeEntry,
+ mergeEntry,
+ reorderEntries,
+ };
+}
+
+type ReorderEntriesArgs = {
+ sessionId: string | null;
+ entries: QueuedMessage[];
+ setQueueEntries: ReturnType["setQueueEntries"];
+ setQueueLoading: ReturnType["setQueueLoading"];
+ metaMax: number | undefined;
+ metaMergeEnabled: boolean | undefined;
+ refetch: (sid: string) => Promise;
+};
+
+type ClearAllArgs = {
+ sessionId: string | null;
+ setQueueEntries: ReturnType["setQueueEntries"];
+ setQueueLoading: ReturnType["setQueueLoading"];
+ metaMax: number | undefined;
+ metaMergeEnabled: boolean | undefined;
+ refetch: (sid: string) => Promise;
+ invalidateRefetch: (sid: string) => void;
+};
+
+/** Clears every pending entry: optimistic empty state, then the backend's
+ * authoritative result. Reconcile errors preserve the mutation error. */
+function useClearAllAction({
+ sessionId,
+ setQueueEntries,
+ setQueueLoading,
+ metaMax,
+ metaMergeEnabled,
+ refetch,
+ invalidateRefetch,
+}: ClearAllArgs) {
+ return useCallback(async () => {
if (!sessionId) return;
setQueueLoading(sessionId, true);
let mutationFailed = false;
@@ -252,31 +333,63 @@ function useQueueActions({
refetch,
invalidateRefetch,
]);
+}
- const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch);
+/** Rewrites the visible pending order, optimistically at first, then
+ * reconciles to the backend's authoritative order. A queue_changed drift
+ * (drain/remove/merge raced the drag) is refetched and rethrown as
+ * QueueReorderError so the panel can swallow it without a toast. */
+function useReorderEntriesAction({
+ sessionId,
+ entries,
+ setQueueEntries,
+ setQueueLoading,
+ metaMax,
+ metaMergeEnabled,
+ refetch,
+}: ReorderEntriesArgs) {
+ return useCallback(
+ async (orderedIds: string[]) => {
+ if (!sessionId) return;
+ // Optimistic: reflect the drop immediately; the refetch below reconciles
+ // to the backend's authoritative order and positions.
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
+ const reordered = orderedIds
+ .map((id) => byId.get(id))
+ .filter((entry): entry is QueuedMessage => entry !== undefined);
+ if (reordered.length === entries.length) {
+ setQueueEntries(sessionId, reordered, {
+ count: reordered.length,
+ max: metaMax ?? 0,
+ mergeEnabled: metaMergeEnabled ?? true,
+ });
+ }
+ setQueueLoading(sessionId, true);
+ let mutationFailed = false;
+ let mutationError: unknown;
+ try {
+ try {
+ await reorderQueuedEntries({ session_id: sessionId, ordered_ids: orderedIds });
+ } catch (err) {
+ mutationFailed = true;
+ mutationError = err;
+ }
- const sendNow = useSendNowAction(sessionId, setQueueLoading, refetch);
-
- const sendEntryNow = useCallback((entryId: string) => sendNow("entry", entryId), [sendNow]);
- const sendAllNow = useCallback(() => sendNow("all"), [sendNow]);
-
- const { editEntry, removeEntry, mergeEntry } = useEntryMutations({
- sessionId,
- removeQueueEntry,
- refetch,
- });
-
- return {
- refetch,
- queue,
- clearAll,
- drainNext,
- sendEntryNow,
- sendAllNow,
- editEntry,
- removeEntry,
- mergeEntry,
- };
+ try {
+ await refetch(sessionId);
+ } catch (reconcileError) {
+ // Preserve the mutation error when both operations fail; it best
+ // explains why the requested order did not land.
+ if (mutationFailed) throw mutationError;
+ throw reconcileError;
+ }
+ if (mutationFailed) throw mutationError;
+ } finally {
+ setQueueLoading(sessionId, false);
+ }
+ },
+ [sessionId, entries, setQueueEntries, setQueueLoading, metaMax, refetch],
+ );
}
type EntryMutationsArgs = {
@@ -388,8 +501,10 @@ export function useQueue(sessionId: string | null) {
editEntry,
removeEntry,
mergeEntry,
+ reorderEntries,
} = useQueueActions({
sessionId,
+ entries,
setQueueEntries: state.setQueueEntries,
removeQueueEntry: state.removeQueueEntry,
setQueueLoading: state.setQueueLoading,
@@ -438,6 +553,7 @@ export function useQueue(sessionId: string | null) {
editEntry,
removeEntry,
mergeEntry,
+ reorderEntries,
refetch: refetchBound,
};
}
diff --git a/apps/web/lib/api/domains/queue-api.test.ts b/apps/web/lib/api/domains/queue-api.test.ts
index 2a24a453e2..94a14496d2 100644
--- a/apps/web/lib/api/domains/queue-api.test.ts
+++ b/apps/web/lib/api/domains/queue-api.test.ts
@@ -12,8 +12,10 @@ import {
QueueSendNowError,
QueueFullError,
QueueEntryNotFoundError,
+ QueueReorderError,
mergeQueuedEntry,
queueMessage,
+ reorderQueuedEntries,
rethrowQueueError,
sendQueuedNow,
updateQueuedMessage,
@@ -308,3 +310,41 @@ describe("sendQueuedNow", () => {
);
});
});
+
+describe("reorderQueuedEntries", () => {
+ it("sends the full ordered id list through message.queue.reorder", async () => {
+ const request = vi.fn().mockResolvedValue({ session_id: "session-1", reordered: 3 });
+ getWebSocketClientMock.mockReturnValue({ request });
+
+ await reorderQueuedEntries({
+ session_id: "session-1",
+ ordered_ids: ["q-c", "q-a", "q-b"],
+ });
+
+ expect(request).toHaveBeenCalledWith("message.queue.reorder", {
+ session_id: "session-1",
+ ordered_ids: ["q-c", "q-a", "q-b"],
+ });
+ });
+
+ it("maps a queue_changed rejection to QueueReorderError", async () => {
+ const request = vi.fn().mockRejectedValue({
+ code: "queue_changed",
+ message: "Queue changed before the reorder could be applied",
+ });
+ getWebSocketClientMock.mockReturnValue({ request });
+
+ await expect(
+ reorderQueuedEntries({ session_id: "session-1", ordered_ids: ["q-a"] }),
+ ).rejects.toBeInstanceOf(QueueReorderError);
+ });
+
+ it("falls through to the shared error mapping for other failures", async () => {
+ const request = vi.fn().mockRejectedValue({ code: "entry_not_found", message: "Gone" });
+ getWebSocketClientMock.mockReturnValue({ request });
+
+ await expect(
+ reorderQueuedEntries({ session_id: "session-1", ordered_ids: ["q-a"] }),
+ ).rejects.toBeInstanceOf(QueueEntryNotFoundError);
+ });
+});
diff --git a/apps/web/lib/api/domains/queue-api.ts b/apps/web/lib/api/domains/queue-api.ts
index 06c2036a80..bb3c8654a7 100644
--- a/apps/web/lib/api/domains/queue-api.ts
+++ b/apps/web/lib/api/domains/queue-api.ts
@@ -66,6 +66,19 @@ export class QueueSendNowError extends Error {
}
}
+/** Error thrown when a reorder's submitted id set no longer matches the
+ * visible pending queue (an entry was drained, removed, merged, or newly
+ * queued since the client's snapshot). The reorder was rejected atomically;
+ * callers refetch the authoritative queue. */
+export class QueueReorderError extends Error {
+ readonly code = "queue_changed" as const;
+
+ constructor(message?: string) {
+ super(message ?? "The queue changed before the reorder could be applied.");
+ this.name = "QueueReorderError";
+ }
+}
+
type WSError = {
code?: string;
message?: string;
@@ -286,3 +299,29 @@ export async function mergeQueuedEntry(params: {
rethrowQueueError(err);
}
}
+
+/** Rewrite the visible pending order of a session's queue. Throws
+ * QueueReorderError when the queue changed since the client's snapshot; the
+ * reorder was rejected atomically and the caller should refetch. */
+export async function reorderQueuedEntries(params: {
+ session_id: string;
+ ordered_ids: string[];
+}): Promise<{ session_id: string; reordered: number }> {
+ const client = getWebSocketClient();
+ if (!client) {
+ throw new Error(WS_CLIENT_UNAVAILABLE);
+ }
+ try {
+ return await client.request<{ session_id: string; reordered: number }>(
+ "message.queue.reorder",
+ params,
+ );
+ } catch (err) {
+ // The shared queue_changed mapping targets Send Now; reorder drift needs
+ // its own error class so callers reconcile silently instead of toasting.
+ if (asWSError(err)?.code === "queue_changed") {
+ throw new QueueReorderError();
+ }
+ rethrowQueueError(err);
+ }
+}
diff --git a/apps/web/src/locales/en/chat.json b/apps/web/src/locales/en/chat.json
index a939a946bf..201e9eb95b 100644
--- a/apps/web/src/locales/en/chat.json
+++ b/apps/web/src/locales/en/chat.json
@@ -43,6 +43,7 @@
"failedToDeleteQuickChat": "Failed to delete quick chat",
"failedToMergeQueuedMessages": "Failed to merge queued messages.",
"failedToRemoveQueuedMessage": "Failed to remove queued message.",
+ "failedToReorderQueuedMessages": "Failed to reorder queued messages.",
"failedToRunQueuedMessage": "Failed to run queued message.",
"failedToStartQuickChat": "Failed to start quick chat",
"fileTreeActions": "File actions",
@@ -96,6 +97,7 @@
"quickChatIntroSecondary": "Quick chats stay outside your task board.",
"removeQueuedMessage": "Remove queued message",
"renameChat": "Rename chat",
+ "reorderQueuedMessage": "Reorder queued message",
"renameSavedOnThisDeviceOnly": "Rename saved on this device only",
"renameSyncFailedDescription": "We could not sync the new name to your other devices.",
"repositories": "Repositories",
@@ -123,6 +125,7 @@
"sendNowTurnChanged": "The active turn changed before Send Now could start.",
"sendNowAttachmentOverflow": "Combined attachments exceed the message limits.",
"sendNowReferenceOverflow": "Combined entity references exceed the message limit.",
+ "sortable": "sortable",
"technicalDetails": "Technical details",
"theAssociatedWorktree": "The associated worktree",
"theTaskAndItsData": "The task and its data",
diff --git a/apps/web/src/locales/pseudo/chat.json b/apps/web/src/locales/pseudo/chat.json
index 527e57310c..9079cbfec9 100644
--- a/apps/web/src/locales/pseudo/chat.json
+++ b/apps/web/src/locales/pseudo/chat.json
@@ -43,6 +43,7 @@
"failedToDeleteQuickChat": "Ƒàĩĺēď ţō ďēĺēţē qũĩćķ ćĥàţ",
"failedToMergeQueuedMessages": "Ƒàĩĺēď ţō ḿēŕĝē qũēũēď ḿēśśàĝēś.",
"failedToRemoveQueuedMessage": "Ƒàĩĺēď ţō ŕēḿōvē qũēũēď ḿēśśàĝē.",
+ "failedToReorderQueuedMessages": "Ƒàĩĺēď ţō ŕēōŕďēŕ qũēũēď ḿēśśàĝēś.",
"failedToRunQueuedMessage": "Ƒàĩĺēď ţō ŕũń qũēũēď ḿēśśàĝē.",
"failedToStartQuickChat": "Ƒàĩĺēď ţō śţàŕţ qũĩćķ ćĥàţ",
"fileTreeActions": "Ƒĩĺē àćţĩōńś",
@@ -83,6 +84,7 @@
"quickChatIntroSecondary": "Qũĩćķ ćĥàţś śţàŷ ōũţśĩďē ŷōũŕ ţàśķ ƀōàŕď.",
"removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē",
"renameChat": "Ŕēńàḿē ćĥàţ",
+ "reorderQueuedMessage": "Ŕēōŕďēŕ qũēũēď ḿēśśàĝē",
"renameSavedOnThisDeviceOnly": "Ŕēńàḿē śàvēď ōń ţĥĩś ďēvĩćē ōńĺŷ",
"renameSyncFailedDescription": "Ŵē ćōũĺď ńōţ śŷńć ţĥē ńēŵ ńàḿē ţō ŷōũŕ ōţĥēŕ ďēvĩćēś.",
"repositories": "Ŕēƥōśĩţōŕĩēś",
@@ -110,6 +112,7 @@
"sendNowTurnChanged": "Ţĥē àćţĩvē ţũŕń ćĥàńĝēď ƀēƒōŕē Śēńď Ńōŵ ćōũĺď śţàŕţ.",
"sendNowAttachmentOverflow": "Ćōḿƀĩńēď àţţàćĥḿēńţś ēxćēēď ţĥē ḿēśśàĝē ĺĩḿĩţś.",
"sendNowReferenceOverflow": "Ćōḿƀĩńēď ēńţĩţŷ ŕēƒēŕēńćēś ēxćēēď ţĥē ḿēśśàĝē ĺĩḿĩţ.",
+ "sortable": "śōŕţàƀĺē",
"technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś",
"theAssociatedWorktree": "Ţĥē àśśōćĩàţēď ŵōŕķţŕēē",
"theTaskAndItsData": "Ţĥē ţàśķ àńď ĩţś ďàţà",
diff --git a/docs/plans/message-queue-reorder/plan.md b/docs/plans/message-queue-reorder/plan.md
new file mode 100644
index 0000000000..13901c46f4
--- /dev/null
+++ b/docs/plans/message-queue-reorder/plan.md
@@ -0,0 +1,339 @@
+---
+spec: docs/specs/ui/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 `