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:
gsxdsm
2026-06-17 03:29:39 -07:00
parent 66be2624fd
commit 4bfb6b46ad
21 changed files with 973 additions and 11 deletions

View File

@@ -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
![Documents view](./screenshots/documents-view.png)
@@ -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.

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

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

View File

@@ -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">

View File

@@ -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);

View File

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

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

View File

@@ -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: [],

View File

@@ -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

View File

@@ -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();

View File

@@ -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({

View File

@@ -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();

View File

@@ -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````");
});
});

View File

@@ -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" }] }),

View File

@@ -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());
});
});

View File

@@ -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,

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

View File

@@ -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",