8f2fe7a8e6
* feat(web): add agent message comments * fix(web): harden agent message comments * fix(web): address message comment review findings * fix(web): allow comments on legacy agent messages * fix(web): address latest message comment review * fix(web): address follow-up message comment review * fix(web): address remaining message comment review * fix(web): keep failed mobile comments open * fix(web): preserve selections over message comments
200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
import type { Message } from "@/lib/types/http";
|
|
import type { MessageTextAnchor } from "@/lib/state/slices/comments";
|
|
import type { AgentMessageComment } from "@/lib/state/slices/comments";
|
|
|
|
const ANCHOR_CONTEXT_LENGTH = 80;
|
|
|
|
export function agentMessageCommentHighlightName(messageId: string) {
|
|
return `agent-message-comment-${messageId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
}
|
|
|
|
export type ResolvedMessageTextRange = { start: number; end: number };
|
|
export type MessageSelection = ResolvedMessageTextRange & {
|
|
selectedText: string;
|
|
rect: DOMRect;
|
|
};
|
|
|
|
export function createMessageTextAnchor(
|
|
messageId: string,
|
|
content: string,
|
|
start: number,
|
|
end: number,
|
|
): MessageTextAnchor {
|
|
const safeStart = Math.max(0, Math.min(start, content.length));
|
|
const safeEnd = Math.max(safeStart, Math.min(end, content.length));
|
|
return {
|
|
messageId,
|
|
start: safeStart,
|
|
end: safeEnd,
|
|
selectedText: content.slice(safeStart, safeEnd),
|
|
prefix: content.slice(Math.max(0, safeStart - ANCHOR_CONTEXT_LENGTH), safeStart),
|
|
suffix: content.slice(safeEnd, safeEnd + ANCHOR_CONTEXT_LENGTH),
|
|
};
|
|
}
|
|
|
|
function isValidRange(content: string, start: number, end: number, selectedText: string) {
|
|
return start >= 0 && end > start && content.slice(start, end) === selectedText;
|
|
}
|
|
|
|
function findQuotedRange(
|
|
anchor: MessageTextAnchor,
|
|
content: string,
|
|
): ResolvedMessageTextRange | null {
|
|
const quote = `${anchor.prefix}${anchor.selectedText}${anchor.suffix}`;
|
|
const quoteIndex = content.indexOf(quote);
|
|
if (quoteIndex !== -1) {
|
|
const start = quoteIndex + anchor.prefix.length;
|
|
return { start, end: start + anchor.selectedText.length };
|
|
}
|
|
|
|
const candidates: number[] = [];
|
|
let candidate = content.indexOf(anchor.selectedText);
|
|
while (candidate !== -1) {
|
|
candidates.push(candidate);
|
|
candidate = content.indexOf(anchor.selectedText, candidate + 1);
|
|
}
|
|
if (candidates.length === 0) return null;
|
|
const prefix = anchor.prefix.slice(-32);
|
|
const suffix = anchor.suffix.slice(0, 32);
|
|
const best = candidates.reduce((current, next) => {
|
|
const score = (index: number) => {
|
|
const before = content.slice(Math.max(0, index - prefix.length), index);
|
|
const after = content.slice(index + anchor.selectedText.length);
|
|
return (
|
|
(prefix && before.endsWith(prefix) ? 2 : 0) +
|
|
(suffix && after.startsWith(suffix) ? 2 : 0) -
|
|
Math.min(Math.abs(index - anchor.start), 1000) / 10000
|
|
);
|
|
};
|
|
return score(next) > score(current) ? next : current;
|
|
});
|
|
return { start: best, end: best + anchor.selectedText.length };
|
|
}
|
|
|
|
export function resolveMessageTextAnchor(
|
|
anchor: MessageTextAnchor,
|
|
content: string,
|
|
): ResolvedMessageTextRange | null {
|
|
if (!anchor.selectedText) return null;
|
|
if (isValidRange(content, anchor.start, anchor.end, anchor.selectedText)) {
|
|
return { start: anchor.start, end: anchor.end };
|
|
}
|
|
return findQuotedRange(anchor, content);
|
|
}
|
|
|
|
function boundaryOffset(root: HTMLElement, container: Node, offset: number): number | null {
|
|
if (!root.contains(container)) return null;
|
|
try {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(root);
|
|
range.setEnd(container, offset);
|
|
return range.toString().length;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Convert a browser selection into offsets relative to one message body. */
|
|
export function getMessageSelection(
|
|
root: HTMLElement,
|
|
selection: Selection | null,
|
|
): MessageSelection | null {
|
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
const range = selection.getRangeAt(0);
|
|
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
|
|
const start = boundaryOffset(root, range.startContainer, range.startOffset);
|
|
const end = boundaryOffset(root, range.endContainer, range.endOffset);
|
|
if (start === null || end === null || end <= start) return null;
|
|
const selectedText = range.toString();
|
|
if (!selectedText.trim()) return null;
|
|
return { start, end, selectedText, rect: range.getBoundingClientRect() };
|
|
}
|
|
|
|
function messageTextNodes(root: HTMLElement): Text[] {
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
const nodes: Text[] = [];
|
|
let node = walker.nextNode();
|
|
while (node) {
|
|
if (node.nodeValue) nodes.push(node as Text);
|
|
node = walker.nextNode();
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
function domRangeForOffsets(textNodes: Text[], start: number, end: number): Range | null {
|
|
let cursor = 0;
|
|
let startBoundary: { node: Text; offset: number } | null = null;
|
|
let endBoundary: { node: Text; offset: number } | null = null;
|
|
for (const textNode of textNodes) {
|
|
const textLength = textNode.data.length;
|
|
const nodeStart = cursor;
|
|
const nodeEnd = cursor + textLength;
|
|
cursor = nodeEnd;
|
|
if (!startBoundary && start >= nodeStart && start <= nodeEnd) {
|
|
startBoundary = { node: textNode, offset: start - nodeStart };
|
|
}
|
|
if (end >= nodeStart && end <= nodeEnd) {
|
|
endBoundary = { node: textNode, offset: end - nodeStart };
|
|
break;
|
|
}
|
|
}
|
|
if (!startBoundary || !endBoundary) return null;
|
|
const range = document.createRange();
|
|
range.setStart(startBoundary.node, startBoundary.offset);
|
|
range.setEnd(endBoundary.node, endBoundary.offset);
|
|
return range;
|
|
}
|
|
|
|
export type MessageCommentDecoration = {
|
|
comment: AgentMessageComment;
|
|
range: Range;
|
|
highlightRects: Array<{ left: number; top: number; width: number; height: number }>;
|
|
left: number;
|
|
top: number;
|
|
};
|
|
|
|
/** Resolve highlights without changing DOM nodes owned by React. */
|
|
export function getMessageCommentDecorations(
|
|
root: HTMLElement,
|
|
comments: AgentMessageComment[],
|
|
): MessageCommentDecoration[] {
|
|
const content = root.textContent ?? "";
|
|
const rootRect = root.getBoundingClientRect();
|
|
const textNodes = messageTextNodes(root);
|
|
const decorations: MessageCommentDecoration[] = [];
|
|
for (const comment of comments) {
|
|
const resolved = resolveMessageTextAnchor(comment.anchor, content);
|
|
if (!resolved) continue;
|
|
const range = domRangeForOffsets(textNodes, resolved.start, resolved.end);
|
|
if (!range) continue;
|
|
const rects = Array.from(range.getClientRects());
|
|
const rect = rects.at(-1) ?? range.getBoundingClientRect();
|
|
const highlightRects = (rects.length > 0 ? rects : [rect]).map((rangeRect) => ({
|
|
left: rangeRect.left - rootRect.left + root.scrollLeft,
|
|
top: rangeRect.top - rootRect.top + root.scrollTop,
|
|
width: rangeRect.width,
|
|
height: rangeRect.height,
|
|
}));
|
|
decorations.push({
|
|
comment,
|
|
range,
|
|
highlightRects,
|
|
left: rect.right - rootRect.left + root.scrollLeft,
|
|
top: rect.top - rootRect.top + root.scrollTop,
|
|
});
|
|
}
|
|
return decorations;
|
|
}
|
|
|
|
export function isSelectableAgentMessage(
|
|
message: Message,
|
|
isTurnActive: boolean,
|
|
isRawView: boolean,
|
|
): boolean {
|
|
if (isTurnActive || isRawView) return false;
|
|
if (message.author_type !== "agent") return false;
|
|
if (message.type && message.type !== "message" && message.type !== "content") return false;
|
|
if (!message.content.trim()) return false;
|
|
return true;
|
|
}
|