diff --git a/packages/dashboard/app/components/NewTaskModal.tsx b/packages/dashboard/app/components/NewTaskModal.tsx
index 8214096e37..7734233c15 100644
--- a/packages/dashboard/app/components/NewTaskModal.tsx
+++ b/packages/dashboard/app/components/NewTaskModal.tsx
@@ -24,9 +24,10 @@ interface NewTaskModalProps {
tasks: Task[]; // for dependency selection
onCreateTask: (input: TaskCreateInput) => Promise
;
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([]);
const [branchMode, setBranchMode] = useState("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);
diff --git a/packages/dashboard/app/components/SelectionCommentPopover.css b/packages/dashboard/app/components/SelectionCommentPopover.css
new file mode 100644
index 0000000000..83c1609803
--- /dev/null
+++ b/packages/dashboard/app/components/SelectionCommentPopover.css
@@ -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;
+ }
+}
diff --git a/packages/dashboard/app/components/SelectionCommentPopover.tsx b/packages/dashboard/app/components/SelectionCommentPopover.tsx
new file mode 100644
index 0000000000..efd8d8045e
--- /dev/null
+++ b/packages/dashboard/app/components/SelectionCommentPopover.tsx
@@ -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(null);
+ const textareaRef = useRef(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 (
+ event.preventDefault()}
+ onClick={() => setPanelExpanded(true)}
+ aria-label={t("selectionComment.addCommentAria", "Add a comment to the selected text and send it to a new task")}
+ >
+
+ {t("selectionComment.addComment", "Add comment")}
+
+ );
+ }
+
+ return (
+
+
{t("selectionComment.title", "Comment on selection")}
+
{trimmedSelectedText}
+
+ );
+}
diff --git a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
index a7817f3ee0..b10b18d6b6 100644
--- a/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/DocumentsView.test.tsx
@@ -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( );
+
+ 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( );
+
+ 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: [],
diff --git a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
index bf81ecdbe2..c581d89e22 100644
--- a/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/FileBrowserModal.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
{
}));
};
+ 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( );
@@ -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( );
+
+ 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( );
+
+ 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();
diff --git a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx
index 8724a69fe7..c67b566a8d 100644
--- a/packages/dashboard/app/components/__tests__/MemoryView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MemoryView.test.tsx
@@ -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 }) =>
,
+ FileEditor: (props: { filePath: string; onSendSelectionToTask?: (description: string) => void }) => {
+ capturedFileEditorProps.push(props);
+ return
;
+ },
}));
vi.mock("lucide-react", () => ({
@@ -94,6 +101,7 @@ function createMemoryData(overrides: Record = {}) {
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( );
+
+ 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( );
+ 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({
diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
index 47bf14cc4b..9622b2cc40 100644
--- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
@@ -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( );
+
+ expect(screen.getByRole("textbox")).toHaveValue("User edited text");
+ });
+
it("creates task with description when submitted", async () => {
const { props } = renderNewTaskModal();
diff --git a/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx b/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx
new file mode 100644
index 0000000000..8689e7938e
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/SelectionCommentPopover.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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````");
+ });
+});
diff --git a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts
index e2b90a31a2..517af47b45 100644
--- a/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useModalManager.test.ts
@@ -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" }] }),
diff --git a/packages/dashboard/app/hooks/__tests__/useSelectionComment.test.ts b/packages/dashboard/app/hooks/__tests__/useSelectionComment.test.ts
new file mode 100644
index 0000000000..a534c1865b
--- /dev/null
+++ b/packages/dashboard/app/hooks/__tests__/useSelectionComment.test.ts
@@ -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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ ref.current = container;
+
+ const { result } = renderHook(() => useSelectionComment(ref));
+
+ act(() => selectText(text));
+
+ await waitFor(() => expect(result.current).toBeNull());
+ });
+});
diff --git a/packages/dashboard/app/hooks/useModalManager.ts b/packages/dashboard/app/hooks/useModalManager.ts
index 28a4d1f754..e4cbaf825b 100644
--- a/packages/dashboard/app/hooks/useModalManager.ts
+++ b/packages/dashboard/app/hooks/useModalManager.ts
@@ -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(null);
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState(null);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState(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,
diff --git a/packages/dashboard/app/hooks/useSelectionComment.ts b/packages/dashboard/app/hooks/useSelectionComment.ts
new file mode 100644
index 0000000000..ee617ac30e
--- /dev/null
+++ b/packages/dashboard/app/hooks/useSelectionComment.ts
@@ -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,
+ options: UseSelectionCommentOptions = {},
+): SelectionCommentState | null {
+ const { getLineRange, locked = false } = options;
+ const [selectionState, setSelectionState] = useState(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;
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index f7ef1e68db..eca22776b0 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -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",