From de57e394084e08c8d60832c5cfebf5703a378ff5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 11 Sep 2026 01:26:25 -0700 Subject: [PATCH] fix(ui): transcript end hides under the PR chip and Side chat questions cannot wrap (#144703) * fix(ui): keep the transcript end above the PR chip and let Side chat questions wrap Users following a long coding session saw the newest transcript rows clipped behind the pull-request chip and task-progress dock while the view believed it was at the end and hid the scroll-to-latest button; a width change "fixed" it. Live on a stable team instance the thread rested 48px short with the last row 25px under the PR chip. End-follow runs in a requestAnimationFrame after the commit that grows a row. For task-driven commits (tool-stream sync, session events) that frame precedes the row's ResizeObserver, so the follow scrolled to a stale maximum, and the outstanding end command never settled. The transcript host now records the maximum an end command targeted and re-targets it after commits when the committed maximum moved. The render-lifecycle change capture also tracks chatStreamSegments identity, so Codex-harness preamble growth schedules a follow at all. Pending-offset restoration moves to a sibling module to stay under the file-length cap. The Side chat prompt becomes an auto-growing textarea that reuses the main composer's sizing owner and send-key rule (Enter or the configured modifier shortcut asks, Shift+Enter adds a line); its composer lifecycle lives in chat-session-rail-composer.ts. Proof: chat-transcript-end-follow.browser.test.ts fails on the previous host (distance 48, programmatic true) and passes now; chat-scroll-dock.e2e.test.ts rests 181-601px short without the stream-segment capture and at 0 with it; chat-session-companion-composer.e2e.test.ts covers wrapping, Shift+Enter, verbatim multi-line asks, shrink-back, and modifier shortcuts. * test(ui): initialize settings in retained panel fixture Repair the exact-head CI failures in checks-ui (1/3) and checks-node-compact-large-9. The retained Review fixture must initialize settings before the Side chat shortcut binding reads them. Reuse loadSettings as production does; preserve runtime code and assertions. * test(ui): use reader input before transcript scroll assertions Exact-head CI exposed synthetic scrollTop writes racing outstanding end commands in position-rail, focused-row, and native-history scenarios. Send real wheel input through the existing cancellation owner before asserting reader geometry. Preserve all assertions, timeouts, and runtime behavior. The ten targeted E2E cases, changed checks, and scoped autoreview pass. * fix(ui): re-pin only a reader still resting at the transcript end The first end-follow repair re-targeted any outstanding end command when the committed maximum moved. An instant follow that does not move emits no scroll event, so that command lingered, and a reader who later left the end without an input event (find-in-page, focus navigation, programmatic scroll) was pulled back by the next growth; three e2e tests had been switched to wheel input to hide exactly that. The host now keeps an explicit end anchor: the maximum a reader is known to rest at, set by an instant end-follow or observed after any commit. A commit that moves the end while the reader still sits at the previous end re-pins them; a reader found anywhere else clears the anchor and is never pulled back. The three e2e tests return to their original programmatic scrolls. Proof: chat-transcript-end-follow.browser.test.ts adds a programmatic departure case (348px yank on the previous retarget, 0 now) and an observed-at-end growth case (48px short without the anchor, 0 now); the restored e2e files pass unmodified. * fix(ui): preserve disclosure anchors during end-follow Exact-head CI exposed transcript disclosure expansion moving the clicked row by 120-357 px. Retire the end anchor when the interaction captures its row and defer end reconciliation during its measurement commit, so the pending virtual sizer cannot re-arm a stale end. The three existing disclosure cases fail before and pass after. All three browser end-follow regressions and 20 focused E2E cases pass; changed checks and scoped P0-P2 autoreview are clean. --- .gitignore | 1 + config/assertion-safety-baseline.txt | 2 +- docs/web/control-ui/chat.md | 2 +- ui/src/e2e/chat-scroll-dock.e2e.test.ts | 393 ++++++++++++++++++ ui/src/e2e/chat-selection-to-main.e2e.test.ts | 2 +- ...hat-session-companion-composer.e2e.test.ts | 107 +++++ .../chat-session-companion-focus.e2e.test.ts | 2 +- .../chat/chat-pane-embedded-panels.test.ts | 2 + .../pages/chat/chat-pane-embedded-panels.ts | 1 + .../chat/chat-responsive.browser.test.ts | 4 +- ui/src/pages/chat/chat-state-controller.ts | 4 + .../components/chat-session-rail-composer.ts | 57 +++ .../chat/components/chat-session-rail.ts | 27 +- ...chat-transcript-end-follow.browser.test.ts | 172 ++++++++ .../chat-transcript-scroll-restore.ts | 96 +++++ .../chat-transcript-virtualizer-host.ts | 136 +++--- 16 files changed, 919 insertions(+), 89 deletions(-) create mode 100644 ui/src/e2e/chat-scroll-dock.e2e.test.ts create mode 100644 ui/src/e2e/chat-session-companion-composer.e2e.test.ts create mode 100644 ui/src/pages/chat/components/chat-session-rail-composer.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-end-follow.browser.test.ts create mode 100644 ui/src/pages/chat/components/chat-transcript-scroll-restore.ts diff --git a/.gitignore b/.gitignore index 033eb7367610..c615d2f17ded 100644 --- a/.gitignore +++ b/.gitignore @@ -186,6 +186,7 @@ ui/src/ui/theme-variants.browser.test.ts ui/src/ui/__screenshots__ ui/src/ui/views/__screenshots__ ui/.vitest-attachments +ui/.vitest/ # Generated docs baseline artifacts (locally generated, only drift detectors tracked) docs/.generated/*.json diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 99d7ce2530d1..da5a8ac15ab9 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3832,7 +3832,7 @@ ui/src/pages/chat/components/chat-model-picker-options.ts 2 ui/src/pages/chat/components/chat-model-picker.ts 5 ui/src/pages/chat/components/chat-pane-header.ts 2 ui/src/pages/chat/components/chat-selection-popup.ts 1 -ui/src/pages/chat/components/chat-session-rail.ts 4 +ui/src/pages/chat/components/chat-session-rail.ts 3 ui/src/pages/chat/components/chat-session-sharing.ts 1 ui/src/pages/chat/components/chat-session-workspace.ts 1 ui/src/pages/chat/components/chat-sidebar-editor-menu.ts 2 diff --git a/docs/web/control-ui/chat.md b/docs/web/control-ui/chat.md index 1c349257a6cb..1ea26838a2db 100644 --- a/docs/web/control-ui/chat.md +++ b/docs/web/control-ui/chat.md @@ -17,7 +17,7 @@ While you watch a running session, the Gateway shows the model's latest safe pre Side chat answers questions about the selected session and its project without entering or interrupting the main agent run. On the first question, the Gateway lazily loads a bounded visible snapshot of the selected session before starting the utility model. If history is temporarily unavailable, the question stays visible with **Retry** instead of being treated as an empty session. Side chat uses read-only access to the target session's history/search and agent workspace. Its bounded thread is held in Gateway memory, is restored when you switch sessions in the Control UI, and is cleared by the rail's trash button, a session reset or deletion, Gateway restart, or idle expiry. It never enters `chat.history`, and private reference context is not stored as operator dialogue. Open it with Shift-Command-S on Apple platforms or Ctrl-Shift-S elsewhere, or type `/btw ` or `/side ` in the main Control UI composer to open the rail and ask there; other clients keep their existing BTW behavior. -Highlighting text in a chat message offers **Ask in side chat**, which opens the rail with a quoted draft ready to edit. +The question box wraps and grows like the main composer; Enter (or your configured send shortcut) asks the question, and Shift+Enter adds a line. Highlighting text in a chat message offers **Ask in side chat**, which opens the rail with a quoted draft ready to edit. The headline owns that run's sidebar subtitle instead of heuristic live activity. It is shared with the official iOS and Android session lists. A final done or failed digest remains visible while the session is unread, then the row returns to its normal work subtitle. diff --git a/ui/src/e2e/chat-scroll-dock.e2e.test.ts b/ui/src/e2e/chat-scroll-dock.e2e.test.ts new file mode 100644 index 000000000000..206260073ce5 --- /dev/null +++ b/ui/src/e2e/chat-scroll-dock.e2e.test.ts @@ -0,0 +1,393 @@ +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import type { Page } from "playwright"; +import { expect, it } from "vitest"; +import { CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT } from "../../../src/gateway/control-ui-contract.js"; +import { SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD } from "../lib/session-pull-requests.ts"; +import { CHAT_TRANSCRIPT_END_THRESHOLD_PX } from "../pages/chat/scroll.ts"; +import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts"; +import { + chatThreadDistanceFromBottom, + captureUiProofEnabled, + createChatFlowE2eSuite, + installMockGateway, + scrollChatThreadToTop, + waitForChatScrollIdle, +} from "./chat-flow.test-support.ts"; +import { waitForWatchedSessionKey } from "./chat-github-publication.test-support.ts"; +import { createControlUiE2eContextOptions } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createChatFlowE2eSuite(); +type DockGeometry = { + distance: number; + overhang: number; + rowKey: string | null; + rowHeight: number; + sizerHeight: number; + latestVisible: string | null; +}; + +async function dockGeometry(page: Page): Promise { + return page.locator(".chat-pane-cache__pane--active").evaluate((pane) => { + const thread = pane.querySelector(".chat-thread"); + const rows = pane.querySelectorAll(".chat-virtual-row"); + const row = rows.item(rows.length - 1); + const sizer = pane.querySelector(".chat-virtual-sizer"); + const dock = pane.querySelector(".chat-prs, .agent-chat__composer-shell"); + if (!thread || !row || !sizer || !dock) { + throw new Error("Expected a transcript row, sizer, and composer dock"); + } + return { + distance: Math.round(thread.scrollHeight - thread.scrollTop - thread.clientHeight), + overhang: Math.round(row.getBoundingClientRect().bottom - dock.getBoundingClientRect().top), + rowKey: row.getAttribute("data-virtual-row-key"), + rowHeight: row.offsetHeight, + sizerHeight: sizer.offsetHeight, + latestVisible: + pane.querySelector(".chat-scroll-to-bottom")?.getAttribute("data-visible") ?? null, + }; + }); +} + +function expectDockClear(report: Record): void { + for (const [stage, { distance, overhang }] of Object.entries(report)) { + expect( + distance, + `${stage} distance from bottom: ${JSON.stringify(report[stage])}`, + ).toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX); + expect(overhang, `${stage} last row overhang into the dock`).toBeLessThanOrEqual(0); + } +} + +suite.define(() => { + it("keeps the transcript end visible when the composer dock grows", async () => { + const context = await suite.newBrowserContext(createControlUiE2eContextOptions()); + const page = await context.newPage(); + const baseTs = Date.now() - 100_000; + const historyMessages = Array.from({ length: 40 }, (_, index) => ({ + content: [{ text: `Dock history ${index}\n${"transcript line\n".repeat(3)}`, type: "text" }], + role: index % 2 === 0 ? "assistant" : "user", + timestamp: baseTs + index, + })); + const gateway = await installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + "config.get", + "progressCard.get", + SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, + ], + historyMessages, + methodResponses: { + [SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD]: { subscribed: true }, + "progressCard.get": { card: null }, + }, + }); + const report: Record = {}; + const proofDir = captureUiProofEnabled + ? createControlUiE2eArtifactDir("chat-scroll-dock") + : null; + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Dock history 39").waitFor({ timeout: 10_000 }); + await expect + .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) + .toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX); + await waitForChatScrollIdle(page); + report.initial = await dockGeometry(page); + + const watchedKey = await waitForWatchedSessionKey(gateway); + await gateway.emitGatewayEvent(CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT, { + sessions: { + [watchedKey]: { + pullRequests: [ + { + number: 144615, + owner: "openclaw", + repo: "openclaw", + branch: "fix/clawhub-publish-metadata-2026-9-4", + title: "fix: publish ClawHub metadata", + url: "https://github.com/openclaw/openclaw/pull/144615", + state: "open", + additions: 295, + deletions: 57, + checks: { state: "failing", passed: 60, failed: 1, skipped: 0, running: 0 }, + checksUrl: "https://github.com/openclaw/openclaw/pull/144615/checks", + }, + ], + rateLimited: false, + status: "ready", + }, + }, + }); + await page.locator(".chat-pr").first().waitFor(); + await waitForChatScrollIdle(page); + report.afterPr = await dockGeometry(page); + + const card = page.locator('[data-progress-card-placement="composer"]'); + await gateway.setMethodResponse("progressCard.get", { + card: { + markdown: + "Core npm and Docker publication verified.\n\n- 90 npm plugins + 3 companions verified; core install and Docker digests passed.\n- ClawHub repair CI found a native-Node import regression; owner-boundary fix underway.\n- Repair PR must land before selected-package recovery.\n- 58 ClawHub uploads await owner recovery; selector sync pending.\n- Telegram/Parallels skipped; Vercel mirror advisory failed.", + revision: 1, + sessionKey: watchedKey, + steps: [ + { status: "completed", step: "Verify signed tag and frozen release evidence" }, + { status: "in_progress", step: "Publish core, plugins, and prepared macOS artifacts" }, + { status: "pending", step: "Verify registries, release assets, and stable closeout" }, + ], + updatedAt: Date.now(), + }, + }); + await gateway.emitGatewayEvent("progressCard.changed", { + revision: 1, + sessionKey: watchedKey, + }); + await expect.poll(() => card.count()).toBe(1); + await waitForChatScrollIdle(page); + report.afterCard = await dockGeometry(page); + + await scrollChatThreadToTop(page); + const button = page.locator(".chat-scroll-to-bottom[data-visible='true']"); + await button.waitFor(); + await button.click(); + await waitForChatScrollIdle(page); + report.afterButton = await dockGeometry(page); + expectDockClear(report); + } finally { + if (proofDir) { + writeFileSync(path.join(proofDir, "geometry.json"), JSON.stringify(report, null, 2)); + } + await context.close(); + } + }); + + it("keeps a growing run frame above the PR chip through committed end-follow", async () => { + const context = await suite.newBrowserContext(createControlUiE2eContextOptions()); + const page = await context.newPage(); + const baseTs = Date.now() - 100_000; + const historyMessages = Array.from({ length: 30 }, (_, index) => ({ + content: [ + { text: `Stream history ${index}\n${"transcript line\n".repeat(3)}`, type: "text" }, + ], + role: index % 2 === 0 ? "assistant" : "user", + timestamp: baseTs + index, + })); + const runId = "dock-growing-run"; + const runHistory: unknown[] = [ + ...historyMessages, + { + role: "user", + content: "Inspect the workspace", + timestamp: baseTs + 50, + __openclaw: { id: "dock-user", idempotencyKey: `${runId}:user`, seq: 31 }, + }, + { + role: "assistant", + phase: "commentary", + content: + "I will inspect the workspace.\n\n" + "Initial commentary paragraph.\n\n".repeat(20), + timestamp: baseTs + 51, + __openclaw: { id: "dock-commentary", runId, seq: 32 }, + }, + { + role: "toolResult", + toolCallId: "dock-seed-tool", + toolName: "exec", + content: [{ type: "text", text: "Initial check complete" }], + timestamp: baseTs + 52, + __openclaw: { id: "dock-seed-tool", runId, seq: 33 }, + }, + ]; + const gateway = await installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.send", + "chat.startup", + "config.get", + SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD, + ], + historyMessages: runHistory, + inFlightRun: { runId, text: "" }, + sessionInfo: { activeRunIds: [runId], hasActiveRun: true, key: "agent:main:main" }, + methodResponses: { + [SESSION_PULL_REQUESTS_SUBSCRIBE_METHOD]: { subscribed: true }, + }, + }); + const report: Record = {}; + const proofDir = captureUiProofEnabled + ? createControlUiE2eArtifactDir("chat-scroll-dock") + : null; + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Stream history 29").waitFor({ timeout: 10_000 }); + const watchedKey = await waitForWatchedSessionKey(gateway); + await gateway.emitGatewayEvent(CONTROL_UI_SESSION_PULL_REQUESTS_CHANGED_EVENT, { + sessions: { + [watchedKey]: { + pullRequests: [ + { + number: 144615, + owner: "openclaw", + repo: "openclaw", + branch: "fix/clawhub-publish-metadata-2026-9-4", + title: "fix: publish ClawHub metadata", + url: "https://github.com/openclaw/openclaw/pull/144615", + state: "open", + additions: 295, + deletions: 57, + }, + ], + rateLimited: false, + status: "ready", + }, + }, + }); + await page.locator(".chat-pr").first().waitFor(); + await expect + .poll(() => chatThreadDistanceFromBottom(page), { timeout: 10_000 }) + .toBeLessThanOrEqual(CHAT_TRANSCRIPT_END_THRESHOLD_PX); + await waitForChatScrollIdle(page); + + const runRow = page.locator('.chat-virtual-row[data-virtual-row-key^="agent-run:"]').last(); + const rowKey = await runRow.getAttribute("data-virtual-row-key"); + let sequence = 0; + let text = ""; + for (let step = 1; step <= 4; step += 1) { + const before = await dockGeometry(page); + text = + `Commentary stage ${step}.\n\n` + + "Additional findings with enough detail to occupy another paragraph.\n\n".repeat( + step * 4, + ); + await gateway.emitGatewayEvent("agent", { + data: { kind: "preamble", itemId: `dock-progress-${step}`, progressText: text }, + runId, + seq: ++sequence, + sessionKey: "agent:main:main", + stream: "item", + ts: Date.now(), + }); + await runRow.getByText(`Commentary stage ${step}.`, { exact: true }).waitFor(); + await waitForChatScrollIdle(page); + const preamble = await dockGeometry(page); + report[`preamble${step}`] = preamble; + expect(preamble.rowKey).toBe(rowKey); + expect(preamble.rowHeight).toBeGreaterThan(before.rowHeight); + expect(preamble.sizerHeight - before.sizerHeight).toBe( + preamble.rowHeight - before.rowHeight, + ); + await gateway.emitGatewayEvent("agent", { + data: { + phase: "start", + name: "exec", + toolCallId: `dock-tool-${step}`, + args: { command: `echo check-${step}` }, + }, + runId, + seq: ++sequence, + sessionKey: "agent:main:main", + stream: "tool", + ts: Date.now(), + }); + await gateway.emitGatewayEvent("agent", { + data: { + phase: "result", + name: "exec", + toolCallId: `dock-tool-${step}`, + result: { content: [{ type: "text", text: "Check complete." }] }, + }, + runId, + seq: ++sequence, + sessionKey: "agent:main:main", + stream: "tool", + ts: Date.now(), + }); + runHistory.push( + { + role: "assistant", + content: [{ type: "text", text }], + openclawStreamFallback: { + replacementText: text, + source: "segment", + itemId: `dock-progress-${step}`, + }, + timestamp: Date.now(), + __openclaw: { id: `dock-progress-${step}`, runId, seq: 34 + step * 2 }, + }, + { + role: "toolResult", + toolCallId: `dock-tool-${step}`, + toolName: "exec", + content: [{ type: "text", text: "Check complete." }], + timestamp: Date.now(), + __openclaw: { id: `dock-result-${step}`, runId, seq: 35 + step * 2 }, + }, + ); + await runRow.getByText(`Commentary stage ${step}.`, { exact: true }).waitFor(); + await waitForChatScrollIdle(page); + const after = await dockGeometry(page); + report[`commentary${step}`] = after; + expect(after.rowKey).toBe(rowKey); + expect(after.rowHeight).toBeGreaterThan(before.rowHeight); + expect(after.sizerHeight - before.sizerHeight).toBe(after.rowHeight - before.rowHeight); + expect(after.latestVisible).toBe("false"); + } + // Completed items are checkpointed before the terminal clears transient activity. + const activeSession = { key: "agent:main:main", activeRunIds: [runId], hasActiveRun: true }; + await gateway.setMethodResponse("chat.history", { + messages: runHistory, + sessionInfo: activeSession, + inFlightRun: { runId, text: "" }, + }); + const historyRequests = (await gateway.getRequests("chat.history")).length; + await gateway.emitGatewayEvent("sessions.changed", { + phase: "message", + session: activeSession, + }); + await gateway.waitForRequest("chat.history", { after: historyRequests }); + await waitForChatScrollIdle(page); + report.checkpoint = await dockGeometry(page); + + const finalMessage = { + role: "assistant", + phase: "final_answer", + content: "Workspace checks complete.", + timestamp: Date.now(), + __openclaw: { id: "dock-final", runId, seq: 44 }, + }; + await gateway.setMethodResponse("chat.history", { + messages: [...runHistory, finalMessage], + sessionInfo: { key: "agent:main:main", activeRunIds: [], hasActiveRun: false }, + inFlightRun: null, + }); + await gateway.emitGatewayEvent("session.message", { + message: finalMessage, + messageId: "dock-final", + messageSeq: 44, + session: { + key: "agent:main:main", + activeRunIds: [], + hasActiveRun: false, + status: "done", + kind: "direct", + updatedAt: Date.now(), + }, + runId, + clientRunId: runId, + activeRunIds: [], + hasActiveRun: false, + sessionKey: "agent:main:main", + }); + await page.getByText("Workspace checks complete.", { exact: true }).waitFor(); + await waitForChatScrollIdle(page); + report.final = await dockGeometry(page); + expectDockClear(report); + } finally { + if (proofDir) { + writeFileSync(path.join(proofDir, "geometry.json"), JSON.stringify(report, null, 2)); + } + await context.close(); + } + }); +}); diff --git a/ui/src/e2e/chat-selection-to-main.e2e.test.ts b/ui/src/e2e/chat-selection-to-main.e2e.test.ts index bf13db2651aa..91948efe1428 100644 --- a/ui/src/e2e/chat-selection-to-main.e2e.test.ts +++ b/ui/src/e2e/chat-selection-to-main.e2e.test.ts @@ -18,7 +18,7 @@ suite.define(() => { historyMessages: [{ role: "assistant", content: selectedText }], }); await page.goto(`${suite.server.baseUrl}chat`); - const composer = page.locator(".agent-chat__composer-combobox textarea"); + const composer = page.locator(".agent-chat__composer-shell textarea"); await composer.waitFor({ state: "visible" }); const text = page.locator(".chat-bubble .chat-text p").filter({ hasText: selectedText }); const popup = page.getByRole("toolbar", { name: "Selection actions" }); diff --git a/ui/src/e2e/chat-session-companion-composer.e2e.test.ts b/ui/src/e2e/chat-session-companion-composer.e2e.test.ts new file mode 100644 index 000000000000..0d980885bd3e --- /dev/null +++ b/ui/src/e2e/chat-session-companion-composer.e2e.test.ts @@ -0,0 +1,107 @@ +import path from "node:path"; +import type { Locator, Page } from "playwright"; +import { expect, it } from "vitest"; +import type { ChatSendShortcut } from "../app/settings.ts"; +import { createControlUiE2eArtifactDir } from "../test-helpers/control-ui-e2e-artifacts.ts"; +import { + controlUiBundledSettingsStorageKey, + installMockGateway, +} from "../test-helpers/control-ui-e2e.ts"; +import { captureUiProofEnabled } from "./chat-flow.test-support.ts"; +import { openChatSidePanelType } from "./chat-side-panel.test-support.ts"; +import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts"; + +const suite = createControlUiE2eSuite({ name: "side-chat composer" }); +const viewport = { width: 1440, height: 900 }; + +async function openSideChat(page: Page, chatSendShortcut: ChatSendShortcut = "enter") { + await page.addInitScript( + ({ key, shortcut }) => { + localStorage.setItem(key, JSON.stringify({ chatSendShortcut: shortcut })); + }, + { key: controlUiBundledSettingsStorageKey(suite.server.baseUrl), shortcut: chatSendShortcut }, + ); + const gateway = await installMockGateway(page, { + methodResponses: { + "sessions.companion.ask": { answer: "The next step is ready.", ts: 1 }, + "sessions.companion.state": { exchanges: [] }, + }, + }); + await page.goto(`${suite.server.baseUrl}chat`); + await openChatSidePanelType(page, "Side chat"); + return gateway; +} + +function composerGeometry(composer: Locator) { + return composer.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); +} + +suite.define(() => { + it("wraps and grows a question, keeps Shift+Enter, then sends and shrinks", async () => { + await suite.withPage({ viewport }, async ({ page }) => { + const gateway = await openSideChat(page); + const composer = page.getByRole("textbox", { name: "Ask in side chat", exact: true }); + const proofDir = captureUiProofEnabled + ? createControlUiE2eArtifactDir("chat-session-companion-composer") + : null; + const empty = await composerGeometry(composer); + if (proofDir) { + await page.screenshot({ path: path.join(proofDir, "empty.png") }); + } + + const question = "Explain the next step and the remaining checks for this session. " + .repeat(4) + .trim(); + await composer.fill(question); + const filled = await composerGeometry(composer); + if (proofDir) { + await page.screenshot({ path: path.join(proofDir, "wrapped.png") }); + } + expect(filled.scrollWidth).toBeLessThanOrEqual(filled.clientWidth); + expect(filled.height).toBeGreaterThan(empty.height); + + const requestsBefore = await gateway.getRequests("sessions.companion.ask"); + await composer.press("End"); + await composer.press("Shift+Enter"); + await page.keyboard.type("Include the final verification."); + const multiline = `${question}\nInclude the final verification.`; + expect(await composer.inputValue()).toBe(multiline); + expect(await gateway.getRequests("sessions.companion.ask")).toHaveLength( + requestsBefore.length, + ); + + await composer.press("Enter"); + const request = await gateway.waitForRequest("sessions.companion.ask"); + expect(request.params).toMatchObject({ question: multiline }); + await expect.poll(() => composer.inputValue()).toBe(""); + await expect.poll(async () => (await composerGeometry(composer)).height).toBe(empty.height); + if (proofDir) { + await page.screenshot({ path: path.join(proofDir, "cleared.png") }); + } + }); + }); + + it.each(["Control", "Meta"])( + "uses %s+Enter to send when the configured shortcut requires a modifier", + async (modifier) => { + await suite.withPage({ viewport }, async ({ page }) => { + const gateway = await openSideChat(page, "modifier-enter"); + const composer = page.getByRole("textbox", { name: "Ask in side chat", exact: true }); + await composer.fill("Explain the next step."); + await composer.press("Enter"); + await page.keyboard.type("Include the checks."); + const question = "Explain the next step.\nInclude the checks."; + expect(await composer.inputValue()).toBe(question); + expect(await gateway.getRequests("sessions.companion.ask")).toHaveLength(0); + await composer.press(`${modifier}+Enter`); + const request = await gateway.waitForRequest("sessions.companion.ask"); + expect(request.params).toMatchObject({ question }); + await expect.poll(() => composer.inputValue()).toBe(""); + }); + }, + ); +}); diff --git a/ui/src/e2e/chat-session-companion-focus.e2e.test.ts b/ui/src/e2e/chat-session-companion-focus.e2e.test.ts index d4b2beda7f8f..376c51aefc3a 100644 --- a/ui/src/e2e/chat-session-companion-focus.e2e.test.ts +++ b/ui/src/e2e/chat-session-companion-focus.e2e.test.ts @@ -52,7 +52,7 @@ suite.define(() => { await expect .poll(() => input.evaluate((element) => document.activeElement === element)) .toBe(true); - const mainInput = page.locator(".agent-chat__composer-combobox textarea"); + const mainInput = page.locator(".agent-chat__composer-shell textarea"); await mainInput.fill("Keep typing here"); await gateway.resolveDeferred("sessions.companion.state", { exchanges: [{ question: "What changed?", answer: "The introduction is ready.", ts: 1 }], diff --git a/ui/src/pages/chat/chat-pane-embedded-panels.test.ts b/ui/src/pages/chat/chat-pane-embedded-panels.test.ts index 21d0cf6fd825..06dba3b4eadc 100644 --- a/ui/src/pages/chat/chat-pane-embedded-panels.test.ts +++ b/ui/src/pages/chat/chat-pane-embedded-panels.test.ts @@ -6,6 +6,7 @@ import "./components/chat-detail-panel.ts"; import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { createDeferred } from "../../../../test/helpers/promise.js"; import type { SessionWorkspaceGetResult, SessionWorkspaceListResult } from "../../api/types.ts"; +import { loadSettings } from "../../app/settings.ts"; import type { TaskSummary } from "../../lib/tasks/task-summary.ts"; import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts"; import { resolveChatAgentId } from "./chat-agent-id.ts"; @@ -685,6 +686,7 @@ describe("chat pane embedded panels", () => { sessionKey: "agent:main:review", sidebarContent: null, sidebarLayout: { columns: [] }, + settings: loadSettings(), } as unknown as ChatPageHost; const mount = document.body.appendChild(document.createElement("div")); const renderPanels = async (layout: SidebarLayout) => { diff --git a/ui/src/pages/chat/chat-pane-embedded-panels.ts b/ui/src/pages/chat/chat-pane-embedded-panels.ts index 836c5ee4d904..c9a1ac04dc94 100644 --- a/ui/src/pages/chat/chat-pane-embedded-panels.ts +++ b/ui/src/pages/chat/chat-pane-embedded-panels.ts @@ -169,6 +169,7 @@ export function sidebarPanelDefinitions( .pullRequests=${params.pullRequests} .companion=${params.companion} .connected=${state?.connected === true} + .sendShortcut=${state?.settings.chatSendShortcut ?? "enter"} .onSubmit=${params.onCompanionSubmit} .onDraftChange=${params.onCompanionDraftChange} .onVisibilityChange=${params.onCompanionVisibilityChange} diff --git a/ui/src/pages/chat/chat-responsive.browser.test.ts b/ui/src/pages/chat/chat-responsive.browser.test.ts index fe40ea2245b0..3bef8cfd6428 100644 --- a/ui/src/pages/chat/chat-responsive.browser.test.ts +++ b/ui/src/pages/chat/chat-responsive.browser.test.ts @@ -669,7 +669,7 @@ function chatHtml(opts: ChatFixtureOptions = {}, mobileNavLayout = false) {