blob: 894a740c8e8c9cfc965c73abe1063f671b5f60b0 [file]
import { useLayoutEffect, useRef, useState } from 'react';
import { TextQuote, X } from './icons.js';
import { cn } from './utils.js';
import type { QuoteRef } from '@maka/core';
import { useUiLocale } from './locale-context.js';
import { getConversationCopy } from './conversation-copy.js';
/**
* Inline quoted-excerpt chip, shown inside the composer (removable) and inside
* a sent user message (read-only). A single-line pill rather than a card: a
* quote is a *reference*, so it should read as one token beside the message
* instead of competing with it for vertical space.
*
* An excerpt too long for the pill stays clipped until the user asks for it —
* clicking expands the chip in place to the full text. The model receives the
* excerpt verbatim either way (formatTextWithInlineRefs); this is presentation
* only. Expandability is measured, not guessed from a character count, so it
* holds for CJK and latin alike.
*
* Keeping the chip on the sent message is deliberate — a quote the user can
* see before sending but not afterwards makes the turn unauditable.
*/
/**
* Strip a leading ATX heading marker from a quoted excerpt for display
* (#2213).
*
* A quote can carry markdown source verbatim (selection capture keeps the raw
* text), so a heading line such as `### Description` would render in the chip
* as cramped literal hashes. The selection capture normalizes whitespace into
* a single line, so an ATX marker can survive at the start of the excerpt
* (other line prefixes like `- `, `> `, `1. ` survive too but are out of
* scope); mid-text `#` runs are treated as content and left alone.
*
* Presentation only — the model still receives `quote.text` verbatim via
* formatTextWithInlineRefs. CommonMark allows up to six `#` and requires a
* space (or end of line) after the marker; `###Description` (no space) is not
* a heading and is preserved.
*/
export function stripQuoteHeadingMarkers(text: string): string {
return text.replace(/^#{1,6}[ \t]+/, '');
}
export function QuoteRefChip(props: {
quote: QuoteRef;
onRemove?: () => void;
className?: string;
}) {
const copy = getConversationCopy(useUiLocale()).messages;
const [expanded, setExpanded] = useState(false);
const [clipped, setClipped] = useState(false);
const textRef = useRef<HTMLButtonElement>(null);
const label = props.quote.label;
const displayText = stripQuoteHeadingMarkers(props.quote.text);
const full = label ? `${label}: ${displayText}` : displayText;
useLayoutEffect(() => {
const el = textRef.current;
if (!el || expanded) return;
setClipped(el.scrollWidth > el.clientWidth + 1);
}, [expanded, displayText, label]);
const canExpand = clipped || expanded;
return (
<span
className={cn(
'maka-quote-chip',
expanded ? 'maka-quote-chip-expanded' : 'maka-quote-chip-collapsed',
props.onRemove ? 'maka-quote-chip-removable' : 'maka-quote-chip-readonly',
props.className,
)}
title={expanded ? undefined : full}
>
<TextQuote
className={cn('maka-quote-chip-icon', expanded && 'maka-quote-chip-icon-expanded')}
aria-hidden="true"
/>
<button
ref={textRef}
type="button"
{...(canExpand
? {
onClick: () => setExpanded((open) => !open),
'aria-expanded': expanded,
'aria-label': expanded ? copy.quoteCollapseAriaLabel : copy.quoteExpandAriaLabel,
}
: { tabIndex: -1 })}
className={cn(
'maka-quote-chip-text',
expanded ? 'maka-quote-chip-text-expanded' : 'maka-quote-chip-text-clipped',
)}
>
{label ? <span className="maka-quote-chip-label">{label} </span> : null}
{displayText}
</button>
{props.onRemove && (
<button
type="button"
onClick={props.onRemove}
className="maka-quote-chip-remove"
aria-label={copy.removeQuoteAriaLabel}
>
<X />
</button>
)}
</span>
);
}