Files
PaperClipAI/ui/src/components/task-chat/TaskChatDescriptionBubble.tsx
T
scotttongandClaude Opus 4.8 2ea22d6cea fix(ui): white text on light-mode user chat bubbles (#10952)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task detail view shows the conversation as chat bubbles. The
requester's own messages sit in a solid accent-colored bubble.
> - The bubble container sets `text-white`, but the message body renders
through `MarkdownBody`. Tailwind prose tokens (`--tw-prose-body`) win
over the inherited container color.
> - `prose-invert` only lightens the prose text in dark mode. In light
mode the prose body stayed its default dark color, so the text read as
near-black on the blue bubble and was hard to read.
> - This pull request maps the human bubble's prose tokens to the
inherited text color in both themes.
> - The benefit is that the requester's chat text is readable
white-on-blue in light mode, and dark mode stays exactly as it was.

## Linked Issues or Issue Description

<!-- No public GitHub issue exists; described in-PR per the bug report
template. -->

**What happened?**
In light mode, the text inside the user's own chat bubbles in the task
detail view rendered as dark (near-black) on the solid blue accent
background. This made the requester's messages hard to read.

**Expected behavior**
The text inside the user's accent-colored chat bubbles should be white
in light mode, matching the bubble's `text-white` intent. Dark mode
already rendered correctly and should not change.

**Steps to reproduce**
1. Open a chat-style task detail view in light mode.
2. Post a message as the requester (human) so it renders in the solid
blue accent bubble.
3. Observe the body text renders dark on blue instead of white.

**Paperclip version or commit**
Reproduces on `master` (branched from `814cb3367`).

**Agent adapter(s) involved**
Not adapter-specific (core UI bug).

## What Changed

- Add the existing `paperclip-markdown-on-accent` class to the
human-branch `MarkdownBody` in `TaskChatBubble.tsx`. This class (already
used by `IssueChatThread` for the same accent bubble) maps prose
body/heading tokens to `currentColor`, so the text follows the bubble's
`text-white` in both themes.
- Apply the same class to the human-branch `MarkdownBody` in
`TaskChatDescriptionBubble.tsx` (the description-as-first-bubble
surface) for consistency.
- Add unit tests covering that the human accent bubble carries the
on-accent class and the agent/neutral bubbles do not.

## Verification

- `pnpm check:token-gates` → 3/3 CLEAN.
- `pnpm --filter ./ui vitest run
src/components/task-chat/TaskChatBubble.test.tsx` → 9/9 passing.
- Manual: in light mode, the requester's chat bubble text renders white
on blue; agent/neutral bubbles unchanged; dark mode unchanged.

This is a visual change. Snapshot baselines are intentionally not
updated, per `doc/design/DECISION-SHEET.md` → "Per-change snapshot
verification demoted to dormant (Jul 13 2026)".

## Risks

Low risk. The change is scoped to the human-branch `MarkdownBody`
className on two chat-bubble components and only remaps prose color
tokens to the inherited text color. Agent and neutral bubbles are
untouched, and dark mode behavior is unchanged.

## Model Used

Claude Opus 4.8 (Anthropic), model id `claude-opus-4-8`, ~200K context
window, extended thinking mode, with tool use / code execution.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 01:08:04 -07:00

179 lines
6.9 KiB
TypeScript

import { useState } from "react";
import { Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
import { FoldCurtain } from "@/components/FoldCurtain";
import { InlineEditor } from "@/components/InlineEditor";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import type { MentionOption } from "@/components/MarkdownEditor";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
/**
* Host binding for the description-as-first-bubble (PAP-375): the LIVE issue
* description plus everything the edit surface needs. Carried on the shared
* thread props (`issueBrief`); only the redesigned TaskChatThread consumes it.
*/
export interface TaskChatIssueBrief {
/** LIVE issue.description (never a frozen copy) — empty string when unset. */
description: string;
/** Originating actor: human-created → blue user bubble, agent-created → agent-side bubble. */
author: "human" | "agent";
/** Creating agent's name, shown in the agent-side avatar header. */
authorName?: string;
/** Creating agent's assigned icon (AgentIconName) for the avatar header. */
agentIcon?: string | null;
/** issue.createdAt — rendered like the other bubbles' footer timestamps. */
createdAt?: string | Date;
onSave: (description: string) => void | Promise<unknown>;
mentions?: MentionOption[];
externalReferences?: MarkdownExternalReferenceMap;
imageUploadHandler?: (file: File) => Promise<string>;
onDropFile?: (file: File) => Promise<void>;
}
interface TaskChatDescriptionBubbleProps {
brief: TaskChatIssueBrief;
}
/** ~12 text-sm lines (20px line-height) — the accepted fold default (PAP-375). */
const FOLD_COLLAPSED_HEIGHT_PX = 240;
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
/**
* The task description as the requester's first chat bubble (PAP-375):
* human-created tasks get the blue user bubble, agent-created tasks the
* agent-side card with the avatar header — same chrome as TaskChatBubble. The
* body is the live issue.description; a hover pencil swaps the bubble for the
* same InlineEditor the pre-redesign page used (mentions, image paste/drop,
* autosave), restoring the edit surface the redesign had gated off. Long
* descriptions fold at ~12 lines behind a Show more curtain; an empty
* description renders a muted ghost row that opens the editor — never an
* empty bubble.
*/
export function TaskChatDescriptionBubble({ brief }: TaskChatDescriptionBubbleProps) {
const [editing, setEditing] = useState(false);
const isHuman = brief.author === "human";
const hasDescription = brief.description.trim().length > 0;
if (editing) {
return (
<div
className="tc-enter-bubble w-full rounded-lg border border-border bg-card px-2 py-1"
data-testid="task-chat-description-editor"
>
<InlineEditor
value={brief.description}
onSave={brief.onSave}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
multiline
defaultEditing
onEditingChange={(next) => {
if (!next) setEditing(false);
}}
mentions={brief.mentions}
externalReferences={brief.externalReferences}
imageUploadHandler={brief.imageUploadHandler}
onDropFile={brief.onDropFile}
/>
</div>
);
}
if (!hasDescription) {
// Accepted default: no fabricated empty bubble — a muted ghost affordance.
return (
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-lg px-1 py-1.5 text-left text-sm text-muted-foreground/70 transition-colors hover:bg-accent/20 hover:text-muted-foreground"
onClick={() => setEditing(true)}
data-testid="task-chat-description-ghost"
>
<Pencil className="h-3.5 w-3.5 shrink-0" aria-hidden />
Add a description...
</button>
);
}
const timestamp = formatTaskChatTimestamp(brief.createdAt);
return (
<div
className={cn(
"tc-enter-bubble group/brief flex w-full flex-col gap-1",
isHuman ? "items-end" : "items-start",
)}
data-testid="task-chat-description-bubble"
data-author={brief.author}
>
{!isHuman && brief.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{brief.agentIcon ? (
<AvatarFallback>
<AgentIcon icon={brief.agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(brief.authorName)}</AvatarFallback>
)}
</Avatar>
<span className="text-sm font-semibold text-foreground">{brief.authorName}</span>
</span>
) : null}
<div
className={cn(
"flex w-full items-start gap-1.5",
isHuman ? "flex-row-reverse" : "flex-row",
)}
>
<div
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
)}
>
<FoldCurtain
collapsedHeight={FOLD_COLLAPSED_HEIGHT_PX}
// The curtain's fade is a mask (background-agnostic); only the
// toggle's muted colors need a lift on the solid blue bubble.
toggleClassName={isHuman ? "text-white/80 hover:text-white hover:bg-white/10" : undefined}
>
<MarkdownBody
// On the solid --liveness-blue human bubble, keep prose body text
// following the bubble's `text-white` in both themes.
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
softBreaks
linkIssueReferences
externalReferences={brief.externalReferences}
>
{brief.description}
</MarkdownBody>
</FoldCurtain>
</div>
<button
type="button"
className="mt-1 shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent/50 hover:text-foreground focus-visible:opacity-100 group-hover/brief:opacity-100"
onClick={() => setEditing(true)}
aria-label="Edit description"
data-testid="task-chat-description-edit"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
{timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">{timestamp}</span>
) : null}
</div>
);
}