mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -06:00
fix(ui): preserve steer order and simplify composer ownership (#124915)
* refactor(ui): simplify composer steer ownership * fix(ui): preserve target order for landed steers
This commit is contained in:
committed by
GitHub
parent
6e026c2fe3
commit
3bf3acbee5
@@ -1,7 +1,4 @@
|
||||
// Control UI E2E tests cover the redesigned chat composer.
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright-core";
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
@@ -10,25 +7,6 @@ const suite = createControlUiE2eSuite({
|
||||
name: "Control UI chat composer redesign",
|
||||
});
|
||||
|
||||
const captureUiProofEnabled = process.env.OPENCLAW_CAPTURE_UI_PROOF === "1";
|
||||
const uiProofArtifactDir = path.join(
|
||||
process.cwd(),
|
||||
".artifacts",
|
||||
"control-ui-e2e",
|
||||
"chat-composer-redesign",
|
||||
);
|
||||
|
||||
async function captureUiProof(page: Page, fileName: string) {
|
||||
if (!captureUiProofEnabled) {
|
||||
return;
|
||||
}
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(uiProofArtifactDir, fileName),
|
||||
});
|
||||
}
|
||||
|
||||
// Browser contexts preserve test isolation; keep one process warm for this file.
|
||||
suite.define(() => {
|
||||
it("keeps mobile picker panels above an attachment-expanded composer", async () => {
|
||||
@@ -203,7 +181,6 @@ suite.define(() => {
|
||||
const emptySend = page.getByRole("button", { name: "Write a message to send." });
|
||||
await expect.poll(() => emptySend.isVisible()).toBe(true);
|
||||
await expect.poll(() => emptySend.isDisabled()).toBe(true);
|
||||
await captureUiProof(page, "01-empty-idle-disabled-send.png");
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start video talk" }).count())
|
||||
.toBe(0);
|
||||
@@ -433,7 +410,6 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => page.getByRole("button", { name: "Start voice input" }).isVisible())
|
||||
.toBe(true);
|
||||
await captureUiProof(page, "02-draft-single-send.png");
|
||||
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
const sendRequest = await gateway.waitForRequest("chat.send");
|
||||
@@ -496,12 +472,10 @@ suite.define(() => {
|
||||
});
|
||||
await expect.poll(() => followUp.isVisible()).toBe(true);
|
||||
await expect.poll(() => page.locator(".chat-send-btn--stop").count()).toBe(0);
|
||||
await captureUiProof(page, "03-active-run-draft-single-follow-up.png");
|
||||
|
||||
await textarea.fill("");
|
||||
const stop = page.getByRole("button", { name: "Stop generating" });
|
||||
await expect.poll(() => stop.isVisible()).toBe(true);
|
||||
await captureUiProof(page, "04-active-run-empty-stop.png");
|
||||
await textarea.press("Escape");
|
||||
const abortRequest = await gateway.waitForRequest("chat.abort");
|
||||
expect(abortRequest.params).toMatchObject({
|
||||
@@ -627,7 +601,6 @@ suite.define(() => {
|
||||
await expect
|
||||
.poll(() => microphonePickerShell.evaluate((node) => getComputedStyle(node).opacity))
|
||||
.toBe("1");
|
||||
await captureUiProof(page, "05-mic-hover-reveals-picker.png");
|
||||
await microphonePicker.click();
|
||||
await expect.poll(() => microphonePicker.getAttribute("aria-expanded")).toBe("true");
|
||||
await expect.poll(() => page.locator(".chat-talk-input-picker[open]").count()).toBe(1);
|
||||
|
||||
@@ -45,14 +45,10 @@ async function expectChatBubbleAbove(page: Page, upperText: string, lowerText: s
|
||||
|
||||
suite.define(() => {
|
||||
it("steers ordinary follow-ups when the server default is steer", async () => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
...(artifactDir
|
||||
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const runtimeConfig = {
|
||||
@@ -127,12 +123,6 @@ suite.define(() => {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await queue.getByText(followUp).waitFor({ timeout: 10_000 });
|
||||
if (artifactDir) {
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/steer-before-persistence.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await gateway.emitGatewayEvent("session.message", {
|
||||
activeRunIds: [activeRunId],
|
||||
clientRunId: activeRunId,
|
||||
@@ -165,19 +155,12 @@ suite.define(() => {
|
||||
.poll(() => page.locator(".chat-thread .chat-group.user", { hasText: followUp }).count())
|
||||
.toBe(1);
|
||||
await expectChatBubbleAbove(page, originalPrompt, followUp);
|
||||
if (artifactDir) {
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/steer-after-persistence.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a non-steer server default for active-run follow-ups", async () => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
@@ -206,12 +189,6 @@ suite.define(() => {
|
||||
await followUpSelect.waitFor({ state: "visible", timeout: 10_000 });
|
||||
expect(await followUpSelect.inputValue()).toBe("server");
|
||||
await page.getByText("Using server default (followup)").waitFor({ timeout: 10_000 });
|
||||
if (artifactDir) {
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/server-followup-setting.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
const configPatchCount = (await gateway.getRequests("config.patch")).length;
|
||||
const configGetCount = (await gateway.getRequests("config.get")).length;
|
||||
const overrideConfig = {
|
||||
@@ -238,12 +215,6 @@ suite.define(() => {
|
||||
runtimeConfig,
|
||||
valid: true,
|
||||
});
|
||||
if (artifactDir) {
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/server-followup-override.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.getByRole("button", { name: "Reset to server default" }).click();
|
||||
await waitForRequests(gateway, "config.patch", configPatchCount + 2);
|
||||
await waitForRequests(gateway, "config.get", configGetCount + 2);
|
||||
@@ -276,14 +247,10 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
it("steers a queued follow-up with modified Enter in Enter shortcut mode", async () => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
...(artifactDir
|
||||
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
@@ -303,28 +270,6 @@ suite.define(() => {
|
||||
|
||||
const steerText = "steer this keyboard follow-up now";
|
||||
await composer.fill(steerText);
|
||||
const enterQueueButton = page.getByRole("button", { name: "Queue message" });
|
||||
const enterTooltip = await enterQueueButton
|
||||
.locator("..")
|
||||
.evaluate((element) => (element as HTMLElement & { content?: string }).content);
|
||||
expect(enterTooltip).toBe("Queue ⏎ · Steer ⌘/Ctrl+Enter");
|
||||
if (artifactDir) {
|
||||
await enterQueueButton.hover();
|
||||
await expect
|
||||
.poll(() =>
|
||||
enterQueueButton.evaluate((button) => {
|
||||
const tooltip = button
|
||||
.closest("openclaw-tooltip")
|
||||
?.shadowRoot?.querySelector("wa-tooltip");
|
||||
return (tooltip as (HTMLElement & { open?: boolean }) | null)?.open === true;
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.screenshot({
|
||||
path: `${artifactDir}/queue-steer-shortcut.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await composer.press("Control+Enter");
|
||||
|
||||
const firstRunSends = await waitForRequests(gateway, "chat.send", 2);
|
||||
@@ -338,17 +283,6 @@ suite.define(() => {
|
||||
});
|
||||
const steeredRow = page.locator(".chat-queue__item--steered", { hasText: steerText });
|
||||
await steeredRow.waitFor({ timeout: 10_000 });
|
||||
await gateway.emitGatewayEvent("chat", {
|
||||
runId: requireString(steerParams.idempotencyKey, "steer send id"),
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
});
|
||||
await steeredRow.waitFor({ state: "detached", timeout: 10_000 });
|
||||
|
||||
await gateway.emitChatFinal({ runId: firstRunId, text: "First shortcut run finished." });
|
||||
await page
|
||||
.getByRole("button", { name: "Stop generating" })
|
||||
.waitFor({ state: "detached", timeout: 10_000 });
|
||||
} finally {
|
||||
await suite.closeBrowserContext(context);
|
||||
}
|
||||
@@ -377,11 +311,6 @@ suite.define(() => {
|
||||
|
||||
const queuedText = "leave this modifier follow-up queued";
|
||||
await composer.fill(queuedText);
|
||||
const queueButton = page.getByRole("button", { name: "Queue message" });
|
||||
const tooltip = await queueButton
|
||||
.locator("..")
|
||||
.evaluate((element) => (element as HTMLElement & { content?: string }).content);
|
||||
expect(tooltip).toBe("Queue");
|
||||
await composer.press("Control+Enter");
|
||||
|
||||
const queuedRow = page.locator(".chat-queue__item", { hasText: queuedText });
|
||||
@@ -459,14 +388,10 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
it("dismisses an informational steer notice when the steer request lands", async () => {
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
const context = await suite.newBrowserContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
...(artifactDir
|
||||
? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } }
|
||||
: {}),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const gateway = await installMockGateway(page);
|
||||
@@ -548,10 +473,6 @@ suite.define(() => {
|
||||
infoSubtle,
|
||||
};
|
||||
});
|
||||
if (artifactDir) {
|
||||
await page.screenshot({ path: `${artifactDir}/steer-pending.png`, fullPage: true });
|
||||
}
|
||||
|
||||
await gateway.emitGatewayEvent("chat", {
|
||||
runId: steerRunId,
|
||||
sessionKey: "main",
|
||||
@@ -560,9 +481,6 @@ suite.define(() => {
|
||||
await row.waitFor({ state: "detached", timeout: 10_000 });
|
||||
await page.getByText(steerText, { exact: true }).waitFor({ timeout: 10_000 });
|
||||
await expectChatBubbleAbove(page, "keep this run active", steerText);
|
||||
if (artifactDir) {
|
||||
await page.screenshot({ path: `${artifactDir}/steer-landed.png`, fullPage: true });
|
||||
}
|
||||
|
||||
expect(pendingPresentation).toMatchObject({
|
||||
badgeColor: pendingPresentation.infoColor,
|
||||
|
||||
@@ -254,24 +254,52 @@ describe("renderChatComposer controls", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Meta", { metaKey: true }],
|
||||
["Control", { ctrlKey: true }],
|
||||
] as const)("uses %s+Enter to steer an active queued follow-up", (_name, modifiers) => {
|
||||
const onSend = vi.fn();
|
||||
const { container } = renderComposer({
|
||||
canAbort: true,
|
||||
draft: "Steer this now",
|
||||
followUpMode: "queue",
|
||||
onAbort: vi.fn(),
|
||||
onSend,
|
||||
sendShortcut: "enter",
|
||||
});
|
||||
["Meta+Enter with a rendered draft", { metaKey: true }, "Steer this now", undefined, undefined],
|
||||
[
|
||||
"Control+Enter with a rendered draft",
|
||||
{ ctrlKey: true },
|
||||
"Steer this now",
|
||||
undefined,
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"Control+Enter with attachment-only content",
|
||||
{ ctrlKey: true },
|
||||
"",
|
||||
() => [{ id: "image-1", mimeType: "image/png", fileName: "proof.png" }],
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
"Control+Enter with live textarea content before the draft prop rerenders",
|
||||
{ ctrlKey: true },
|
||||
"",
|
||||
undefined,
|
||||
"Steer the live textarea value",
|
||||
],
|
||||
] as const)(
|
||||
"uses %s to steer an active queued follow-up",
|
||||
(_name, modifiers, draft, getAttachments, liveDraft) => {
|
||||
const onSend = vi.fn();
|
||||
const { container } = renderComposer({
|
||||
canAbort: true,
|
||||
draft,
|
||||
followUpMode: "queue",
|
||||
getAttachments,
|
||||
onAbort: vi.fn(),
|
||||
onSend,
|
||||
sendShortcut: "enter",
|
||||
});
|
||||
const textarea = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (textarea && liveDraft !== undefined) {
|
||||
textarea.value = liveDraft;
|
||||
}
|
||||
|
||||
pressComposerEnter(container, modifiers);
|
||||
pressComposerEnter(container, modifiers);
|
||||
|
||||
expect(onSend).toHaveBeenCalledOnce();
|
||||
expect(onSend).toHaveBeenCalledWith({ followUpMode: "steer" });
|
||||
});
|
||||
expect(onSend).toHaveBeenCalledOnce();
|
||||
expect(onSend).toHaveBeenCalledWith("steer");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["modifier-enter", true, "queue", false],
|
||||
@@ -374,6 +402,17 @@ describe("renderChatComposer controls", () => {
|
||||
},
|
||||
tooltip: t("chat.queue.steer"),
|
||||
},
|
||||
{
|
||||
overrides: {
|
||||
canAbort: true,
|
||||
connected: false,
|
||||
draft: "Queue until the gateway reconnects",
|
||||
followUpMode: "queue" as const,
|
||||
onAbort: vi.fn(),
|
||||
sendShortcut: "enter" as const,
|
||||
},
|
||||
tooltip: t("chat.runControls.queue"),
|
||||
},
|
||||
];
|
||||
for (const testCase of unavailable) {
|
||||
const view = renderComposer(testCase.overrides);
|
||||
|
||||
@@ -1,46 +1,16 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../../i18n/index.ts";
|
||||
import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts";
|
||||
import { renderComposerFixture, resetComposerFixture } from "./chat-composer.test-support.ts";
|
||||
|
||||
type ComposerProps = Parameters<typeof renderChatComposer>[0];
|
||||
type ComposerOverrides = Parameters<typeof renderComposerFixture>[0];
|
||||
|
||||
function renderComposer(overrides: Partial<ComposerProps> = {}) {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatComposer({
|
||||
paneId: crypto.randomUUID(),
|
||||
sessionKey: "main",
|
||||
currentAgentId: "main",
|
||||
connected: true,
|
||||
canSend: true,
|
||||
disabledReason: null,
|
||||
sending: false,
|
||||
messages: [],
|
||||
stream: null,
|
||||
queue: [],
|
||||
draft: "",
|
||||
sessions: null,
|
||||
assistantName: "OpenClaw",
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onQueueRemove: vi.fn(),
|
||||
onNewSession: vi.fn(),
|
||||
...overrides,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return container;
|
||||
function renderComposer(overrides: ComposerOverrides = {}) {
|
||||
return renderComposerFixture(overrides).container;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
resetChatComposerState();
|
||||
document.body.replaceChildren();
|
||||
vi.useRealTimers();
|
||||
await i18n.setLocale("en");
|
||||
vi.restoreAllMocks();
|
||||
await resetComposerFixture();
|
||||
});
|
||||
|
||||
describe("renderChatComposer context usage", () => {
|
||||
|
||||
@@ -1,49 +1,17 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts";
|
||||
import { renderComposerFixture, resetComposerFixture } from "./chat-composer.test-support.ts";
|
||||
|
||||
type ComposerProps = Parameters<typeof renderChatComposer>[0];
|
||||
|
||||
function renderComposer(overrides: Partial<ComposerProps>) {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
renderChatComposer({
|
||||
paneId: crypto.randomUUID(),
|
||||
sessionKey: "main",
|
||||
currentAgentId: "main",
|
||||
connected: true,
|
||||
canSend: true,
|
||||
disabledReason: null,
|
||||
sending: false,
|
||||
messages: [],
|
||||
stream: null,
|
||||
queue: [],
|
||||
draft: "",
|
||||
sessions: null,
|
||||
assistantName: "OpenClaw",
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onQueueRemove: vi.fn(),
|
||||
onNewSession: vi.fn(),
|
||||
...overrides,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetChatComposerState();
|
||||
document.body.replaceChildren();
|
||||
afterEach(async () => {
|
||||
await resetComposerFixture();
|
||||
});
|
||||
|
||||
describe("archived session composer banner", () => {
|
||||
it("disables its action with the mutation reason", () => {
|
||||
const onAction = vi.fn();
|
||||
const reason = "Operator write access is required.";
|
||||
const container = renderComposer({
|
||||
const { container } = renderComposerFixture({
|
||||
canSend: false,
|
||||
disabledBanner: {
|
||||
kind: "composer-replacement",
|
||||
@@ -64,7 +32,7 @@ describe("archived session composer banner", () => {
|
||||
});
|
||||
|
||||
it("renders a standard primary action with progress feedback", () => {
|
||||
const container = renderComposer({
|
||||
const { container } = renderComposerFixture({
|
||||
canSend: false,
|
||||
disabledBanner: {
|
||||
kind: "composer-replacement",
|
||||
|
||||
@@ -3,37 +3,15 @@
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n, t } from "../../i18n/index.ts";
|
||||
import { createComposerProps } from "./chat-composer.test-support.ts";
|
||||
import { renderChatComposer, resetChatComposerState } from "./components/chat-composer.ts";
|
||||
|
||||
type ComposerProps = Parameters<typeof renderChatComposer>[0];
|
||||
|
||||
function props(overrides: Partial<ComposerProps> = {}): ComposerProps {
|
||||
return {
|
||||
paneId: crypto.randomUUID(),
|
||||
sessionKey: "main",
|
||||
currentAgentId: "main",
|
||||
connected: true,
|
||||
canSend: true,
|
||||
disabledReason: null,
|
||||
sending: false,
|
||||
messages: [],
|
||||
stream: null,
|
||||
queue: [],
|
||||
draft: "",
|
||||
sessions: null,
|
||||
assistantName: "OpenClaw",
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onQueueRemove: vi.fn(),
|
||||
onNewSession: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderComposer(overrides: Partial<ComposerProps> = {}): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(renderChatComposer(props(overrides)), container);
|
||||
render(renderChatComposer(createComposerProps(overrides)), container);
|
||||
return container;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ export function createComposerProps(overrides: Partial<ComposerProps> = {}): Com
|
||||
onDraftChange: vi.fn(),
|
||||
onSend: vi.fn(),
|
||||
onQueueRemove: vi.fn(),
|
||||
onNewSession: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,55 +921,6 @@ describe("handleChatGatewayEvent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retires a landed steer chip when its request run finishes inside the active run", () => {
|
||||
const activePrompt = {
|
||||
id: "active-prompt",
|
||||
text: "Keep this run active",
|
||||
createdAt: 1,
|
||||
sendRunId: "active-run",
|
||||
sendState: "waiting-model" as const,
|
||||
sessionKey: "main",
|
||||
};
|
||||
const state = createState({
|
||||
sessionKey: "main",
|
||||
chatRunId: "active-run",
|
||||
chatQueue: [
|
||||
activePrompt,
|
||||
{
|
||||
id: "landed-steer-chip",
|
||||
text: "Use the deployment plan",
|
||||
createdAt: 3,
|
||||
kind: "steered",
|
||||
pendingRunId: "steer-request-run",
|
||||
sendRunId: "steer-request-run",
|
||||
steerTargetRunId: "active-run",
|
||||
sessionKey: "main",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
handleChatGatewayEvent(state, {
|
||||
runId: "steer-request-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
}),
|
||||
).toBe("final");
|
||||
|
||||
expect(state.chatQueue).toEqual([activePrompt]);
|
||||
expect(state.chatRunId).toBe("active-run");
|
||||
expect(state.chatMessages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
__openclaw: { idempotencyKey: "active-run:user" },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
__openclaw: { idempotencyKey: "steer-request-run:user" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a pending steer chip when an unrelated request run finishes", () => {
|
||||
const chip = {
|
||||
id: "pending-steer-chip",
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
} from "./chat-pane-state.ts";
|
||||
import { dismissRealtimeTalkError } from "./chat-realtime.ts";
|
||||
import { activeChatRunStartupStatus } from "./chat-run-startup.ts";
|
||||
import type { ChatSendOptions } from "./chat-send-contract.ts";
|
||||
import { refreshChatCommands, refreshPageChat } from "./chat-state-refresh.ts";
|
||||
import {
|
||||
resolveChatAgentId,
|
||||
@@ -524,12 +523,15 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
||||
state.requestUpdate?.();
|
||||
},
|
||||
onRemoveAttachment: this.removeBrowserAnnotation,
|
||||
onSend: (options?: ChatSendOptions) =>
|
||||
onSend: (followUpModeOverride) =>
|
||||
catalogKey
|
||||
? void this.continueCatalogSession(catalogKey)
|
||||
: suggestionViewer
|
||||
? void this.addCurrentSessionSuggestion()
|
||||
: void state.handleSendChat(undefined, options),
|
||||
: void state.handleSendChat(
|
||||
undefined,
|
||||
followUpModeOverride ? { followUpMode: followUpModeOverride } : undefined,
|
||||
),
|
||||
onCompact: sessionActionCallbacks.onCompact,
|
||||
// Checkpoint deep-link carries the archived filter so the row stays findable.
|
||||
onOpenSessionCheckpoints: () => {
|
||||
@@ -581,7 +583,6 @@ export class ChatPane extends ChatPaneBrowserAnnotationRender {
|
||||
replyMessageAccess: catalogKey ? undefined : this.currentReplyMessageAccess(state.sessionKey),
|
||||
onRewindMessage: sessionActionCallbacks.onRewindMessage,
|
||||
onForkMessage: sessionActionCallbacks.onForkMessage,
|
||||
onNewSession: () => void this.createSession(),
|
||||
onClearHistory: sessionActionCallbacks.onClearHistory,
|
||||
agentsList: state.agentsList,
|
||||
currentAgentId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Control UI page module owns Chat queue storage and queue item cleanup.
|
||||
import { compareChatQueueOrder, isMovableChatQueueItem } from "../../lib/chat/chat-queue-order.ts";
|
||||
import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts";
|
||||
import type { SenderIdentity } from "../../lib/chat/sender-label.ts";
|
||||
import { scopedAgentIdForSession, type SessionScopeHost } from "../../lib/sessions/index.ts";
|
||||
@@ -31,6 +32,18 @@ type ChatQueueStoreHost = {
|
||||
type ChatQueueSessionHost = ChatQueueStoreHost & ChatComposerScope & { sessionKey: string };
|
||||
export type ChatQueueScopedSessionHost = ChatQueueSessionHost & SessionScopeHost;
|
||||
|
||||
export function isSteerableQueuedMessage(item: ChatQueueItem): boolean {
|
||||
return (
|
||||
isMovableChatQueueItem(item) &&
|
||||
(item.sendState === undefined || item.sendState === "waiting-idle") &&
|
||||
!item.localCommandName
|
||||
);
|
||||
}
|
||||
|
||||
export function steerableQueuedMessage(queue: readonly ChatQueueItem[]): ChatQueueItem | undefined {
|
||||
return queue.toSorted(compareChatQueueOrder).find(isSteerableQueuedMessage);
|
||||
}
|
||||
|
||||
function isProcessLiveQueueProjection(item: ChatQueueItem): boolean {
|
||||
return item.sendState === "sending" || item.sendState === "executing-command";
|
||||
}
|
||||
|
||||
@@ -17,10 +17,6 @@ type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
|
||||
agents?: AgentsListResult["agents"];
|
||||
};
|
||||
|
||||
export type ChatSendOptions = {
|
||||
followUpMode?: ControlUiFollowUpMode;
|
||||
};
|
||||
|
||||
export type ChatHost = ChatInputHistoryState &
|
||||
ChatScrollHost &
|
||||
ToolStreamHost &
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ChatAttachment, ChatQueueSkillWorkshopRevision } from "../../lib/c
|
||||
import { parseSlashCommand } from "../../lib/chat/commands.ts";
|
||||
import { extractCompanionCommandQuestion } from "../../lib/chat/companion-question.ts";
|
||||
import { resolveCurrentUserIdentity } from "../../lib/chat/current-user-identity.ts";
|
||||
import type { ControlUiFollowUpMode } from "../../lib/chat/follow-up-mode.ts";
|
||||
import { scopedAgentIdForSession, visibleSessionMatches } from "../../lib/sessions/index.ts";
|
||||
import {
|
||||
getChatAttachmentDataUrl,
|
||||
@@ -36,7 +37,7 @@ import {
|
||||
submittedCommandScopeIsVisible,
|
||||
type ChatCommandComposerRecovery,
|
||||
} from "./chat-send-composer.ts";
|
||||
import type { ChatHost, ChatSendOptions } from "./chat-send-contract.ts";
|
||||
import type { ChatHost } from "./chat-send-contract.ts";
|
||||
import { chatOutboxDrainDependencies, deliverChatQueueItem } from "./chat-send-delivery.ts";
|
||||
import {
|
||||
canSendVolatileQueueItem,
|
||||
@@ -69,7 +70,8 @@ import {
|
||||
sendQueuedChatMessageWithQueueMode as sendQueuedChatMessageWithQueueModeLifecycle,
|
||||
} from "./steer-lifecycle.ts";
|
||||
|
||||
type ChatSendSubmitOptions = ChatSendOptions & {
|
||||
type ChatSendSubmitOptions = {
|
||||
followUpMode?: ControlUiFollowUpMode;
|
||||
restoreDraft?: boolean;
|
||||
skillWorkshopRevision?: ChatQueueSkillWorkshopRevision;
|
||||
/** Lets request-scoped UI actions recover from rejected local commands. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readSessionMessageIdentity } from "@openclaw/gateway-client/browser";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
@@ -208,6 +209,48 @@ describe("canonical session message recovery", () => {
|
||||
expect(state.chatStream).toBe("Current partial reply");
|
||||
});
|
||||
|
||||
it("orders an active queued turn before its landed steer", () => {
|
||||
const activePrompt = {
|
||||
id: "active-prompt",
|
||||
text: "Keep this run active",
|
||||
createdAt: 1,
|
||||
sendRunId: "active-run",
|
||||
sendState: "waiting-model" as const,
|
||||
sessionKey: "main",
|
||||
};
|
||||
const { state } = createSessionEventState({
|
||||
chatRunId: "active-run",
|
||||
chatQueue: [
|
||||
activePrompt,
|
||||
{
|
||||
id: "landed-steer-chip",
|
||||
text: "Use the deployment plan",
|
||||
createdAt: 2,
|
||||
kind: "steered",
|
||||
pendingRunId: "steer-request-run",
|
||||
sendRunId: "steer-request-run",
|
||||
steerTargetRunId: "active-run",
|
||||
sessionKey: "main",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
handlePageGatewayEvent(state, {
|
||||
type: "event",
|
||||
event: "chat",
|
||||
payload: {
|
||||
runId: "steer-request-run",
|
||||
sessionKey: state.sessionKey,
|
||||
state: "final",
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.chatQueue).toEqual([activePrompt]);
|
||||
expect(
|
||||
state.chatMessages.map((message) => readSessionMessageIdentity(message)?.idempotencyKey),
|
||||
).toEqual(["active-run:user", "steer-request-run:user"]);
|
||||
});
|
||||
|
||||
it("renders distinct live peers immediately and coalesces their stale history", async () => {
|
||||
let resolveHistory!: (result: {
|
||||
messages: unknown[];
|
||||
|
||||
@@ -710,7 +710,6 @@ function createChatProps(overrides: Partial<ChatProps> = {}): ChatProps {
|
||||
onAbort: () => undefined,
|
||||
onQueueRemove: () => undefined,
|
||||
onQueueSteer: () => undefined,
|
||||
onNewSession: () => undefined,
|
||||
onClearHistory: () => undefined,
|
||||
onOpenSessionCheckpoints: () => undefined,
|
||||
agentsList: null,
|
||||
|
||||
@@ -33,13 +33,13 @@ import type { ProviderUsageDisplayProps } from "../../lib/provider-quota-summary
|
||||
import type { SessionToolOverrides } from "../../lib/sessions/patch.ts";
|
||||
import type { UiSessionDefaultsHost } from "../../lib/sessions/session-key.ts";
|
||||
import type { ChatRunStartupStatus } from "./chat-run-startup.ts";
|
||||
import type { ChatSendOptions } from "./chat-send-contract.ts";
|
||||
import { type ChatCloudStartupNoticeProps, renderChatViewNotices } from "./chat-view-notices.ts";
|
||||
import { createChatAttachmentDropHandlers } from "./components/chat-attachments.ts";
|
||||
import type { BackgroundTasksProps } from "./components/chat-background-tasks.types.ts";
|
||||
import type {
|
||||
CapabilityMenuProps,
|
||||
ChatComposerDisabledBanner,
|
||||
ChatComposerProps,
|
||||
ChatQueuedEditProps,
|
||||
} from "./components/chat-composer-types.ts";
|
||||
import { isChatRunWorking, renderChatComposer } from "./components/chat-composer.ts";
|
||||
@@ -197,7 +197,7 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
|
||||
onRequestUpdate?: () => void;
|
||||
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
|
||||
onSlashIntent?: () => void | Promise<void>;
|
||||
onSend: (options?: ChatSendOptions) => void;
|
||||
onSend: ChatComposerProps["onSend"];
|
||||
onCompact?: () => void | Promise<void>;
|
||||
onOpenSessionCheckpoints?: () => void | Promise<void>;
|
||||
onToggleRealtimeTalk?: () => void;
|
||||
@@ -216,7 +216,6 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
|
||||
onHistoryIntent?: (event: Event) => void;
|
||||
onCompanionQuestion?: (question: string) => void;
|
||||
onCompanionPrefill?: (question: string) => void;
|
||||
onNewSession: () => void;
|
||||
onClearHistory?: () => void;
|
||||
agentsList: {
|
||||
agents: Array<{
|
||||
@@ -435,7 +434,6 @@ export function renderChat(props: ChatProps) {
|
||||
onGatewayQuestionChange: props.onGatewayQuestionChange,
|
||||
onGatewayQuestionSubmit: props.onGatewayQuestionSubmit,
|
||||
onGatewayQuestionSkip: props.onGatewayQuestionSkip,
|
||||
onNewSession: props.onNewSession,
|
||||
onClearReply: props.onClearReply,
|
||||
onAttachmentsChange: props.onAttachmentsChange,
|
||||
onRemoveAttachment: props.onRemoveAttachment,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { ref } from "lit/directives/ref.js";
|
||||
import type { ChatSendShortcut } from "../../../app/settings.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { syncDropdownItemRadio } from "../../../components/web-awesome.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
@@ -23,7 +22,7 @@ export type ChatRunControlsProps = {
|
||||
hasAttachments?: boolean;
|
||||
isBusy: boolean;
|
||||
followUpMode?: ControlUiFollowUpMode;
|
||||
sendShortcut: ChatSendShortcut;
|
||||
steerNowEnabled: boolean;
|
||||
suggestionComposer?: boolean;
|
||||
sending: boolean;
|
||||
voiceActive?: boolean;
|
||||
@@ -38,7 +37,6 @@ export type ChatRunControlsProps = {
|
||||
onPrimaryActionPointerDown?: (event: PointerEvent) => void;
|
||||
onAbort?: () => void;
|
||||
onSend: () => void;
|
||||
onStoreDraft: (draft: string) => void;
|
||||
onToggleVoice?: () => void;
|
||||
onToggleCamera?: () => void;
|
||||
microphonePicker?: TemplateResult | typeof nothing;
|
||||
@@ -220,21 +218,12 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
|
||||
: interruptsActiveRun
|
||||
? t("chat.runControls.sendMessage")
|
||||
: t("chat.runControls.queueMessage");
|
||||
const queueSteerShortcutAvailable =
|
||||
props.canAbort &&
|
||||
props.canSend &&
|
||||
hasComposedContent &&
|
||||
props.followUpMode === "queue" &&
|
||||
props.sendShortcut === "enter";
|
||||
const queueSteerShortcutAvailable = props.steerNowEnabled && props.canSend && hasComposedContent;
|
||||
const activeRunActionTooltip = queueSteerShortcutAvailable
|
||||
? `${activeRunActionLabel} ⏎ · ${t("chat.queue.steer")} ${t("chat.sendShortcutModifierEnter")}`
|
||||
: activeRunActionLabel;
|
||||
const storeDraftAndSend = () => {
|
||||
if (props.draft.trim()) {
|
||||
props.onStoreDraft(props.draft);
|
||||
}
|
||||
props.onSend();
|
||||
};
|
||||
// Lit passes the click event to handlers; keep it out of the scalar send override.
|
||||
const send = () => props.onSend();
|
||||
const abortAction = props.canAbort
|
||||
? html`
|
||||
<openclaw-tooltip .content=${t("chat.runControls.stop")}>
|
||||
@@ -262,7 +251,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${storeDraftAndSend}
|
||||
@click=${send}
|
||||
?disabled=${!props.canSend || props.sending}
|
||||
aria-label=${activeRunActionDescription}
|
||||
>
|
||||
@@ -279,7 +268,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
@pointerdown=${props.onPrimaryActionPointerDown}
|
||||
@click=${storeDraftAndSend}
|
||||
@click=${send}
|
||||
disabled
|
||||
aria-label=${emptySendDescription}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChatSendShortcut } from "../../../app/settings.ts";
|
||||
import { steerableQueuedMessage } from "../chat-queue.ts";
|
||||
import { restoreHistoryCaret, scrollActiveMenuOptionIntoView } from "./chat-composer-dom.ts";
|
||||
import { steerableQueuedMessage } from "./chat-composer-queue.ts";
|
||||
import {
|
||||
getActiveSkillMenuOptionId,
|
||||
resetSkillMenuState,
|
||||
@@ -24,6 +24,7 @@ type ComposerKeyDownDeps = {
|
||||
commitDraft: (draft: string) => void;
|
||||
syncDraftAfterSend: (target: HTMLTextAreaElement | null) => void;
|
||||
showAbortableUi: boolean;
|
||||
steerNowEnabled: boolean;
|
||||
};
|
||||
|
||||
function handleComposerMenuKeyDown<T>(
|
||||
@@ -92,6 +93,7 @@ export function createComposerKeyDownHandler({
|
||||
commitDraft,
|
||||
syncDraftAfterSend,
|
||||
showAbortableUi,
|
||||
steerNowEnabled,
|
||||
}: ComposerKeyDownDeps): (event: KeyboardEvent) => void {
|
||||
return (event) => {
|
||||
// The handler only ever binds to the composer textarea; narrowing here
|
||||
@@ -233,16 +235,9 @@ export function createComposerKeyDownHandler({
|
||||
}
|
||||
event.preventDefault();
|
||||
commitDraft(target.value);
|
||||
const steerImmediately =
|
||||
sendShortcut === "enter" &&
|
||||
showAbortableUi &&
|
||||
props.followUpMode === "queue" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
hasComposedContent;
|
||||
const steerImmediately = steerNowEnabled && (event.metaKey || event.ctrlKey) && !event.altKey;
|
||||
if (steerImmediately) {
|
||||
props.onSend({ followUpMode: "steer" });
|
||||
props.onSend("steer");
|
||||
} else {
|
||||
props.onSend();
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import {
|
||||
chatQueueMovableSegments,
|
||||
compareChatQueueOrder,
|
||||
isMovableChatQueueItem,
|
||||
} from "../../../lib/chat/chat-queue-order.ts";
|
||||
import type { ChatQueueItem } from "../../../lib/chat/chat-types.ts";
|
||||
import { isSteerableQueuedMessage } from "../chat-queue.ts";
|
||||
import { isInflightSteer, isSteeredQueueItem } from "../steered-chip.ts";
|
||||
import { renderChatAuthorAvatar } from "./chat-author-avatar.ts";
|
||||
|
||||
@@ -32,17 +32,6 @@ type ChatQueueReorder = {
|
||||
const DRAG_MIME = "application/x-openclaw-queued-message";
|
||||
const DRAG_OVER_CLASS = "chat-queue__item--drop-target";
|
||||
|
||||
export function steerableQueuedMessage(queue: readonly ChatQueueItem[]): ChatQueueItem | undefined {
|
||||
return queue
|
||||
.toSorted(compareChatQueueOrder)
|
||||
.find(
|
||||
(item) =>
|
||||
!isSteeredQueueItem(item) &&
|
||||
(item.sendState === undefined || item.sendState === "waiting-idle") &&
|
||||
!item.localCommandName,
|
||||
);
|
||||
}
|
||||
|
||||
function sendStateLabel(item: ChatQueueItem): string | null {
|
||||
switch (item.sendState) {
|
||||
case "waiting-model":
|
||||
@@ -112,10 +101,7 @@ function renderChatQueueItem(
|
||||
const steered = isSteeredQueueItem(item) && !failed;
|
||||
const reconnecting = item.sendState === "waiting-reconnect";
|
||||
const busy = item.sendState === "executing-command" || isInflightSteer(item);
|
||||
const canSteer =
|
||||
Boolean(props.canAbort && props.onQueueSteer) &&
|
||||
!failed &&
|
||||
steerableQueuedMessage([item]) === item;
|
||||
const canSteer = Boolean(props.canAbort && props.onQueueSteer) && isSteerableQueuedMessage(item);
|
||||
const segment = reorder.segments.find((ids) => ids.includes(item.id)) ?? [];
|
||||
const moveIndex = segment.indexOf(item.id);
|
||||
const move = props.onQueueMove;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { SlashCommandDef } from "../../../lib/chat/commands.ts";
|
||||
import type { ControlUiFollowUpMode } from "../../../lib/chat/follow-up-mode.ts";
|
||||
import type { ProviderUsageDisplayProps } from "../../../lib/provider-quota-summary.ts";
|
||||
import type { SessionToolOverrides } from "../../../lib/sessions/patch.ts";
|
||||
import type { ChatSendOptions } from "../chat-send-contract.ts";
|
||||
import type { ComposerDictationController } from "../composer-dictation.ts";
|
||||
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "../input-history.ts";
|
||||
import type { RealtimeTalkConversationEntry } from "../realtime-talk-conversation.ts";
|
||||
@@ -120,7 +119,7 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
onDraftChange: (next: string) => void;
|
||||
onHistoryKeydown?: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
|
||||
onSlashIntent?: () => void | Promise<void>;
|
||||
onSend: (options?: ChatSendOptions) => void;
|
||||
onSend: (followUpModeOverride?: "steer") => void;
|
||||
onCompact?: () => void | Promise<void>;
|
||||
onToggleRealtimeTalk?: () => void;
|
||||
onToggleRealtimeCamera?: () => void;
|
||||
@@ -133,7 +132,6 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
|
||||
onQueueSteer?: (id: string) => void;
|
||||
onQueueMove?: (id: string, toIndex: number) => void;
|
||||
queuedEdit?: ChatQueuedEditProps;
|
||||
onNewSession: () => void;
|
||||
onClearReply?: () => void;
|
||||
onGoalCommand?: (command: string) => void;
|
||||
onGatewayQuestionChange?: () => void;
|
||||
|
||||
@@ -137,6 +137,11 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
: t("chat.composer.runInterrupted");
|
||||
const requestUpdate = props.onRequestUpdate ?? (() => {});
|
||||
const sendShortcut = normalizeChatSendShortcut(props.sendShortcut);
|
||||
const steerNowEnabled =
|
||||
props.connected &&
|
||||
sendShortcut === "enter" &&
|
||||
showAbortableUi &&
|
||||
props.followUpMode === "queue";
|
||||
const gatewayQuestionPrompts =
|
||||
props.gatewayQuestionPrompts?.filter(
|
||||
(prompt) =>
|
||||
@@ -252,6 +257,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
commitDraft: (draft) => commitComposerDraft(props, draft),
|
||||
syncDraftAfterSend: syncComposerDraftAfterSend,
|
||||
showAbortableUi,
|
||||
steerNowEnabled,
|
||||
});
|
||||
|
||||
const syncComposerValue = (target: HTMLTextAreaElement) => {
|
||||
@@ -492,7 +498,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
hasAttachments: !props.suggestionComposer && Boolean(props.attachments?.length),
|
||||
isBusy,
|
||||
followUpMode: props.followUpMode,
|
||||
sendShortcut,
|
||||
steerNowEnabled,
|
||||
suggestionComposer: props.suggestionComposer,
|
||||
sending: props.sending,
|
||||
voiceActive: props.realtimeTalkActive,
|
||||
@@ -504,7 +510,6 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
voiceVideoPending: props.realtimeTalkVideoPending,
|
||||
onAbort: props.onAbort,
|
||||
onSend: handleSend,
|
||||
onStoreDraft: () => {},
|
||||
onToggleVoice: props.onToggleRealtimeTalk ? handleVoicePrimaryAction : undefined,
|
||||
onToggleCamera: props.onToggleRealtimeCamera,
|
||||
microphonePicker,
|
||||
|
||||
@@ -190,6 +190,17 @@ export function preserveQueuedUserTurn(state: SteerLifecycleHost, item: ChatQueu
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
if (item.kind === "steered") {
|
||||
// A started target may exist only as an optimistic queue row. Preserve it
|
||||
// before the landed steer or stable history can invert the user turns.
|
||||
const targetRunId = item.steerTargetRunId?.trim() || item.pendingRunId;
|
||||
const target = state.chatQueue.find(
|
||||
(candidate) => candidate.kind !== "steered" && candidate.sendRunId === targetRunId,
|
||||
);
|
||||
if (target) {
|
||||
preserveQueuedUserTurn(state, target);
|
||||
}
|
||||
}
|
||||
const content = buildUserChatMessageContentBlocks(
|
||||
item.text,
|
||||
durableDeliveredAttachments(item.attachments),
|
||||
@@ -265,17 +276,6 @@ export function retireSteeredChipsForRequestRun(
|
||||
);
|
||||
let firstPersistedSteerIndex: number | undefined;
|
||||
for (const item of landed) {
|
||||
// A started active turn can still exist only as an optimistic queue row.
|
||||
// Promote that target before its landed steer so stable transcript history
|
||||
// cannot render the newer steer ahead of the original prompt. Older persisted
|
||||
// chips used pendingRunId as both identities, so retain it as the migration fallback.
|
||||
const targetRunId = item.steerTargetRunId?.trim() || item.pendingRunId;
|
||||
const target = state.chatQueue.find(
|
||||
(candidate) => candidate.id !== item.id && candidate.sendRunId === targetRunId,
|
||||
);
|
||||
if (target) {
|
||||
preserveQueuedUserTurn(state, target);
|
||||
}
|
||||
const persistedIndex = findQueuedSendMessageIndex(state.chatMessages, item, true);
|
||||
if (
|
||||
persistedIndex >= 0 &&
|
||||
|
||||
Reference in New Issue
Block a user