diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 9e6e0f20..34984506 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -447,7 +447,7 @@ export const ActivityItem = ({ return (
+
{structured ? (
{autoSummary && autoSummary !== messageType ? ( diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 6e6b7b83..95958717 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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(); diff --git a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx index 2be69e8b..006e3d26 100644 --- a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx +++ b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx @@ -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(null); const viewRef = useRef(null); @@ -104,10 +108,15 @@ export const MembersJsonEditor = ({ return (
-
+
+
+ +
+
+
{error ?

{error}

: null}
); diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 6e1c67bb..9781ad4e 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -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); }; diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index c2820f9a..2ee40a73 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index e24b99f3..613ebda0 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -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) && diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 02c89396..91106df6 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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) && diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 621719f5..7281dbb9 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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 = ({
{showWorkflow && onWorkflowChange && workflowExpanded ? ( diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 7a435ad2..da8f9800 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -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 = ({ {!hideContent && (
- - {showJsonEditor ? ( + {showJsonEditor && !jsonEditorOpen ? ( ) : null}
@@ -208,7 +210,12 @@ export const MembersEditorSection = ({ /> ))} {jsonEditorOpen && showJsonEditor ? ( - + ) : null}
{hasDuplicates ? ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 8d3814c4..b1975755 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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; diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 255f8ecc..783d4e92 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -425,7 +425,9 @@ export const MessagesPanel = ({ {searchAndFilterBar}
{/* Scrollable content */} -
{messagesContent}
+
+ {messagesContent} +
); } diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index ea4e8073..d5525093 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -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 0 || teamSuggestions.length > 0 || taskSuggestions.length > 0 || @@ -570,10 +648,18 @@ export const MentionableTextarea = React.forwardRef { + 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) => { - if (chips.length === 0 || !onChipRemove) return; - const textarea = internalRef.current; if (!textarea) return; @@ -584,6 +670,17 @@ export const MentionableTextarea = React.forwardRef { + 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 { + 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 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 {seg.value} ); } + if (seg.type === 'url') { + return ( + + + {seg.value} + + ); + } // mention (member or team) const isTeamMention = seg.suggestion.type === 'team'; const colorSet = seg.suggestion.color diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts index a5b8d277..77a53e40 100644 --- a/src/renderer/hooks/useTaskSuggestions.ts +++ b/src/renderer/hooks/useTaskSuggestions.ts @@ -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', diff --git a/src/renderer/types/inlineChip.ts b/src/renderer/types/inlineChip.ts index fd65b5ae..b35caa64 100644 --- a/src/renderer/types/inlineChip.ts +++ b/src/renderer/types/inlineChip.ts @@ -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)); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index 06f3a663..ad81a311 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -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, }); }