agent-ecosystem/src/renderer/components/ui/MentionableTextarea.tsx
iliya 01c7744f36 feat: add task logging functionality and enhance team management
- Introduced TEAM_GET_LOGS_FOR_TASK IPC channel to retrieve session logs related to specific tasks.
- Implemented handleGetLogsForTask function to validate inputs and fetch logs for a given task.
- Updated TeamMemberLogsFinder to include findLogsForTask method for session log retrieval based on task ID.
- Enhanced UI components to support displaying logs for tasks, improving task management and visibility.
- Updated related services and components to accommodate the new logging functionality.

These changes aim to enhance task tracking and improve collaboration within teams by providing access to relevant session logs.
2026-02-23 12:45:12 +02:00

304 lines
9.7 KiB
TypeScript

import * as React from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useMentionDetection } from '@renderer/hooks/useMentionDetection';
import { cn } from '@renderer/lib/utils';
import { AutoResizeTextarea } from './auto-resize-textarea';
import { MentionSuggestionList } from './MentionSuggestionList';
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { MentionSuggestion } from '@renderer/types/mention';
// ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments)
// ---------------------------------------------------------------------------
interface TextSegment {
type: 'text';
value: string;
}
interface MentionSegment {
type: 'mention';
value: string;
suggestion: MentionSuggestion;
}
type Segment = TextSegment | MentionSegment;
/**
* 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].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 end = i + 1 + suggestion.name.length;
if (end > text.length) continue;
if (text.slice(i + 1, end).toLowerCase() !== suggestion.name.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;
}
// Default fallback color for mentions without a team color
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
const DEFAULT_MENTION_TEXT = '#60a5fa';
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface MentionableTextareaProps extends Omit<
AutoResizeTextareaProps,
'value' | 'onChange' | 'onKeyDown' | 'onSelect'
> {
value: string;
onValueChange: (v: string) => void;
suggestions: MentionSuggestion[];
hintText?: string;
showHint?: boolean;
/** Content rendered at the right side of the footer row (e.g. "Draft saved") */
footerRight?: React.ReactNode;
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
cornerAction?: React.ReactNode;
}
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
(
{
value,
onValueChange,
suggestions,
hintText = 'Use @ to mention team members',
showHint = true,
footerRight,
cornerAction,
style,
className,
...textareaProps
},
forwardedRef
) => {
const internalRef = React.useRef<HTMLTextAreaElement | null>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const setRefs = React.useCallback(
(node: HTMLTextAreaElement | null) => {
internalRef.current = node;
if (typeof forwardedRef === 'function') {
forwardedRef(node);
} else if (forwardedRef) {
// eslint-disable-next-line no-param-reassign -- ref merging requires mutation
forwardedRef.current = node;
}
},
[forwardedRef]
);
const {
isOpen,
query,
filteredSuggestions,
selectedIndex,
dropdownPosition,
selectSuggestion,
handleKeyDown,
handleChange,
handleSelect,
} = useMentionDetection({
suggestions,
value,
onValueChange,
textareaRef: internalRef,
});
// Sync backdrop font with textarea computed font to prevent caret drift.
// Chromium UA stylesheet may apply different font-family / letter-spacing
// to <textarea> vs <div>, and sub-pixel differences accumulate over text length.
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]); // re-sync when value changes (textarea may reflow)
// --- Mention overlay ---
const hasMentionOverlay = suggestions.length > 0;
const segments = React.useMemo(
() => (hasMentionOverlay ? parseMentionSegments(value, suggestions) : []),
[hasMentionOverlay, value, suggestions]
);
// Sync backdrop scroll with textarea scroll
const handleScroll = React.useCallback(() => {
const textarea = internalRef.current;
const backdrop = backdropRef.current;
if (textarea && backdrop) {
backdrop.scrollTop = textarea.scrollTop;
}
}, []);
// When overlay is active: textarea text is transparent, caret stays visible
const textareaStyle: React.CSSProperties | undefined = hasMentionOverlay
? {
...style,
color: 'transparent',
caretColor: 'var(--color-text)',
position: 'relative' as const,
zIndex: 10,
background: 'transparent',
}
: style;
const showFooter = (showHint && suggestions.length > 0) || footerRight;
return (
<div className="relative">
{/* Inner wrapper for textarea + backdrop overlay */}
<div className="relative">
{hasMentionOverlay ? (
<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)]',
cornerAction && 'pb-12 pr-[4.25rem]'
)}
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>;
}
const colorSet = seg.suggestion.color
? getTeamColorSet(seg.suggestion.color)
: null;
const bg = colorSet?.badge ?? 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}
<AutoResizeTextarea
ref={setRefs}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
{...textareaProps}
className={cn(className, cornerAction && 'pb-12 pr-[4.25rem]')}
onScroll={handleScroll}
style={textareaStyle}
/>
{cornerAction ? (
<div className="pointer-events-none absolute bottom-2 right-2 z-20 flex items-end justify-end">
<div className="pointer-events-auto">{cornerAction}</div>
</div>
) : null}
</div>
{showFooter ? (
<div className="mt-1 flex items-center justify-between">
{showHint && suggestions.length > 0 ? (
<span className="text-[10px] text-[var(--color-text-muted)]">{hintText}</span>
) : (
<span />
)}
{footerRight}
</div>
) : null}
{isOpen && dropdownPosition ? (
<div className="absolute left-0 z-50 w-full" style={{ top: `${dropdownPosition.top}px` }}>
<MentionSuggestionList
suggestions={filteredSuggestions}
selectedIndex={selectedIndex}
onSelect={selectSuggestion}
query={query}
/>
</div>
) : null}
</div>
);
}
);
MentionableTextarea.displayName = 'MentionableTextarea';