1422 lines
51 KiB
TypeScript
1422 lines
51 KiB
TypeScript
import * as React from 'react';
|
|
|
|
import { PROSE_LINK } from '@renderer/constants/cssVariables';
|
|
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
|
import { useFileSuggestions } from '@renderer/hooks/useFileSuggestions';
|
|
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
|
|
import { useTheme } from '@renderer/hooks/useTheme';
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { chipToken } from '@renderer/types/inlineChip';
|
|
import {
|
|
createChipFromSelection,
|
|
findChipBoundary,
|
|
reconcileChips,
|
|
removeChipTokenFromText,
|
|
} from '@renderer/utils/chipUtils';
|
|
import {
|
|
doesSuggestionMatchQuery,
|
|
getSuggestionInsertionText,
|
|
} from '@renderer/utils/mentionSuggestions';
|
|
import { nameColorSet } from '@renderer/utils/projectColor';
|
|
import { findTaskReferenceMatches } from '@renderer/utils/taskReferenceUtils';
|
|
import {
|
|
findUrlBoundary,
|
|
findUrlMatches,
|
|
removeUrlMatchFromText,
|
|
} from '@renderer/utils/urlMatchUtils';
|
|
import { getKnownSlashCommand, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
|
|
|
|
import { AutoResizeTextarea } from './auto-resize-textarea';
|
|
import { ChipInteractionLayer } from './ChipInteractionLayer';
|
|
import { CodeChipBadge } from './CodeChipBadge';
|
|
import { MentionInteractionLayer } from './MentionInteractionLayer';
|
|
import { MentionSuggestionList } from './MentionSuggestionList';
|
|
import { SlashCommandInteractionLayer } from './SlashCommandInteractionLayer';
|
|
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
|
|
import { UrlInteractionLayer } from './UrlInteractionLayer';
|
|
|
|
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
|
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Segment types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface TextSegment {
|
|
type: 'text';
|
|
value: string;
|
|
}
|
|
|
|
interface MentionSegment {
|
|
type: 'mention';
|
|
value: string;
|
|
suggestion: MentionSuggestion;
|
|
}
|
|
|
|
interface TaskSegment {
|
|
type: 'task';
|
|
value: string;
|
|
suggestion: MentionSuggestion;
|
|
encoded: boolean;
|
|
/** Zero-width metadata chars rendered in backdrop for caret alignment */
|
|
hiddenSuffix?: string;
|
|
}
|
|
|
|
interface UrlSegment {
|
|
type: 'url';
|
|
value: string;
|
|
}
|
|
|
|
interface ChipSegment {
|
|
type: 'chip';
|
|
value: string;
|
|
chip: InlineChip;
|
|
}
|
|
|
|
interface SlashCommandSegment {
|
|
type: 'slash_command';
|
|
value: string;
|
|
known: boolean;
|
|
}
|
|
|
|
type Segment =
|
|
| TextSegment
|
|
| MentionSegment
|
|
| TaskSegment
|
|
| UrlSegment
|
|
| ChipSegment
|
|
| SlashCommandSegment;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mention segment parsing (splits text into plain text + @mention segments)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Splits text into alternating text / @mention segments.
|
|
*
|
|
* Rules:
|
|
* - `@` must be at start of text or preceded by whitespace
|
|
* - The name after `@` must exactly match a suggestion name (case-insensitive)
|
|
* - The character after the name must be whitespace, punctuation, or end-of-text
|
|
* - Longer names are tried first (greedy matching)
|
|
*/
|
|
function parseMentionSegments(text: string, suggestions: MentionSuggestion[]): Segment[] {
|
|
if (!text || suggestions.length === 0) return [{ type: 'text', value: text }];
|
|
|
|
// Sort by name length descending for greedy matching
|
|
const sorted = [...suggestions]
|
|
.filter((suggestion) => suggestion.type !== 'task')
|
|
.sort((a, b) => b.name.length - a.name.length);
|
|
|
|
const segments: Segment[] = [];
|
|
let i = 0;
|
|
let textStart = 0;
|
|
|
|
while (i < text.length) {
|
|
if (text[i] !== '@') {
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// @ must be at start or after whitespace
|
|
if (i > 0) {
|
|
const ch = text[i - 1];
|
|
if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let matched = false;
|
|
for (const suggestion of sorted) {
|
|
const insertionText = getSuggestionInsertionText(suggestion);
|
|
const end = i + 1 + insertionText.length;
|
|
if (end > text.length) continue;
|
|
if (text.slice(i + 1, end).toLowerCase() !== insertionText.toLowerCase()) continue;
|
|
|
|
// Character after name must be boundary
|
|
if (end < text.length) {
|
|
const after = text[end];
|
|
// eslint-disable-next-line no-useless-escape -- escaped chars needed for regex character class
|
|
if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue;
|
|
}
|
|
|
|
// Flush preceding text
|
|
if (i > textStart) {
|
|
segments.push({ type: 'text', value: text.slice(textStart, i) });
|
|
}
|
|
|
|
segments.push({ type: 'mention', value: text.slice(i, end), suggestion });
|
|
i = end;
|
|
textStart = i;
|
|
matched = true;
|
|
break;
|
|
}
|
|
|
|
if (!matched) i++;
|
|
}
|
|
|
|
if (textStart < text.length) {
|
|
segments.push({ type: 'text', value: text.slice(textStart) });
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
function parseSuggestionSegments(
|
|
text: string,
|
|
mentionSuggestions: MentionSuggestion[],
|
|
taskSuggestions: MentionSuggestion[]
|
|
): Segment[] {
|
|
if (!text) return [{ type: 'text', value: text }];
|
|
|
|
const urlMatches = findUrlMatches(text);
|
|
if (urlMatches.length > 0) {
|
|
const segments: Segment[] = [];
|
|
let lastEnd = 0;
|
|
|
|
for (const match of urlMatches) {
|
|
if (match.start > lastEnd) {
|
|
segments.push(
|
|
...parseSuggestionSegments(
|
|
text.slice(lastEnd, match.start),
|
|
mentionSuggestions,
|
|
taskSuggestions
|
|
)
|
|
);
|
|
}
|
|
segments.push({
|
|
type: 'url',
|
|
value: match.value,
|
|
});
|
|
lastEnd = match.end;
|
|
}
|
|
|
|
if (lastEnd < text.length) {
|
|
segments.push(
|
|
...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions)
|
|
);
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
const taskMatches = findTaskReferenceMatches(text, taskSuggestions);
|
|
if (taskMatches.length === 0) {
|
|
return parseMentionSegments(text, mentionSuggestions);
|
|
}
|
|
|
|
const segments: Segment[] = [];
|
|
let lastEnd = 0;
|
|
|
|
for (const match of taskMatches) {
|
|
if (match.start > lastEnd) {
|
|
segments.push(...parseMentionSegments(text.slice(lastEnd, match.start), mentionSuggestions));
|
|
}
|
|
// Compute hidden suffix: zero-width metadata chars between visible text and match.end
|
|
const visibleEnd = match.start + match.raw.length;
|
|
const hiddenSuffix =
|
|
match.encoded && match.end > visibleEnd ? text.slice(visibleEnd, match.end) : undefined;
|
|
segments.push({
|
|
type: 'task',
|
|
value: match.raw,
|
|
suggestion: match.suggestion,
|
|
encoded: match.encoded,
|
|
hiddenSuffix,
|
|
});
|
|
lastEnd = match.end;
|
|
}
|
|
|
|
if (lastEnd < text.length) {
|
|
segments.push(...parseMentionSegments(text.slice(lastEnd), mentionSuggestions));
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extended segment parser: chips + mentions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parses text into segments: first extracts chip tokens, then runs mention parsing
|
|
* on the text fragments between chips.
|
|
*/
|
|
function parseSegments(
|
|
text: string,
|
|
mentionSuggestions: MentionSuggestion[],
|
|
taskSuggestions: MentionSuggestion[],
|
|
chips: InlineChip[]
|
|
): Segment[] {
|
|
if (!text) return [{ type: 'text', value: text }];
|
|
const slashCommand = parseStandaloneSlashCommand(text);
|
|
if (slashCommand) {
|
|
return [
|
|
{
|
|
type: 'slash_command',
|
|
value: slashCommand.raw,
|
|
known: getKnownSlashCommand(slashCommand.name) !== null,
|
|
},
|
|
];
|
|
}
|
|
if (chips.length === 0) return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions);
|
|
|
|
// Build a map of chip tokens for fast lookup
|
|
const chipTokenMap = new Map<string, InlineChip>();
|
|
for (const chip of chips) {
|
|
chipTokenMap.set(chipToken(chip), chip);
|
|
}
|
|
|
|
// Find all chip token positions, sorted by index
|
|
const chipPositions: { start: number; end: number; token: string; chip: InlineChip }[] = [];
|
|
for (const [token, chip] of chipTokenMap) {
|
|
let searchFrom = 0;
|
|
while (searchFrom < text.length) {
|
|
const idx = text.indexOf(token, searchFrom);
|
|
if (idx === -1) break;
|
|
chipPositions.push({ start: idx, end: idx + token.length, token, chip });
|
|
searchFrom = idx + 1;
|
|
}
|
|
}
|
|
chipPositions.sort((a, b) => a.start - b.start);
|
|
|
|
if (chipPositions.length === 0) {
|
|
return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions);
|
|
}
|
|
|
|
const segments: Segment[] = [];
|
|
let lastEnd = 0;
|
|
|
|
for (const pos of chipPositions) {
|
|
// Text before this chip → parse for mentions
|
|
if (pos.start > lastEnd) {
|
|
const fragment = text.slice(lastEnd, pos.start);
|
|
segments.push(...parseSuggestionSegments(fragment, mentionSuggestions, taskSuggestions));
|
|
}
|
|
segments.push({ type: 'chip', value: pos.token, chip: pos.chip });
|
|
lastEnd = pos.end;
|
|
}
|
|
|
|
// Remaining text after last chip → parse for mentions
|
|
if (lastEnd < text.length) {
|
|
segments.push(
|
|
...parseSuggestionSegments(text.slice(lastEnd), mentionSuggestions, taskSuggestions)
|
|
);
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
// Default fallback color for mentions without a team color
|
|
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
|
|
const DEFAULT_MENTION_TEXT = '#60a5fa';
|
|
const URL_BADGE_BG = 'var(--url-badge-bg)';
|
|
const URL_BADGE_BORDER = 'var(--url-badge-border)';
|
|
const URL_BADGE_TEXT = 'var(--url-badge-text)';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface MentionableTextareaProps extends Omit<
|
|
AutoResizeTextareaProps,
|
|
'value' | 'onChange' | 'onKeyDown' | 'onSelect'
|
|
> {
|
|
value: string;
|
|
onValueChange: (v: string) => void;
|
|
suggestions: MentionSuggestion[];
|
|
/** Surface class applied behind the textarea/overlay content. */
|
|
surfaceClassName?: string;
|
|
/** Optional decorative treatment for the surface shell. */
|
|
surfaceDecoration?: 'none' | 'orbit-border';
|
|
/** Solid color used by the bottom fade behind corner actions. */
|
|
surfaceFadeColor?: string;
|
|
hintText?: string;
|
|
showHint?: boolean;
|
|
/** Content rendered at the right side of the footer row (e.g. "Saved") */
|
|
footerRight?: React.ReactNode;
|
|
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
|
|
cornerAction?: React.ReactNode;
|
|
/** Content rendered in the bottom-left corner inside the textarea (e.g. mode selector) */
|
|
cornerActionLeft?: React.ReactNode;
|
|
/** Density of the reserved bottom inset used by corner actions. */
|
|
cornerActionInset?: 'default' | 'compact';
|
|
/** Inline code chips to display as badges */
|
|
chips?: InlineChip[];
|
|
/** Called when a chip is removed (by X button, backspace, or reconciliation) */
|
|
onChipRemove?: (chipId: string) => void;
|
|
/** Project path for @file search. When provided, enables file suggestions alongside members. */
|
|
projectPath?: string | null;
|
|
/** Called when a file chip is created via @ selection. Parent must add chip to state. */
|
|
onFileChipInsert?: (chip: InlineChip) => void;
|
|
/** Team suggestions for cross-team @mentions */
|
|
teamSuggestions?: MentionSuggestion[];
|
|
/** Task suggestions for #task references */
|
|
taskSuggestions?: MentionSuggestion[];
|
|
/** Slash command suggestions for /command autocomplete */
|
|
commandSuggestions?: MentionSuggestion[];
|
|
/** Called when Enter (without Shift) is pressed. */
|
|
onModEnter?: () => void;
|
|
/** Called when Shift+Tab is pressed. */
|
|
onShiftTab?: () => void;
|
|
/** Ref that receives the dismiss callback to close mention dropdown from outside */
|
|
dismissMentionsRef?: React.MutableRefObject<(() => void) | null>;
|
|
/** Additional rotating tips to append after the defaults */
|
|
extraTips?: string[];
|
|
}
|
|
|
|
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
|
|
(
|
|
{
|
|
value,
|
|
onValueChange,
|
|
suggestions,
|
|
surfaceClassName,
|
|
surfaceDecoration = 'none',
|
|
surfaceFadeColor = 'var(--color-surface-raised)',
|
|
hintText,
|
|
showHint = true,
|
|
footerRight,
|
|
cornerAction,
|
|
cornerActionLeft,
|
|
cornerActionInset = 'default',
|
|
chips = [],
|
|
onChipRemove,
|
|
projectPath,
|
|
onFileChipInsert,
|
|
teamSuggestions = [],
|
|
taskSuggestions = [],
|
|
commandSuggestions = [],
|
|
onModEnter,
|
|
onShiftTab,
|
|
dismissMentionsRef,
|
|
extraTips = [],
|
|
style,
|
|
className,
|
|
...textareaProps
|
|
},
|
|
forwardedRef
|
|
) => {
|
|
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
const backdropRef = React.useRef<HTMLDivElement>(null);
|
|
const surfaceShellRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [scrollTop, setScrollTop] = React.useState(0);
|
|
const { isLight } = useTheme();
|
|
const orbitGlowId = React.useId();
|
|
const [surfaceShellMetrics, setSurfaceShellMetrics] = React.useState(() => ({
|
|
width: 0,
|
|
height: 0,
|
|
borderRadius: 6,
|
|
}));
|
|
|
|
// --- File search activation ---
|
|
const enableFiles = !!projectPath;
|
|
const enableTaskSearch = taskSuggestions.length > 0;
|
|
|
|
const setRefs = React.useCallback(
|
|
(node: HTMLTextAreaElement | null) => {
|
|
internalRef.current = node;
|
|
if (typeof forwardedRef === 'function') {
|
|
forwardedRef(node);
|
|
} else if (forwardedRef) {
|
|
forwardedRef.current = node;
|
|
}
|
|
},
|
|
[forwardedRef]
|
|
);
|
|
|
|
const {
|
|
isOpen,
|
|
activeTriggerChar,
|
|
query,
|
|
selectedIndex,
|
|
setSelectedIndex,
|
|
dropdownPosition,
|
|
selectSuggestion,
|
|
dismiss,
|
|
getTriggerIndex,
|
|
handleKeyDown: mentionHandleKeyDown,
|
|
handleChange: mentionHandleChange,
|
|
handleSelect: mentionHandleSelect,
|
|
} = useMentionDetection({
|
|
value,
|
|
onValueChange,
|
|
textareaRef: internalRef,
|
|
triggerChars:
|
|
commandSuggestions.length > 0 ? ['@', '#', '/'] : enableTaskSearch ? ['@', '#'] : ['@'],
|
|
isTriggerEnabled: (triggerChar) => {
|
|
if (triggerChar === '#') return enableTaskSearch;
|
|
if (triggerChar === '/') return commandSuggestions.length > 0;
|
|
return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0;
|
|
},
|
|
isTriggerMatchValid: (trigger, text) => {
|
|
if (trigger.triggerChar !== '/') return true;
|
|
return text.slice(0, trigger.triggerIndex).trim().length === 0;
|
|
},
|
|
});
|
|
|
|
// Expose dismiss to parent via ref for external close (e.g. Send button click)
|
|
React.useEffect(() => {
|
|
if (dismissMentionsRef) dismissMentionsRef.current = dismiss;
|
|
}, [dismiss, dismissMentionsRef]);
|
|
|
|
// --- File suggestions ---
|
|
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
|
|
enableFiles ? projectPath : null,
|
|
activeTriggerChar === '@' ? query : '',
|
|
isOpen && enableFiles && activeTriggerChar === '@'
|
|
);
|
|
|
|
const isAtTrigger = activeTriggerChar !== '#' && activeTriggerChar !== '/';
|
|
|
|
const memberSuggestions = React.useMemo(() => {
|
|
if (!isOpen || !isAtTrigger) return [];
|
|
if (!query) return suggestions;
|
|
return suggestions.filter((member) => doesSuggestionMatchQuery(member, query));
|
|
}, [isAtTrigger, isOpen, query, suggestions]);
|
|
|
|
// --- Team suggestions filtered by query ---
|
|
const filteredTeamSuggestions = React.useMemo(() => {
|
|
if (teamSuggestions.length === 0 || !isOpen || !isAtTrigger) return [];
|
|
if (!query) return teamSuggestions;
|
|
return teamSuggestions.filter((team) => doesSuggestionMatchQuery(team, query));
|
|
}, [teamSuggestions, isAtTrigger, isOpen, query]);
|
|
|
|
const filteredTaskSuggestions = React.useMemo(() => {
|
|
if (taskSuggestions.length === 0 || !isOpen || activeTriggerChar !== '#') return [];
|
|
if (!query) return taskSuggestions;
|
|
return taskSuggestions.filter((task) => doesSuggestionMatchQuery(task, query));
|
|
}, [taskSuggestions, activeTriggerChar, isOpen, query]);
|
|
|
|
const filteredCommandSuggestions = React.useMemo(() => {
|
|
if (commandSuggestions.length === 0 || !isOpen || activeTriggerChar !== '/') return [];
|
|
if (!query) return commandSuggestions;
|
|
return commandSuggestions.filter((command) => doesSuggestionMatchQuery(command, query));
|
|
}, [commandSuggestions, activeTriggerChar, isOpen, query]);
|
|
|
|
// Merged suggestion list: members → online teams → offline teams → files
|
|
const atSuggestions = React.useMemo(() => {
|
|
const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
|
|
const offlineTeams = filteredTeamSuggestions.filter((t) => !t.isOnline);
|
|
const merged = [...memberSuggestions, ...onlineTeams, ...offlineTeams];
|
|
if (!enableFiles) return merged;
|
|
if (fileSuggestions.length === 0) return merged;
|
|
return [...merged, ...fileSuggestions];
|
|
}, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]);
|
|
const effectiveSuggestions =
|
|
activeTriggerChar === '/'
|
|
? filteredCommandSuggestions
|
|
: activeTriggerChar === '#'
|
|
? filteredTaskSuggestions
|
|
: atSuggestions;
|
|
|
|
React.useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (effectiveSuggestions.length === 0) {
|
|
setSelectedIndex(0);
|
|
return;
|
|
}
|
|
if (selectedIndex >= effectiveSuggestions.length) {
|
|
setSelectedIndex(0);
|
|
}
|
|
}, [effectiveSuggestions.length, isOpen, selectedIndex, setSelectedIndex]);
|
|
|
|
// --- File selection handler ---
|
|
const handleFileSelect = React.useCallback(
|
|
(s: MentionSuggestion) => {
|
|
const textarea = internalRef.current;
|
|
const triggerIdx = getTriggerIndex();
|
|
if (!textarea || triggerIdx < 0 || !s.filePath) return;
|
|
|
|
const replaceStart = triggerIdx;
|
|
const replaceEnd = triggerIdx + 1 + query.length;
|
|
const before = value.slice(0, replaceStart);
|
|
const after = value.slice(replaceEnd);
|
|
|
|
if (onFileChipInsert && onChipRemove) {
|
|
// Chip mode: create InlineChip and insert chip token
|
|
const chip = createChipFromSelection(
|
|
{
|
|
type: 'sendMessage',
|
|
filePath: s.filePath,
|
|
fromLine: null,
|
|
toLine: null,
|
|
selectedText: '',
|
|
formattedContext: '',
|
|
displayPath: s.relativePath,
|
|
},
|
|
chips
|
|
);
|
|
|
|
if (chip) {
|
|
const token = chipToken(chip);
|
|
const newValue = before + token + after;
|
|
onValueChange(newValue);
|
|
onFileChipInsert(chip);
|
|
dismiss();
|
|
|
|
requestAnimationFrame(() => {
|
|
const cursor = before.length + token.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
});
|
|
} else {
|
|
// Duplicate chip — just dismiss
|
|
dismiss();
|
|
}
|
|
} else {
|
|
// Text mode: insert backtick-wrapped relative path
|
|
const displayPath = s.relativePath ?? s.name;
|
|
const insertion = `\`${displayPath}\` `;
|
|
const newValue = before + insertion + after;
|
|
onValueChange(newValue);
|
|
dismiss();
|
|
|
|
requestAnimationFrame(() => {
|
|
const cursor = before.length + insertion.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
});
|
|
}
|
|
},
|
|
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
|
|
);
|
|
|
|
// --- Folder selection handler (inserts folder as chip with folder icon) ---
|
|
const handleFolderSelect = React.useCallback(
|
|
(s: MentionSuggestion) => {
|
|
const textarea = internalRef.current;
|
|
const triggerIdx = getTriggerIndex();
|
|
if (!textarea || triggerIdx < 0) return;
|
|
|
|
const replaceStart = triggerIdx;
|
|
const replaceEnd = triggerIdx + 1 + query.length;
|
|
const before = value.slice(0, replaceStart);
|
|
const after = value.slice(replaceEnd);
|
|
|
|
if (onFileChipInsert && onChipRemove) {
|
|
// Chip mode: create folder InlineChip
|
|
const chip = createChipFromSelection(
|
|
{
|
|
type: 'sendMessage',
|
|
filePath: s.filePath ?? '',
|
|
fromLine: null,
|
|
toLine: null,
|
|
selectedText: '',
|
|
formattedContext: '',
|
|
displayPath: s.relativePath,
|
|
isFolder: true,
|
|
},
|
|
chips
|
|
);
|
|
|
|
if (chip) {
|
|
const token = chipToken(chip);
|
|
const newValue = before + token + after;
|
|
onValueChange(newValue);
|
|
onFileChipInsert(chip);
|
|
dismiss();
|
|
|
|
requestAnimationFrame(() => {
|
|
const cursor = before.length + token.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
});
|
|
} else {
|
|
dismiss();
|
|
}
|
|
} else {
|
|
// Text mode fallback: insert backtick-wrapped relative path
|
|
const displayPath = s.relativePath ?? s.name;
|
|
const insertion = `\`${displayPath}\` `;
|
|
const newValue = before + insertion + after;
|
|
onValueChange(newValue);
|
|
dismiss();
|
|
|
|
requestAnimationFrame(() => {
|
|
const cursor = before.length + insertion.length;
|
|
textarea.setSelectionRange(cursor, cursor);
|
|
});
|
|
}
|
|
},
|
|
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
|
|
);
|
|
|
|
// --- Active selection handler ---
|
|
const handleActiveSelect = React.useCallback(
|
|
(s: MentionSuggestion) => {
|
|
if (s.type === 'file') {
|
|
handleFileSelect(s);
|
|
} else if (s.type === 'folder') {
|
|
handleFolderSelect(s);
|
|
} else {
|
|
selectSuggestion(s);
|
|
}
|
|
},
|
|
[handleFileSelect, handleFolderSelect, selectSuggestion]
|
|
);
|
|
|
|
// Sync backdrop font with textarea computed font to prevent caret drift.
|
|
React.useLayoutEffect(() => {
|
|
const textarea = internalRef.current;
|
|
const backdrop = backdropRef.current;
|
|
if (!textarea || !backdrop) return;
|
|
const cs = window.getComputedStyle(textarea);
|
|
backdrop.style.font = cs.font;
|
|
backdrop.style.letterSpacing = cs.letterSpacing;
|
|
backdrop.style.wordSpacing = cs.wordSpacing;
|
|
backdrop.style.textIndent = cs.textIndent;
|
|
backdrop.style.textTransform = cs.textTransform;
|
|
backdrop.style.tabSize = cs.tabSize;
|
|
}, [value]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (surfaceDecoration !== 'orbit-border') return;
|
|
const shell = surfaceShellRef.current;
|
|
if (!shell) return;
|
|
|
|
const updateMetrics = () => {
|
|
const rect = shell.getBoundingClientRect();
|
|
const computedStyle = window.getComputedStyle(shell);
|
|
const borderRadius = Number.parseFloat(computedStyle.borderTopLeftRadius) || 6;
|
|
setSurfaceShellMetrics((prev) => {
|
|
if (
|
|
Math.abs(prev.width - rect.width) < 0.5 &&
|
|
Math.abs(prev.height - rect.height) < 0.5 &&
|
|
Math.abs(prev.borderRadius - borderRadius) < 0.5
|
|
) {
|
|
return prev;
|
|
}
|
|
return {
|
|
width: rect.width,
|
|
height: rect.height,
|
|
borderRadius,
|
|
};
|
|
});
|
|
};
|
|
|
|
updateMetrics();
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
updateMetrics();
|
|
});
|
|
resizeObserver.observe(shell);
|
|
return () => resizeObserver.disconnect();
|
|
}, [surfaceDecoration]);
|
|
|
|
// --- Overlay activation ---
|
|
const hasOverlay =
|
|
value.includes('http://') ||
|
|
value.includes('https://') ||
|
|
parseStandaloneSlashCommand(value) !== null ||
|
|
suggestions.length > 0 ||
|
|
teamSuggestions.length > 0 ||
|
|
taskSuggestions.length > 0 ||
|
|
chips.length > 0;
|
|
|
|
// Combine member + team suggestions for overlay parsing
|
|
const mentionOverlaySuggestions = React.useMemo(
|
|
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
|
|
[suggestions, teamSuggestions]
|
|
);
|
|
const slashCommand = React.useMemo(() => parseStandaloneSlashCommand(value), [value]);
|
|
const knownSlashCommand = React.useMemo(
|
|
() => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null),
|
|
[slashCommand]
|
|
);
|
|
|
|
const segments = React.useMemo(
|
|
() =>
|
|
hasOverlay ? parseSegments(value, mentionOverlaySuggestions, taskSuggestions, chips) : [],
|
|
[hasOverlay, value, mentionOverlaySuggestions, taskSuggestions, chips]
|
|
);
|
|
|
|
// Sync backdrop scroll with textarea scroll + track scrollTop for interaction layer
|
|
const handleScroll = React.useCallback(() => {
|
|
const textarea = internalRef.current;
|
|
const backdrop = backdropRef.current;
|
|
if (textarea) {
|
|
if (backdrop) {
|
|
backdrop.scrollTop = textarea.scrollTop;
|
|
}
|
|
setScrollTop(textarea.scrollTop);
|
|
}
|
|
}, []);
|
|
|
|
// --- Chip keyboard handling (atomic cursor / backspace / delete) ---
|
|
const findEncodedTaskBoundary = React.useCallback(
|
|
(cursorPos: number) => {
|
|
const boundary = findTaskReferenceMatches(value, taskSuggestions).find(
|
|
(match) => match.encoded && cursorPos >= match.start && cursorPos <= match.end
|
|
);
|
|
return boundary ? { start: boundary.start, end: boundary.end } : null;
|
|
},
|
|
[taskSuggestions, value]
|
|
);
|
|
|
|
const findUrlTokenBoundary = React.useCallback(
|
|
(cursorPos: number) => findUrlBoundary(value, cursorPos),
|
|
[value]
|
|
);
|
|
|
|
const handleChipKeyDown = React.useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
const textarea = internalRef.current;
|
|
if (!textarea) return;
|
|
|
|
const { selectionStart, selectionEnd } = textarea;
|
|
// Only act on collapsed cursor
|
|
if (selectionStart !== selectionEnd && !e.shiftKey) return;
|
|
|
|
const cursorPos = selectionStart;
|
|
|
|
if (e.key === 'Backspace') {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.end) {
|
|
e.preventDefault();
|
|
const newText = removeUrlMatchFromText(value, urlBoundary);
|
|
onValueChange(newText);
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
|
|
});
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.end) {
|
|
e.preventDefault();
|
|
const newText = value.slice(0, taskBoundary.start) + value.slice(taskBoundary.end);
|
|
onValueChange(newText);
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(taskBoundary.start, taskBoundary.start);
|
|
});
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
// If cursor is at chip end → delete entire chip
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.end) {
|
|
e.preventDefault();
|
|
const newText = removeChipTokenFromText(value, boundary.chip);
|
|
onValueChange(newText);
|
|
onChipRemove(boundary.chip.id);
|
|
// Set cursor to where chip started
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(boundary.start, boundary.start);
|
|
});
|
|
}
|
|
} else if (e.key === 'Delete') {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.start) {
|
|
e.preventDefault();
|
|
const newText = removeUrlMatchFromText(value, urlBoundary);
|
|
onValueChange(newText);
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
|
|
});
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.start) {
|
|
e.preventDefault();
|
|
const newText = value.slice(0, taskBoundary.start) + value.slice(taskBoundary.end);
|
|
onValueChange(newText);
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(taskBoundary.start, taskBoundary.start);
|
|
});
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
// If cursor is at chip start → delete entire chip
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.start) {
|
|
e.preventDefault();
|
|
const newText = removeChipTokenFromText(value, boundary.chip);
|
|
onValueChange(newText);
|
|
onChipRemove(boundary.chip.id);
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(boundary.start, boundary.start);
|
|
});
|
|
}
|
|
} else if (e.key === 'ArrowLeft' && !e.shiftKey) {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(taskBoundary.start, taskBoundary.start);
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
// If cursor is at chip end → jump to chip start
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(boundary.start, boundary.start);
|
|
}
|
|
} else if (e.key === 'ArrowRight' && !e.shiftKey) {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(urlBoundary.end, urlBoundary.end);
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(taskBoundary.end, taskBoundary.end);
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
// If cursor is at chip start → jump to chip end
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(boundary.end, boundary.end);
|
|
}
|
|
} else if (e.key === 'ArrowLeft' && e.shiftKey) {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(urlBoundary.start, selectionEnd);
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(taskBoundary.start, selectionEnd);
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
// Extend selection past chip atomically
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.end) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(boundary.start, selectionEnd);
|
|
}
|
|
} else if (e.key === 'ArrowRight' && e.shiftKey) {
|
|
const urlBoundary = findUrlTokenBoundary(cursorPos);
|
|
if (cursorPos === urlBoundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(selectionStart, urlBoundary.end);
|
|
return;
|
|
}
|
|
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
|
if (cursorPos === taskBoundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(selectionStart, taskBoundary.end);
|
|
return;
|
|
}
|
|
if (chips.length === 0 || !onChipRemove) return;
|
|
const boundary = findChipBoundary(value, chips, cursorPos);
|
|
if (cursorPos === boundary?.start) {
|
|
e.preventDefault();
|
|
textarea.setSelectionRange(selectionStart, boundary.end);
|
|
}
|
|
}
|
|
},
|
|
[chips, findEncodedTaskBoundary, findUrlTokenBoundary, onChipRemove, value, onValueChange]
|
|
);
|
|
|
|
// Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic
|
|
const composedHandleKeyDown = React.useCallback(
|
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
// When the suggestion dropdown is open, let it consume Enter/Arrow keys first
|
|
if (isOpen && effectiveSuggestions.length > 0) {
|
|
mentionHandleKeyDown(e, effectiveSuggestions.length, (index) => {
|
|
const next = effectiveSuggestions[index];
|
|
if (next) handleActiveSelect(next);
|
|
});
|
|
if (e.defaultPrevented) return;
|
|
}
|
|
// Shift+Tab → cycle action mode
|
|
if (e.key === 'Tab' && e.shiftKey && onShiftTab) {
|
|
e.preventDefault();
|
|
onShiftTab();
|
|
return;
|
|
}
|
|
// Enter (without Shift) → submit; Shift+Enter → newline
|
|
if (e.key === 'Enter' && !e.shiftKey && onModEnter) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
dismiss();
|
|
onModEnter();
|
|
return;
|
|
}
|
|
handleChipKeyDown(e);
|
|
},
|
|
[
|
|
onModEnter,
|
|
onShiftTab,
|
|
handleChipKeyDown,
|
|
mentionHandleKeyDown,
|
|
isOpen,
|
|
effectiveSuggestions.length,
|
|
effectiveSuggestions,
|
|
handleActiveSelect,
|
|
dismiss,
|
|
]
|
|
);
|
|
|
|
// --- Chip reconciliation on text change ---
|
|
const composedHandleChange = React.useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
mentionHandleChange(e);
|
|
|
|
// Reconcile chips after text changes (paste/cut/undo)
|
|
if (chips.length > 0 && onChipRemove) {
|
|
const newText = e.target.value;
|
|
const surviving = reconcileChips(chips, newText);
|
|
if (surviving.length < chips.length) {
|
|
const survivingIds = new Set(surviving.map((c) => c.id));
|
|
for (const chip of chips) {
|
|
if (!survivingIds.has(chip.id)) {
|
|
onChipRemove(chip.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[mentionHandleChange, chips, onChipRemove]
|
|
);
|
|
|
|
// --- Snap cursor on click/select if inside chip ---
|
|
const composedHandleSelect = React.useCallback(
|
|
(e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
|
mentionHandleSelect(e);
|
|
|
|
if (chips.length > 0) {
|
|
const textarea = internalRef.current;
|
|
if (!textarea) return;
|
|
const { selectionStart, selectionEnd } = textarea;
|
|
// Only snap collapsed cursor
|
|
if (selectionStart !== selectionEnd) return;
|
|
|
|
const boundary = findChipBoundary(value, chips, selectionStart);
|
|
if (boundary && selectionStart > boundary.start && selectionStart < boundary.end) {
|
|
// Snap to nearest boundary
|
|
const distToStart = selectionStart - boundary.start;
|
|
const distToEnd = boundary.end - selectionStart;
|
|
const snapTo = distToStart <= distToEnd ? boundary.start : boundary.end;
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(snapTo, snapTo);
|
|
});
|
|
}
|
|
}
|
|
|
|
const textarea = internalRef.current;
|
|
if (!textarea) return;
|
|
const { selectionStart, selectionEnd } = textarea;
|
|
if (selectionStart !== selectionEnd) return;
|
|
const taskBoundary = findEncodedTaskBoundary(selectionStart);
|
|
if (
|
|
taskBoundary &&
|
|
selectionStart > taskBoundary.start &&
|
|
selectionStart < taskBoundary.end
|
|
) {
|
|
const distToStart = selectionStart - taskBoundary.start;
|
|
const distToEnd = taskBoundary.end - selectionStart;
|
|
const snapTo = distToStart <= distToEnd ? taskBoundary.start : taskBoundary.end;
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(snapTo, snapTo);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const urlBoundary = findUrlTokenBoundary(selectionStart);
|
|
if (urlBoundary && selectionStart > urlBoundary.start && selectionStart < urlBoundary.end) {
|
|
const distToStart = selectionStart - urlBoundary.start;
|
|
const distToEnd = urlBoundary.end - selectionStart;
|
|
const snapTo = distToStart <= distToEnd ? urlBoundary.start : urlBoundary.end;
|
|
requestAnimationFrame(() => {
|
|
textarea.setSelectionRange(snapTo, snapTo);
|
|
});
|
|
}
|
|
},
|
|
[mentionHandleSelect, chips, value, findEncodedTaskBoundary, findUrlTokenBoundary]
|
|
);
|
|
|
|
// --- Chip remove handler (from X button in interaction layer) ---
|
|
const handleChipRemove = React.useCallback(
|
|
(chipId: string) => {
|
|
const chip = chips.find((c) => c.id === chipId);
|
|
if (chip) {
|
|
const newText = removeChipTokenFromText(value, chip);
|
|
onValueChange(newText);
|
|
}
|
|
onChipRemove?.(chipId);
|
|
},
|
|
[chips, value, onValueChange, onChipRemove]
|
|
);
|
|
|
|
// When overlay is active: textarea text is transparent, caret stays visible
|
|
const textareaStyle: React.CSSProperties | undefined = hasOverlay
|
|
? {
|
|
...style,
|
|
color: 'transparent',
|
|
caretColor: 'var(--color-text)',
|
|
position: 'relative' as const,
|
|
zIndex: 10,
|
|
background: 'transparent',
|
|
}
|
|
: style;
|
|
|
|
// --- Rotating tips ---
|
|
const rotatingTips = React.useMemo(
|
|
() => [
|
|
'Tip: Use @ for members/files and # for tasks',
|
|
'Tip: Mention "create a task" to add it to the kanban',
|
|
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
|
|
...extraTips,
|
|
],
|
|
[extraTips]
|
|
);
|
|
const [tipIndex, setTipIndex] = React.useState(0);
|
|
const [tipVisible, setTipVisible] = React.useState(true);
|
|
|
|
const advanceTip = React.useCallback(() => {
|
|
setTipIndex((prev) => (prev + 1) % rotatingTips.length);
|
|
setTipVisible(true);
|
|
}, [rotatingTips.length]);
|
|
|
|
React.useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setTipVisible(false);
|
|
setTimeout(advanceTip, 300);
|
|
}, 10000);
|
|
return () => clearInterval(interval);
|
|
}, [advanceTip]);
|
|
|
|
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
|
const showHintRow =
|
|
showHint &&
|
|
(suggestions.length > 0 ||
|
|
enableFiles ||
|
|
teamSuggestions.length > 0 ||
|
|
enableTaskSearch ||
|
|
commandSuggestions.length > 0);
|
|
const showFooter = showHintRow || footerRight;
|
|
const hasCornerActions = Boolean(cornerAction || cornerActionLeft);
|
|
const cornerInsetClass = cornerActionInset === 'compact' ? 'pb-10' : 'pb-12';
|
|
const cornerFadeHeight = cornerActionInset === 'compact' ? 40 : 48;
|
|
const cornerActionOffsetClass = cornerActionInset === 'compact' ? 'bottom-1.5' : 'bottom-2';
|
|
const orbitTrackWidth = 1;
|
|
const orbitStrokeWidth = 1.35;
|
|
const orbitGlowWidth = 3;
|
|
const orbitInset = 0;
|
|
const orbitWidth = Math.max(surfaceShellMetrics.width, 0);
|
|
const orbitHeight = Math.max(surfaceShellMetrics.height, 0);
|
|
const orbitRadius = Math.max(surfaceShellMetrics.borderRadius, 0);
|
|
const orbitRight = orbitInset + orbitWidth;
|
|
const orbitBottom = orbitInset + orbitHeight;
|
|
const orbitMidX = orbitInset + orbitWidth / 2;
|
|
const orbitPathData =
|
|
orbitWidth > 0 && orbitHeight > 0
|
|
? [
|
|
`M ${orbitMidX} ${orbitInset}`,
|
|
`H ${orbitRight - orbitRadius}`,
|
|
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitRight} ${orbitInset + orbitRadius}`,
|
|
`V ${orbitBottom - orbitRadius}`,
|
|
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitRight - orbitRadius} ${orbitBottom}`,
|
|
`H ${orbitInset + orbitRadius}`,
|
|
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitInset} ${orbitBottom - orbitRadius}`,
|
|
`V ${orbitInset + orbitRadius}`,
|
|
`A ${orbitRadius} ${orbitRadius} 0 0 1 ${orbitInset + orbitRadius} ${orbitInset}`,
|
|
`H ${orbitMidX}`,
|
|
'Z',
|
|
].join(' ')
|
|
: '';
|
|
|
|
return (
|
|
<div className="relative">
|
|
{/* Inner wrapper for textarea + backdrop overlay */}
|
|
<div ref={surfaceShellRef} className={cn('relative rounded-md', surfaceClassName)}>
|
|
{surfaceDecoration === 'orbit-border' &&
|
|
surfaceShellMetrics.width > 0 &&
|
|
surfaceShellMetrics.height > 0 ? (
|
|
<svg
|
|
className="message-composer-orbit-svg pointer-events-none absolute inset-0 z-[16] size-full"
|
|
viewBox={`0 0 ${surfaceShellMetrics.width} ${surfaceShellMetrics.height}`}
|
|
aria-hidden="true"
|
|
>
|
|
<defs>
|
|
<filter id={orbitGlowId} x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="1.35" />
|
|
</filter>
|
|
</defs>
|
|
<path
|
|
className="message-composer-orbit-track"
|
|
d={orbitPathData}
|
|
pathLength="100"
|
|
fill="none"
|
|
strokeWidth={orbitTrackWidth}
|
|
/>
|
|
<path
|
|
className="message-composer-orbit-glow"
|
|
d={orbitPathData}
|
|
pathLength="100"
|
|
fill="none"
|
|
filter={`url(#${orbitGlowId})`}
|
|
strokeWidth={orbitGlowWidth}
|
|
/>
|
|
<path
|
|
className="message-composer-orbit-glow message-composer-orbit-glow-secondary"
|
|
d={orbitPathData}
|
|
pathLength="100"
|
|
fill="none"
|
|
filter={`url(#${orbitGlowId})`}
|
|
strokeWidth={orbitGlowWidth}
|
|
/>
|
|
<path
|
|
className="message-composer-orbit-path"
|
|
d={orbitPathData}
|
|
pathLength="100"
|
|
fill="none"
|
|
strokeWidth={orbitStrokeWidth}
|
|
/>
|
|
<path
|
|
className="message-composer-orbit-path message-composer-orbit-path-secondary"
|
|
d={orbitPathData}
|
|
pathLength="100"
|
|
fill="none"
|
|
strokeWidth={orbitStrokeWidth}
|
|
/>
|
|
</svg>
|
|
) : null}
|
|
{hasOverlay ? (
|
|
<div
|
|
ref={backdropRef}
|
|
className={cn(
|
|
'pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md border border-transparent px-3 py-2 text-sm text-[var(--color-text)]',
|
|
hasCornerActions && cornerInsetClass
|
|
)}
|
|
style={{
|
|
whiteSpace: 'pre-wrap',
|
|
wordWrap: 'break-word',
|
|
overflowWrap: 'break-word',
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
{segments.map((seg, idx) => {
|
|
if (seg.type === 'text') {
|
|
return <React.Fragment key={idx}>{seg.value}</React.Fragment>;
|
|
}
|
|
if (seg.type === 'chip') {
|
|
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
|
|
}
|
|
if (seg.type === 'slash_command') {
|
|
return (
|
|
<span
|
|
key={idx}
|
|
style={{
|
|
backgroundColor: seg.known
|
|
? 'rgba(245, 158, 11, 0.18)'
|
|
: 'rgba(148, 163, 184, 0.16)',
|
|
color: seg.known ? '#f59e0b' : 'var(--color-text-secondary)',
|
|
borderRadius: '4px',
|
|
boxShadow: `inset 0 0 0 1px ${
|
|
seg.known ? 'rgba(245, 158, 11, 0.3)' : 'rgba(148, 163, 184, 0.24)'
|
|
}`,
|
|
padding: '2px 0',
|
|
}}
|
|
>
|
|
{seg.value}
|
|
</span>
|
|
);
|
|
}
|
|
if (seg.type === 'task') {
|
|
return (
|
|
<React.Fragment key={idx}>
|
|
<span
|
|
className={seg.encoded ? 'rounded' : 'underline decoration-transparent'}
|
|
style={
|
|
seg.encoded
|
|
? {
|
|
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
|
color: PROSE_LINK,
|
|
// Only vertical padding (doesn't affect inline text flow).
|
|
// No horizontal padding/margin/box-shadow spread to avoid
|
|
// caret drift or visual overlap with adjacent text.
|
|
padding: '2px 0',
|
|
}
|
|
: { color: PROSE_LINK }
|
|
}
|
|
>
|
|
{seg.value}
|
|
</span>
|
|
{seg.hiddenSuffix}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
if (seg.type === 'url') {
|
|
return (
|
|
<span
|
|
key={idx}
|
|
style={{
|
|
backgroundColor: URL_BADGE_BG,
|
|
color: URL_BADGE_TEXT,
|
|
borderRadius: '4px',
|
|
boxShadow: `inset 0 0 0 1px ${URL_BADGE_BORDER}`,
|
|
}}
|
|
>
|
|
{seg.value}
|
|
</span>
|
|
);
|
|
}
|
|
// mention (member or team)
|
|
const isTeamMention = seg.suggestion.type === 'team';
|
|
const colorSet = seg.suggestion.color
|
|
? getTeamColorSet(seg.suggestion.color)
|
|
: isTeamMention
|
|
? nameColorSet(seg.suggestion.name, isLight)
|
|
: null;
|
|
const bg = colorSet ? getThemedBadge(colorSet, isLight) : DEFAULT_MENTION_BG;
|
|
const fg = colorSet?.text ?? DEFAULT_MENTION_TEXT;
|
|
return (
|
|
<span
|
|
key={idx}
|
|
style={{
|
|
backgroundColor: bg,
|
|
color: fg,
|
|
borderRadius: '3px',
|
|
boxShadow: `0 0 0 1.5px ${bg}`,
|
|
}}
|
|
>
|
|
{seg.value}
|
|
</span>
|
|
);
|
|
})}{' '}
|
|
</div>
|
|
) : null}
|
|
|
|
{taskSuggestions.length > 0 ? (
|
|
<TaskReferenceInteractionLayer
|
|
taskSuggestions={taskSuggestions}
|
|
value={value}
|
|
textareaRef={internalRef}
|
|
scrollTop={scrollTop}
|
|
/>
|
|
) : null}
|
|
|
|
{value.includes('http://') || value.includes('https://') ? (
|
|
<UrlInteractionLayer
|
|
value={value}
|
|
textareaRef={internalRef}
|
|
scrollTop={scrollTop}
|
|
onRemove={(match) => {
|
|
const newText = removeUrlMatchFromText(value, match);
|
|
onValueChange(newText);
|
|
requestAnimationFrame(() => {
|
|
internalRef.current?.setSelectionRange(match.start, match.start);
|
|
});
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{mentionOverlaySuggestions.length > 0 ? (
|
|
<MentionInteractionLayer
|
|
suggestions={mentionOverlaySuggestions}
|
|
value={value}
|
|
textareaRef={internalRef}
|
|
scrollTop={scrollTop}
|
|
/>
|
|
) : null}
|
|
|
|
{slashCommand ? (
|
|
<SlashCommandInteractionLayer
|
|
command={slashCommand}
|
|
definition={knownSlashCommand}
|
|
value={value}
|
|
textareaRef={internalRef}
|
|
scrollTop={scrollTop}
|
|
/>
|
|
) : null}
|
|
|
|
<AutoResizeTextarea
|
|
ref={setRefs}
|
|
value={value}
|
|
onChange={composedHandleChange}
|
|
onKeyDown={composedHandleKeyDown}
|
|
onSelect={composedHandleSelect}
|
|
{...textareaProps}
|
|
className={cn(className, hasCornerActions && cornerInsetClass)}
|
|
onScroll={handleScroll}
|
|
style={textareaStyle}
|
|
/>
|
|
|
|
{chips.length > 0 && onChipRemove ? (
|
|
<ChipInteractionLayer
|
|
chips={chips}
|
|
value={value}
|
|
textareaRef={internalRef}
|
|
scrollTop={scrollTop}
|
|
onRemove={handleChipRemove}
|
|
/>
|
|
) : null}
|
|
|
|
{/* Gradient fade overlay before corner action buttons */}
|
|
{hasCornerActions ? (
|
|
<div
|
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-[15] rounded-b-md"
|
|
style={{
|
|
height: cornerFadeHeight,
|
|
background: `linear-gradient(to bottom, transparent 0%, ${surfaceFadeColor} 75%)`,
|
|
}}
|
|
/>
|
|
) : null}
|
|
|
|
{cornerAction ? (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute right-2 z-20 flex items-end justify-end',
|
|
cornerActionOffsetClass
|
|
)}
|
|
>
|
|
<div className="pointer-events-auto">{cornerAction}</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{cornerActionLeft ? (
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute left-2 z-20 flex items-end justify-start',
|
|
cornerActionOffsetClass
|
|
)}
|
|
>
|
|
<div className="pointer-events-auto">{cornerActionLeft}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{showFooter ? (
|
|
<div className="mt-1 flex items-start justify-between gap-2">
|
|
{showHintRow ? (
|
|
<span
|
|
className="block min-h-6 flex-1 overflow-hidden text-[10px] leading-3 text-[var(--color-text-muted)] transition-opacity duration-300"
|
|
style={{ opacity: tipVisible ? 1 : 0, maxHeight: '1.5rem' }}
|
|
>
|
|
{resolvedHintText}
|
|
</span>
|
|
) : (
|
|
<span className="min-h-6 flex-1" />
|
|
)}
|
|
{footerRight}
|
|
</div>
|
|
) : null}
|
|
{isOpen && dropdownPosition ? (
|
|
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
|
|
<MentionSuggestionList
|
|
suggestions={effectiveSuggestions}
|
|
selectedIndex={selectedIndex}
|
|
onSelect={handleActiveSelect}
|
|
query={query}
|
|
hasFileSearch={enableFiles}
|
|
filesLoading={enableFiles && filesLoading && activeTriggerChar === '@'}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
MentionableTextarea.displayName = 'MentionableTextarea';
|