diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 2d0b1b1258..fb90c2d0bb 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index de4c0cc669..0f5baed30e 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1696,6 +1696,7 @@ function AppInner() { projectId={currentProject?.id} addToast={addToast} onOpenDetail={openDetailTask} + onSendSelectionToTask={modalManager.openNewTaskWithDescription} /> @@ -1786,7 +1787,11 @@ function AppInner() { return ( - + ); diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index c626cd2700..efc15e1098 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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 ?? ""} /> diff --git a/packages/dashboard/app/components/DocumentsView.tsx b/packages/dashboard/app/components/DocumentsView.tsx index 031834954b..ff38a3a8b3 100644 --- a/packages/dashboard/app/components/DocumentsView.tsx +++ b/packages/dashboard/app/components/DocumentsView.tsx @@ -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("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(null); + const plainPreviewRef = useRef(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>(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 ? ( + + ) : null; const searchPlaceholder = activeTab === "project" ? t("documents.searchProjectFiles", "Search project markdown files…") @@ -541,14 +559,15 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi ) : fileError ? (

{fileError}

) : renderProjectMarkdown ? ( -
+
{fileContent ?? ""}
) : ( -
{fileContent ?? ""}
+
{fileContent ?? ""}
)} + {selectionPopover}
)} diff --git a/packages/dashboard/app/components/FileBrowserModal.tsx b/packages/dashboard/app/components/FileBrowserModal.tsx index ba6d10d256..b012c4fe0c 100644 --- a/packages/dashboard/app/components/FileBrowserModal.tsx +++ b/packages/dashboard/app/components/FileBrowserModal.tsx @@ -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} /> )} diff --git a/packages/dashboard/app/components/FileEditor.tsx b/packages/dashboard/app/components/FileEditor.tsx index 3c0cc851d6..060076455e 100644 --- a/packages/dashboard/app/components/FileEditor.tsx +++ b/packages/dashboard/app/components/FileEditor.tsx @@ -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(null); + const previewRef = useRef(null); const editorViewRef = useRef(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 ? ( + + ) : null; + useEffect(() => { writeBooleanPref(FILE_EDITOR_MARKDOWN_PREVIEW_STORAGE_KEY, showPreview); }, [showPreview]); @@ -254,12 +286,13 @@ export function FileEditor({ ) : null} {effectiveShowPreview ? ( -
+
{content}
) : (
)} + {selectionPopover}
); } diff --git a/packages/dashboard/app/components/MemoryView.tsx b/packages/dashboard/app/components/MemoryView.tsx index 1862739efe..7770ad5582 100644 --- a/packages/dashboard/app/components/MemoryView.tsx +++ b/packages/dashboard/app/components/MemoryView.tsx @@ -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("working"); const [expandedCategories, setExpandedCategories] = useState>(new Set()); @@ -454,6 +455,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) { onChange={setSelectedFileContent} readOnly={!isWritable} filePath={selectedFilePath} + onSendSelectionToTask={onSendSelectionToTask} />
@@ -671,6 +673,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) { onChange={setInsightsEditorContent} readOnly={false} filePath=".fusion/memory/INSIGHTS.md" + onSendSelectionToTask={onSendSelectionToTask} />
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 ( + + ); + } + + return ( +
+

{t("selectionComment.title", "Comment on selection")}

+
{trimmedSelectedText}
+