Adds a text-selection comment flow that opens New Task with source context. - Add a reusable selection comment hook and popover for editor/document text selections. - Thread selected-file and line-range context into the New Task modal. - Enable comment-on-selection entry points in file editor, browser, documents, and memory surfaces. - Cover the selection flow with focused component and hook tests, plus docs and copy updates. Files changed: docs/dashboard-guide.md | 6 +- packages/dashboard/app/App.tsx | 7 +- packages/dashboard/app/components/AppModals.tsx | 2 + .../dashboard/app/components/DocumentsView.tsx | 25 ++- .../dashboard/app/components/FileBrowserModal.tsx | 3 + packages/dashboard/app/components/FileEditor.tsx | 35 +++- packages/dashboard/app/components/MemoryView.tsx | 5 +- packages/dashboard/app/components/NewTaskModal.tsx | 15 +- .../app/components/SelectionCommentPopover.css | 73 +++++++++ .../app/components/SelectionCommentPopover.tsx | 181 +++++++++++++++++++++ .../components/__tests__/DocumentsView.test.tsx | 59 +++++++ .../components/__tests__/FileBrowserModal.test.tsx | 79 +++++++++ .../app/components/__tests__/FileEditor.test.tsx | 60 +++++++ .../app/components/__tests__/MemoryView.test.tsx | 48 +++++- .../app/components/__tests__/NewTaskModal.test.tsx | 17 ++ .../__tests__/SelectionCommentPopover.test.tsx | 75 +++++++++ .../app/hooks/__tests__/useModalManager.test.ts | 29 ++++ .../hooks/__tests__/useSelectionComment.test.ts | 140 ++++++++++++++++ packages/dashboard/app/hooks/useModalManager.ts | 19 ++- .../dashboard/app/hooks/useSelectionComment.ts | 95 +++++++++++ packages/i18n/locales/en/app.json | 11 ++ 21 files changed, 973 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6528 Fusion-Task-Lineage: ef79e450-e69f-497b-97cd-f82d3c832fdf
182 lines
5.7 KiB
TypeScript
182 lines
5.7 KiB
TypeScript
import "./SelectionCommentPopover.css";
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MessageSquarePlus } from "lucide-react";
|
|
import type { SelectionCommentLineRange } from "../hooks/useSelectionComment";
|
|
|
|
interface SelectionCommentPopoverProps {
|
|
selectedText: string;
|
|
anchorRect: DOMRect | null;
|
|
filePath?: string;
|
|
lineRange?: SelectionCommentLineRange;
|
|
onSubmit: (description: string) => void;
|
|
onCancel?: () => void;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
function buildFence(text: string): string {
|
|
let fence = "```";
|
|
while (text.includes(fence)) {
|
|
fence += "`";
|
|
}
|
|
return fence;
|
|
}
|
|
|
|
export function composeSelectionCommentDescription({
|
|
filePath,
|
|
selectedText,
|
|
comment,
|
|
lineRange,
|
|
}: {
|
|
filePath?: string;
|
|
selectedText: string;
|
|
comment: string;
|
|
lineRange?: SelectionCommentLineRange;
|
|
}): string {
|
|
const normalizedSnippet = selectedText.trim();
|
|
const normalizedComment = comment.trim();
|
|
const fence = buildFence(normalizedSnippet);
|
|
const lines = lineRange ? [`Lines: ${lineRange.start === lineRange.end ? lineRange.start : `${lineRange.start}-${lineRange.end}`}`, ""] : [];
|
|
|
|
return [
|
|
`File: ${filePath?.trim() || "Unknown file"}`,
|
|
...lines,
|
|
"Selected snippet:",
|
|
`${fence}text`,
|
|
normalizedSnippet,
|
|
fence,
|
|
"",
|
|
"Comment:",
|
|
normalizedComment,
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* FNXC:SelectionComment 2026-06-16-23:56:
|
|
* The selected text affordance is intentionally stateless beyond a short comment: it formats file path, optional line range, snippet, and user note into a New Task description instead of adding a persistent review/comment model.
|
|
*/
|
|
export function SelectionCommentPopover({
|
|
selectedText,
|
|
anchorRect,
|
|
filePath,
|
|
lineRange,
|
|
onSubmit,
|
|
onCancel,
|
|
onOpenChange,
|
|
}: SelectionCommentPopoverProps) {
|
|
const { t } = useTranslation("app");
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [comment, setComment] = useState("");
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const trimmedSelectedText = selectedText.trim();
|
|
const style = useMemo(() => {
|
|
if (!anchorRect) return undefined;
|
|
return {
|
|
"--selection-comment-left": `${anchorRect.left + anchorRect.width / 2}px`,
|
|
"--selection-comment-top": `${anchorRect.bottom}px`,
|
|
} as CSSProperties;
|
|
}, [anchorRect]);
|
|
|
|
useEffect(() => {
|
|
setExpanded(false);
|
|
setComment("");
|
|
onOpenChange?.(false);
|
|
}, [onOpenChange, trimmedSelectedText]);
|
|
|
|
useEffect(() => {
|
|
if (!expanded) return;
|
|
textareaRef.current?.focus();
|
|
}, [expanded]);
|
|
|
|
const setPanelExpanded = useCallback((open: boolean) => {
|
|
setExpanded(open);
|
|
onOpenChange?.(open);
|
|
}, [onOpenChange]);
|
|
|
|
const handleCancel = useCallback(() => {
|
|
setPanelExpanded(false);
|
|
setComment("");
|
|
onCancel?.();
|
|
}, [onCancel, setPanelExpanded]);
|
|
|
|
useEffect(() => {
|
|
if (!expanded) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
|
handleCancel();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown);
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
};
|
|
}, [expanded, handleCancel]);
|
|
|
|
const handleSubmit = useCallback(() => {
|
|
const description = composeSelectionCommentDescription({
|
|
filePath,
|
|
selectedText: trimmedSelectedText,
|
|
comment,
|
|
lineRange,
|
|
});
|
|
onSubmit(description);
|
|
setPanelExpanded(false);
|
|
setComment("");
|
|
}, [comment, filePath, lineRange, onSubmit, setPanelExpanded, trimmedSelectedText]);
|
|
|
|
if (!style || !trimmedSelectedText) {
|
|
return null;
|
|
}
|
|
|
|
if (!expanded) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-sm selection-comment-trigger"
|
|
style={style}
|
|
onMouseDown={(event) => event.preventDefault()}
|
|
onClick={() => setPanelExpanded(true)}
|
|
aria-label={t("selectionComment.addCommentAria", "Add a comment to the selected text and send it to a new task")}
|
|
>
|
|
<MessageSquarePlus size={14} />
|
|
{t("selectionComment.addComment", "Add comment")}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={rootRef} className="card selection-comment-panel" style={style} role="dialog" aria-label={t("selectionComment.dialogAria", "Comment on selected text")}>
|
|
<p className="selection-comment-title">{t("selectionComment.title", "Comment on selection")}</p>
|
|
<pre className="selection-comment-snippet" aria-label={t("selectionComment.selectedSnippet", "Selected snippet")}>{trimmedSelectedText}</pre>
|
|
<textarea
|
|
ref={textareaRef}
|
|
className="input selection-comment-textarea"
|
|
value={comment}
|
|
onChange={(event) => setComment(event.target.value)}
|
|
placeholder={t("selectionComment.commentPlaceholder", "Describe the task this snippet should become…")}
|
|
aria-label={t("selectionComment.commentAria", "Comment for the new task")}
|
|
/>
|
|
<div className="selection-comment-actions">
|
|
<button type="button" className="btn btn-sm" onClick={handleCancel}>
|
|
{t("selectionComment.cancel", "Cancel")}
|
|
</button>
|
|
<button type="button" className="btn btn-primary btn-sm" onClick={handleSubmit} disabled={!comment.trim()}>
|
|
{t("selectionComment.sendToNewTask", "Send to new task")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|