fix(ui): align the mobile task chat composer with the thread (#11296)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task thread is where people read work and guide agents.
> - The mobile composer should use the same content width as the thread.
> - The composer kept the desktop 80% width on mobile, so its edges did
not align with the thread.
> - Long assignee-aware placeholder text could also clip inside the
mobile editor.
> - Some extracted style tokens used legacy HSL wrappers around complete
semantic colors, which made those declarations invalid.
> - This pull request makes the composer full width on mobile, preserves
the narrower desktop layout, wraps the placeholder, and repairs the
invalid color compositions.
> - The benefit is a stable mobile composer that aligns with the task
thread and keeps its intended visual styles.

## Linked Issues or Issue Description

Related work: Refs #11263.

**What happened?**

At mobile widths, the task chat composer used the same 80% width as the
desktop composer. Its horizontal edges did not align with the full task
thread. A long assignee-aware placeholder could clip on one line. The
composer's extracted shadow also used a legacy `hsl(var(...))` wrapper
around complete semantic color values, so the browser could reject the
declaration.

**Expected behavior**

The composer must match the task thread width on mobile. It must stay
narrower on larger screens. Long placeholder text must wrap inside the
editor. Semantic color tokens must form valid shadows and gradients.

**Steps to reproduce**

1. Open a task with the chat-style thread on a mobile viewport.
2. Compare the composer edges with the task thread edges.
3. Select an assignee whose placeholder text wraps to two lines.
4. Inspect the computed composer shadow and the extracted semantic color
styles.

**Paperclip version or commit**

The change is based on `dc6fcd1ff1` from `master`.

**Deployment mode**

Local build from source. The behavior also applies to packaged web
builds.

## What Changed

- Made the task chat composer full width below the medium breakpoint and
kept the 80% desktop width.
- Matched the composer dock padding to the task thread padding.
- Allowed long composer placeholders to wrap and reserved enough mobile
editor height for two lines.
- Replaced invalid legacy HSL wrappers around full semantic colors in
extracted shadows, gradients, and approval styles.
- Added a token gate that prevents legacy `hsl(var(--token))` wrappers
from returning.
- Added focused regression tests for responsive width, padding,
placeholder wrapping, mobile height, and semantic shadow validity.

## Verification

- `pnpm check:token-gates` — all four gates pass.
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/TaskChatThread.test.tsx
src/components/task-chat/TaskChatComposer.test.tsx
src/components/task-chat/TaskChatComposerStyles.test.ts` — 37 tests
pass.
- `pnpm --filter @paperclipai/ui typecheck` — passes.
- `pnpm --filter @paperclipai/ui build` — passes. The build prints
existing CSS optimizer and bundle-size warnings.

## Risks

- Low risk. The width change is limited to the mobile breakpoint. The
desktop 80% layout remains in place.
- The semantic token fixes can affect shadows and gradients that were
previously invalid. The new gate prevents the invalid wrapper pattern
from returning.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with `gpt-5.6-sol`. The context-window size is not
exposed in this environment. The model used reasoning, repository tools,
code execution, and GitHub tools.

## 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:
Dotta 2026-08-14 12:53:11 -04:00 committed by GitHub
parent d95340b0b8
commit d2665ff6b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 18 deletions

View File

@ -49,6 +49,11 @@
* `fontSize: "..."` / `font-size:` string-literal declarations in
* inline styles or css-in-js.
*
* Gate 4 zero legacy hsl(var(--token)) wrappers in the token layer.
* Semantic colors are complete color values (currently OKLCH), not bare
* HSL channels. Wrapping one in hsl() creates an invalid declaration and
* can void an entire composed box-shadow.
*
* The ALLOWLIST is parsed from the machine-readable block in
* ui/src/index.css (search for "── ALLOWLIST" below it), one entry per
* line in the form:
@ -240,6 +245,17 @@ function findFontSizeIssues(content) {
return issues;
}
// Semantic color custom properties hold complete color values. Legacy
// Tailwind-v3-era hsl(var(--token) / alpha) composition is therefore invalid.
const LEGACY_HSL_VAR_WRAPPER_RE = /\bhsla?\(\s*var\(--[^)]+\)[^)]*\)/g;
function findLegacyHslVarWrapperIssues(content) {
return Array.from(content.matchAll(LEGACY_HSL_VAR_WRAPPER_RE), (match) => ({
index: match.index,
snippet: match[0],
}));
}
function lineNumberAt(content, index) {
return content.slice(0, index).split("\n").length;
}
@ -248,7 +264,7 @@ function main() {
const allowlist = loadAllowlist(CSS_PATH);
const files = listFiles();
const violations = { gate1: [], gate2: [], gate3: [] };
const violations = { gate1: [], gate2: [], gate3: [], gate4: [] };
let allowlistedSkips = 0;
for (const filePath of files) {
@ -277,7 +293,16 @@ function main() {
}
}
const totalViolations = violations.gate1.length + violations.gate2.length + violations.gate3.length;
const tokenLayer = readFileSync(CSS_PATH, "utf8");
for (const issue of findLegacyHslVarWrapperIssues(tokenLayer)) {
violations.gate4.push({
file: relPathToPosix(CSS_PATH),
line: lineNumberAt(tokenLayer, issue.index),
snippet: issue.snippet,
});
}
const totalViolations = Object.values(violations).reduce((total, gate) => total + gate.length, 0);
console.log("check-token-gates summary");
console.log(` Files scanned: ${files.length}`);
@ -287,6 +312,7 @@ function main() {
console.log(` Gate 1 (color literals): ${violations.gate1.length === 0 ? "CLEAN" : `${violations.gate1.length} violation(s)`}`);
console.log(` Gate 2 (arbitrary bracket vals): ${violations.gate2.length === 0 ? "CLEAN" : `${violations.gate2.length} violation(s)`}`);
console.log(` Gate 3 (raw font-size): ${violations.gate3.length === 0 ? "CLEAN" : `${violations.gate3.length} violation(s)`}`);
console.log(` Gate 4 (legacy hsl(var())): ${violations.gate4.length === 0 ? "CLEAN" : `${violations.gate4.length} violation(s)`}`);
if (totalViolations > 0) {
console.log("\nViolations:\n");

View File

@ -54,6 +54,19 @@ function render(ui: ReactElement) {
}
describe("TaskChatThread draft pass-through", () => {
it("keeps the composer dock aligned with the thread's horizontal padding", () => {
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
/>,
);
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
expect(dock?.classList).toContain("px-4");
expect(dock?.classList).not.toContain("px-1");
});
it("forwards draftKey so the composer restores a task's saved draft", () => {
localStorage.setItem("task-chat-draft:issue-1", "half-written thought");
@ -71,15 +84,15 @@ describe("TaskChatThread draft pass-through", () => {
});
describe("TaskChatThread composer alignment (PAP-498)", () => {
it("keeps the composer dock at 80% of the thread width", () => {
it("matches the thread width on mobile and stays narrower on larger screens", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
expect(dock?.className).toContain("w-(--pct-80)");
expect(dock?.className).not.toContain("w-full");
expect(dock?.className).toContain("w-full");
expect(dock?.className).toContain("md:w-(--pct-80)");
});
});

View File

@ -610,6 +610,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
</div>
{showComposer ? (
<div
data-testid="task-chat-composer-dock"
className={cn(
"sticky",
// Mobile mirrors the flag-off thread's dock: lifted above the
@ -624,9 +625,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
// Keep the composer visibly narrower than the thread while its
// accessories and footer continue to share the same column.
"mx-auto flex w-(--pct-80) max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
// Match the thread width on mobile. Keep the intentionally
// narrower composer on larger screens.
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:w-(--pct-80)",
)}
>
{composerAccessory}

View File

@ -43,10 +43,12 @@ vi.mock("@mdxeditor/editor", async () => {
markdown,
onChange,
readOnly,
contentEditableClassName,
}: {
markdown: string;
onChange?: (value: string) => void;
readOnly?: boolean;
contentEditableClassName?: string;
},
forwardedRef: React.ForwardedRef<MockHandle | null>,
) {
@ -84,6 +86,7 @@ vi.mock("@mdxeditor/editor", async () => {
<div
ref={editableRef}
data-testid="mdx-editor"
data-content-class-name={contentEditableClassName}
contentEditable={!readOnly}
suppressContentEditableWarning
onInput={(e) => {
@ -255,6 +258,18 @@ describe("TaskChatComposer", () => {
expect(composer?.className).not.toContain("p-2");
});
it("scopes the wrapping placeholder override to the task-chat composer", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(container.firstElementChild?.classList).toContain("paperclip-task-chat-composer");
});
it("reserves enough mobile editor height for a wrapped two-line placeholder", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" mobile />);
expect(editable().dataset.contentClassName).toContain("min-h-(--sz-72px)");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);

View File

@ -371,7 +371,7 @@ export function TaskChatComposer({
return (
<div
className={cn(
"rounded-xl border border-input bg-card p-(--sz-18px) shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
"paperclip-task-chat-composer rounded-xl border border-input bg-card p-(--sz-18px) shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
)}
onKeyDownCapture={(e) => {
// Shift+Tab cycles the pending mode; captured on the wrapper so it
@ -400,7 +400,7 @@ export function TaskChatComposer({
className={cn(disabled && "opacity-60")}
contentClassName={
mobile
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
? "max-h-(--sz-28dvh) min-h-(--sz-72px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
}
/>

View File

@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const stylesheet = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
function cssBlock(selector: string): string {
const start = stylesheet.indexOf(`${selector} {`);
expect(start, `Missing CSS selector: ${selector}`).toBeGreaterThanOrEqual(0);
const bodyStart = stylesheet.indexOf("{", start);
const bodyEnd = stylesheet.indexOf("\n}", bodyStart);
expect(bodyEnd, `Missing CSS block end: ${selector}`).toBeGreaterThan(bodyStart);
return stylesheet.slice(bodyStart + 1, bodyEnd);
}
function customPropertyValue(name: string): string {
const match = stylesheet.match(new RegExp(`^\\s*${name}:\\s*([^;]+);`, "m"));
expect(match, `Missing CSS custom property: ${name}`).not.toBeNull();
return match?.[1].trim() ?? "";
}
describe("task-chat composer styles", () => {
it("wraps long placeholders within the composer instead of clipping them", () => {
const block = cssBlock(
'.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"]',
);
expect(block).toContain("display: block");
expect(block).toContain("width: 100%");
expect(block).toContain("overflow-wrap: anywhere");
expect(block).toContain("white-space: normal");
});
it("keeps the composer's combined shadow valid with full-color semantic tokens", () => {
const shadow = customPropertyValue("--shadow-extract-7");
expect(shadow).toContain("color-mix(in oklab, var(--primary) 16%, transparent)");
expect(shadow).not.toContain("hsl(var(");
});
});

View File

@ -596,8 +596,8 @@
to { opacity: 1; transform: none; }
}
@keyframes tc-approval-pulse {
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.0); }
50% { box-shadow: 0 0 0 3px hsl(var(--primary) / 0.18); }
0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent); }
50% { box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 18%, transparent); }
}
@keyframes tc-cursor-blink {
0%, 45% { opacity: 1; }
@ -703,7 +703,7 @@
/* The pill keyframes carry the X-centering; with animation:none restore it. */
.tc-scroll-pill-in,
.tc-scroll-pill-out { transform: translate(-50%, 0); }
.tc-approval { animation: none; box-shadow: 0 0 0 2px hsl(var(--primary) / 0.18); }
.tc-approval { animation: none; box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 18%, transparent); }
.tc-cursor { animation: none; opacity: 1; }
}
@ -1233,6 +1233,16 @@
color: var(--muted-foreground);
}
/* MDXEditor defaults placeholders to a single clipped line. The task-chat
composer uses longer, assignee-aware copy, so keep it inside the editor by
giving the absolutely positioned placeholder a wrapping inline size. */
.paperclip-task-chat-composer .paperclip-mdxeditor [class*="_placeholder_"] {
display: block;
width: 100%;
overflow-wrap: anywhere;
white-space: normal;
}
.paperclip-mdxeditor-content {
font-size: inherit;
line-height: inherit;
@ -2222,12 +2232,12 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--shadow-extract-4: 0 -12px 28px rgba(15,23,42,0.08); /* Extracted from ui/src/components/ChatComposer.test.tsx (shadow-[0_-12px_28px_rgba(15,23,42,0.08)]). */
--shadow-extract-5: 0 -12px 28px rgba(0,0,0,0.28); /* Extracted from ui/src/components/ChatComposer.test.tsx (shadow-[0_-12px_28px_rgba(0,0,0,0.28)]). */
--shadow-extract-6: 0 0 0 1px var(--color-background); /* Extracted from ui/src/components/DocumentAnnotationLayer.tsx (shadow-[0_0_0_1px_var(--color-background)]). */
--shadow-extract-7: 0 -12px 28px rgba(15,23,42,0.08),0 0 0 1px hsl(var(--primary)/0.16); /* Extracted from ui/src/components/IssueChatThread.tsx (shadow-[0_-12px_28px_rgba(15,23,42,0.08),0_0_0_1px_hsl(var(--primary)/0.16)]). */
--shadow-extract-7: 0 -12px 28px rgba(15,23,42,0.08),0 0 0 1px color-mix(in oklab, var(--primary) 16%, transparent); /* Extracted from ui/src/components/IssueChatThread.tsx; corrected for full-color semantic tokens. */
--shadow-extract-8: 0 1px 0 rgba(15,23,42,0.02); /* Extracted from ui/src/components/IssueRecoveryActionCard.tsx (shadow-[0_1px_0_rgba(15,23,42,0.02)]). */
--shadow-extract-9: 0 18px 42px rgba(15,23,42,0.06); /* Extracted from ui/src/components/IssueThreadInteractionCard.tsx (shadow-[0_18px_42px_rgba(15,23,42,0.06)]). */
--shadow-extract-10: 0 1px 0 1px hsl(var(--border)); /* Extracted from ui/src/components/KeyboardShortcutsCheatsheet.tsx (shadow-[0_1px_0_1px_hsl(var(--border))]). */
--shadow-extract-10: 0 1px 0 1px var(--border); /* Extracted from ui/src/components/KeyboardShortcutsCheatsheet.tsx; corrected for full-color semantic tokens. */
--shadow-extract-11: 0 18px 50px rgba(37,99,235,0.08); /* LiveRunWidget.tsx glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */
--shadow-extract-12: 0 0 0 2px hsl(var(--background)); /* Extracted from ui/src/components/SidebarNavItem.tsx (shadow-[0_0_0_2px_hsl(var(--background))]). */
--shadow-extract-12: 0 0 0 2px var(--background); /* Extracted from ui/src/components/SidebarNavItem.tsx; corrected for full-color semantic tokens. */
--shadow-extract-13: 0 0 0 3px rgba(245,158,11,0.18); /* Extracted from ui/src/components/environment-variables-editor/index.tsx (shadow-[0_0_0_3px_rgba(245,158,11,0.18)]). */
--shadow-extract-14: 0 0 12px rgba(37,99,235,0.08); /* AgentDetail.tsx live-card glow. Gallery feedback r2: liveness glow recolored cyan->status blue (value edit, site unchanged; originally extracted as rgba(6,182,212,0.08)). */
--shadow-extract-18: 0 18px 44px -28px rgba(0,0,0,0.45); /* Extracted from ui/src/pages/ProfileSettings.tsx (shadow-[0_18px_44px_-28px_rgba(0,0,0,0.45)]). */
@ -2376,8 +2386,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--code-highlight-bg-resolved: var(--paperclip-code-highlight-bg); /* Extracted from ui/src/components/FileViewerSheet.tsx; fallback dropped in A5 now that --paperclip-code-highlight-bg is a real token (was var(--paperclip-code-highlight-bg, rgba(250,204,21,0.12))). */
--code-gutter-fg-resolved: var(--paperclip-code-gutter-fg, var(--muted-foreground)); /* Extracted from ui/src/components/FileViewerSheet.tsx (text-[var(--paperclip-code-gutter-fg,theme(colors.muted.foreground))]). */
--code-highlight-border-resolved: var(--paperclip-code-highlight-border); /* Extracted from ui/src/components/FileViewerSheet.tsx; fallback dropped in A5 now that --paperclip-code-highlight-border is a real token (was var(--paperclip-code-highlight-border, rgb(234,179,8))). */
--gradient-extract-25: linear-gradient(135deg,hsl(var(--primary)) 0%,hsl(var(--accent)) 55%,hsl(var(--muted)) 100%); /* Extracted from ui/src/components/SidebarAccountMenu.tsx (bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]). */
--gradient-extract-26: linear-gradient(135deg,hsl(var(--primary)) 0%,hsl(var(--accent)) 58%,color-mix(in oklab,hsl(var(--background)) 76%,white 24%) 100%); /* Extracted from ui/src/pages/ProfileSettings.tsx (bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_58%,color-mix(in_oklab,hsl(var(--background))_76%,white_24%)_100%)]). */
--gradient-extract-25: linear-gradient(135deg,var(--primary) 0%,var(--accent) 55%,var(--muted) 100%); /* Extracted from ui/src/components/SidebarAccountMenu.tsx; corrected for full-color semantic tokens. */
--gradient-extract-26: linear-gradient(135deg,var(--primary) 0%,var(--accent) 58%,color-mix(in oklab,var(--background) 76%,white 24%) 100%); /* Extracted from ui/src/pages/ProfileSettings.tsx; corrected for full-color semantic tokens. */
}
/* ALLOWLIST (Phase 2, design/token-extraction)