diff --git a/desktop/frontend-next/src/ui/changed-files-pill.interaction.test.tsx b/desktop/frontend-next/src/ui/changed-files-pill.interaction.test.tsx
index 495968bb07..76668ef14c 100644
--- a/desktop/frontend-next/src/ui/changed-files-pill.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/changed-files-pill.interaction.test.tsx
@@ -144,11 +144,11 @@ describe("the changed-files pill above the input box", () => {
vi.spyOn(pane.port, "steer").mockImplementation((text) => pane.port.queueFollowup(text));
fireEvent.change(box(), { target: { value: "next thing", selectionStart: 10 } });
fireEvent.click(await screen.findByRole("button", { name: "插话" }));
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
pane.send({ kind: "notice", level: "warn", audience: "operator", code: "x.y", text: "运行时提示" });
await screen.findByText("运行时提示");
expect(pill()).toBeTruthy();
- expect(screen.getByRole("button", { name: "取回" }).closest(".composeaux")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "撤回" }).closest(".composeaux")).toBeTruthy();
expect(pill()!.closest(".composeaux, .composenotes")).toBeNull();
expect(document.querySelector(".composenotes")!.contains(screen.getByText("运行时提示"))).toBe(true);
});
diff --git a/desktop/frontend-next/src/ui/queue-composer.interaction.test.tsx b/desktop/frontend-next/src/ui/queue-composer.interaction.test.tsx
index 812fc52238..b3da7b04f8 100644
--- a/desktop/frontend-next/src/ui/queue-composer.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/queue-composer.interaction.test.tsx
@@ -43,11 +43,11 @@ describe("queued submission and withdrawal", () => {
type(BODY);
expect(screen.getByText("Enter 排队 · Shift+Enter 换行")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "排队发送" }));
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
expect(followup).toHaveBeenCalledWith(BODY, undefined);
expect(steer).not.toHaveBeenCalled();
expect((await port.queue()).items[0].intent).toBe("followup");
- fireEvent.click(screen.getByRole("button", { name: "取回" }));
+ fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
fireEvent.click(screen.getByRole("radio", { name: "下个工具边界插话" }));
expect(screen.getByRole("button", { name: "插话" })).toBeTruthy();
@@ -64,10 +64,10 @@ describe("queued submission and withdrawal", () => {
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
cleanup();
render(
);
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
expect((await port.queue()).items[0].intent).toBe("followup");
- fireEvent.click(screen.getByRole("button", { name: "取回" }));
+ fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
});
@@ -76,10 +76,10 @@ describe("queued submission and withdrawal", () => {
vi.spyOn(port, "steer").mockImplementation((text) => port.queueFollowup(text));
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "插话" }));
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
type(draft);
- fireEvent.click(screen.getByRole("button", { name: "取回" }));
+ fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(draft && draft !== BODY ? `${draft}\n${BODY}` : BODY));
});
@@ -165,20 +165,20 @@ describe("queued submission and withdrawal", () => {
type(BODY);
fireEvent.click(screen.getByRole("button", { name: "插话" }));
await waitFor(() => expect(box().value).toBe(BODY));
- expect(screen.queryByRole("button", { name: "取回" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "撤回" })).toBeNull();
});
it("pulls the queue after reload without filling the composer", async () => {
const port = new MockPort();
await port.queueFollowup(BODY);
await open(port);
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
cleanup();
render(
);
- await screen.findByRole("button", { name: "取回" });
+ await screen.findByRole("button", { name: "撤回" });
expect(box().value).toBe("");
- fireEvent.click(screen.getByRole("button", { name: "取回" }));
+ fireEvent.click(screen.getByRole("button", { name: "撤回" }));
await waitFor(() => expect(box().value).toBe(BODY));
});
diff --git a/desktop/frontend-next/src/ui/queue-edit-read-order.interaction.test.tsx b/desktop/frontend-next/src/ui/queue-edit-read-order.interaction.test.tsx
deleted file mode 100644
index 3544f3b429..0000000000
--- a/desktop/frontend-next/src/ui/queue-edit-read-order.interaction.test.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, expect, it, vi } from "vitest";
-import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import "./testkit";
-import { MockPort } from "../port/mock";
-import { HttpError } from "../port/port";
-import { Queue } from "./Queue";
-
-afterEach(() => { cleanup(); vi.restoreAllMocks(); });
-
-const FIRST = "Check the unit tests.";
-const SECOND = "Check the packaged application.";
-const DRAFT = "Check the packaged application on Windows as well.";
-
-function deferred() {
- let resolve!: (body: string) => void;
- let reject!: (error: unknown) => void;
- const promise = new Promise
((yes, no) => { resolve = yes; reject = no; });
- return { promise, resolve, reject };
-}
-
-async function openQueue() {
- const port = new MockPort();
- await port.setQueuePaused(true);
- const stamp = vi.spyOn(Date, "now").mockReturnValueOnce(1).mockReturnValueOnce(2);
- const first = await port.queueFollowup(FIRST);
- const second = await port.queueFollowup(SECOND);
- stamp.mockRestore();
- expect(first.itemId).not.toBe(second.itemId);
- const reads = new Map([[first.itemId, deferred()], [second.itemId, deferred()]]);
- const edit = vi.spyOn(port, "editQueued");
- render( reads.get(id)!.promise} onEdit={(id, body) => void port.editQueued(id, body)}
- onMove={() => {}} onCancel={() => {}} onSendNow={() => {}}
- onRetry={() => {}} onRefresh={() => {}} onPause={() => {}} />);
- const choose = async (body: string) => {
- const row = screen.getByText(body).closest(".qi")! as HTMLElement;
- await userEvent.click(within(row).getByRole("button", { name: "改" }));
- };
- await choose(FIRST);
- await choose(SECOND);
- return { port, first, second, earlier: reads.get(first.itemId)!, latest: reads.get(second.itemId)!, edit };
-}
-
-const box = () => document.querySelector(".qedit");
-
-it.each(["body", "refusal"])("keeps the latest queued draft when an earlier read returns a late %s", async (result) => {
- const { port, first, second, earlier, latest, edit } = await openQueue();
- await act(async () => { latest.resolve(await port.readQueued(second.itemId)); });
- expect(box()?.value).toBe(SECOND);
- fireEvent.change(box()!, { target: { value: DRAFT } });
- await act(async () => {
- if (result === "body") earlier.resolve(await port.readQueued(first.itemId));
- else earlier.reject(new HttpError(409, "gone", { code: "inbox.not_found" }));
- });
-
- expect(box()?.value).toBe(DRAFT);
- expect(document.activeElement).toBe(box());
- expect(screen.queryByRole("alert")).toBeNull();
- fireEvent.keyDown(box()!, { key: "Enter" });
- expect(edit).toHaveBeenCalledExactlyOnceWith(second.itemId, DRAFT);
- expect(await port.readQueued(first.itemId)).toBe(FIRST);
- expect(await port.readQueued(second.itemId)).toBe(DRAFT);
-});
-
-it("waits for the latest selected row even when the earlier row answers first", async () => {
- const { port, first, second, earlier, latest } = await openQueue();
- await act(async () => { earlier.resolve(await port.readQueued(first.itemId)); });
- expect(box()).toBeNull();
- await act(async () => { latest.resolve(await port.readQueued(second.itemId)); });
- expect(box()?.value).toBe(SECOND);
- expect(document.activeElement).toBe(box());
-});
-
-it("keeps the latest read's refusal after an older body arrives", async () => {
- const { port, first, earlier, latest } = await openQueue();
- await act(async () => { latest.reject(new HttpError(409, "gone", { code: "inbox.not_found" })); });
- const failure = screen.getByRole("alert");
- expect(failure.textContent).toContain("该条已不在待送达队列中");
- expect(box()).toBeNull();
- await act(async () => { earlier.resolve(await port.readQueued(first.itemId)); });
- expect(box()).toBeNull();
- expect(screen.getByRole("alert")).toBe(failure);
-});
diff --git a/desktop/frontend-next/src/ui/queue-escape.interaction.test.tsx b/desktop/frontend-next/src/ui/queue-escape.interaction.test.tsx
deleted file mode 100644
index ee6de967b8..0000000000
--- a/desktop/frontend-next/src/ui/queue-escape.interaction.test.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-// @vitest-environment jsdom
-import { afterEach, expect, it, vi } from "vitest";
-import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import "./testkit";
-import { App } from "./App";
-import { MockHub } from "../port/mock_hub";
-
-afterEach(cleanup);
-
-const BODY = "Check the failing tests after the current task.";
-
-async function openWindow() {
- const hub = new MockHub();
- const [rt] = await hub.runtimes();
- const port = hub.portFor(rt);
- port.providerSetup = async () => null;
- port.welcomeSeen = async () => true;
- const status = await port.status();
- vi.spyOn(port, "status").mockResolvedValue({ ...status, running: true });
- const cancel = vi.spyOn(port, "cancel").mockResolvedValue(undefined);
- const edit = vi.spyOn(port, "editQueued");
- await port.setQueuePaused(true);
- const queued = await port.queueFollowup(BODY);
- await act(async () => { render(); });
- await screen.findByRole("button", { name: "改" });
- await waitFor(() => expect(document.querySelector(".studio-runstate[data-running]")).not.toBeNull());
- return { port, queued, cancel, edit };
-}
-
-async function openEditor() {
- await userEvent.click(screen.getByRole("button", { name: "改" }));
- return waitFor(() => {
- const box = document.querySelector(".qedit");
- expect(box).not.toBeNull();
- expect(document.activeElement).toBe(box);
- return box!;
- });
-}
-
-it("dismisses a queued draft with Escape without stopping the active turn", async () => {
- const { port, queued, cancel, edit } = await openWindow();
- const box = await openEditor();
- expect(box.value).toBe(BODY);
- fireEvent.change(box, { target: { value: "An unsaved replacement" } });
- await userEvent.keyboard("{Escape}");
-
- expect(document.querySelector(".qedit")).toBeNull();
- expect(cancel).not.toHaveBeenCalled();
- expect(edit).not.toHaveBeenCalled();
- expect(await port.readQueued(queued.itemId)).toBe(BODY);
- expect((await port.queue()).items.map((item) => item.id)).toContain(queued.itemId);
-
- const reopened = await openEditor();
- expect(reopened.value).toBe(BODY);
- await userEvent.keyboard("{Escape}");
- expect(cancel).not.toHaveBeenCalled();
-
- const composer = document.querySelector(".compose textarea")!;
- expect(composer).not.toBeNull();
- composer.focus();
- await userEvent.keyboard("{Escape}");
- expect(cancel).not.toHaveBeenCalled();
- expect(document.querySelector(".stophint")!.textContent).toBe("再按一次 Esc 停止当前任务");
- await userEvent.keyboard("{Escape}");
- expect(cancel).toHaveBeenCalledTimes(1);
-});
-
-it("still saves a queued draft with Enter without stopping the active turn", async () => {
- const { port, queued, cancel, edit } = await openWindow();
- const box = await openEditor();
- const replacement = "Check the build as well.";
- fireEvent.change(box, { target: { value: replacement } });
- await userEvent.keyboard("{Enter}");
-
- await waitFor(() => expect(document.querySelector(".qedit")).toBeNull());
- expect(edit).toHaveBeenCalledExactlyOnceWith(queued.itemId, replacement);
- expect(await port.readQueued(queued.itemId)).toBe(replacement);
- expect(cancel).not.toHaveBeenCalled();
-});
diff --git a/desktop/frontend-next/src/ui/queue-image-reason.interaction.test.tsx b/desktop/frontend-next/src/ui/queue-image-reason.interaction.test.tsx
index 54a25b9944..60b2758364 100644
--- a/desktop/frontend-next/src/ui/queue-image-reason.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/queue-image-reason.interaction.test.tsx
@@ -16,7 +16,7 @@ function rows(items: QueueItem[]) {
const queue: QueueSnapshot = { revision: 1, paused: false, items, capacity: { items: items.length, maxItems: 64, bytes: 10, maxBytes: 64 << 20 } };
const noop = () => {};
const { container } = render(
- ""} onEdit={noop} onMove={noop} onSendNow={noop} onCancel={noop} onRetry={noop} onRefresh={noop} onPause={noop} />,
+ ,
);
return container;
}
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
index b5f352936e..ec63074669 100644
--- a/desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/queue-send-now.interaction.test.tsx
@@ -51,9 +51,8 @@ function draw(port: MockPort) {
}, [port]);
const actions = useQueueActions({ port: port as unknown as AgentPort, dispatch, fail, moved });
return ;
+ onCancel={actions.onQueueCancel} onSendNow={actions.onQueueSendNow} onToSteer={actions.onQueueToSteer}
+ onRetry={actions.onQueueRetry} onPause={actions.onQueuePause} />;
}
render();
return { beforeCancel, dispatch, fail };
@@ -129,3 +128,33 @@ describe("sending the selected queue entry now", () => {
expect(beforeCancel).toEqual([]);
});
});
+
+describe("making a queued follow-up guidance", () => {
+ it("takes the line back and says it again as a steer, whole", async () => {
+ const port = await queued();
+ // Lines held while the queue was paused have no timer for the fixture's
+ // cancel to clear, so it refuses them; the kernel lets a held line go.
+ vi.spyOn(port, "cancelQueued").mockImplementation(async (id: string) => {
+ (port as unknown as { dropQueued: (id: string) => void }).dropQueued(id);
+ });
+ const { dispatch, fail } = draw(port);
+ await userEvent.click((await screen.findAllByRole("button", { name: "改为插话" }))[1]);
+ await waitFor(() => expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", queued: "steer" })));
+ const snapshot = await port.queue();
+ const bodies = await Promise.all(snapshot.items.map((item) => port.readQueued(item.id)));
+ expect(bodies.filter((b) => b === BODIES[1])).toHaveLength(1);
+ expect(snapshot.items.find((item) => item.preview.startsWith(BODIES[1]))?.intent).toBe("steer");
+ expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__unsent" }));
+ expect(fail).not.toHaveBeenCalled();
+ });
+
+ it("keeps the line queued when it cannot be read back", async () => {
+ const port = await queued();
+ const error = new Error("read failed");
+ vi.spyOn(port, "readQueued").mockRejectedValue(error);
+ const { fail } = draw(port);
+ await userEvent.click((await screen.findAllByRole("button", { name: "改为插话" }))[0]);
+ await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
+ expect((await port.queue()).items).toHaveLength(3);
+ });
+});
diff --git a/desktop/frontend-next/src/ui/queue.interaction.test.tsx b/desktop/frontend-next/src/ui/queue.interaction.test.tsx
index edc48803e0..5f649ee201 100644
--- a/desktop/frontend-next/src/ui/queue.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/queue.interaction.test.tsx
@@ -1,24 +1,18 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "./testkit";
import { Queue } from "./Queue";
-import { HttpError } from "../port/port";
import type { Queue as QueueSnapshot, QueueItem } from "../port/port";
-afterEach(() => { cleanup(); localStorage.clear(); vi.restoreAllMocks(); });
-
-// The preview is cut at 120 runes, so the box has to be filled from the read
-// and never from the row: saving the row back files a truncated line as the
-// whole instruction.
-const BODY = "把 gate_test.go 里那三个跳过的用例打开,确认它们真的在 CI 里跑到了,再把 README 里说它们被跳过的那段删掉。";
+afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const item = (over: Partial = {}): QueueItem => ({
id: "i1",
intent: "followup",
state: "queued",
- preview: BODY.slice(0, 20),
+ preview: "把 gate_test.go 里那三个跳过的用例打开",
createdAt: "2026-08-25T10:00:00Z",
...over,
});
@@ -31,146 +25,52 @@ const snapshot = (over: Partial = {}): QueueSnapshot => ({
...over,
});
-function draw(onRead: (id: string) => Promise) {
- const onEdit = vi.fn();
- render(
- {}}
- onSendNow={() => {}}
- onCancel={() => {}}
- onRetry={() => {}}
- onRefresh={() => {}}
- onPause={() => {}}
- />,
- );
- return { onEdit, edit: () => userEvent.click(screen.getByRole("button", { name: "改" })) };
+function draw(queue: QueueSnapshot, over: Partial[0]> = {}) {
+ const on = { onSendNow: vi.fn(), onToSteer: vi.fn(), onCancel: vi.fn(), onRetry: vi.fn(), onPause: vi.fn() };
+ render();
+ return on;
}
-const box = () => document.querySelector(".qedit");
-
-describe("editing a pending entry", () => {
- // The arm the browser guard cannot reach: it needs an inbox_changed to land
- // in the panel, and the bench owns the subscription the fixture emits into.
- it("opens on the whole instruction, not on the row's preview", async () => {
- const { edit } = draw(async () => BODY);
- await edit();
- expect(box()?.value).toBe(BODY);
+describe("the actions on a queued line", () => {
+ it("hands the entry to the steer conversion", async () => {
+ const on = draw(snapshot());
+ await userEvent.click(screen.getByRole("button", { name: "改为插话" }));
+ expect(on.onToSteer).toHaveBeenCalledWith(expect.objectContaining({ id: "i1" }));
});
- it("does not open an editor when the body cannot be read", async () => {
- const { onEdit, edit } = draw(async () => {
- throw new HttpError(409, "no such entry", { code: "inbox.not_found" });
- });
- await edit();
- expect(box()).toBeNull();
- // The row keeps its own text. Anything typed into a blank box would have
- // replaced, in full, a line the reader never saw.
- expect(screen.getByText(item().preview)).toBeTruthy();
- expect(onEdit).not.toHaveBeenCalled();
+ it("withdraws exactly that entry", async () => {
+ const on = draw(snapshot({ items: [item({ id: "a" }), item({ id: "b" })] }));
+ await userEvent.click(screen.getAllByRole("button", { name: "撤回" })[1]);
+ expect(on.onCancel).toHaveBeenCalledWith("b");
});
- // The refusal is the kernel's identity said in this window's language, not
- // the English that rode along for the log.
- it("says why, in the reader's language", async () => {
- const { edit } = draw(async () => {
- throw new HttpError(409, "no such entry", { code: "inbox.not_found" });
- });
- await edit();
- const why = document.querySelector(".qwhy[data-err]");
- expect(why?.textContent).toContain("该条已不在待送达队列中");
- expect(why?.textContent).not.toContain("no such entry");
+ it("sends exactly that entry now", async () => {
+ const on = draw(snapshot());
+ await userEvent.click(screen.getByRole("button", { name: "立即发送" }));
+ expect(on.onSendNow).toHaveBeenCalledWith(expect.objectContaining({ id: "i1" }));
});
- // A failure that outlives its click reads as the next one's answer.
- it("clears the last failure when the read succeeds", async () => {
- let fail = true;
- const { edit } = draw(async () => {
- if (fail) throw new HttpError(409, "gone", { code: "inbox.not_found" });
- return BODY;
- });
- await edit();
- expect(document.querySelector(".qwhy[data-err]")).toBeTruthy();
- fail = false;
- await edit();
- expect(document.querySelector(".qwhy[data-err]")).toBeNull();
- expect(box()?.value).toBe(BODY);
+ it("offers no conversion or send-now with no turn in the way", () => {
+ draw(snapshot(), { running: false });
+ expect(screen.queryByRole("button", { name: "改为插话" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
+ expect(screen.getByRole("button", { name: "撤回" })).toBeTruthy();
+ });
+
+ it("releases a held queue", async () => {
+ const on = draw(snapshot({ paused: true }));
+ await userEvent.click(screen.getByRole("button", { name: "继续派发" }));
+ expect(on.onPause).toHaveBeenCalledWith(false);
});
});
describe("retrying a held entry", () => {
it("asks the kernel to retry exactly that entry", async () => {
- const onRetry = vi.fn();
- render(
- BODY}
- onEdit={() => {}}
- onMove={() => {}}
- onSendNow={() => {}}
- onCancel={() => {}}
- onRetry={onRetry}
- onRefresh={() => {}}
- onPause={() => {}}
- />,
+ const on = draw(
+ snapshot({ paused: true, items: [item({ id: "u1", state: "uncertain", blockCode: "steer_unapplied" })] }),
+ { running: false },
);
await userEvent.click(screen.getByRole("button", { name: "重试" }));
- expect(onRetry).toHaveBeenCalledWith("u1");
+ expect(on.onRetry).toHaveBeenCalledWith("u1");
});
});
-
-describe("queue send shortcut", () => {
- it.each([false, true])("preserves Escape propagation when abandoning an edit (composing=%s)", async (composing) => {
- const key = vi.fn();
- window.addEventListener("keydown", key);
- try {
- const { edit, onEdit } = draw(async () => BODY);
- await edit();
- if (composing) fireEvent.compositionStart(box()!);
- expect(fireEvent.keyDown(box()!, { key: "Escape" })).toBe(false);
- expect(key).toHaveBeenCalledTimes(1);
- expect(box()).toBeNull();
- expect(onEdit).not.toHaveBeenCalled();
- } finally { window.removeEventListener("keydown", key); }
- });
- it("keeps Enter for a newline and commits with Control+Enter", async () => {
- localStorage.setItem("rx-send-shortcut", "modifier_enter");
- const { edit, onEdit } = draw(async () => BODY);
- await edit();
- fireEvent.keyDown(box()!, { key: "Enter" });
- expect(onEdit).not.toHaveBeenCalled();
- expect(box()).not.toBeNull();
- fireEvent.keyDown(box()!, { key: "Enter", ctrlKey: true });
- expect(onEdit).toHaveBeenCalledWith("i1", BODY);
- expect(box()).toBeNull();
- });
- it("does not commit during or just after IME composition", async () => {
- const { edit, onEdit } = draw(async () => BODY);
- await edit();
- fireEvent.compositionStart(box()!);
- fireEvent.keyDown(box()!, { key: "Enter", isComposing: true });
- fireEvent.compositionEnd(box()!);
- fireEvent.keyDown(box()!, { key: "Enter" });
- expect(onEdit).not.toHaveBeenCalled();
- expect(box()).not.toBeNull();
- });
-});
-
-it.each(["enter", "modifier_enter"])("keeps touch Enter in the queue editor (%s)", async (mode) => {
- const media = window.matchMedia("(pointer: coarse)");
- vi.spyOn(window, "matchMedia").mockReturnValue({ ...media, matches: true });
- localStorage.setItem("rx-send-shortcut", mode);
- const { edit, onEdit } = draw(async () => BODY);
- await edit();
- fireEvent.keyDown(box()!, { key: "Enter", code: "Enter" });
- expect(onEdit).not.toHaveBeenCalled();
- expect(box()).not.toBeNull();
- if (mode === "enter") expect(box()?.hasAttribute("aria-keyshortcuts")).toBe(false);
- if (mode === "modifier_enter") fireEvent.keyDown(box()!, { key: "Enter", code: "Enter", ctrlKey: true });
- else fireEvent.blur(box()!);
- expect(onEdit).toHaveBeenCalledWith("i1", BODY);
-});
diff --git a/desktop/frontend-next/src/ui/queue.test.tsx b/desktop/frontend-next/src/ui/queue.test.tsx
index 5f9f161873..4abd838de6 100644
--- a/desktop/frontend-next/src/ui/queue.test.tsx
+++ b/desktop/frontend-next/src/ui/queue.test.tsx
@@ -26,18 +26,16 @@ const snapshot = (over: Partial = {}): QueueSnapshot => ({
...over,
});
-const paint = (q: QueueSnapshot, running: boolean) =>
+const paint = (q: QueueSnapshot, running: boolean, shown?: ReadonlySet) =>
renderToStaticMarkup(
""}
- onEdit={() => {}}
- onMove={() => {}}
+ shown={shown}
onSendNow={() => {}}
+ onToSteer={() => {}}
onCancel={() => {}}
onRetry={() => {}}
- onRefresh={() => {}}
onPause={() => {}}
/>,
);
@@ -45,28 +43,43 @@ const paint = (q: QueueSnapshot, running: boolean) =>
const draw = (q: QueueSnapshot) => paint(q, true);
const drawIdle = (q: QueueSnapshot) => paint(q, false);
-describe("compact queue presentation", () => {
- it("keeps action identities and order when edit and withdraw become icons", () => {
- const html = draw(snapshot({ items: [item({ state: "queued" }), item({ id: "i2", state: "queued" })] }));
- expect(html).toContain('title="编辑" aria-label="改"');
- expect(html).toContain('title="取回" aria-label="取回"');
+describe("queue strip", () => {
+ it("draws one pill row per entry with the design's three actions in order", () => {
+ const html = draw(snapshot({ items: [item({ state: "queued", intent: "followup" }), item({ id: "i2", state: "queued", intent: "followup" })] }));
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
- expect(actions).toEqual(["queue.pause", "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel",
- "queue.move", "queue.move", "queue.edit", "queue.send-now", "queue.cancel"]);
+ expect(actions).toEqual(["queue.to-steer", "queue.send-now", "queue.cancel", "queue.to-steer", "queue.send-now", "queue.cancel"]);
+ expect(html).toContain("改为插话");
+ expect(html).toContain("立即发送");
+ expect(html).toContain("撤回");
+ expect(html).not.toContain("暂停派发");
expect(html).not.toContain("draggable");
});
it("offers only send-now and withdraw on guidance the turn has already accepted", () => {
const html = draw(snapshot({ items: [item(), item({ id: "i2" })] }));
const actions = [...html.matchAll(/data-action="([^"]+)"/g)].map((m) => m[1]);
- expect(actions).toEqual(["queue.pause", "queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
+ expect(actions).toEqual(["queue.send-now", "queue.cancel", "queue.send-now", "queue.cancel"]);
});
- it("says paused once and preserves the resume control", () => {
+ it("numbers a follow-up by its place in the strip", () => {
+ const html = draw(snapshot({ items: [item({ state: "queued", intent: "followup" }), item({ id: "i2", state: "queued", intent: "followup" })] }));
+ expect(html).toContain("排队 1");
+ expect(html).toContain("排队 2");
+ });
+
+ it("offers no steer conversion on a line that carries a picture or frozen files", () => {
+ expect(draw(snapshot({ items: [item({ state: "queued", intent: "followup", images: true })] }))).not.toContain("queue.to-steer");
+ expect(draw(snapshot({ items: [item({ state: "queued", intent: "followup", refs: [{ path: "a.go" }] })] }))).not.toContain("queue.to-steer");
+ });
+
+ it("leaves out guidance the transcript already shows as its own bubble", () => {
+ expect(paint(snapshot(), true, new Set(["i1"]))).toBe("");
+ });
+
+ it("keeps the release control when the queue is held", () => {
const html = draw(snapshot({ paused: true }));
- expect(html).toContain("发送已暂停");
- expect(html).not.toContain('data-hold');
expect(html).toContain("继续派发");
+ expect(html).not.toContain("queue.send-now");
});
it("keeps long reasons in titles and limits inline reasons to held entries", () => {
@@ -152,19 +165,18 @@ describe("what the pending strip says back", () => {
});
it("names an accepted steer as taken, not as waiting", () => {
- const html = draw(snapshot());
- expect(html).toContain("插话已收");
- expect(html).toContain('data-tone="accent"');
+ expect(draw(snapshot())).toContain("插话已收");
});
- it("keeps a queued follow-up apart from a steer the kernel took", () => {
+ it("says a queued follow-up is queued, not accepted", () => {
const html = draw(snapshot({ items: [item({ intent: "followup", state: "queued" })] }));
- expect(html).toContain("尚未送达,可以改,也可以取回");
+ expect(html).toContain("排队 1");
expect(html).not.toContain("插话已收");
});
+
it("offers taking it back while that is still true", () => {
- expect(draw(snapshot())).toContain("取回");
+ expect(draw(snapshot())).toContain("撤回");
});
// Past the tool boundary the text is the model's, and the kernel refuses the
@@ -187,39 +199,12 @@ describe("what the pending strip says back", () => {
expect(drawIdle(snapshot())).not.toContain("立即发送");
});
- it("keeps technical capacity out of the ordinary one-line queue", () => {
- const html = draw(snapshot());
- expect(html).not.toContain("1/64");
- expect(html).not.toContain("3.1k/64M");
- expect(html).toContain("尚未送达,可以改,也可以取回");
- });
// Capacity becomes useful when it needs attention rather than occupying the
// primary layout for every one-line follow-up.
- it("reveals both capacity limits when either is under pressure", () => {
- const html = draw(snapshot({ capacity: { items: 49, maxItems: 64, bytes: 3174, maxBytes: 64 << 20 } }));
- expect(html).toContain("49/64");
- expect(html).toContain("3.1k/64M");
- });
- it("draws each capacity limit as a labelled meter with its own value and maximum", () => {
- const html = draw(snapshot({ capacity: { items: 64, maxItems: 64, bytes: 3174, maxBytes: 64 << 20 } }));
- expect(html).toMatch(/role="meter" aria-label="条目" aria-valuemin="0" aria-valuemax="64" aria-valuenow="64" data-full="true"/);
- expect(html).toMatch(/role="meter" aria-label="字节" aria-valuemin="0" aria-valuemax="67108864" aria-valuenow="3174"/);
- expect(html).not.toMatch(/aria-label="字节"[^>]*data-full/);
- });
- it("draws no item meter when the queue has no item limit, only the byte one under pressure", () => {
- const html = draw(snapshot({ capacity: { items: 300, maxItems: 0, bytes: 60 << 20, maxBytes: 64 << 20 } }));
- expect(html).not.toContain("300/0");
- expect(html).not.toMatch(/aria-label="条目" aria-valuemin/);
- expect(html).toMatch(/role="meter" aria-label="字节"/);
- });
- it("shows no capacity strip for a long unlimited queue with room in the bytes", () => {
- const html = draw(snapshot({ capacity: { items: 300, maxItems: 0, bytes: 3174, maxBytes: 64 << 20 } }));
- expect(html).not.toContain('class="qcap"');
- });
it("renders nothing when there is nothing waiting and the queue is not held", () => {
expect(draw(snapshot({ items: [], capacity: { items: 0, maxItems: 64, bytes: 0, maxBytes: 64 << 20 } }))).toBe("");
diff --git a/desktop/frontend-next/src/ui/queueactions.ts b/desktop/frontend-next/src/ui/queueactions.ts
index ab40ab34f7..377e46ef96 100644
--- a/desktop/frontend-next/src/ui/queueactions.ts
+++ b/desktop/frontend-next/src/ui/queueactions.ts
@@ -13,8 +13,8 @@ interface Inputs {
sessionPath?: string;
}
-/** The outbox as the kernel holds it, and the eight things that can be done to
- * a line still in it. Nothing here patches the snapshot: every edit is asked
+/** The outbox as the kernel holds it, and what can be done to a line still in
+ * it: send it now, make it guidance, take it back, retry it. Nothing here patches the snapshot: every edit is asked
* of the kernel and the answer is read back whole, which is also what puts
* another window's lines in front of this one. */
export function useQueueActions({ port, dispatch, fail, moved, sessionState = "settled", sessionPath }: Inputs) {
@@ -40,12 +40,31 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionState = "s
}, [port, moved, sessionState, sessionPath]);
const taking = useRef(new Set());
- const onQueueEdit = useCallback((id: string, text: string) => void port.editQueued(id, text).catch(fail), [port, fail]);
- const onQueueMove = useCallback((id: string, to: number) => void port.moveQueued(id, to).catch(fail), [port, fail]);
const onQueueRetry = useCallback((id: string) => void port.retryQueued(id).catch(fail), [port, fail]);
- 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]);
+ // A follow-up becomes guidance by being taken back and said again as a steer;
+ // the steer the kernel cannot admit is converted to a follow-up, so the line
+ // is never lost. The body is read before the entry is given up.
+ const onQueueToSteer = useCallback(
+ async (item: QueueItem) => {
+ if (taking.current.has(item.id)) return;
+ taking.current.add(item.id);
+ try {
+ const text = await port.readQueued(item.id);
+ await port.cancelQueued(item.id);
+ dispatch({ kind: "__unsent", id: item.id } as never);
+ const id = localId();
+ dispatch({ kind: "__user", text, pending: true, id } as never);
+ const said = await port.steer(text);
+ if (said.itemId) dispatch({ kind: "__queued", id, itemId: said.itemId, queued: said.paused || said.disposition === "queued_followup" ? "followup" : "steer", paused: said.paused } as never);
+ } catch (e) {
+ fail(e);
+ } finally {
+ taking.current.delete(item.id);
+ }
+ },
+ [port, fail, dispatch],
+ );
// 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(
@@ -90,5 +109,5 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionState = "s
},
[port, fail, dispatch, onRestoreText],
);
- return { queue, restored, onRestoreText, onQueueEdit, onQueueMove, onQueueRetry, onQueueRefresh, onQueuePause, onQueueRead, onQueueSendNow, onQueueCancel };
+ return { queue, restored, onRestoreText, onQueueRetry, onQueuePause, onQueueToSteer, onQueueSendNow, onQueueCancel };
}
diff --git a/desktop/frontend-next/src/ui/steer-wait.interaction.test.tsx b/desktop/frontend-next/src/ui/steer-wait.interaction.test.tsx
new file mode 100644
index 0000000000..5a3beed393
--- /dev/null
+++ b/desktop/frontend-next/src/ui/steer-wait.interaction.test.tsx
@@ -0,0 +1,44 @@
+// @vitest-environment jsdom
+import "./testkit";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Item } from "../state/session";
+import { UserCard } from "./cards/UserCard";
+
+afterEach(() => { cleanup(); vi.restoreAllMocks(); });
+
+const row = (over: Partial> = {}) =>
+ ({ t: "user", id: "row", text: "测试也要覆盖带 BOM 的文件", pending: true, queued: "steer", itemId: "i1", ...over }) as Extract- ;
+
+describe("guidance said into a running turn, not read yet", () => {
+ it("stands at the end of the conversation as a dashed bubble that says it is waiting", () => {
+ render( {}} />);
+ expect(screen.getByText("插话 · 等待插入")).toBeTruthy();
+ expect(screen.getByText("测试也要覆盖带 BOM 的文件").className).toContain("sw-txt");
+ expect(document.querySelector(".steerwait")).toBeTruthy();
+ });
+
+ it("withdraws the entry the kernel named", async () => {
+ const onWithdraw = vi.fn();
+ render();
+ await userEvent.click(screen.getByRole("button", { name: "撤回" }));
+ expect(onWithdraw).toHaveBeenCalledWith("i1");
+ });
+
+ it("turns into an ordinary guidance row once the turn has read it", () => {
+ render( {}} />);
+ expect(document.querySelector(".steerwait")).toBeNull();
+ expect(screen.getByText("插话")).toBeTruthy();
+ });
+
+ it("keeps a queued follow-up on the ordinary pending row", () => {
+ render( {}} />);
+ expect(document.querySelector(".steerwait")).toBeNull();
+ });
+
+ it("draws the ordinary row when nothing can withdraw it", () => {
+ render();
+ expect(document.querySelector(".steerwait")).toBeNull();
+ });
+});
diff --git a/desktop/frontend-next/src/ui/touch-enter.interaction.test.tsx b/desktop/frontend-next/src/ui/touch-enter.interaction.test.tsx
index a77a867ba1..c7613f5710 100644
--- a/desktop/frontend-next/src/ui/touch-enter.interaction.test.tsx
+++ b/desktop/frontend-next/src/ui/touch-enter.interaction.test.tsx
@@ -4,9 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Item } from "../state/session";
-import type { Checkpoint, Queue as QueueSnapshot } from "../port/port";
+import type { Checkpoint } from "../port/port";
import { UserCard } from "./cards/UserCard";
-import { Queue } from "./Queue";
afterEach(() => {
cleanup();
@@ -36,34 +35,6 @@ async function openRewrite() {
return { resend, area: screen.getByRole("textbox") as HTMLTextAreaElement };
}
-const queue: QueueSnapshot = {
- revision: 1,
- paused: false,
- items: [{ id: "i1", intent: "followup", state: "queued", preview: "first", createdAt: "2026-08-25T10:00:00Z" }],
- capacity: { items: 1, maxItems: 64, bytes: 5, maxBytes: 64 << 20 },
-};
-
-async function openQueueEditor() {
- const onEdit = vi.fn();
- render(
- "first"}
- onEdit={onEdit}
- onMove={() => {}}
- onSendNow={() => {}}
- onCancel={() => {}}
- onRetry={() => {}}
- onRefresh={() => {}}
- onPause={() => {}}
- />,
- );
- await userEvent.click(screen.getByRole("button", { name: "改" }));
- const area = (await screen.findByRole("textbox")) as HTMLTextAreaElement;
- return { onEdit, area };
-}
-
describe("rewrite box", () => {
it("Enter resends with a hardware keyboard", async () => {
pointer(false);
@@ -128,50 +99,3 @@ describe("rewrite box", () => {
expect(resend).not.toHaveBeenCalled();
});
});
-
-describe("queue editor", () => {
- it("Enter commits with a hardware keyboard", async () => {
- pointer(false);
- const { onEdit, area } = await openQueueEditor();
- await userEvent.type(area, " more{Enter}");
- expect(onEdit).toHaveBeenCalledWith("i1", "first more");
- });
-
- it("Shift+Enter is a newline with a hardware keyboard", async () => {
- pointer(false);
- const { onEdit, area } = await openQueueEditor();
- await userEvent.type(area, "{Shift>}{Enter}{/Shift}more");
- expect(onEdit).not.toHaveBeenCalled();
- expect(area.value).toBe("first\nmore");
- });
-
- it("Enter is a newline on a touch device", async () => {
- pointer(true);
- const { onEdit, area } = await openQueueEditor();
- await userEvent.type(area, "{Enter}more");
- expect(onEdit).not.toHaveBeenCalled();
- expect(area.value).toBe("first\nmore");
- });
-
- it("leaving the box still commits the multi-line draft on a touch device", async () => {
- pointer(true);
- const { onEdit, area } = await openQueueEditor();
- await userEvent.type(area, "{Enter}more");
- fireEvent.blur(area);
- expect(onEdit).toHaveBeenCalledWith("i1", "first\nmore");
- });
-
- it.each([false, true])("Enter during IME composition never commits (touch=%s)", async (coarse) => {
- pointer(coarse);
- const { onEdit, area } = await openQueueEditor();
- fireEvent.keyDown(area, { key: "Enter", isComposing: true });
- expect(onEdit).not.toHaveBeenCalled();
- });
-
- it.each([false, true])("Escape closes the editor (touch=%s)", async (coarse) => {
- pointer(coarse);
- const { area } = await openQueueEditor();
- await userEvent.type(area, "{Escape}");
- expect(screen.queryByRole("textbox")).toBeNull();
- });
-});