Files
kandev/apps/web/lib/chat/agent-message-comments.ts
José Almeida 8f2fe7a8e6 feat(web): add agent message comments (#1835)
* 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
2026-07-21 13:32:19 +01:00

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