diff --git a/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts new file mode 100644 index 0000000000..73e2a9f532 --- /dev/null +++ b/apps/desktop/src/main/__tests__/chat-primitive-cascade-contract.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { REPO_ROOT, readAllRendererCss, stripCssComments } from './css-test-helpers.js'; + +/** + * Zero-visual governance contract for issue #332 PR1 — the chat + * conversation-flow row/bubble *shell* moved onto the `@maka/ui` `Message` / + * `Bubble` primitives. These assertions lock the two halves of "zero visual + * change": the bespoke shell CSS is retired, while the Markdown prose and the + * still-hand-written turn machinery (PR2) keep their exact layout. + */ +describe('chat primitive shell migration contract (#332 PR1)', () => { + it('retires the bespoke bubble/row shell selectors', async () => { + const css = stripCssComments(await readAllRendererCss()); + for (const selector of [ + '.maka-bubble-user', + '.maka-bubble-truncated', + '.maka-bubble-assistant-stack', + '.message.user', + '.message.assistant', + '.message.system', + '.message >', + '.message pre', + ]) { + assert.ok( + !css.includes(selector), + `retired shell selector "${selector}" still present in renderer CSS`, + ); + } + }); + + it('preserves the assistant Markdown prose (OUT of scope)', async () => { + const css = await readAllRendererCss(); + for (const selector of [ + '.maka-bubble-assistant {', + '.maka-bubble-assistant p', + '.maka-bubble-assistant pre', + '.maka-bubble-assistant table', + '.maka-bubble-assistant li.task-list-item', + ]) { + assert.ok(css.includes(selector), `prose rule "${selector}" must be preserved`); + } + }); + + it('keeps the row + re-anchors turn layout onto the Message primitive', async () => { + const css = await readAllRendererCss(); + // The centered reading column / entrance animation stay authored. + assert.ok(css.includes('.maka-message-row'), '.maka-message-row row base must stay'); + // Lineage row + footer (PR2, still hand-written) ride the primitive's + // data hook so their measure column survives until they migrate. + assert.ok( + css.includes('[data-slot="message"][data-role="assistant"] .maka-turn-footer'), + 'turn footer layout must be re-anchored to the Message primitive', + ); + assert.ok( + css.includes('[data-slot="message"][data-role="system"] pre'), + 'system note pre styling must be re-anchored to the Message primitive', + ); + }); + + it('pins the user bubble shell to the retired .maka-bubble-user pixels', async () => { + const rawSrc = await readFile( + resolve(REPO_ROOT, 'packages', 'ui', 'src', 'primitives', 'chat.tsx'), + 'utf8', + ); + // Strip comments so the assertions reflect real classNames, not prose that + // happens to name the scale utilities it is telling us to avoid. + const chatSrc = rawSrc.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + // The shell values are LITERAL Tailwind arbitrary utilities, so the variant + // class string compiles 1:1 to its declarations on a leaf element with + // nothing to resolve or override — asserting the exact string here is + // equivalent to asserting the computed style, without a browser. Matching + // the WHOLE string (not just "contains each literal") also pins the set + // closed: a stray extra `rounded-[12px]` / `px-4` / second `max-w-*` that + // would silently override the shell makes this fail. Values mirror the + // retired `.maka-bubble-user` exactly (border-radius:10px; padding:10px + // 14px; line-height:1.6; max-width:min(100%,640px); --chat-user-bg) and + // never the semantic scale (`rounded-lg`, `px-3.5`) or primary/accent. + const bubbleBlock = chatSrc.slice(chatSrc.indexOf('bubbleVariants')); + const userClass = bubbleBlock.match(/user:\s*"([^"]*)"/)?.[1]; + assert.equal( + userClass, + 'max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-[10px] bg-[var(--chat-user-bg)] px-[14px] py-[10px] leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]', + 'user bubble variant must match the retired .maka-bubble-user pixels exactly', + ); + }); +}); diff --git a/apps/desktop/src/renderer/maka-tokens.css b/apps/desktop/src/renderer/maka-tokens.css index eb2ec757d8..d0ff93cc2e 100644 --- a/apps/desktop/src/renderer/maka-tokens.css +++ b/apps/desktop/src/renderer/maka-tokens.css @@ -1132,12 +1132,6 @@ transform: rotate(45deg); } - .maka-bubble-assistant-stack { - display: flex; - flex-direction: column; - gap: 0; - } - /* The streaming bubble (out-of-band, before the in-progress turn is * fully persisted) sits at the bottom of the chat surface; give it the * same horizontal frame as a real turn message. */ @@ -1147,25 +1141,10 @@ previous turn while it lands. */ box-sizing: border-box; } - /* User message: a tinted, width-capped block anchored to the right. - PR-CHAT-CHROME-FOLLOWUP-0: the previous treatment was transparent - right-aligned text — for a long message it filled most of the - column, so the right-anchor was imperceptible. Re-tinting with the - existing `--chat-user-bg` token + a width cap makes "what the human - said" read clearly on the right at any length. The block shrink- - wraps to its content because `.message.user` aligns items to - flex-end (so short messages stay compact). Radius 10px matches the - code-block family and stays under the sharp-identity ceiling. */ - .maka-bubble-user { - background: var(--chat-user-bg); - color: var(--chat-user-foreground, var(--foreground)); - border-radius: 10px; - padding: 10px 14px; - line-height: 1.6; - max-width: min(100%, 78%); - white-space: pre-wrap; - word-wrap: break-word; - } + /* User message: a tinted, width-capped block anchored to the right — now + the `Bubble variant="user"` chat primitive (issue #332 PR1). It keeps the + neutral `--chat-user-bg` token path; the shell utilities live in + packages/ui/src/primitives/chat.tsx. */ /* Assistant: no bubble — just text on background, like an editor. The bubble itself flows naturally; child markdown elements (h*, p, ul, code blocks, tables, blockquotes) all need their own spacing/typography @@ -1443,13 +1422,6 @@ border-top: 1px solid var(--border); } - /* User content stays verbatim text, but shares the same no-bubble body - language as assistant messages. */ - .maka-bubble-user { - white-space: pre-wrap; - max-width: min(100%, 640px); - } - /* Hover-revealed copy button on assistant messages. The wrapper is position: relative so the button can anchor to the top-right corner. The hover/focus-within guard keeps it out of the way until the user diff --git a/apps/desktop/src/renderer/styles/reasoning-panel.css b/apps/desktop/src/renderer/styles/reasoning-panel.css index 763b212a20..480aef032e 100644 --- a/apps/desktop/src/renderer/styles/reasoning-panel.css +++ b/apps/desktop/src/renderer/styles/reasoning-panel.css @@ -108,23 +108,10 @@ cursor: help; } - /* PR-UI-Cx (@kenji msg cd09bcac) — "已截断" pill on the streaming - assistant bubble. Fires when `applyAssistantDelta` either tail-kept - a single oversize delta or head-capped the per-session total. Same - visual family as the reasoning-panel truncated pill, positioned as - a footer affordance under the bubble's Markdown body so it doesn't - compete with the running text. */ - .maka-bubble-truncated { - display: inline-block; - margin-top: 6px; - font-size: 10px; - color: var(--warning-text, var(--info-text)); - border: 1px solid oklch(from var(--warning) l c h / 0.24); - background: oklch(from var(--warning) l c h / 0.05); - border-radius: 4px; - padding: 0 5px; - cursor: help; - } + /* The streaming "已截断" pill (PR-UI-Cx, @kenji msg cd09bcac) moved onto the + `Bubble variant="assistant"` chat primitive as inline utilities (issue + #332 PR1); its sibling `.maka-reasoning-panel-truncated` pill below keeps + the same visual family. */ .maka-reasoning-panel-chevron { font-size: 12px; diff --git a/apps/desktop/src/renderer/styles/tool-output.css b/apps/desktop/src/renderer/styles/tool-output.css index 6207950242..432314921a 100644 --- a/apps/desktop/src/renderer/styles/tool-output.css +++ b/apps/desktop/src/renderer/styles/tool-output.css @@ -21,19 +21,6 @@ animation: none; } -.message > span { - display: block; - margin-bottom: 6px; - color: var(--foreground-50); - font-size: 11px; - font-weight: 600; - letter-spacing: 0.01em; -} - -.message.user > span { color: var(--foreground-60); } -.message.assistant > span { color: var(--accent); } -.message.system > span { color: var(--info-text); } - /* PR-CHAT-CHROME-FOLLOWUP-0: the relative time is always visible now (it was `opacity: 0` until hover, which hid it on touch + from assistive tech). It stays quiet — 12px at the shared chrome size, @@ -49,28 +36,22 @@ } /* User turn meta row: quiet time + a copy affordance, right-aligned - beneath the message block (the parent `.message.user` aligns items - to flex-end, so this row shrink-wraps and hugs the right edge). */ + beneath the message block (the parent `Message role="user"` aligns its + items to flex-end, so this row shrink-wraps and hugs the right edge). */ .maka-message-meta { display: inline-flex; align-items: center; gap: 6px; } -.message.user { - display: flex; - flex-direction: column; - align-items: flex-end; - /* Separate the meta row from the bubble. Kept below the 8px turn - rhythm so the time + copy still read as belonging to the bubble - (proximity) rather than floating between turns. */ - gap: 6px; -} - +/* Turn summary / lineage / footer share the assistant turn's left-anchored + measure column. The `[data-role="assistant"]` parts ride the chat Message + primitive (issue #332 PR1) — the lineage row + footer are still hand-written + (PR2), so this rule keeps their layout until they migrate. */ .maka-turn > .maka-turn-summary, .maka-turn > .maka-turn-lineage-row, -.message.assistant .maka-turn-lineage-row, -.message.assistant .maka-turn-footer { +[data-slot="message"][data-role="assistant"] .maka-turn-lineage-row, +[data-slot="message"][data-role="assistant"] .maka-turn-footer { display: flex; max-width: var(--maka-chat-measure, 680px); width: 100%; @@ -79,20 +60,12 @@ justify-content: flex-start; } -.message.assistant, -.message.system { - max-width: var(--maka-chat-measure, 680px); - width: 100%; - margin-left: 0; - margin-right: auto; -} - -.message pre { +[data-slot="message"] pre { margin: 0; font: inherit; } -.message.system pre { +[data-slot="message"][data-role="system"] pre { display: inline-flex; border-radius: 999px; background: oklch(from var(--info) l c h / 0.08); diff --git a/packages/ui/src/__tests__/chat-primitives.test.ts b/packages/ui/src/__tests__/chat-primitives.test.ts new file mode 100644 index 0000000000..85ee2030bc --- /dev/null +++ b/packages/ui/src/__tests__/chat-primitives.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Bubble, Message } from '../primitives/chat.js'; + +// The re-anchored renderer selectors key off the primitives' own `data-slot` / +// `data-role` / `data-variant`, so a consumer must never be able to clobber +// them. Both primitives are hook-free pure functions, so calling them directly +// and inspecting the returned element's props proves the structural hooks win +// over conflicting props — no DOM, no renderer needed. +test('Message keeps its own data-slot/data-role over conflicting props', () => { + const el = Message({ + variant: 'assistant', + 'data-slot': 'spoofed', + 'data-role': 'user', + } as never); + const props = el.props as Record; + assert.equal(props['data-slot'], 'message'); + assert.equal(props['data-role'], 'assistant'); +}); + +test('Bubble keeps its own data-slot/data-variant over conflicting props', () => { + const el = Bubble({ + variant: 'user', + 'data-slot': 'spoofed', + 'data-variant': 'assistant', + } as never); + const props = el.props as Record; + assert.equal(props['data-slot'], 'bubble'); + assert.equal(props['data-variant'], 'user'); +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index f856ef12c9..e5b55528de 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -195,6 +195,7 @@ import { cn, } from './ui.js'; import { Alert, AlertAction, AlertDescription, AlertTitle } from './primitives/alert.js'; +import { Bubble, Message } from './primitives/chat.js'; import { Button as PrimitiveButton } from './primitives/button.js'; import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from './primitives/empty.js'; import { InputGroup, InputGroupAddon, InputGroupInput } from './primitives/input-group.js'; @@ -4374,7 +4375,7 @@ export function ChatView(props: { // text_complete. Wrapping here makes streaming structurally // identical to TurnView's committed turn.
-
+ {/* PR-UI-LAYOUT-42: Reasoning panel for Anthropic-style * extended thinking. Renders ABOVE the streaming * answer because thinking always precedes the @@ -4398,7 +4399,7 @@ export function ChatView(props: { onSettled={props.onStreamingSettled} /> )} -
+
)} {/* Defensive: if any tool ended up outside a turn (e.g. legacy @@ -4449,9 +4450,9 @@ const MessageBody = memo(function MessageBody(props: { role: string; text: strin // turn footer's copy (same primitive + `.maka-turn-footer-action`). return ( <> -
+ {props.text} -
+
{props.ts !== undefined && ( @@ -4464,9 +4465,9 @@ const MessageBody = memo(function MessageBody(props: { role: string; text: strin // Assistant / system body: open prose, no bubble. Per-turn timing lives in // the turn summary; copy + the other actions live in the turn footer. return ( -
+ -
+ ); }); @@ -4769,24 +4770,24 @@ function TurnView(props: {
)} {turn.user && ( -
-
+ )} {turn.notes.map((note) => ( -
-
+ ))} {turn.tools.length > 0 && (
@@ -4794,13 +4795,13 @@ function TurnView(props: {
)} {turn.assistant && ( -
-
+
{turn.assistantThinking && (
@@ -4872,7 +4873,7 @@ function TurnView(props: { assistantText={turn.assistant.text} /> )} -
+ )} ); @@ -5157,11 +5158,11 @@ function StreamingAssistantBubble(props: { text: string; live: boolean; truncate }, [props.live, catchingUp, props.onSettled]); return ( -
+ {props.truncated && (
)} -
+
); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index eec5bc549e..37665d3b92 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -27,6 +27,7 @@ export * from './utils.js'; // consumers can `import { Alert, Empty, Sidebar, ... } from '@maka/ui'`. export * from './bot-brand.js'; export * from './primitives/alert.js'; +export * from './primitives/chat.js'; export * from './primitives/empty.js'; export * from './primitives/item.js'; export * from './primitives/spinner.js'; diff --git a/packages/ui/src/primitives/chat.tsx b/packages/ui/src/primitives/chat.tsx new file mode 100644 index 0000000000..1f08ab2d4e --- /dev/null +++ b/packages/ui/src/primitives/chat.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { cn } from "../utils.js"; +import { cva, type VariantProps } from "class-variance-authority"; +import type React from "react"; + +/** + * Chat conversation-flow primitives (issue #332, PR1). + * + * `Message` is the per-turn row container; `Bubble` is the message body + * surface. They retire the bespoke `.message.{role}` / `.maka-bubble-user` + * shell CSS, moving the row/bubble *shell* onto the Tailwind substrate while + * leaving Markdown prose (`.maka-bubble-assistant *`, maka-tokens.css) and the + * turn machinery (summary / lineage / footer / markers — PR2) untouched. + * + * The row keeps the authored `.maka-message-row` base (centered reading column + * + entrance fade/animation + the `data-maka-visual-smoke` disable). That base + * lives in maka-tokens.css's `@layer components`, so the role utilities below + * (utilities layer) win over its `margin: 0 auto` for the left-anchored + * assistant/system rows. The neutral `--chat-user-bg` token path is preserved + * verbatim — the user bubble is never switched to `primary`/`accent`. + */ + +const messageVariants = cva("maka-message-row", { + variants: { + variant: { + // `.message.user`: shrink-wrap column, body hugs the right edge. No + // margin override — the row stays centered (its `margin: 0 auto`). + user: "flex flex-col items-end gap-1.5", + // `.message.assistant` / `.message.system`: left-anchor inside the + // measure column (override the row's centering). + assistant: "ml-0 mr-auto", + system: "ml-0 mr-auto", + }, + }, +}); + +export interface MessageProps + extends React.ComponentPropsWithoutRef<"article"> { + // The chat role. Named `variant` (not `role`) so it never shadows the native + // HTML/ARIA `role` attribute, which still flows through `...props`. Emitted + // to the DOM as `data-role` — the hook the turn lineage/footer and system + // `pre` rules anchor on. + variant: "user" | "assistant" | "system"; +} + +export function Message({ + className, + variant, + ...props +}: MessageProps): React.ReactElement { + return ( + // `{...props}` is spread first so the structural `data-*` hooks the + // re-anchored selectors depend on always land last and can't be clobbered + // by a consumer passing `data-slot` / `data-role`. +
+ ); +} + +const bubbleVariants = cva("", { + variants: { + variant: { + // `.maka-bubble-user`: tinted, width-capped, right-anchored block. + // Values are LITERAL (`rounded-[10px]`, `px-[14px] py-[10px]`), not the + // design-system scale (`rounded-lg`, `px-3.5`): the retired CSS hardcoded + // these pixels, so the literal is the faithful, self-evidently-equal + // translation and immune to later scale/token re-tuning (the visual + // refresh, not this governance pass, owns adopting the scale). Keeps the + // neutral `--chat-user-bg` token path (never primary/accent). + user: "max-w-[min(100%,640px)] whitespace-pre-wrap break-words rounded-[10px] bg-[var(--chat-user-bg)] px-[14px] py-[10px] leading-[1.6] text-[color:var(--chat-user-foreground,var(--foreground))]", + // Assistant / system: open prose, no bubble. Typography stays authored + // under `.maka-bubble-assistant` (Markdown prose, OUT of scope), so this + // variant re-emits that class as the styling hook. + assistant: "maka-bubble-assistant", + }, + }, +}); + +export interface BubbleProps extends React.ComponentPropsWithoutRef<"div"> { + variant: VariantProps["variant"]; +} + +export function Bubble({ + className, + variant, + ...props +}: BubbleProps): React.ReactElement { + return ( +
+ ); +}