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:
iliya 2026-03-11 14:18:43 +02:00
parent 057591060a
commit f48b75cbc7
15 changed files with 385 additions and 35 deletions

View file

@ -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 ? (

View file

@ -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();

View file

@ -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>
);

View file

@ -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);
};

View file

@ -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;

View file

@ -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) &&

View file

@ -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) &&

View file

@ -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 ? (

View file

@ -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 ? (

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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

View file

@ -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',

View file

@ -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));

View file

@ -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,
});
}