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