mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
fix(ui): make the Coordinator composer footer opaque (#18455)
## Thinking Path
> - Paperclip helps people manage AI agents and their work.
> - The Coordinator workspace shows a task transcript and a message
composer.
> - The composer footer can cover part of the transcript on desktop.
> - The footer has a transparent background, so text shows through
accessory panels and gaps.
> - This pull request gives the footer a solid background from the
active theme.
> - Users can read the composer controls without text from the
transcript behind them.
## Linked Issues or Issue Description
**What happened?**
The Coordinator composer footer lets transcript text show through the
area above the message field.
**Expected behavior**
The full footer has a solid surface in light and dark themes.
**Steps to reproduce**
1. Enable the Coordinator workspace.
2. Open a task with enough messages to scroll.
3. Show a composer accessory, such as queued messages or a pending
question.
4. Scroll the transcript behind the footer on desktop.
**Paperclip version or commit**
Base commit: f2db909393.
**Deployment mode**
Built from source. The change affects the Coordinator workspace.
Related work: https://github.com/paperclipai/paperclip/pull/17983 added
the workspace. Searches for an opaque or transparent composer fix found
no duplicate.
## What Changed
- Use `var(--background)` for the full Coordinator composer footer.
- Keep the outer footer square so transcript text cannot show at its
corners.
- Block clicks through the footer gaps. Forward wheel input from these
gaps to the transcript.
- Keep editor scrolling, zoom input, and the classic workspace behavior.
- Add regression tests for the footer surface, wheel input, and canceled
forwarded events.
- Keep wheel modifier keys. Use the font size to calculate horizontal
distance for line-mode input.
- Remove the old comment that describes a transparent footer.
## Verification
- Passed: 173 tests in `TaskChatComposerDock`, `TaskChatComposerStyles`,
`TaskChatComposer`, `TaskChatQueuedMessages`, and `TaskMessageScroller`.
- The surface and wheel tests fail on the previous implementation and
pass with this change.
- The latest wheel tests reproduce lost modifier keys and excessive
horizontal distance on the prior commit. The cancellation test fails
when the cancellation guard is removed.
- Passed: `pnpm check:token-gates` and `git diff --check`.
- Passed: local Chromium checks with the production dock and built UI
CSS in light and dark themes at desktop and mobile sizes. The checks
cover solid corners, blocked clicks in footer gaps, wheel input, message
entry, and horizontal overflow.
- The browser fixture reproduces the hidden-control click when the old
pointer rules are restored. These are local fixture checks, not a
deployed acceptance result.
- Passed: `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter
@paperclipai/ui build`.
- Full local `pnpm -r typecheck` and `pnpm build` could not finish
because Cargo is absent from this environment. The full local test run
was stopped without a complete result after these missing prerequisites
were confirmed. Full CI now passes on the current commit.
- Passed: [full
CI](https://github.com/paperclipai/paperclip/actions/runs/41205893876)
on commit `37feaef28`. Greptile and Paperclip both gave this commit 5/5.
Both review threads are resolved.
- Paperclip review found hidden clicks through footer gaps and exposed
corners. This change addresses both findings. The first CI quality
review required a regression test. This change adds those tests. The
later review requested a cancellation test and better horizontal wheel
handling. This change addresses both items.
## Risks
- Low risk. The CSS selector applies only inside the Coordinator
workspace.
- The footer now captures clicks across its full area. Wheel input in
empty footer space still scrolls the transcript.
- The dock forwards wheel input to the transcript handler so
earlier-history loading still receives that input.
- No API, data, or migration changes.
## Model Used
- OpenAI Codex, based on GPT-6 (`gpt-6`), with reasoning, tool use, and
code execution. The runtime does not expose its context window. The
model ID for the original saved CSS patch is not retained in the task
evidence.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
47f81e850b
commit
f46c8071fb
4 files changed
+150
-9
No files matched your search
@@ -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(
|
||||
<div className={coordinator ? "coordinator-workspace" : undefined}>
|
||||
<div data-testid="task-chat-thread">
|
||||
<div data-testid="task-chat-scroller" onWheel={onTranscriptWheel} />
|
||||
<TaskChatComposerDock mobile={false} streamlined>
|
||||
<textarea aria-label="Message" />
|
||||
</TaskChatComposerDock>
|
||||
</div>
|
||||
</div>,
|
||||
));
|
||||
const dock = container.querySelector<HTMLDivElement>('[data-testid="task-chat-composer-dock"]')!;
|
||||
const scroller = container.querySelector<HTMLDivElement>('[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();
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
useLayoutEffect(() => {
|
||||
const dock = dockRef.current;
|
||||
const thread = dock?.closest<HTMLElement>('[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<HTMLElement>('[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");
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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_"]',
|
||||
|
||||
+2
-4
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user