feat: improve task reference handling and enhance UI components
- Updated ActivityItem to improve layout with overflow handling and minimum width adjustments. - Enhanced CreateTaskDialog and ReviewDialog to strip encoded task references from input, ensuring cleaner task management. - Refactored MembersJsonEditor to include a close button for better user experience. - Improved SendMessageDialog and TaskCommentInput to handle encoded task references, enhancing message composition. - Updated MentionableTextarea to support URL detection and parsing, improving text input functionality. - Enhanced MembersEditorSection with a new button for adding members, improving UI consistency. - Refactored task reference utilities to streamline task suggestion resolution and metadata handling.
This commit is contained in:
parent
057591060a
commit
f48b75cbc7
15 changed files with 385 additions and 35 deletions
|
|
@ -447,7 +447,7 @@ export const ActivityItem = ({
|
|||
|
||||
return (
|
||||
<article
|
||||
className="group rounded-md"
|
||||
className="group overflow-hidden rounded-md"
|
||||
style={{
|
||||
marginLeft: isUserSent ? 15 : undefined,
|
||||
backgroundColor:
|
||||
|
|
@ -484,7 +484,7 @@ export const ActivityItem = ({
|
|||
role={isHeaderClickable ? 'button' : undefined}
|
||||
tabIndex={isHeaderClickable ? 0 : undefined}
|
||||
className={[
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
'flex min-w-0 items-center gap-2 px-3 py-2',
|
||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={handleHeaderToggle}
|
||||
|
|
@ -641,7 +641,7 @@ export const ActivityItem = ({
|
|||
|
||||
{/* Content — collapsed for system messages, expanded for others */}
|
||||
{isExpanded ? (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="min-w-0 overflow-hidden px-3 pb-3">
|
||||
{structured ? (
|
||||
<div className="space-y-2">
|
||||
{autoSummary && autoSummary !== messageType ? (
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
|
@ -184,7 +185,7 @@ export const CreateTaskDialog = ({
|
|||
owner || undefined,
|
||||
blockedBy.length > 0 ? blockedBy : undefined,
|
||||
related.length > 0 ? related : undefined,
|
||||
promptDraft.value.trim() || undefined,
|
||||
stripEncodedTaskReferenceMetadata(promptDraft.value.trim()) || undefined,
|
||||
startImmediately
|
||||
);
|
||||
descriptionDraft.clearDraft();
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ import {
|
|||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface MembersJsonEditorProps {
|
||||
value: string;
|
||||
onChange: (json: string) => void;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const membersEditorTheme = EditorView.theme({
|
||||
|
|
@ -41,6 +44,7 @@ export const MembersJsonEditor = ({
|
|||
value,
|
||||
onChange,
|
||||
error,
|
||||
onClose,
|
||||
}: MembersJsonEditorProps): React.JSX.Element => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -104,10 +108,15 @@ export const MembersJsonEditor = ({
|
|||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-hidden rounded border border-[var(--color-border)]"
|
||||
/>
|
||||
<div className="overflow-hidden rounded border border-[var(--color-border)]">
|
||||
<div className="flex items-center justify-end border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 px-2 text-xs" onClick={onClose}>
|
||||
<X className="size-3.5" />
|
||||
Hide JSON
|
||||
</Button>
|
||||
</div>
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
{error ? <p className="text-[11px] text-red-300">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { Send } from 'lucide-react';
|
||||
|
|
@ -56,11 +57,11 @@ export const ReviewDialog = ({
|
|||
[members, colorMap]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim();
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
const comment = trimmed || undefined;
|
||||
const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined;
|
||||
draft.clearDraft();
|
||||
onSubmit(comment);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -214,7 +215,7 @@ export const SendMessageDialog = ({
|
|||
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
|
||||
const trimmedText = textDraft.value.trim();
|
||||
const trimmedText = stripEncodedTaskReferenceMetadata(textDraft.value).trim();
|
||||
const serialized = serializeChipsWithText(trimmedText, chipDraft.chips);
|
||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
||||
const remaining = MAX_TEXT_LENGTH - finalText.length;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -73,7 +74,7 @@ export const TaskCommentInput = ({
|
|||
[members, colorMap]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim();
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
const canSubmit =
|
||||
(trimmed.length > 0 || pendingAttachments.length > 0) &&
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
linkifyTaskIdsInMarkdown,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
@ -145,7 +148,7 @@ export const TaskCommentsSection = ({
|
|||
[members, colorMap]
|
||||
);
|
||||
|
||||
const trimmed = draft.value.trim();
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.value).trim();
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
const canSubmit =
|
||||
(trimmed.length > 0 || chipDraft.chips.length > 0) &&
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Info, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -191,10 +191,12 @@ export const MemberDraftRow = ({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
className="h-8 w-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
aria-label={`Remove ${member.name || `member ${index + 1}`}`}
|
||||
title="Remove member"
|
||||
onClick={() => onRemove(member.id)}
|
||||
>
|
||||
Remove
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
||||
|
|
@ -174,12 +175,13 @@ export const MembersEditorSection = ({
|
|||
<Label>Members</Label>
|
||||
{!hideContent && (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={addMember}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={addMember}>
|
||||
<Plus className="size-3.5" />
|
||||
Add member
|
||||
</Button>
|
||||
{showJsonEditor ? (
|
||||
{showJsonEditor && !jsonEditorOpen ? (
|
||||
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
|
||||
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
|
||||
Edit as JSON
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -208,7 +210,12 @@ export const MembersEditorSection = ({
|
|||
/>
|
||||
))}
|
||||
{jsonEditorOpen && showJsonEditor ? (
|
||||
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
|
||||
<MembersJsonEditor
|
||||
value={jsonText}
|
||||
onChange={handleJsonChange}
|
||||
error={jsonError}
|
||||
onClose={toggleJsonEditor}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{hasDuplicates ? (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||
|
||||
|
|
@ -136,7 +137,7 @@ export const MessageComposer = ({
|
|||
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
|
||||
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
|
||||
|
||||
const trimmed = draft.text.trim();
|
||||
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
|
||||
|
||||
const selectedMember = members.find((m) => m.name === recipient);
|
||||
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
|
||||
|
|
|
|||
|
|
@ -425,7 +425,9 @@ export const MessagesPanel = ({
|
|||
{searchAndFilterBar}
|
||||
</div>
|
||||
{/* Scrollable content */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-2">{messagesContent}</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-2">
|
||||
{messagesContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
reconcileChips,
|
||||
removeChipTokenFromText,
|
||||
} from '@renderer/utils/chipUtils';
|
||||
import { Link2 } from 'lucide-react';
|
||||
|
||||
import { AutoResizeTextarea } from './auto-resize-textarea';
|
||||
import { ChipInteractionLayer } from './ChipInteractionLayer';
|
||||
|
|
@ -49,6 +50,12 @@ interface TaskSegment {
|
|||
type: 'task';
|
||||
value: string;
|
||||
suggestion: MentionSuggestion;
|
||||
encoded: boolean;
|
||||
}
|
||||
|
||||
interface UrlSegment {
|
||||
type: 'url';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ChipSegment {
|
||||
|
|
@ -57,7 +64,41 @@ interface ChipSegment {
|
|||
chip: InlineChip;
|
||||
}
|
||||
|
||||
type Segment = TextSegment | MentionSegment | TaskSegment | ChipSegment;
|
||||
type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment;
|
||||
|
||||
interface TextMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
function trimUrlMatch(rawUrl: string): string {
|
||||
return rawUrl.replace(/[),.!?;:]+$/g, '');
|
||||
}
|
||||
|
||||
function findUrlMatches(text: string): TextMatch[] {
|
||||
if (!text) return [];
|
||||
|
||||
const matches: TextMatch[] = [];
|
||||
for (const match of text.matchAll(URL_REGEX)) {
|
||||
const rawValue = match[0];
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
const trimmedValue = trimUrlMatch(rawValue);
|
||||
if (!trimmedValue) continue;
|
||||
|
||||
matches.push({
|
||||
start,
|
||||
end: start + trimmedValue.length,
|
||||
value: trimmedValue,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention segment parsing (splits text into plain text + @mention segments)
|
||||
|
|
@ -142,6 +183,37 @@ function parseSuggestionSegments(
|
|||
): 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);
|
||||
|
|
@ -158,6 +230,7 @@ function parseSuggestionSegments(
|
|||
type: 'task',
|
||||
value: match.raw,
|
||||
suggestion: match.suggestion,
|
||||
encoded: match.encoded,
|
||||
});
|
||||
lastEnd = match.end;
|
||||
}
|
||||
|
|
@ -235,6 +308,9 @@ function parseSegments(
|
|||
// 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 = 'rgba(30, 58, 138, 0.32)';
|
||||
const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)';
|
||||
const URL_BADGE_TEXT = '#f8fafc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
|
|
@ -540,6 +616,8 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
|
||||
// --- Overlay activation ---
|
||||
const hasOverlay =
|
||||
value.includes('http://') ||
|
||||
value.includes('https://') ||
|
||||
suggestions.length > 0 ||
|
||||
teamSuggestions.length > 0 ||
|
||||
taskSuggestions.length > 0 ||
|
||||
|
|
@ -570,10 +648,18 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}, []);
|
||||
|
||||
// --- 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 handleChipKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (chips.length === 0 || !onChipRemove) return;
|
||||
|
||||
const textarea = internalRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
|
|
@ -584,6 +670,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
const cursorPos = selectionStart;
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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) {
|
||||
|
|
@ -597,6 +694,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
});
|
||||
}
|
||||
} else if (e.key === 'Delete') {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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) {
|
||||
|
|
@ -609,6 +717,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
});
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && !e.shiftKey) {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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) {
|
||||
|
|
@ -616,6 +731,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.start, boundary.start);
|
||||
}
|
||||
} else if (e.key === 'ArrowRight' && !e.shiftKey) {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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) {
|
||||
|
|
@ -623,6 +745,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.end, boundary.end);
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && e.shiftKey) {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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) {
|
||||
|
|
@ -630,6 +759,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.start, selectionEnd);
|
||||
}
|
||||
} else if (e.key === 'ArrowRight' && e.shiftKey) {
|
||||
const taskBoundary = findEncodedTaskBoundary(cursorPos);
|
||||
if (taskBoundary && 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();
|
||||
|
|
@ -637,7 +773,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}
|
||||
}
|
||||
},
|
||||
[chips, onChipRemove, value, onValueChange]
|
||||
[chips, findEncodedTaskBoundary, onChipRemove, value, onValueChange]
|
||||
);
|
||||
|
||||
// Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic
|
||||
|
|
@ -715,8 +851,26 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
},
|
||||
[mentionHandleSelect, chips, value]
|
||||
[mentionHandleSelect, chips, value, findEncodedTaskBoundary]
|
||||
);
|
||||
|
||||
// --- Chip remove handler (from X button in interaction layer) ---
|
||||
|
|
@ -804,13 +958,41 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="font-medium underline decoration-transparent"
|
||||
style={{ color: PROSE_LINK }}
|
||||
className={
|
||||
seg.encoded
|
||||
? 'rounded px-1.5 py-0.5 font-medium'
|
||||
: 'font-medium underline decoration-transparent'
|
||||
}
|
||||
style={
|
||||
seg.encoded
|
||||
? {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
color: PROSE_LINK,
|
||||
boxShadow: '0 0 0 1.5px rgba(59, 130, 246, 0.15)',
|
||||
}
|
||||
: { color: PROSE_LINK }
|
||||
}
|
||||
>
|
||||
{seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (seg.type === 'url') {
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 align-baseline"
|
||||
style={{
|
||||
backgroundColor: URL_BADGE_BG,
|
||||
color: URL_BADGE_TEXT,
|
||||
boxShadow: `inset 0 0 0 1px ${URL_BADGE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Link2 size={11} className="shrink-0 opacity-80" />
|
||||
<span className="truncate">{seg.value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// mention (member or team)
|
||||
const isTeamMention = seg.suggestion.type === 'team';
|
||||
const colorSet = seg.suggestion.color
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createEncodedTaskReference } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -36,7 +37,7 @@ function buildTaskSuggestion({
|
|||
return {
|
||||
id: `task:${teamName}:${task.id}`,
|
||||
name: displayId,
|
||||
insertText: displayId,
|
||||
insertText: createEncodedTaskReference(displayId, task.id, teamName),
|
||||
subtitle: task.subject,
|
||||
color: teamColor,
|
||||
type: 'task',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
|
|
@ -97,9 +98,10 @@ export function chipToMarkdown(chip: InlineChip): string {
|
|||
* Replaces each chip token in the text with its markdown representation.
|
||||
*/
|
||||
export function serializeChipsWithText(text: string, chips: InlineChip[]): string {
|
||||
if (chips.length === 0) return text;
|
||||
const strippedText = stripEncodedTaskReferenceMetadata(text);
|
||||
if (chips.length === 0) return strippedText;
|
||||
|
||||
let result = text;
|
||||
let result = strippedText;
|
||||
for (const chip of chips) {
|
||||
const token = chipToken(chip);
|
||||
result = result.split(token).join(chipToMarkdown(chip));
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
|
|||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g;
|
||||
const TASK_META_START = '\u2063';
|
||||
const TASK_META_END = '\u2064';
|
||||
const ZERO_WIDTH_ALPHABET = ['\u200B', '\u200C', '\u200D', '\u2060'] as const;
|
||||
const ZERO_WIDTH_TO_BITS = new Map(
|
||||
ZERO_WIDTH_ALPHABET.map((char, index) => [char, index] as const)
|
||||
);
|
||||
|
||||
function isAllowedTaskRefBoundary(char: string | undefined): boolean {
|
||||
if (!char) return true;
|
||||
|
|
@ -47,6 +53,113 @@ export interface TaskReferenceMatch {
|
|||
raw: string;
|
||||
ref: string;
|
||||
suggestion: MentionSuggestion;
|
||||
encoded: boolean;
|
||||
}
|
||||
|
||||
interface EncodedTaskMetadata {
|
||||
taskId: string;
|
||||
teamName: string;
|
||||
displayId: string;
|
||||
}
|
||||
|
||||
interface EncodedTaskMetadataMatch {
|
||||
metadata: EncodedTaskMetadata;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function encodeZeroWidthPayload(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let encoded = '';
|
||||
|
||||
for (const byte of bytes) {
|
||||
encoded += ZERO_WIDTH_ALPHABET[(byte >> 6) & 0b11];
|
||||
encoded += ZERO_WIDTH_ALPHABET[(byte >> 4) & 0b11];
|
||||
encoded += ZERO_WIDTH_ALPHABET[(byte >> 2) & 0b11];
|
||||
encoded += ZERO_WIDTH_ALPHABET[byte & 0b11];
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
|
||||
function decodeZeroWidthPayload(value: string): string | null {
|
||||
if (value.length % 4 !== 0) return null;
|
||||
|
||||
const bytes = new Uint8Array(value.length / 4);
|
||||
for (let i = 0; i < value.length; i += 4) {
|
||||
const a = ZERO_WIDTH_TO_BITS.get(value.charAt(i) as (typeof ZERO_WIDTH_ALPHABET)[number]);
|
||||
const b = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 1) as (typeof ZERO_WIDTH_ALPHABET)[number]);
|
||||
const c = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 2) as (typeof ZERO_WIDTH_ALPHABET)[number]);
|
||||
const d = ZERO_WIDTH_TO_BITS.get(value.charAt(i + 3) as (typeof ZERO_WIDTH_ALPHABET)[number]);
|
||||
if (a == null || b == null || c == null || d == null) return null;
|
||||
bytes[i / 4] = (a << 6) | (b << 4) | (c << 2) | d;
|
||||
}
|
||||
|
||||
try {
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEncodedTaskMetadata(
|
||||
text: string,
|
||||
position: number
|
||||
): EncodedTaskMetadataMatch | null {
|
||||
if (text[position] !== TASK_META_START) return null;
|
||||
|
||||
const end = text.indexOf(TASK_META_END, position + 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const encodedPayload = text.slice(position + 1, end);
|
||||
const decodedPayload = decodeZeroWidthPayload(encodedPayload);
|
||||
if (!decodedPayload) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(decodedPayload) as EncodedTaskMetadata;
|
||||
if (!parsed.taskId || !parsed.teamName || !parsed.displayId) return null;
|
||||
return {
|
||||
metadata: parsed,
|
||||
end: end + 1,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskSuggestionFromMetadata(
|
||||
metadata: EncodedTaskMetadata,
|
||||
taskSuggestions: MentionSuggestion[]
|
||||
): MentionSuggestion {
|
||||
return (
|
||||
taskSuggestions.find(
|
||||
(suggestion) =>
|
||||
suggestion.type === 'task' &&
|
||||
suggestion.taskId === metadata.taskId &&
|
||||
suggestion.teamName === metadata.teamName
|
||||
) ?? {
|
||||
id: `task:${metadata.teamName}:${metadata.taskId}`,
|
||||
name: metadata.displayId,
|
||||
type: 'task',
|
||||
taskId: metadata.taskId,
|
||||
teamName: metadata.teamName,
|
||||
teamDisplayName: metadata.teamName,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function createEncodedTaskReference(
|
||||
displayId: string,
|
||||
taskId: string,
|
||||
teamName: string
|
||||
): string {
|
||||
const encodedPayload = encodeZeroWidthPayload(
|
||||
JSON.stringify({
|
||||
displayId,
|
||||
taskId,
|
||||
teamName,
|
||||
} satisfies EncodedTaskMetadata)
|
||||
);
|
||||
return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`;
|
||||
}
|
||||
|
||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
|
|
@ -56,6 +169,26 @@ export function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
export function stripEncodedTaskReferenceMetadata(text: string): string {
|
||||
if (!text.includes(TASK_META_START)) return text;
|
||||
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const start = text.indexOf(TASK_META_START, cursor);
|
||||
if (start === -1) {
|
||||
result += text.slice(cursor);
|
||||
break;
|
||||
}
|
||||
|
||||
result += text.slice(cursor, start);
|
||||
const match = extractEncodedTaskMetadata(text, start);
|
||||
cursor = match ? match.end : start + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findTaskReferenceMatches(
|
||||
text: string,
|
||||
taskSuggestions: MentionSuggestion[]
|
||||
|
|
@ -76,15 +209,19 @@ export function findTaskReferenceMatches(
|
|||
const preceding = start > 0 ? text[start - 1] : undefined;
|
||||
if (!isAllowedTaskRefBoundary(preceding)) continue;
|
||||
|
||||
const suggestion = resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []);
|
||||
const metadataMatch = extractEncodedTaskMetadata(text, start + raw.length);
|
||||
const suggestion = metadataMatch
|
||||
? buildTaskSuggestionFromMetadata(metadataMatch.metadata, taskSuggestions)
|
||||
: resolveTaskSuggestion(suggestionsByRef.get(ref.toLowerCase()) ?? []);
|
||||
if (!suggestion) continue;
|
||||
|
||||
matches.push({
|
||||
start,
|
||||
end: start + raw.length,
|
||||
end: metadataMatch?.end ?? start + raw.length,
|
||||
raw,
|
||||
ref,
|
||||
suggestion,
|
||||
encoded: metadataMatch != null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue