app/components/TaskEditor.tsx (413 lines of code) (raw):
import React, { useState, useEffect, useRef } from "react";
import {
GitHubService,
parseIssueMentions,
type GitHubIssue,
type IssueMention,
} from "~/lib/githubService";
import { IssueEnhancer } from "~/lib/issueEnhancer";
import {
IssuePill,
IssueAutocomplete,
IssuePopover,
IssueHoverPopover,
} from "./IssueComponents";
interface TaskEditorProps {
taskInput: string;
onTaskInputChange: (value: string) => void;
onKeyPress: (e: React.KeyboardEvent) => void;
onSubmit?: () => void; // New optional prop for Cmd+Enter submission
selectedRepo: string;
templateText: string;
}
export const TaskEditor = ({
taskInput,
onTaskInputChange,
onKeyPress,
onSubmit,
selectedRepo,
templateText,
}: TaskEditorProps) => {
// Refs for better positioning
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// GitHub Issues State
const [issuePopover, setIssuePopover] = useState<{
issue: GitHubIssue;
position: { x: number; y: number };
} | null>(null);
const [issueAutocomplete, setIssueAutocomplete] = useState<{
issues: GitHubIssue[];
position: { x: number; y: number };
searchTerm: string;
} | null>(null);
const [loadingIssues, setLoadingIssues] = useState<Set<number>>(new Set());
const [autocompleteTimeout, setAutocompleteTimeout] =
useState<NodeJS.Timeout | null>(null);
const [loadedIssues, setLoadedIssues] = useState<Map<number, GitHubIssue>>(
new Map()
);
const [hoveredIssue, setHoveredIssue] = useState<number | null>(null);
const [hoverPosition, setHoverPosition] = useState<{
x: number;
y: number;
} | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
};
}, [autocompleteTimeout]);
// Clear local state when repository changes
useEffect(() => {
setLoadedIssues(new Map());
setLoadingIssues(new Set());
}, [selectedRepo]);
// Parse issue mentions from task input
const issueMentions = parseIssueMentions(taskInput);
// Calculate pill positions using a canvas for precise text measurement
const calculatePillPositions = () => {
const textarea = textareaRef.current;
if (!textarea) return [];
const style = window.getComputedStyle(textarea);
const font = `${style.fontSize} ${style.fontFamily}`;
const lineHeight =
parseInt(style.lineHeight) || parseInt(style.fontSize) * 1.2;
const paddingLeft = parseInt(style.paddingLeft) || 0;
const paddingTop = parseInt(style.paddingTop) || 0;
// Create canvas for text measurement
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.font = font;
return issueMentions.map((mention) => {
const textBeforeMention = taskInput.substring(0, mention.startIndex);
const lines = textBeforeMention.split("\n");
const lineIndex = lines.length - 1;
const charIndexInLine = lines[lines.length - 1].length;
// Measure the exact width of text before the mention on the current line
const textBeforeOnLine = lines[lineIndex];
const xOffset = ctx.measureText(textBeforeOnLine).width;
// Measure the exact width of the mention text
const mentionText = `#${mention.number}`;
const mentionWidth = ctx.measureText(mentionText).width;
return {
issueNumber: mention.number,
position: {
left: paddingLeft + xOffset,
top: paddingTop + lineIndex * lineHeight,
width: mentionWidth,
height: lineHeight,
},
};
});
};
// Auto-load issues when mentioned
useEffect(() => {
if (!selectedRepo) return;
// Get unique issue numbers that need to be loaded
const issueNumbersToLoad = issueMentions
.map((mention) => mention.number)
.filter(
(number) => !loadedIssues.has(number) && !loadingIssues.has(number)
);
// Remove duplicates
const uniqueIssueNumbers = [...new Set(issueNumbersToLoad)];
if (uniqueIssueNumbers.length === 0) return;
// Mark all issues as loading
setLoadingIssues((prev) => {
const newSet = new Set(prev);
uniqueIssueNumbers.forEach((number) => newSet.add(number));
return newSet;
});
// Load all issues concurrently
const loadIssues = async () => {
const repoUrl = selectedRepo.includes("github.com")
? selectedRepo
: `https://github.com/${selectedRepo}`;
const loadPromises = uniqueIssueNumbers.map(async (issueNumber) => {
try {
const issue = await GitHubService.getIssue(repoUrl, issueNumber);
return { issueNumber, issue, error: null };
} catch (error) {
console.warn(`Failed to load issue #${issueNumber}:`, error);
return { issueNumber, issue: null, error };
}
});
const results = await Promise.allSettled(loadPromises);
// Update loaded issues
setLoadedIssues((prev) => {
const newMap = new Map(prev);
results.forEach((result) => {
if (result.status === "fulfilled" && result.value.issue) {
newMap.set(result.value.issueNumber, result.value.issue);
}
});
return newMap;
});
// Remove from loading set
setLoadingIssues((prev) => {
const newSet = new Set(prev);
uniqueIssueNumbers.forEach((number) => newSet.delete(number));
return newSet;
});
};
loadIssues();
}, [issueMentions, selectedRepo]);
// Handle # symbol for issue autocomplete
const handleTaskInputChange = async (value: string) => {
onTaskInputChange(value);
// Clear existing timeout
if (autocompleteTimeout) {
clearTimeout(autocompleteTimeout);
}
// Check if user typed # at the end
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPosition = textarea.selectionStart || 0;
const textBeforeCursor = value.substring(0, cursorPosition);
const hashMatch = textBeforeCursor.match(/#(\d*)$/);
if (hashMatch && selectedRepo) {
const searchTerm = hashMatch[1];
// Debounce the API call
const timeout = setTimeout(async () => {
try {
const repoUrl = selectedRepo.includes("github.com")
? selectedRepo
: `https://github.com/${selectedRepo}`;
const issues = await GitHubService.getRepositoryIssues(
repoUrl,
searchTerm
);
if (issues.length >= 0) {
const rect = textarea.getBoundingClientRect();
const style = window.getComputedStyle(textarea);
const lineHeight = parseInt(style.lineHeight) || 24;
// Calculate cursor position
const textBeforeCursor = value.substring(0, cursorPosition);
const lines = textBeforeCursor.split("\n");
const yOffset = (lines.length - 1) * lineHeight;
setIssueAutocomplete({
issues,
position: {
x: rect.left + 10,
y: rect.top + yOffset + lineHeight + 10,
},
searchTerm,
});
}
} catch (error) {
console.warn("Failed to fetch issues for autocomplete:", error);
}
}, 300);
setAutocompleteTimeout(timeout);
} else {
setIssueAutocomplete(null);
}
};
// Handle issue selection from autocomplete
const handleIssueSelect = (issue: GitHubIssue) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPosition = textarea.selectionStart || 0;
const currentValue = taskInput;
const textBeforeCursor = currentValue.substring(0, cursorPosition);
const textAfterCursor = currentValue.substring(cursorPosition);
// Replace the # part with the full issue reference
const hashMatch = textBeforeCursor.match(/#(\d*)$/);
if (hashMatch) {
const beforeHash = textBeforeCursor.substring(0, hashMatch.index);
const newValue = `${beforeHash}#${issue.number}${textAfterCursor}`;
onTaskInputChange(newValue);
// Set cursor position after the issue number
requestAnimationFrame(() => {
const newCursorPos = beforeHash.length + `#${issue.number}`.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
});
}
setIssueAutocomplete(null);
};
// Render issue pills
const renderIssuePills = () => {
const pillPositions = calculatePillPositions();
return pillPositions.map((pillData, index) => {
const issue = loadedIssues.get(pillData.issueNumber);
const isLoading = loadingIssues.has(pillData.issueNumber);
if (!issue && !isLoading) return null;
return (
<IssuePill
key={`pill-${pillData.issueNumber}`}
issue={issue}
isLoading={isLoading}
position={pillData.position}
onHover={(e) => {
if (issue) {
setHoveredIssue(issue.number);
setHoverPosition({ x: e.clientX, y: e.clientY });
}
}}
onLeave={() => {
setHoveredIssue(null);
setHoverPosition(null);
}}
onClick={(e) => {
if (issue) {
e.preventDefault();
e.stopPropagation();
setIssuePopover({
issue,
position: { x: e.clientX, y: e.clientY },
});
}
}}
/>
);
});
};
// Render text with transparent issue mentions
const renderTextWithTransparentIssues = () => {
if (issueMentions.length === 0) {
return taskInput;
}
const parts = [];
let lastIndex = 0;
issueMentions.forEach((mention, index) => {
// Add text before mention
if (mention.startIndex > lastIndex) {
parts.push(
<span key={`text-${index}`}>
{taskInput.substring(lastIndex, mention.startIndex)}
</span>
);
}
// Add transparent mention (so pills show through)
parts.push(
<span
key={`mention-${index}`}
// className="text-transparent select-none"
className="select-none py-8"
>
#{mention.number}
</span>
);
lastIndex = mention.endIndex;
});
// Add remaining text
if (lastIndex < taskInput.length) {
parts.push(<span key="text-end">{taskInput.substring(lastIndex)}</span>);
}
return parts;
};
return (
<>
<div className="rounded-lg bg-white shadow-sm dark:bg-gray-800">
{/* Textarea with Issue Pills Overlay */}
<div className="relative px-6 pt-6" ref={containerRef}>
{/* Background textarea for functionality */}
<textarea
ref={textareaRef}
placeholder="Describe a task to create (use #123 to reference GitHub issues)..."
value={taskInput}
onChange={(e) => handleTaskInputChange(e.target.value)}
onKeyPress={onKeyPress}
className={`relative z-10 h-32 w-full resize-none bg-transparent text-base leading-relaxed focus:outline-none ${
issueMentions.length > 0
? "text-transparent caret-gray-900 dark:caret-gray-100"
: "text-gray-900 dark:text-gray-100"
} placeholder-gray-400 dark:placeholder-gray-500`}
rows={6}
onKeyDown={(e) => {
if (e.key === "Escape" && issueAutocomplete) {
e.preventDefault();
setIssueAutocomplete(null);
}
// Handle Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux) for submission
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
// Call the onSubmit handler if provided, otherwise fall back to onKeyPress
if (onSubmit) {
onSubmit();
} else {
// Create a synthetic KeyboardEvent to maintain compatibility with existing onKeyPress handler
const syntheticEvent = {
...e,
key: "Enter",
preventDefault: () => e.preventDefault(),
stopPropagation: () => e.stopPropagation(),
} as React.KeyboardEvent;
onKeyPress(syntheticEvent);
}
}
}}
onBlur={(e) => {
setTimeout(() => {
if (
!document.querySelector("[data-issue-autocomplete]:hover")
) {
setIssueAutocomplete(null);
}
}, 150);
}}
/>
{/* Text overlay with transparent issue mentions */}
{issueMentions.length > 0 && (
<div className="z-15 pointer-events-none absolute inset-6 whitespace-pre-wrap text-base leading-relaxed text-gray-900 dark:text-gray-100">
{renderTextWithTransparentIssues()}
</div>
)}
{/* Issue Pills Overlay */}
{issueMentions.length > 0 && (
<div className="pointer-events-none absolute inset-6 z-20">
<div className="relative h-full w-full">{renderIssuePills()}</div>
</div>
)}
</div>
{/* Footer with template indicator, repo link, and issue enhancement preview */}
<div className="flex items-center justify-between rounded-b-lg px-4 py-3">
<div className="flex flex-wrap items-center gap-0">
{/* Repository Link */}
{selectedRepo && (
<button
onClick={() => {
const repoUrl = selectedRepo.includes("github.com")
? selectedRepo
: `https://github.com/${selectedRepo}`;
window.open(repoUrl, "_blank", "noopener,noreferrer");
}}
className="rounded p-1.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
title={`Open ${selectedRepo.replace(
/^https:\/\/github\.com\//,
""
)} on GitHub`}
>
<i className="fab fa-github" style={{ fontSize: "14px" }}></i>
</button>
)}
{templateText && templateText.trim() && (
<div
className="cursor-help rounded p-1.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
title={`Template: ${templateText}`}
>
<i className="fas fa-magic" style={{ fontSize: "14px" }}></i>
</div>
)}
{/* Show issue enhancement preview */}
{issueMentions.length > 0 && (
<button
onClick={() => {
const repoUrl = selectedRepo.includes("github.com")
? selectedRepo
: `https://github.com/${selectedRepo}/issues`;
window.open(repoUrl, "_blank", "noopener,noreferrer");
}}
className="rounded p-1.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
title={`Open ${selectedRepo.replace(
/^https:\/\/github\.com\//,
""
)} on GitHub`}
>
<i className="fas fa-hashtag" style={{ fontSize: "14px" }}></i>
</button>
)}
</div>
</div>
</div>
{/* Issue Pills Hover Popover */}
{hoveredIssue && loadedIssues.has(hoveredIssue) && hoverPosition && (
<IssueHoverPopover
issue={loadedIssues.get(hoveredIssue)!}
position={hoverPosition}
/>
)}
{/* Issue Popover */}
{issuePopover && (
<IssuePopover
issue={issuePopover.issue}
position={issuePopover.position}
onClose={() => setIssuePopover(null)}
/>
)}
{/* Issue Autocomplete */}
{issueAutocomplete && (
<IssueAutocomplete
issues={issueAutocomplete.issues}
position={issueAutocomplete.position}
searchTerm={issueAutocomplete.searchTerm}
onSelect={handleIssueSelect}
onClose={() => setIssueAutocomplete(null)}
/>
)}
</>
);
};