From 49094ac587f5f2be29965c0081c96c7dc1587ded Mon Sep 17 00:00:00 2001
From: xiexian <3468714225@qq.com>
Date: Thu, 8 Oct 2026 14:09:03 +0800
Subject: [PATCH] fix(studio): send the selected queue item next
---
.../ui/queue-send-now.interaction.test.tsx | 131 ++++++++++++++++++
desktop/frontend-next/src/ui/queueactions.ts | 14 +-
2 files changed, 138 insertions(+), 7 deletions(-)
create mode 100644 desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx
diff --git a/desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx b/desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx
new file mode 100644
index 0000000000..b5f352936e
--- /dev/null
+++ b/desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx
@@ -0,0 +1,131 @@
+// @vitest-environment jsdom
+import { useEffect, useState } from "react";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import "./testkit";
+import { MockPort } from "../port/mock";
+import type { AgentPort } from "../port/port";
+import { Queue } from "./Queue";
+import { useQueueActions } from "./queueactions";
+
+const ports: MockPort[] = [];
+afterEach(async () => {
+ cleanup();
+ for (const port of ports.splice(0)) await port.setQueuePaused(true);
+ localStorage.clear();
+ vi.restoreAllMocks();
+});
+
+const BODIES = ["First queued instruction", "Second queued instruction", "Third queued instruction"];
+const STEER = "Selected guidance: " + "keep the whole instruction, including its final sentence. ".repeat(4);
+
+async function queued() {
+ const port = new MockPort();
+ ports.push(port);
+ let now = Date.now();
+ vi.spyOn(Date, "now").mockImplementation(() => ++now);
+ await port.setQueuePaused(true);
+ for (const text of BODIES) await port.queueFollowup(text);
+ await port.setQueuePaused(false);
+ return port;
+}
+
+function draw(port: MockPort) {
+ const dispatch = vi.fn();
+ const fail = vi.fn();
+ const beforeCancel: string[][] = [];
+ const cancel = port.cancel.bind(port);
+ vi.spyOn(port, "cancel").mockImplementation(async () => {
+ const snapshot = await port.queue();
+ beforeCancel.push(await Promise.all(snapshot.items.map((item) => port.readQueued(item.id))));
+ await cancel();
+ });
+ function Panel() {
+ const [moved, setMoved] = useState(0);
+ useEffect(() => {
+ const detach = port.subscribe((event) => {
+ if (event.kind === "inbox_changed") setMoved((n) => n + 1);
+ });
+ return () => { detach(); };
+ }, [port]);
+ const actions = useQueueActions({ port: port as unknown as AgentPort, dispatch, fail, moved });
+ return ;
+ }
+ render();
+ return { beforeCancel, dispatch, fail };
+}
+
+async function send(index: number) {
+ const buttons = await screen.findAllByRole("button", { name: "立即发送" });
+ await userEvent.click(buttons[index]);
+}
+
+describe("sending the selected queue entry now", () => {
+ it.each([1, 2])("puts row %i first before stopping the current reply", async (index) => {
+ const port = await queued();
+ const { beforeCancel, fail } = draw(port);
+ await send(index);
+ await waitFor(() => expect(beforeCancel).toEqual([[BODIES[index], ...BODIES.filter((_, i) => i !== index)]]));
+ expect(fail).not.toHaveBeenCalled();
+ const snapshot = await port.queue();
+ expect(snapshot.items).toHaveLength(3);
+ });
+
+ it("keeps the order when the selected entry is already first", async () => {
+ const port = await queued();
+ const { beforeCancel, fail } = draw(port);
+ await send(0);
+ await waitFor(() => expect(beforeCancel).toEqual([BODIES]));
+ expect(fail).not.toHaveBeenCalled();
+ });
+
+ it("promotes the replacement follow-up for accepted guidance, preserving its full body", async () => {
+ const port = await queued();
+ const accepted = await port.steer(STEER);
+ const { beforeCancel, dispatch, fail } = draw(port);
+ await send(3);
+ await waitFor(() => expect(beforeCancel).toEqual([[STEER, ...BODIES]]));
+ const snapshot = await port.queue();
+ expect(snapshot.items.map((item) => item.id)).not.toContain(accepted.itemId);
+ expect(snapshot.items[0].intent).toBe("followup");
+ expect(dispatch).toHaveBeenCalledWith({ kind: "__unsent", id: accepted.itemId });
+ expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", itemId: snapshot.items[0].id }));
+ expect(fail).not.toHaveBeenCalled();
+ });
+
+ it("keeps the current reply running when reordering fails", async () => {
+ const port = await queued();
+ const error = new Error("queue write failed");
+ vi.spyOn(port, "moveQueued").mockRejectedValue(error);
+ const { beforeCancel, fail } = draw(port);
+ await send(1);
+ await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
+ expect(beforeCancel).toEqual([]);
+ const snapshot = await port.queue();
+ expect(await Promise.all(snapshot.items.map((item) => port.readQueued(item.id)))).toEqual(BODIES);
+ });
+
+ it.each(["readQueued", "cancelQueued", "queueFollowup"] as const)("does not stop the reply if guidance conversion fails at %s", async (method) => {
+ const port = await queued();
+ await port.steer(STEER);
+ const error = new Error("conversion failed");
+ vi.spyOn(port, method).mockRejectedValue(error);
+ const { beforeCancel, fail } = draw(port);
+ await send(3);
+ await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
+ expect(beforeCancel).toEqual([]);
+ });
+
+ it("offers no send-now action while the queue is paused", async () => {
+ const port = await queued();
+ await port.setQueuePaused(true);
+ const { beforeCancel } = draw(port);
+ await screen.findByText(BODIES[0]);
+ expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
+ expect(beforeCancel).toEqual([]);
+ });
+});
diff --git a/desktop/frontend-next/src/ui/queueactions.ts b/desktop/frontend-next/src/ui/queueactions.ts
index 06e8c2e91a..13b313a080 100644
--- a/desktop/frontend-next/src/ui/queueactions.ts
+++ b/desktop/frontend-next/src/ui/queueactions.ts
@@ -37,14 +37,12 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const onQueueRefresh = useCallback((id: string) => void port.refreshQueued(id).catch(fail), [port, fail]);
const onQueuePause = useCallback((on: boolean) => void port.setQueuePaused(on).catch(fail), [port, fail]);
const onQueueRead = useCallback((id: string) => port.readQueued(id), [port]);
- // "Send now" means the only thing it can while a turn holds the session: end
- // that turn, and the queue dispatches this line as the next one. Guidance the
- // running turn already accepted has to leave it first — a turn that ends
- // without reading an accepted steer parks it as uncertain and pauses the
- // whole queue, which is the opposite of sending it.
+ // Cancellation lets the dispatcher take the queue head. Accepted guidance
+ // must leave the active turn and become a follow-up before that turn ends.
const onQueueSendNow = useCallback(
async (item: QueueItem) => {
try {
+ let itemId = item.id;
if (item.state === "steer_accepted") {
const text = await port.readQueued(item.id);
await port.cancelQueued(item.id);
@@ -52,14 +50,16 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const id = localId();
dispatch({ kind: "__user", text, pending: false, id } as never);
const again = await port.queueFollowup(text);
- if (again?.itemId) dispatch({ kind: "__queued", id, itemId: again.itemId, queued: "followup" } as never);
+ itemId = again.itemId;
+ dispatch({ kind: "__queued", id, itemId, queued: "followup" } as never);
}
+ await port.moveQueued(itemId, 0);
await port.cancel();
} catch (e) {
fail(e);
}
},
- [port, fail],
+ [port, fail, dispatch],
);
// The panel knows the entry, never the row the composer minted for it, so
// taking one back here has to name it the way the kernel does. The body is