diff --git a/ui/src/e2e/chat-composer-redesign.e2e.test.ts b/ui/src/e2e/chat-composer-redesign.e2e.test.ts index e4065b8aab6f..51362ac19028 100644 --- a/ui/src/e2e/chat-composer-redesign.e2e.test.ts +++ b/ui/src/e2e/chat-composer-redesign.e2e.test.ts @@ -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); diff --git a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts index cac32393343e..7e6237f054cd 100644 --- a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts @@ -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, diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index 58429acc4b6d..d26d1213dcf6 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -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("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); diff --git a/ui/src/pages/chat/chat-composer-context.test.ts b/ui/src/pages/chat/chat-composer-context.test.ts index 678a70d93c87..cacd3787c174 100644 --- a/ui/src/pages/chat/chat-composer-context.test.ts +++ b/ui/src/pages/chat/chat-composer-context.test.ts @@ -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[0]; +type ComposerOverrides = Parameters[0]; -function renderComposer(overrides: Partial = {}) { - 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", () => { diff --git a/ui/src/pages/chat/chat-composer-disabled-banner.test.ts b/ui/src/pages/chat/chat-composer-disabled-banner.test.ts index d2830a3d2cf0..5e2dcd18bd89 100644 --- a/ui/src/pages/chat/chat-composer-disabled-banner.test.ts +++ b/ui/src/pages/chat/chat-composer-disabled-banner.test.ts @@ -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[0]; - -function renderComposer(overrides: Partial) { - 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", diff --git a/ui/src/pages/chat/chat-composer-pointer-activation.test.ts b/ui/src/pages/chat/chat-composer-pointer-activation.test.ts index 40f824a6a9b3..1960440aff62 100644 --- a/ui/src/pages/chat/chat-composer-pointer-activation.test.ts +++ b/ui/src/pages/chat/chat-composer-pointer-activation.test.ts @@ -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[0]; -function props(overrides: Partial = {}): 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 = {}): HTMLElement { const container = document.createElement("div"); document.body.append(container); - render(renderChatComposer(props(overrides)), container); + render(renderChatComposer(createComposerProps(overrides)), container); return container; } diff --git a/ui/src/pages/chat/chat-composer.test-support.ts b/ui/src/pages/chat/chat-composer.test-support.ts index e4be3025ebad..af2a9d06eff7 100644 --- a/ui/src/pages/chat/chat-composer.test-support.ts +++ b/ui/src/pages/chat/chat-composer.test-support.ts @@ -23,7 +23,6 @@ export function createComposerProps(overrides: Partial = {}): Com onDraftChange: vi.fn(), onSend: vi.fn(), onQueueRemove: vi.fn(), - onNewSession: vi.fn(), ...overrides, }; } diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 3b4500dca3ee..7856bde20ab3 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -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", diff --git a/ui/src/pages/chat/chat-pane-render.ts b/ui/src/pages/chat/chat-pane-render.ts index 430dd45e5371..cc0c17adafb5 100644 --- a/ui/src/pages/chat/chat-pane-render.ts +++ b/ui/src/pages/chat/chat-pane-render.ts @@ -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, diff --git a/ui/src/pages/chat/chat-queue.ts b/ui/src/pages/chat/chat-queue.ts index 690d931a12b2..b383fac0a1dd 100644 --- a/ui/src/pages/chat/chat-queue.ts +++ b/ui/src/pages/chat/chat-queue.ts @@ -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"; } diff --git a/ui/src/pages/chat/chat-send-contract.ts b/ui/src/pages/chat/chat-send-contract.ts index f6cdb2eed4a7..cf7f02733267 100644 --- a/ui/src/pages/chat/chat-send-contract.ts +++ b/ui/src/pages/chat/chat-send-contract.ts @@ -17,10 +17,6 @@ type ChatAgentsListSnapshot = Partial> & { agents?: AgentsListResult["agents"]; }; -export type ChatSendOptions = { - followUpMode?: ControlUiFollowUpMode; -}; - export type ChatHost = ChatInputHistoryState & ChatScrollHost & ToolStreamHost & diff --git a/ui/src/pages/chat/chat-send-submit.ts b/ui/src/pages/chat/chat-send-submit.ts index d7d73acc67ad..147897870fcb 100644 --- a/ui/src/pages/chat/chat-send-submit.ts +++ b/ui/src/pages/chat/chat-send-submit.ts @@ -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. */ diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index 0f871ecf78eb..1d014c413a41 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -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[]; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 021ab21216ed..05c375099a80 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -710,7 +710,6 @@ function createChatProps(overrides: Partial = {}): ChatProps { onAbort: () => undefined, onQueueRemove: () => undefined, onQueueSteer: () => undefined, - onNewSession: () => undefined, onClearHistory: () => undefined, onOpenSessionCheckpoints: () => undefined, agentsList: null, diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 83182a7db732..fabfe81208a0 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -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; - onSend: (options?: ChatSendOptions) => void; + onSend: ChatComposerProps["onSend"]; onCompact?: () => void | Promise; onOpenSessionCheckpoints?: () => void | Promise; 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, diff --git a/ui/src/pages/chat/components/chat-composer-controls.ts b/ui/src/pages/chat/components/chat-composer-controls.ts index 8bd41bc23932..c027657d6abc 100644 --- a/ui/src/pages/chat/components/chat-composer-controls.ts +++ b/ui/src/pages/chat/components/chat-composer-controls.ts @@ -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` @@ -262,7 +251,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {