330 lines
14 KiB
TypeScript
330 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Part } from '@opencode-ai/sdk/v2';
|
|
import type { AgentMentionInfo } from '../types';
|
|
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { getDirectoryForFilePath } from '@/lib/path-utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import {
|
|
buildAgentMentionUrl,
|
|
parseSkillHref,
|
|
} from '@/lib/messages/inlineMessageLinks';
|
|
import { prepareUserMarkdownContent, SKILL_TOKEN_PATTERN } from './userTextPartContent';
|
|
import { extractTerminalContexts } from '@/lib/messages/terminalContext';
|
|
import { readContextPart } from '@/lib/messages/contextParts';
|
|
import UserContextPart from './UserContextPart';
|
|
|
|
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
|
|
|
type UserTextPartProps = {
|
|
part: Part;
|
|
messageId: string;
|
|
isMobile: boolean;
|
|
agentMention?: AgentMentionInfo;
|
|
/**
|
|
* Message-level collapse: when provided, all parts of the user message
|
|
* share one expanded state owned by the message body, expanding any part
|
|
* expands the whole message, and the message body renders the single
|
|
* collapse control. When absent the part collapses on its own (legacy
|
|
* single-part behavior).
|
|
*/
|
|
messageExpanded?: boolean;
|
|
onExpandMessage?: () => void;
|
|
};
|
|
|
|
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
|
return mode === 'markdown' ? 'markdown' : 'plain';
|
|
};
|
|
|
|
const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMention, messageExpanded, onExpandMessage }) => {
|
|
// Structured context (inline comments, terminal selections, annotations,
|
|
// PR context) renders as a dedicated block instead of raw prompt text.
|
|
const contextPayload = React.useMemo(() => readContextPart(part), [part]);
|
|
|
|
const partWithText = part as PartWithText;
|
|
const rawText = partWithText.text;
|
|
const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || '';
|
|
const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]);
|
|
const textContent = terminalContextState.visibleText;
|
|
|
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
const [isTruncated, setIsTruncated] = React.useState(false);
|
|
const userMessageRenderingMode = useUIStore((state) => state.userMessageRenderingMode);
|
|
const collapsibleUserMessages = useUIStore((state) => state.collapsibleUserMessages);
|
|
const skills = useSkillsStore((state) => state.skills);
|
|
const openContextFile = useUIStore((state) => state.openContextFile);
|
|
const effectiveDirectory = useEffectiveDirectory();
|
|
const { t } = useI18n();
|
|
const normalizedRenderingMode = normalizeUserMessageRenderingMode(userMessageRenderingMode);
|
|
const isControlled = messageExpanded !== undefined;
|
|
const effectiveExpanded = messageExpanded ?? isExpanded;
|
|
const isCollapsed = collapsibleUserMessages && !effectiveExpanded;
|
|
const textRef = React.useRef<HTMLDivElement>(null);
|
|
const skillByName = React.useMemo(() => new Map(skills.map((skill) => [skill.name, skill])), [skills]);
|
|
|
|
const openSkill = React.useCallback((name: string) => {
|
|
const skill = skillByName.get(name);
|
|
if (!skill?.path) return;
|
|
openContextFile(effectiveDirectory || getDirectoryForFilePath('', skill.path) || '/', skill.path);
|
|
}, [effectiveDirectory, openContextFile, skillByName]);
|
|
|
|
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
return false;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
return element.contains(range.startContainer) || element.contains(range.endContainer);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const el = textRef.current;
|
|
if (!el) return;
|
|
if (!collapsibleUserMessages || effectiveExpanded) return;
|
|
|
|
const checkTruncation = () => {
|
|
setIsTruncated(el.scrollHeight > el.clientHeight);
|
|
};
|
|
|
|
checkTruncation();
|
|
// A just-sent message mounts while its turn is still settling, so the
|
|
// synchronous read can land before the clamp has its final geometry.
|
|
// One deferred re-read covers that without waiting for an observer.
|
|
const initialFrame = window.requestAnimationFrame(checkTruncation);
|
|
|
|
// `el` is the clamped box: once line-clamp pins it to two lines its own
|
|
// size stops changing, so observing it alone freezes the first
|
|
// measurement. Markdown settles after mount (highlighting, late layout),
|
|
// and a message measured while still short would never regain the
|
|
// expand affordance. The children keep their natural height under the
|
|
// clamp, so they are what reports content growth.
|
|
const resizeObserver = new ResizeObserver(checkTruncation);
|
|
resizeObserver.observe(el);
|
|
|
|
const observeChildren = () => {
|
|
for (const child of Array.from(el.children)) {
|
|
resizeObserver.observe(child);
|
|
}
|
|
};
|
|
observeChildren();
|
|
|
|
// The renderer swaps subtrees as it settles; re-observe the new children.
|
|
const mutationObserver = new MutationObserver(() => {
|
|
observeChildren();
|
|
checkTruncation();
|
|
});
|
|
mutationObserver.observe(el, { childList: true, subtree: true });
|
|
|
|
return () => {
|
|
window.cancelAnimationFrame(initialFrame);
|
|
mutationObserver.disconnect();
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [collapsibleUserMessages, textContent, effectiveExpanded]);
|
|
|
|
React.useEffect(() => {
|
|
if (!collapsibleUserMessages) {
|
|
setIsExpanded(false);
|
|
setIsTruncated(false);
|
|
}
|
|
}, [collapsibleUserMessages]);
|
|
|
|
const handleClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
|
const target = event.target as HTMLElement | null;
|
|
const skillLink = target?.closest<HTMLElement>('[data-skill-name]');
|
|
const skillName = skillLink?.dataset.skillName
|
|
?? parseSkillHref(target?.closest<HTMLAnchorElement>('a[href]')?.getAttribute('href'));
|
|
if (skillName) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openSkill(skillName);
|
|
return;
|
|
}
|
|
|
|
const element = textRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
if (hasActiveSelectionInElement(element)) {
|
|
return;
|
|
}
|
|
|
|
// Measure at click time instead of trusting the observed flag: whether
|
|
// the text is clipped right now is what decides if expanding does
|
|
// anything, and the flag can still be catching up on a fresh message.
|
|
if (collapsibleUserMessages && !effectiveExpanded && element.scrollHeight > element.clientHeight) {
|
|
setIsTruncated(true);
|
|
if (isControlled) {
|
|
onExpandMessage?.();
|
|
} else {
|
|
setIsExpanded(true);
|
|
}
|
|
}
|
|
}, [collapsibleUserMessages, effectiveExpanded, hasActiveSelectionInElement, isControlled, onExpandMessage, openSkill]);
|
|
|
|
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
|
|
event.stopPropagation();
|
|
setIsExpanded(false);
|
|
}, []);
|
|
|
|
const processedMarkdownContent = React.useMemo(() => {
|
|
return prepareUserMarkdownContent({
|
|
textContent,
|
|
agentMention,
|
|
skillNames: new Set(skillByName.keys()),
|
|
});
|
|
}, [agentMention, skillByName, textContent]);
|
|
|
|
const plainTextContent = React.useMemo(() => {
|
|
const nodes: React.ReactNode[] = [];
|
|
let cursor = 0;
|
|
let agentMentionUsed = false;
|
|
let match: RegExpExecArray | null;
|
|
SKILL_TOKEN_PATTERN.lastIndex = 0;
|
|
|
|
while ((match = SKILL_TOKEN_PATTERN.exec(textContent)) !== null) {
|
|
const prefix = match[1] || '';
|
|
const skillName = match[2];
|
|
const slashIndex = match.index + prefix.length;
|
|
if (!skillByName.has(skillName)) continue;
|
|
|
|
if (match.index > cursor) nodes.push(textContent.slice(cursor, match.index));
|
|
if (prefix) nodes.push(prefix);
|
|
nodes.push(
|
|
<button
|
|
key={`skill-${slashIndex}-${skillName}`}
|
|
type="button"
|
|
className="text-primary hover:underline"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openSkill(skillName);
|
|
}}
|
|
>
|
|
/{skillName}
|
|
</button>
|
|
);
|
|
cursor = slashIndex + skillName.length + 1;
|
|
}
|
|
|
|
if (cursor < textContent.length) nodes.push(textContent.slice(cursor));
|
|
|
|
const withSkills = nodes.length > 0 ? nodes : [textContent];
|
|
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
|
|
return withSkills;
|
|
}
|
|
|
|
return withSkills.flatMap((node, index) => {
|
|
if (agentMentionUsed || typeof node !== 'string') return node;
|
|
const idx = node.indexOf(agentMention.token);
|
|
if (idx === -1) return node;
|
|
agentMentionUsed = true;
|
|
return [
|
|
node.slice(0, idx),
|
|
<a
|
|
key={`agent-${index}`}
|
|
href={buildAgentMentionUrl(agentMention.name)}
|
|
className="text-primary hover:underline"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
{agentMention.token}
|
|
</a>,
|
|
node.slice(idx + agentMention.token.length),
|
|
];
|
|
});
|
|
}, [agentMention, openSkill, skillByName, textContent]);
|
|
|
|
if (contextPayload) {
|
|
return (
|
|
<UserContextPart
|
|
payload={contextPayload}
|
|
collapsed={isCollapsed}
|
|
onExpand={isControlled ? onExpandMessage : () => setIsExpanded(true)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="relative" key={part.id || `${messageId}-user-text`}>
|
|
{collapsibleUserMessages && !isControlled && isExpanded && (
|
|
<button
|
|
type="button"
|
|
onClick={handleCollapse}
|
|
className="absolute top-0 right-0 z-10 flex items-center justify-center rounded-sm bg-[var(--surface-elevated)] p-0.5 text-[var(--surface-mutedForeground)] hover:text-[var(--surface-foreground)] hover:bg-[var(--interactive-hover)] transition-colors"
|
|
aria-label={t('chat.message.userText.collapseAria')}
|
|
>
|
|
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"break-words font-sans typography-markdown-body",
|
|
!isControlled && isExpanded && "pb-3",
|
|
normalizedRenderingMode === 'plain' && 'whitespace-pre-wrap',
|
|
isCollapsed && "line-clamp-2",
|
|
collapsibleUserMessages && isTruncated && !effectiveExpanded && "cursor-pointer"
|
|
)}
|
|
ref={textRef}
|
|
onClick={handleClick}
|
|
>
|
|
{normalizedRenderingMode === 'markdown' ? (
|
|
<SimpleMarkdownRenderer
|
|
content={processedMarkdownContent}
|
|
className={cn(
|
|
"[&_.markdown-content>*:first-child]:mt-0 [&_.markdown-content>*:last-child]:mb-0",
|
|
isCollapsed && [
|
|
"[&_.markdown-content>*]:my-0",
|
|
"[&_[data-component='markdown-code']]:my-0",
|
|
"[&_[data-component='markdown-code']]:inline",
|
|
"[&_[data-component='markdown-code']]:border-0",
|
|
"[&_[data-component='markdown-code']]:bg-transparent",
|
|
"[&_[data-component='markdown-code']>*:first-child]:hidden",
|
|
"[&_[data-component='markdown-code']>div]:inline",
|
|
"[&_[data-component='markdown-code']>div]:p-0",
|
|
"[&_[data-component='markdown-code']_pre]:inline",
|
|
"[&_[data-component='markdown-code']_code]:inline",
|
|
"[&_[data-md-code-line]]:!inline",
|
|
"[&_[data-md-code-line-number]]:hidden",
|
|
"[&_[data-md-code-line-break]]:!inline",
|
|
]
|
|
)}
|
|
disableLinkSafety
|
|
enableFileReferences={false}
|
|
/>
|
|
) : (
|
|
plainTextContent
|
|
)}
|
|
</div>
|
|
{terminalContextState.contexts.length > 0 ? (
|
|
<div className="mt-2 space-y-1.5">
|
|
{terminalContextState.contexts.map((context, index) => (
|
|
<details key={`${context.terminalLabel}-${context.startLine}-${index}`} className="rounded-md border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2 py-1.5 text-xs">
|
|
<summary className="cursor-pointer text-[var(--surface-mutedForeground)]">
|
|
{t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })}
|
|
</summary>
|
|
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap font-mono text-[var(--surface-foreground)]">{context.text}</pre>
|
|
</details>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default React.memo(UserTextPart);
|