diff --git a/ui/src/components/task-chat/TaskChatComposerDock.test.tsx b/ui/src/components/task-chat/TaskChatComposerDock.test.tsx
new file mode 100644
index 00000000000..68ba2e5623c
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatComposerDock.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TaskChatComposerDock } from "./TaskChatComposerDock";
+
+describe("TaskChatComposerDock", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ flushSync(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+ });
+
+ function render(coordinator = true) {
+ const onTranscriptWheel = vi.fn();
+ flushSync(() => root.render(
+
,
+ ));
+ const dock = container.querySelector('[data-testid="task-chat-composer-dock"]')!;
+ const scroller = container.querySelector('[data-testid="task-chat-scroller"]')!;
+ const scrollBy = vi.fn();
+ scroller.scrollBy = scrollBy;
+ Object.defineProperty(scroller, "clientHeight", { value: 400 });
+ Object.defineProperty(scroller, "clientWidth", { value: 600 });
+ vi.spyOn(window, "getComputedStyle").mockReturnValue({ lineHeight: "24px", fontSize: String(16) } as CSSStyleDeclaration);
+ return { dock, scroller, scrollBy, onTranscriptWheel };
+ }
+
+ it.each([
+ [WheelEvent.DOM_DELTA_PIXEL, 30],
+ [WheelEvent.DOM_DELTA_LINE, 720],
+ [WheelEvent.DOM_DELTA_PAGE, 12000],
+ ])("scrolls the transcript from a footer gap with delta mode %i", (deltaMode, top) => {
+ const { dock, scrollBy, onTranscriptWheel } = render();
+ const event = new WheelEvent("wheel", { deltaY: -30, deltaMode, bubbles: true, cancelable: true });
+
+ dock.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(scrollBy).toHaveBeenCalledWith({ top: -top, left: 0, behavior: "instant" });
+ expect(onTranscriptWheel).toHaveBeenCalledOnce();
+ expect(onTranscriptWheel.mock.calls[0][0].deltaY).toBe(-30);
+ });
+
+ it("does not scroll again when a transcript listener cancels the forwarded wheel", () => {
+ const { dock, scroller, scrollBy, onTranscriptWheel } = render();
+ scroller.addEventListener("wheel", event => event.preventDefault());
+ const event = new WheelEvent("wheel", { deltaY: -30, bubbles: true, cancelable: true });
+
+ dock.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(onTranscriptWheel).toHaveBeenCalledOnce();
+ expect(onTranscriptWheel.mock.calls[0][0].defaultPrevented).toBe(true);
+ expect(scrollBy).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [WheelEvent.DOM_DELTA_PIXEL, 3],
+ [WheelEvent.DOM_DELTA_LINE, 24],
+ [WheelEvent.DOM_DELTA_PAGE, 1800],
+ ])("preserves modifiers and horizontal wheel distance with delta mode %i", (deltaMode, left) => {
+ const { dock, scrollBy, onTranscriptWheel } = render();
+ const event = new WheelEvent("wheel", {
+ deltaX: 3, deltaMode, shiftKey: true, altKey: true, metaKey: true, bubbles: true, cancelable: true,
+ });
+
+ dock.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(true);
+ expect(scrollBy).toHaveBeenCalledWith({ left, top: 0, behavior: "instant" });
+ expect(onTranscriptWheel.mock.calls[0][0]).toMatchObject({
+ deltaX: 3, deltaMode, shiftKey: true, altKey: true, metaKey: true, ctrlKey: false,
+ });
+ });
+
+ it("keeps editor scrolling and zoom gestures with their original targets", () => {
+ const { dock, scrollBy, onTranscriptWheel } = render();
+ const editorEvent = new WheelEvent("wheel", { deltaY: 30, bubbles: true, cancelable: true });
+ const zoomEvent = new WheelEvent("wheel", { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true });
+
+ dock.querySelector("textarea")!.dispatchEvent(editorEvent);
+ dock.dispatchEvent(zoomEvent);
+
+ expect(editorEvent.defaultPrevented).toBe(false);
+ expect(zoomEvent.defaultPrevented).toBe(false);
+ expect(scrollBy).not.toHaveBeenCalled();
+ expect(onTranscriptWheel).not.toHaveBeenCalled();
+ });
+
+ it("preserves the classic workspace wheel behavior", () => {
+ const { dock, scrollBy } = render(false);
+ const event = new WheelEvent("wheel", { deltaY: 30, bubbles: true, cancelable: true });
+
+ dock.dispatchEvent(event);
+
+ expect(event.defaultPrevented).toBe(false);
+ expect(scrollBy).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/src/components/task-chat/TaskChatComposerDock.tsx b/ui/src/components/task-chat/TaskChatComposerDock.tsx
index cfdb2f36b32..e3df7825abc 100644
--- a/ui/src/components/task-chat/TaskChatComposerDock.tsx
+++ b/ui/src/components/task-chat/TaskChatComposerDock.tsx
@@ -8,20 +8,38 @@ interface TaskChatComposerDockProps {
concealed?: boolean;
}
-/** A normal flex footer beside the message scroller, on desktop and mobile. */
export function TaskChatComposerDock({ children, mobile, streamlined, concealed = false }: TaskChatComposerDockProps) {
const dockRef = useRef(null);
useLayoutEffect(() => {
const dock = dockRef.current;
const thread = dock?.closest('[data-testid="task-chat-thread"]');
if (!dock || !thread || !thread.closest(".coordinator-workspace")) return;
+ const forwardGapWheel = (event: WheelEvent) => {
+ if (event.target !== dock || event.ctrlKey || event.defaultPrevented) return;
+ const scroller = thread.querySelector('[data-testid="task-chat-scroller"]');
+ if (!scroller) return;
+ event.preventDefault();
+ const { deltaX, deltaY, deltaMode, altKey, ctrlKey, metaKey, shiftKey } = event;
+ if (!scroller.dispatchEvent(new WheelEvent("wheel", {
+ deltaX, deltaY, deltaMode, altKey, ctrlKey, metaKey, shiftKey, bubbles: true, cancelable: true,
+ }))) return;
+ const style = window.getComputedStyle(scroller);
+ const fontSize = Number.parseFloat(style.fontSize);
+ const lineHeight = Number.parseFloat(style.lineHeight) || fontSize;
+ const verticalUnit = deltaMode === WheelEvent.DOM_DELTA_LINE ? lineHeight
+ : deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientHeight : 1;
+ const horizontalUnit = deltaMode === WheelEvent.DOM_DELTA_LINE ? fontSize / 2
+ : deltaMode === WheelEvent.DOM_DELTA_PAGE ? scroller.clientWidth : 1;
+ scroller.scrollBy({ left: deltaX * horizontalUnit, top: deltaY * verticalUnit, behavior: "instant" });
+ };
+ dock.addEventListener("wheel", forwardGapWheel, { passive: false });
const measure = () => thread.style.setProperty("--task-composer-dock-height", `${dock.getBoundingClientRect().height}px`);
measure();
- if (typeof ResizeObserver === "undefined") return;
- const observer = new ResizeObserver(measure);
- observer.observe(dock);
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
+ observer?.observe(dock);
return () => {
- observer.disconnect();
+ dock.removeEventListener("wheel", forwardGapWheel);
+ observer?.disconnect();
thread.style.removeProperty("--task-composer-dock-height");
};
}, []);
diff --git a/ui/src/components/task-chat/TaskChatComposerStyles.test.ts b/ui/src/components/task-chat/TaskChatComposerStyles.test.ts
index ce79a25ad20..13b427fcfa2 100644
--- a/ui/src/components/task-chat/TaskChatComposerStyles.test.ts
+++ b/ui/src/components/task-chat/TaskChatComposerStyles.test.ts
@@ -21,6 +21,14 @@ function customPropertyValue(name: string): string {
}
describe("task-chat composer styles", () => {
+ it("covers transcript text and blocks clicks across the Coordinator footer", () => {
+ const block = cssBlock('.coordinator-workspace [data-testid="task-chat-composer-dock"]');
+
+ expect(block).toContain("background: var(--background)");
+ expect(block).toContain("pointer-events: auto");
+ expect(block).not.toContain("border-radius");
+ });
+
it("wraps long placeholders within the composer instead of clipping them", () => {
const block = cssBlock(
'.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"]',
diff --git a/ui/src/index.css b/ui/src/index.css
index 6bc21d29dac..404057e5d06 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -5018,14 +5018,12 @@ html:not(.dark) .coordinator-sidebar {
.coordinator-workspace [data-conversation-text="human"] { background: var(--bubble-human-bg); color: var(--bubble-human-fg); }
.coordinator-workspace [data-question-answer] { background: var(--card); }
.dark .coordinator-workspace [data-question-answer] { background: var(--muted); }
-/* Only the composer surface obscures history; its surrounding dock is clear. */
.coordinator-workspace [data-testid="task-chat-composer-dock"] {
flex-shrink: 0;
- background: transparent;
+ background: var(--background);
backdrop-filter: none;
- pointer-events: none;
+ pointer-events: auto;
}
-.coordinator-workspace [data-testid="task-chat-composer-dock"] > * { pointer-events: auto; }
@media (min-width: 768px) {
.coordinator-workspace [data-testid="task-chat-thread"] { position: relative; }
.coordinator-workspace [data-testid="task-chat-composer-dock"] { position: absolute; inset-inline: 0; bottom: 0; }