- 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.
304 lines
9.7 KiB
TypeScript
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';
|