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:
Peter Steinberger
2026-08-16 21:12:56 -07:00
committed by GitHub
parent 6e026c2fe3
commit 3bf3acbee5
21 changed files with 166 additions and 345 deletions
@@ -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,
+55 -16
View File
@@ -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,
};
}
-49
View File
@@ -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",
+5 -4
View File
@@ -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,
+13
View File
@@ -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";
}
-4
View File
@@ -17,10 +17,6 @@ type ChatAgentsListSnapshot = Partial<Omit<AgentsListResult, "agents">> & {
agents?: AgentsListResult["agents"];
};
export type ChatSendOptions = {
followUpMode?: ControlUiFollowUpMode;
};
export type ChatHost = ChatInputHistoryState &
ChatScrollHost &
ToolStreamHost &
+4 -2
View File
@@ -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. */
+43
View File
@@ -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[];
-1
View File
@@ -710,7 +710,6 @@ function createChatProps(overrides: Partial<ChatProps> = {}): ChatProps {
onAbort: () => undefined,
onQueueRemove: () => undefined,
onQueueSteer: () => undefined,
onNewSession: () => undefined,
onClearHistory: () => undefined,
onOpenSessionCheckpoints: () => undefined,
agentsList: null,
+2 -4
View File
@@ -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,
+11 -11
View File
@@ -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 &&