FN-6528: add selection comments for new tasks
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
This commit is contained in:
@@ -450,6 +450,7 @@ Features:
|
||||
- Open project markdown files with inline preview
|
||||
- Jump directly from a document group to the owning task detail modal
|
||||
- Toggle between raw text and rendered markdown using the **Markdown/Plain** button
|
||||
- Highlight text in raw or rendered project-file previews, choose **Add comment**, and send the file path, selected snippet, and your comment to the **New Task** dialog
|
||||
|
||||

|
||||
|
||||
@@ -480,6 +481,8 @@ Documents view supports toggling between raw text and formatted markdown when vi
|
||||
|
||||
The toggle button is accessible with `aria-pressed` for screen readers. Toggle state is scoped per-document, so switching between documents resets the view to raw mode.
|
||||
|
||||
Project-file previews also support selection comments in both raw and rendered markdown modes. Select text, click **Add comment**, enter a short note, and Fusion opens **New Task** with a seeded description containing the file path, snippet, and comment.
|
||||
|
||||
## Todo View
|
||||
|
||||
Todo View is an experimental dashboard surface for managing per-project todo lists and turning items into planning or task workflows.
|
||||
@@ -523,10 +526,11 @@ The Files modal provides a workspace-aware file browser and editor.
|
||||
- Use **New File** or **New Folder** in the browser header to create entries in the current folder; new files open in the editor after creation
|
||||
- Source/text editing supports a **Line #** header toggle to show or hide line numbers in the editor gutter
|
||||
- The line-number preference is saved per project and restored automatically when you switch projects
|
||||
- In editable files and markdown preview mode, highlighted text exposes **Add comment** so you can send the file path, selected snippet, best-effort line range, and your note to the **New Task** dialog without copy/paste
|
||||
|
||||
## Memory View
|
||||
|
||||
Memory view provides a multi-file editor for project and daily memory files.
|
||||
Memory view provides a multi-file editor for project and daily memory files. Its file editors share the same highlighted-text **Add comment** affordance as the Files modal, so memory snippets can seed a New Task with file path, snippet, and comment context.
|
||||
|
||||
> Available when the `experimentalFeatures.memoryView` toggle is enabled.
|
||||
|
||||
|
||||
@@ -1696,6 +1696,7 @@ function AppInner() {
|
||||
projectId={currentProject?.id}
|
||||
addToast={addToast}
|
||||
onOpenDetail={openDetailTask}
|
||||
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
@@ -1786,7 +1787,11 @@ function AppInner() {
|
||||
return (
|
||||
<PageErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<MemoryView addToast={addToast} projectId={currentProject?.id} />
|
||||
<MemoryView
|
||||
addToast={addToast}
|
||||
projectId={currentProject?.id}
|
||||
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -404,6 +404,7 @@ export function AppModals({
|
||||
onClose={closeFilesWithNav}
|
||||
onWorkspaceChange={modalManager.setFileWorkspace}
|
||||
projectId={projectId}
|
||||
onSendSelectionToTask={modalManager.openNewTaskWithDescription}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -446,6 +447,7 @@ export function AppModals({
|
||||
onCreateTask={handleModalCreateWithOnboardingTracking}
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
initialDescription={modalManager.newTaskInitialDescription ?? ""}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api";
|
||||
import { useDocuments } from "../hooks/useDocuments";
|
||||
import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles";
|
||||
import { useSelectionComment } from "../hooks/useSelectionComment";
|
||||
import { SelectionCommentPopover } from "./SelectionCommentPopover";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
@@ -18,6 +20,7 @@ export interface DocumentsViewProps {
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onOpenDetail: (task: TaskDetail) => void;
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
}
|
||||
|
||||
interface DocumentCardProps {
|
||||
@@ -172,7 +175,7 @@ function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownSta
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) {
|
||||
export function DocumentsView({ projectId, addToast, onOpenDetail, onSendSelectionToTask }: DocumentsViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -184,10 +187,16 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
const initialTabSetRef = useRef(false);
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null);
|
||||
const plainPreviewRef = useRef<HTMLPreElement>(null);
|
||||
// Markdown render toggle for project file preview
|
||||
const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false);
|
||||
// Markdown render toggles per task document card (scoped by doc ID)
|
||||
const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState<Map<string, boolean>>(new Map());
|
||||
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
|
||||
const markdownSelection = useSelectionComment(markdownPreviewRef, { locked: selectionCommentOpen });
|
||||
const plainSelection = useSelectionComment(plainPreviewRef, { locked: selectionCommentOpen });
|
||||
const activeProjectSelection = renderProjectMarkdown ? markdownSelection : plainSelection;
|
||||
|
||||
const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : "";
|
||||
|
||||
@@ -374,6 +383,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
}, [activeTab, refreshProjectFiles, refreshDocuments]);
|
||||
|
||||
const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length;
|
||||
const selectionPopover = selectedFile && onSendSelectionToTask && activeProjectSelection ? (
|
||||
<SelectionCommentPopover
|
||||
selectedText={activeProjectSelection.selectedText}
|
||||
anchorRect={activeProjectSelection.anchorRect}
|
||||
filePath={selectedFile.path}
|
||||
onSubmit={onSendSelectionToTask}
|
||||
onOpenChange={setSelectionCommentOpen}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const searchPlaceholder = activeTab === "project"
|
||||
? t("documents.searchProjectFiles", "Search project markdown files…")
|
||||
@@ -541,14 +559,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
|
||||
) : fileError ? (
|
||||
<p className="documents-content-state documents-content-state--error">{fileError}</p>
|
||||
) : renderProjectMarkdown ? (
|
||||
<div className="documents-content-markdown">
|
||||
<div ref={markdownPreviewRef} className="documents-content-markdown">
|
||||
<div className="markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent ?? ""}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="document-card-content-text documents-content-viewer-text">{fileContent ?? ""}</pre>
|
||||
<pre ref={plainPreviewRef} className="document-card-content-text documents-content-viewer-text">{fileContent ?? ""}</pre>
|
||||
)}
|
||||
{selectionPopover}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -63,6 +63,7 @@ interface FileBrowserModalProps {
|
||||
onClose: () => void;
|
||||
onWorkspaceChange?: (workspace: string) => void;
|
||||
projectId?: string;
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +76,7 @@ export function FileBrowserModal({
|
||||
onClose,
|
||||
onWorkspaceChange,
|
||||
projectId,
|
||||
onSendSelectionToTask,
|
||||
}: FileBrowserModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { projectName, workspaces } = useWorkspaces(projectId);
|
||||
@@ -451,6 +453,7 @@ export function FileBrowserModal({
|
||||
canToggleLineNumbers={!isBinaryFile(selectedFile)}
|
||||
toolbarExpanded={toolbarActionsExpanded}
|
||||
toolbarActionsId={toolbarActionsId}
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { EditorState, Compartment, type Extension } from "@codemirror/state";
|
||||
import { syntaxHighlighting, defaultHighlightStyle } from "@codemirror/language";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { useSelectionComment } from "../hooks/useSelectionComment";
|
||||
import { SelectionCommentPopover } from "./SelectionCommentPopover";
|
||||
import { resolveCodeMirrorLanguage } from "../utils/codemirror-language";
|
||||
|
||||
interface FileEditorProps {
|
||||
@@ -19,6 +21,7 @@ interface FileEditorProps {
|
||||
canToggleLineNumbers?: boolean;
|
||||
toolbarExpanded?: boolean;
|
||||
toolbarActionsId?: string;
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
}
|
||||
|
||||
const FILE_EDITOR_MARKDOWN_PREVIEW_STORAGE_KEY = "fn-file-editor-markdown-preview";
|
||||
@@ -67,6 +70,7 @@ export function FileEditor({
|
||||
canToggleLineNumbers = true,
|
||||
toolbarExpanded,
|
||||
toolbarActionsId: externalToolbarActionsId,
|
||||
onSendSelectionToTask,
|
||||
}: FileEditorProps) {
|
||||
const { t } = useTranslation("app");
|
||||
/*
|
||||
@@ -80,6 +84,7 @@ export function FileEditor({
|
||||
const expanded = isControlled ? toolbarExpanded : internalExpanded;
|
||||
|
||||
const editorHostRef = useRef<HTMLDivElement>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
const syncingFromPropsRef = useRef(false);
|
||||
const onChangeRef = useRef(onChange);
|
||||
@@ -111,6 +116,33 @@ export function FileEditor({
|
||||
}
|
||||
}, [isControlled]);
|
||||
|
||||
const [selectionCommentOpen, setSelectionCommentOpen] = useState(false);
|
||||
const getCodeMirrorLineRange = useCallback(() => {
|
||||
const view = editorViewRef.current;
|
||||
if (!view) return undefined;
|
||||
const range = view.state.selection.main;
|
||||
if (range.empty) return undefined;
|
||||
const fromLine = view.state.doc.lineAt(Math.min(range.from, range.to)).number;
|
||||
const toLine = view.state.doc.lineAt(Math.max(range.from, range.to)).number;
|
||||
return { start: fromLine, end: toLine };
|
||||
}, []);
|
||||
const editorSelection = useSelectionComment(editorHostRef, {
|
||||
locked: selectionCommentOpen,
|
||||
getLineRange: getCodeMirrorLineRange,
|
||||
});
|
||||
const previewSelection = useSelectionComment(previewRef, { locked: selectionCommentOpen });
|
||||
const activeSelection = effectiveShowPreview ? previewSelection : editorSelection;
|
||||
const selectionPopover = onSendSelectionToTask && activeSelection ? (
|
||||
<SelectionCommentPopover
|
||||
selectedText={activeSelection.selectedText}
|
||||
anchorRect={activeSelection.anchorRect}
|
||||
filePath={filePath}
|
||||
lineRange={activeSelection.lineRange}
|
||||
onSubmit={onSendSelectionToTask}
|
||||
onOpenChange={setSelectionCommentOpen}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
useEffect(() => {
|
||||
writeBooleanPref(FILE_EDITOR_MARKDOWN_PREVIEW_STORAGE_KEY, showPreview);
|
||||
}, [showPreview]);
|
||||
@@ -254,12 +286,13 @@ export function FileEditor({
|
||||
) : null}
|
||||
|
||||
{effectiveShowPreview ? (
|
||||
<div className="file-editor-preview markdown-body">
|
||||
<div ref={previewRef} className="file-editor-preview markdown-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="file-editor-codemirror" ref={editorHostRef} aria-label={filePath ? t("fileEditor.editorFor", `Editor for ${filePath}`) : t("fileEditor.fileEditor", "File editor")} />
|
||||
)}
|
||||
{selectionPopover}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMemoryData } from "../hooks/useMemoryData";
|
||||
interface MemoryViewProps {
|
||||
projectId?: string;
|
||||
addToast: (message: string, type: "success" | "error" | "info") => void;
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
}
|
||||
|
||||
type Tab = "working" | "insights" | "engines";
|
||||
@@ -98,7 +99,7 @@ function countTotalInsights(categories: ParsedInsightCategory[]): number {
|
||||
return categories.reduce((sum, cat) => sum + cat.items.length, 0);
|
||||
}
|
||||
|
||||
export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
export function MemoryView({ projectId, addToast, onSendSelectionToTask }: MemoryViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [activeTab, setActiveTab] = useState<Tab>("working");
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
|
||||
@@ -454,6 +455,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onChange={setSelectedFileContent}
|
||||
readOnly={!isWritable}
|
||||
filePath={selectedFilePath}
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -671,6 +673,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
|
||||
onChange={setInsightsEditorContent}
|
||||
readOnly={false}
|
||||
filePath=".fusion/memory/INSIGHTS.md"
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>
|
||||
</div>
|
||||
<div className="memory-action-bar">
|
||||
|
||||
@@ -24,9 +24,10 @@ interface NewTaskModalProps {
|
||||
tasks: Task[]; // for dependency selection
|
||||
onCreateTask: (input: TaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
initialDescription?: string;
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast }: NewTaskModalProps) {
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "" }: NewTaskModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
@@ -42,6 +43,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} as React.CSSProperties)
|
||||
: {};
|
||||
const [description, setDescription] = useState("");
|
||||
const wasOpenRef = useRef(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
const [branchMode, setBranchMode] = useState<BranchSelectionMode>("project-default");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -80,6 +82,17 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
|
||||
const { nodes } = useNodes();
|
||||
|
||||
/**
|
||||
* FNXC:SelectionComment 2026-06-16-23:58:
|
||||
* Selection comments open the normal New Task dialog with a prefilled description; seed only on the closed→open transition so rerenders do not overwrite user edits.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
setDescription(initialDescription);
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [initialDescription, isOpen]);
|
||||
|
||||
// Load agents for agent picker
|
||||
const loadAgents = useCallback(() => {
|
||||
setShowAgentPicker(true);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
.selection-comment-trigger,
|
||||
.selection-comment-panel {
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
left: var(--selection-comment-left);
|
||||
top: var(--selection-comment-top);
|
||||
}
|
||||
|
||||
.selection-comment-trigger {
|
||||
transform: translate(-50%, calc(-1 * var(--space-xl)));
|
||||
box-shadow: var(--shadow-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selection-comment-panel {
|
||||
width: min(var(--selection-comment-panel-width, calc(var(--space-2xl) * 12)), calc(100vw - (var(--space-lg) * 2)));
|
||||
transform: translate(-50%, var(--space-xs));
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.selection-comment-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.selection-comment-snippet {
|
||||
margin: 0;
|
||||
max-height: calc(var(--space-2xl) * 3);
|
||||
overflow: auto;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm);
|
||||
font-family: var(--font-mono);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.selection-comment-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.selection-comment-textarea {
|
||||
min-height: calc(var(--space-2xl) * 2.5);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.selection-comment-trigger,
|
||||
.selection-comment-panel {
|
||||
left: max(var(--space-lg), min(var(--selection-comment-left), calc(100vw - var(--space-lg))));
|
||||
}
|
||||
|
||||
.selection-comment-trigger {
|
||||
transform: translate(-50%, calc(-1 * var(--space-2xl)));
|
||||
}
|
||||
|
||||
.selection-comment-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.selection-comment-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
181
packages/dashboard/app/components/SelectionCommentPopover.tsx
Normal file
181
packages/dashboard/app/components/SelectionCommentPopover.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,27 @@ const mockUseProjectMarkdownFiles = vi.mocked(useProjectMarkdownFiles);
|
||||
const mockFetchWorkspaceFileContent = vi.mocked(fetchWorkspaceFileContent);
|
||||
const mockFetchTaskDetail = vi.mocked(fetchTaskDetail);
|
||||
|
||||
function mockSelectionRect() {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => rect),
|
||||
});
|
||||
Object.defineProperty(Range.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList),
|
||||
});
|
||||
}
|
||||
|
||||
function selectNodeText(node: Node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
}
|
||||
|
||||
const mockTaskDocuments: TaskDocumentWithTask[] = [
|
||||
{
|
||||
id: "doc-1",
|
||||
@@ -186,6 +207,44 @@ describe("DocumentsView", () => {
|
||||
expect(await screen.findByText(/Hello docs/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends selected plain project file preview text to a new task description", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
const plainPreview = await screen.findByText(/Hello docs/);
|
||||
selectNodeText(plainPreview);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Create a docs task." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: README.md"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Hello docs"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Create a docs task."));
|
||||
});
|
||||
|
||||
it("sends selected markdown project file preview text to a new task description", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open README.md" }));
|
||||
await screen.findByText(/Hello docs/);
|
||||
fireEvent.click(screen.getByRole("button", { name: /switch to markdown/i }));
|
||||
const markdownPreviewText = await screen.findByText("Hello docs");
|
||||
selectNodeText(markdownPreviewText);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Review this rendered content." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: README.md"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Hello docs"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Review this rendered content."));
|
||||
});
|
||||
|
||||
it("search filters task documents", async () => {
|
||||
mockUseProjectMarkdownFiles.mockReturnValue({
|
||||
files: [],
|
||||
|
||||
@@ -23,6 +23,27 @@ const mockUseWorkspaceFileBrowser = vi.mocked(workspaceBrowserHook.useWorkspaceF
|
||||
const mockUseWorkspaceFileEditor = vi.mocked(workspaceEditorHook.useWorkspaceFileEditor);
|
||||
const mockUseWorkspaces = vi.mocked(workspacesHook.useWorkspaces);
|
||||
|
||||
function mockSelectionRect() {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => rect),
|
||||
});
|
||||
Object.defineProperty(Range.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList),
|
||||
});
|
||||
}
|
||||
|
||||
function selectNodeText(node: Node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
}
|
||||
|
||||
describe("FileBrowserModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnWorkspaceChange = vi.fn();
|
||||
@@ -115,6 +136,64 @@ describe("FileBrowserModal", () => {
|
||||
expect(mockUseWorkspaceFileEditor).toHaveBeenLastCalledWith("project", "file1.ts", true, undefined);
|
||||
});
|
||||
|
||||
it("sends selected code text from the embedded editor to a new task description", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("file1.ts"));
|
||||
await waitFor(() => expect(screen.getByLabelText("Editor for file1.ts")).toBeInTheDocument());
|
||||
selectNodeText(document.querySelector(".cm-content") as Node);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Investigate this file." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: file1.ts"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("console.log"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Investigate this file."));
|
||||
});
|
||||
|
||||
it("sends selected markdown preview text from the embedded editor to a new task description", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
mockUseWorkspaceFileEditor.mockReturnValue({
|
||||
...defaultEditorState,
|
||||
content: "# Heading\n\nPreview body",
|
||||
originalContent: "# Heading\n\nPreview body",
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
initialFile="README.md"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onSendSelectionToTask={onSendSelectionToTask}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("README.md").length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByRole("button", { name: /toggle editor options/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /preview mode/i }));
|
||||
selectNodeText(await screen.findByText("Preview body"));
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Turn preview note into work." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: README.md"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Preview body"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Turn preview note into work."));
|
||||
});
|
||||
|
||||
it("opens with an initial file selected", async () => {
|
||||
render(
|
||||
<FileBrowserModal
|
||||
|
||||
@@ -55,6 +55,27 @@ describe("FileEditor", () => {
|
||||
}));
|
||||
};
|
||||
|
||||
const mockSelectionRect = () => {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => rect),
|
||||
});
|
||||
Object.defineProperty(Range.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList),
|
||||
});
|
||||
};
|
||||
|
||||
const selectNodeText = (node: Node) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
};
|
||||
|
||||
it("renders CodeMirror editor with file-path aria-label", () => {
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
render(<FileEditor content="" onChange={vi.fn()} filePath="a.ts" />);
|
||||
@@ -136,6 +157,45 @@ describe("FileEditor", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit mode/i }));
|
||||
expect(document.querySelector(".cm-editor")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends selected CodeMirror text to a new task description", async () => {
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<FileEditor content="alpha\nbeta" onChange={vi.fn()} filePath="src/example.ts" onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
|
||||
act(() => {
|
||||
getEditorView().dispatch({ selection: { anchor: 0, head: 5 } });
|
||||
});
|
||||
const content = document.querySelector(".cm-content") as HTMLElement;
|
||||
selectNodeText(content);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Extract this constant." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: src/example.ts"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Lines: 1"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("alpha"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Extract this constant."));
|
||||
});
|
||||
|
||||
it("sends selected markdown preview text to a new task description", async () => {
|
||||
mockSelectionRect();
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
render(<FileEditor content="# Hello\n\nPreview text" onChange={vi.fn()} filePath="readme.md" onSendSelectionToTask={onSendSelectionToTask} readOnly />);
|
||||
|
||||
const preview = document.querySelector(".file-editor-preview .markdown-body") ?? document.querySelector(".file-editor-preview");
|
||||
selectNodeText(preview as Node);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "Document this follow-up." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("File: readme.md"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Preview text"));
|
||||
expect(onSendSelectionToTask).toHaveBeenCalledWith(expect.stringContaining("Document this follow-up."));
|
||||
});
|
||||
it("line-number toggle still flips state and gutter visibility", () => {
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
const onToggle = vi.fn();
|
||||
|
||||
@@ -4,6 +4,10 @@ import userEvent from "@testing-library/user-event";
|
||||
import { MemoryView } from "../MemoryView";
|
||||
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
|
||||
|
||||
const { capturedFileEditorProps } = vi.hoisted(() => ({
|
||||
capturedFileEditorProps: [] as Array<{ filePath: string; onSendSelectionToTask?: (description: string) => void }>,
|
||||
}));
|
||||
|
||||
const mockUseMemoryData = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useMemoryData", () => ({
|
||||
@@ -11,7 +15,10 @@ vi.mock("../../hooks/useMemoryData", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../FileEditor", () => ({
|
||||
FileEditor: ({ filePath }: { filePath: string }) => <div aria-label={`Editor for ${filePath}`} />,
|
||||
FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => {
|
||||
capturedFileEditorProps.push(props);
|
||||
return <div aria-label={`Editor for ${props.filePath}`} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -94,6 +101,7 @@ function createMemoryData(overrides: Record<string, unknown> = {}) {
|
||||
describe("MemoryView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedFileEditorProps.length = 0;
|
||||
mockUseMemoryData.mockReturnValue(createMemoryData());
|
||||
});
|
||||
|
||||
@@ -110,6 +118,44 @@ describe("MemoryView", () => {
|
||||
expect(screen.queryByText("This memory backend is read-only. Changes cannot be saved.")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes selection-to-task callback to the memory file editor", () => {
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
|
||||
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
|
||||
expect(capturedFileEditorProps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filePath: ".fusion/memory/MEMORY.md",
|
||||
onSendSelectionToTask,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes selection-to-task callback to the raw insights editor", async () => {
|
||||
const onSendSelectionToTask = vi.fn();
|
||||
mockUseMemoryData.mockReturnValue(
|
||||
createMemoryData({
|
||||
insightsExists: true,
|
||||
insightsContent: "## Patterns\n- Keep useful notes",
|
||||
}),
|
||||
);
|
||||
|
||||
render(<MemoryView addToast={vi.fn()} onSendSelectionToTask={onSendSelectionToTask} />);
|
||||
await userEvent.click(screen.getByRole("tab", { name: "Insights" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Edit Raw" }));
|
||||
|
||||
expect(capturedFileEditorProps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
filePath: ".fusion/memory/INSIGHTS.md",
|
||||
onSendSelectionToTask,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows read-only warning after backend resolves as non-writable", () => {
|
||||
mockUseMemoryData.mockReturnValue(
|
||||
createMemoryData({
|
||||
|
||||
@@ -205,6 +205,23 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the description when opened with an initial description", () => {
|
||||
renderNewTaskModal({ initialDescription: "File: README.md\n\nComment:\nFollow up" });
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("File: README.md\n\nComment:\nFollow up");
|
||||
expect(screen.getByRole("button", { name: "Create Task" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("does not clobber user edits when initialDescription changes while open", () => {
|
||||
const { rerender, props } = renderNewTaskModal({ initialDescription: "Seeded description" });
|
||||
const descTextarea = screen.getByRole("textbox");
|
||||
|
||||
fireEvent.change(descTextarea, { target: { value: "User edited text" } });
|
||||
rerender(<NewTaskModal {...props} initialDescription="Different seed" />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("User edited text");
|
||||
});
|
||||
|
||||
it("creates task with description when submitted", async () => {
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SelectionCommentPopover, composeSelectionCommentDescription } from "../SelectionCommentPopover";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
MessageSquarePlus: () => null,
|
||||
}));
|
||||
|
||||
describe("SelectionCommentPopover", () => {
|
||||
it("renders a trigger for a selection and submits a composed task description", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<SelectionCommentPopover
|
||||
selectedText="const answer = 42;"
|
||||
anchorRect={new DOMRect(20, 30, 100, 16)}
|
||||
filePath="src/example.ts"
|
||||
lineRange={{ start: 4, end: 4 }}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), {
|
||||
target: { value: "Turn this into a configurable value." },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send to new task/i }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith([
|
||||
"File: src/example.ts",
|
||||
"Lines: 4",
|
||||
"",
|
||||
"Selected snippet:",
|
||||
"```text",
|
||||
"const answer = 42;",
|
||||
"```",
|
||||
"",
|
||||
"Comment:",
|
||||
"Turn this into a configurable value.",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("cancels cleanly without submitting", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<SelectionCommentPopover
|
||||
selectedText="snippet"
|
||||
anchorRect={new DOMRect(20, 30, 100, 16)}
|
||||
filePath="README.md"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
onOpenChange={onOpenChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add a comment/i }));
|
||||
fireEvent.change(screen.getByLabelText(/comment for the new task/i), { target: { value: "A note" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(true);
|
||||
expect(onOpenChange).toHaveBeenLastCalledWith(false);
|
||||
expect(screen.getByRole("button", { name: /add a comment/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a longer markdown fence when the snippet contains backticks", () => {
|
||||
expect(composeSelectionCommentDescription({
|
||||
filePath: "README.md",
|
||||
selectedText: "```js\ncode\n```",
|
||||
comment: "Move this example.",
|
||||
})).toContain("````text\n```js\ncode\n```\n````");
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ describe("useModalManager", () => {
|
||||
);
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(false);
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
|
||||
act(() => {
|
||||
@@ -62,6 +63,7 @@ describe("useModalManager", () => {
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(true);
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
expect(result.current.anyModalOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
@@ -69,9 +71,36 @@ describe("useModalManager", () => {
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(false);
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
expect(result.current.anyModalOpen).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the new task modal with a seeded description and resets it on close", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [] }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.openNewTaskWithDescription("File: README.md\n\nComment:\nCreate a task");
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(true);
|
||||
expect(result.current.newTaskInitialDescription).toContain("README.md");
|
||||
|
||||
act(() => {
|
||||
result.current.closeNewTask();
|
||||
});
|
||||
|
||||
expect(result.current.newTaskModalOpen).toBe(false);
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.openNewTask();
|
||||
});
|
||||
|
||||
expect(result.current.newTaskInitialDescription).toBeNull();
|
||||
});
|
||||
|
||||
it("handles planning open, resume, and close lifecycle", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useModalManager({ projectId: "proj_1", planningSessions: [{ id: "plan-1" }] }),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSelectionComment } from "../useSelectionComment";
|
||||
|
||||
function mockRangeRect() {
|
||||
const rect = new DOMRect(10, 20, 80, 12);
|
||||
Object.defineProperty(Range.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => rect),
|
||||
});
|
||||
Object.defineProperty(Range.prototype, "getClientRects", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => ({ 0: rect, length: 1, item: () => rect, [Symbol.iterator]: function* () { yield rect; } }) as DOMRectList),
|
||||
});
|
||||
}
|
||||
|
||||
function selectText(node: Node) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
const selection = document.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
}
|
||||
|
||||
describe("useSelectionComment", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
mockRangeRect();
|
||||
});
|
||||
|
||||
it("reports selected text and anchor rect inside the container", async () => {
|
||||
const container = document.createElement("div");
|
||||
const text = document.createTextNode("selected snippet");
|
||||
container.append(text);
|
||||
document.body.append(container);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
const { result } = renderHook(() => useSelectionComment(ref));
|
||||
|
||||
act(() => selectText(text));
|
||||
|
||||
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
||||
expect(result.current?.anchorRect.left).toBe(10);
|
||||
});
|
||||
|
||||
it("clears selection state when the selection is outside the container", async () => {
|
||||
const container = document.createElement("div");
|
||||
container.textContent = "inside";
|
||||
const outside = document.createElement("div");
|
||||
outside.textContent = "outside";
|
||||
document.body.append(container, outside);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
const { result } = renderHook(() => useSelectionComment(ref));
|
||||
|
||||
act(() => selectText(outside.firstChild as Node));
|
||||
|
||||
await waitFor(() => expect(result.current).toBeNull());
|
||||
});
|
||||
|
||||
it("clears selection state when the selection is collapsed", async () => {
|
||||
const container = document.createElement("div");
|
||||
const text = document.createTextNode("selected snippet");
|
||||
container.append(text);
|
||||
document.body.append(container);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
const { result } = renderHook(() => useSelectionComment(ref));
|
||||
act(() => selectText(text));
|
||||
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
||||
|
||||
act(() => {
|
||||
const selection = document.getSelection();
|
||||
selection?.collapse(text, 0);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps the existing selection state while locked", async () => {
|
||||
const container = document.createElement("div");
|
||||
const text = document.createTextNode("selected snippet");
|
||||
container.append(text);
|
||||
document.body.append(container);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
let locked = false;
|
||||
const { result, rerender } = renderHook(() => useSelectionComment(ref, { locked }));
|
||||
act(() => selectText(text));
|
||||
await waitFor(() => expect(result.current?.selectedText).toBe("selected snippet"));
|
||||
|
||||
locked = true;
|
||||
rerender();
|
||||
act(() => {
|
||||
const selection = document.getSelection();
|
||||
selection?.collapse(text, 0);
|
||||
document.dispatchEvent(new Event("selectionchange"));
|
||||
});
|
||||
|
||||
expect(result.current?.selectedText).toBe("selected snippet");
|
||||
});
|
||||
|
||||
it("propagates a line range from the optional mapper", async () => {
|
||||
const container = document.createElement("div");
|
||||
const text = document.createTextNode("selected snippet");
|
||||
container.append(text);
|
||||
document.body.append(container);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
const { result } = renderHook(() => useSelectionComment(ref, { getLineRange: () => ({ start: 2, end: 5 }) }));
|
||||
|
||||
act(() => selectText(text));
|
||||
|
||||
await waitFor(() => expect(result.current?.lineRange).toEqual({ start: 2, end: 5 }));
|
||||
});
|
||||
|
||||
it("ignores whitespace-only selections", async () => {
|
||||
const container = document.createElement("div");
|
||||
const text = document.createTextNode(" ");
|
||||
container.append(text);
|
||||
document.body.append(container);
|
||||
const ref = createRef<HTMLElement>();
|
||||
ref.current = container;
|
||||
|
||||
const { result } = renderHook(() => useSelectionComment(ref));
|
||||
|
||||
act(() => selectText(text));
|
||||
|
||||
await waitFor(() => expect(result.current).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ interface UseModalManagerOptions {
|
||||
export interface ModalManager {
|
||||
// State
|
||||
newTaskModalOpen: boolean;
|
||||
newTaskInitialDescription: string | null;
|
||||
isPlanningOpen: boolean;
|
||||
planningInitialPlan: string | null;
|
||||
planningResumeSessionId: string | undefined;
|
||||
@@ -69,6 +70,7 @@ export interface ModalManager {
|
||||
|
||||
// Handlers
|
||||
openNewTask: () => void;
|
||||
openNewTaskWithDescription: (description: string) => void;
|
||||
closeNewTask: () => void;
|
||||
|
||||
openPlanning: () => void;
|
||||
@@ -155,6 +157,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
const { planningSessions } = options;
|
||||
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [newTaskInitialDescription, setNewTaskInitialDescription] = useState<string | null>(null);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
@@ -217,8 +220,18 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
modelOnboardingOpen,
|
||||
);
|
||||
|
||||
const openNewTask = useCallback(() => setNewTaskModalOpen(true), []);
|
||||
const closeNewTask = useCallback(() => setNewTaskModalOpen(false), []);
|
||||
const openNewTask = useCallback(() => {
|
||||
setNewTaskInitialDescription(null);
|
||||
setNewTaskModalOpen(true);
|
||||
}, []);
|
||||
const openNewTaskWithDescription = useCallback((description: string) => {
|
||||
setNewTaskInitialDescription(description);
|
||||
setNewTaskModalOpen(true);
|
||||
}, []);
|
||||
const closeNewTask = useCallback(() => {
|
||||
setNewTaskModalOpen(false);
|
||||
setNewTaskInitialDescription(null);
|
||||
}, []);
|
||||
|
||||
const openPlanning = useCallback(() => setIsPlanningOpen(true), []);
|
||||
const openPlanningWithInitialPlan = useCallback((initialPlan: string) => {
|
||||
@@ -412,6 +425,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
|
||||
return {
|
||||
newTaskModalOpen,
|
||||
newTaskInitialDescription,
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
@@ -447,6 +461,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
modelOnboardingOpen,
|
||||
anyModalOpen,
|
||||
openNewTask,
|
||||
openNewTaskWithDescription,
|
||||
closeNewTask,
|
||||
openPlanning,
|
||||
openPlanningWithInitialPlan,
|
||||
|
||||
95
packages/dashboard/app/hooks/useSelectionComment.ts
Normal file
95
packages/dashboard/app/hooks/useSelectionComment.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useState, type RefObject } from "react";
|
||||
|
||||
export interface SelectionCommentLineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SelectionCommentState {
|
||||
selectedText: string;
|
||||
anchorRect: DOMRect;
|
||||
lineRange?: SelectionCommentLineRange;
|
||||
}
|
||||
|
||||
interface UseSelectionCommentOptions {
|
||||
getLineRange?: (selection: Selection) => SelectionCommentLineRange | undefined;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
function isNodeInside(container: HTMLElement, node: Node | null): boolean {
|
||||
if (!node) return false;
|
||||
return container === node || container.contains(node);
|
||||
}
|
||||
|
||||
function getRangeAnchorRect(range: Range): DOMRect | null {
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
return rect;
|
||||
}
|
||||
const firstRect = range.getClientRects()[0];
|
||||
return firstRect ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:SelectionComment 2026-06-16-23:49:
|
||||
* File content surfaces need a shared selection detector so editor, markdown preview, and read-only preview containers can offer the same comment-to-New-Task affordance without mutating the file or disrupting copy selection.
|
||||
*/
|
||||
export function useSelectionComment(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
options: UseSelectionCommentOptions = {},
|
||||
): SelectionCommentState | null {
|
||||
const { getLineRange, locked = false } = options;
|
||||
const [selectionState, setSelectionState] = useState<SelectionCommentState | null>(null);
|
||||
|
||||
const refreshSelection = useCallback(() => {
|
||||
if (locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const selection = document.getSelection();
|
||||
if (!container || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||
setSelectionState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = selection.toString().trim();
|
||||
if (!selectedText) {
|
||||
setSelectionState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNodeInside(container, range.commonAncestorContainer) || !isNodeInside(container, selection.anchorNode) || !isNodeInside(container, selection.focusNode)) {
|
||||
setSelectionState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorRect = getRangeAnchorRect(range);
|
||||
if (!anchorRect) {
|
||||
setSelectionState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectionState({
|
||||
selectedText,
|
||||
anchorRect,
|
||||
lineRange: getLineRange?.(selection),
|
||||
});
|
||||
}, [containerRef, getLineRange, locked]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("selectionchange", refreshSelection);
|
||||
document.addEventListener("mouseup", refreshSelection);
|
||||
document.addEventListener("touchend", refreshSelection);
|
||||
document.addEventListener("keyup", refreshSelection);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", refreshSelection);
|
||||
document.removeEventListener("mouseup", refreshSelection);
|
||||
document.removeEventListener("touchend", refreshSelection);
|
||||
document.removeEventListener("keyup", refreshSelection);
|
||||
};
|
||||
}, [refreshSelection]);
|
||||
|
||||
return selectionState;
|
||||
}
|
||||
@@ -1948,6 +1948,17 @@
|
||||
"toggleWordWrap": "Toggle word wrap",
|
||||
"wrap": "Wrap"
|
||||
},
|
||||
"selectionComment": {
|
||||
"addComment": "Add comment",
|
||||
"addCommentAria": "Add a comment to the selected text and send it to a new task",
|
||||
"cancel": "Cancel",
|
||||
"commentAria": "Comment for the new task",
|
||||
"commentPlaceholder": "Describe the task this snippet should become…",
|
||||
"dialogAria": "Comment on selected text",
|
||||
"selectedSnippet": "Selected snippet",
|
||||
"sendToNewTask": "Send to new task",
|
||||
"title": "Comment on selection"
|
||||
},
|
||||
"fileMention": {
|
||||
"empty": "No tasks or files found",
|
||||
"fileHeader": "Files",
|
||||
|
||||
Reference in New Issue
Block a user