Refine issue workflow surfaces and live updates

This commit is contained in:
dotta 2026-04-09 10:26:17 -05:00
parent b4a58ba8a6
commit 03dff1a29a
48 changed files with 2800 additions and 1163 deletions

View file

@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
}
export function shouldAcceptAutocompleteKey(
key: string,
trigger: MentionState["trigger"] | null,
skillEnterArmed = false,
): boolean {
if (key === "Tab") return true;
if (key !== "Enter") return false;
return trigger === "mention" || (trigger === "skill" && skillEnterArmed);
}
export function isSameAutocompleteSession(
left: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
right: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
): boolean {
if (!left || !right) return false;
return left.trigger === right.trigger
&& left.marker === right.marker
&& left.query === right.query
&& left.textNode === right.textNode
&& left.atPos === right.atPos
&& left.endPos === right.endPos;
}
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
const parsed = parseMentionChipHref(href);
if (!parsed) return false;
if (option.kind === "skill") {
return parsed.kind === "skill" && parsed.skillId === option.skillId;
}
if (option.kind === "project" && option.projectId) {
return parsed.kind === "project" && parsed.projectId === option.projectId;
}
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
return parsed.kind === "agent" && parsed.agentId === agentId;
}
export function findClosestAutocompleteAnchor(
editable: HTMLElement,
option: AutocompleteOption,
origin?: Pick<MentionState, "left" | "top"> | null,
): HTMLAnchorElement | null {
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? ""));
if (matchingMentions.length === 0) return null;
if (!origin) return matchingMentions[0] ?? null;
const containerRect = editable.getBoundingClientRect();
return matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = rectA.left - containerRect.left;
const topA = rectA.top - containerRect.top;
const leftB = rectB.left - containerRect.left;
const topB = rectB.top - containerRect.top;
const distA = Math.hypot(leftA - origin.left, topA - origin.top);
const distB = Math.hypot(leftB - origin.left, topB - origin.top);
return distA - distB;
})[0] ?? null;
}
export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean {
const selection = window.getSelection();
if (!selection) return false;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
if (text.length > 0) {
range.setStart(nextSibling, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
/** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`;
@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const skillEnterArmedRef = useRef(false);
const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const checkMention = useCallback(() => {
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& (!mentions || mentions.length === 0)
) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
&& slashCommands.length === 0
) {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
const previous = mentionStateRef.current;
const sameSession = isSameAutocompleteSession(previous, result);
mentionStateRef.current = result;
if (result) {
setMentionState(result);
if (!sameSession) {
skillEnterArmedRef.current = false;
setMentionIndex(0);
} else {
setMentionState(null);
}
setMentionState(result);
}, [mentions, slashCommands.length]);
useEffect(() => {
@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// also fires after typing (e.g. space to dismiss).
const onInput = () => requestAnimationFrame(checkMention);
let selRafId: number | null = null;
const onSelectionChange = () => {
if (selRafId !== null) return;
selRafId = requestAnimationFrame(() => {
selRafId = null;
checkMention();
});
};
document.addEventListener("selectionchange", onSelectionChange);
document.addEventListener("selectionchange", checkMention);
el?.addEventListener("input", onInput, true);
return () => {
document.removeEventListener("selectionchange", onSelectionChange);
document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true);
if (selRafId !== null) cancelAnimationFrame(selRafId);
};
}, [checkMention, mentions, slashCommands.length]);
@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!editable) return;
decorateProjectMentions();
let rafId: number | null = null;
const observer = new MutationObserver(() => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
decorateProjectMentions();
});
decorateProjectMentions();
});
observer.observe(editable, {
subtree: true,
childList: true,
characterData: true,
});
return () => {
observer.disconnect();
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, [decorateProjectMentions]);
return () => observer.disconnect();
}, [decorateProjectMentions, value]);
const selectMention = useCallback(
(option: AutocompleteOption) => {
@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
onChange(next);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
decorateProjectMentions();
editable.focus();
const restoreSelection = (attemptsRemaining: number) => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
if (!(editable instanceof HTMLElement)) return;
const mentionHref = option.kind === "skill"
? option.href
: option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => {
const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === expectedLabel;
});
const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => {
const rectA = a.getBoundingClientRect();
const rectB = b.getBoundingClientRect();
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
const distA = Math.hypot(leftA - state.left, topA - state.top);
const distB = Math.hypot(leftB - state.left, topB - state.top);
return distA - distB;
})[0] ?? null;
if (!target) return;
decorateProjectMentions();
editable.focus();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
const nextSibling = target.nextSibling;
if (nextSibling?.nodeType === Node.TEXT_NODE) {
const text = nextSibling.textContent ?? "";
if (text.startsWith(" ")) {
range.setStart(nextSibling, 1);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return;
}
const target = findClosestAutocompleteAnchor(editable, option, state);
if (!target) {
if (attemptsRemaining > 0) {
requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
}
return;
}
range.setStartAfter(target);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
});
});
placeCaretAfterMentionAnchor(target);
};
requestAnimationFrame(() => restoreSelection(4));
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
},
[decorateProjectMentions, onChange],
@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (mentionActive) {
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault();
e.stopPropagation();
mentionStateRef.current = null;
skillEnterArmedRef.current = false;
setMentionState(null);
return;
}
@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
setMentionIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
if (
shouldAcceptAutocompleteKey(
e.key,
mentionStateRef.current?.trigger ?? null,
skillEnterArmedRef.current,
)
) {
e.preventDefault();
e.stopPropagation();
selectMention(filteredMentions[mentionIndex]);
@ -865,7 +921,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
onMouseEnter={() => {
if (mentionStateRef.current?.trigger === "skill") {
skillEnterArmedRef.current = true;
}
setMentionIndex(i);
}}
>
{option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />