From 229d7953482f0bf58f2fca398682ba364f1524cf Mon Sep 17 00:00:00 2001 From: lionellc Date: Sun, 1 Feb 2026 20:16:11 +0800 Subject: [PATCH] feat: Add folder selection support in conversation context (#289) --- src/App.tsx | 20 ++++--- .../app/hooks/useComposerInsert.test.tsx | 55 +++++++++++++++++++ src/features/app/hooks/useComposerInsert.ts | 8 +-- .../components/FilePreviewPopover.test.tsx | 26 +++++++++ .../files/components/FilePreviewPopover.tsx | 4 +- .../files/components/FileTreePanel.tsx | 34 ++++++++++-- src/features/layout/hooks/useLayoutNodes.tsx | 2 + .../workspaces/components/WorkspaceHome.tsx | 6 +- 8 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 src/features/app/hooks/useComposerInsert.test.tsx diff --git a/src/App.tsx b/src/App.tsx index f19840c4..f0171379 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -254,6 +254,7 @@ function MainApp() { closeSettings, } = useSettingsModalState(); const composerInputRef = useRef(null); + const workspaceHomeTextareaRef = useRef(null); const { updaterState, @@ -1054,13 +1055,6 @@ function MainApp() { startStatus, }); - const handleInsertComposerText = useComposerInsert({ - activeThreadId, - draftText: activeDraft, - onDraftChange: handleDraftChange, - textareaRef: composerInputRef, - }); - const { runs: workspaceRuns, draft: workspacePrompt, @@ -1085,6 +1079,16 @@ function MainApp() { sendUserMessageToThread, onWorktreeCreated: handleWorktreeCreated, }); + + const canInsertComposerText = showWorkspaceHome + ? Boolean(activeWorkspace) + : Boolean(activeThreadId); + const handleInsertComposerText = useComposerInsert({ + isEnabled: canInsertComposerText, + draftText: showWorkspaceHome ? workspacePrompt : activeDraft, + onDraftChange: showWorkspaceHome ? setWorkspacePrompt : handleDraftChange, + textareaRef: showWorkspaceHome ? workspaceHomeTextareaRef : composerInputRef, + }); const RECENT_THREAD_LIMIT = 8; const { recentThreadInstances, recentThreadsUpdatedAt } = useMemo(() => { if (!activeWorkspaceId) { @@ -1920,6 +1924,7 @@ function MainApp() { prompts, files, onInsertComposerText: handleInsertComposerText, + canInsertComposerText, textareaRef: composerInputRef, composerEditorSettings, composerEditorExpanded, @@ -2010,6 +2015,7 @@ function MainApp() { onDismissDictationHint={clearDictationHint} dictationTranscript={dictationTranscript} onDictationTranscriptHandled={clearDictationTranscript} + textareaRef={workspaceHomeTextareaRef} agentMdContent={agentMdContent} agentMdExists={agentMdExists} agentMdTruncated={agentMdTruncated} diff --git a/src/features/app/hooks/useComposerInsert.test.tsx b/src/features/app/hooks/useComposerInsert.test.tsx new file mode 100644 index 00000000..c4b99596 --- /dev/null +++ b/src/features/app/hooks/useComposerInsert.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { RefObject } from "react"; +import { useComposerInsert } from "./useComposerInsert"; + +describe("useComposerInsert", () => { + it("inserts text when enabled", () => { + const textarea = document.createElement("textarea"); + textarea.value = "Hello"; + textarea.selectionStart = 5; + textarea.selectionEnd = 5; + const textareaRef: RefObject = { current: textarea }; + const onDraftChange = vi.fn(); + + const { result } = renderHook(() => + useComposerInsert({ + isEnabled: true, + draftText: "Hello", + onDraftChange, + textareaRef, + }), + ); + + act(() => { + result.current("./src"); + }); + + expect(onDraftChange).toHaveBeenCalledWith("Hello ./src"); + }); + + it("does nothing when disabled", () => { + const textarea = document.createElement("textarea"); + textarea.value = "Hello"; + textarea.selectionStart = 5; + textarea.selectionEnd = 5; + const textareaRef: RefObject = { current: textarea }; + const onDraftChange = vi.fn(); + + const { result } = renderHook(() => + useComposerInsert({ + isEnabled: false, + draftText: "Hello", + onDraftChange, + textareaRef, + }), + ); + + act(() => { + result.current("./src"); + }); + + expect(onDraftChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/app/hooks/useComposerInsert.ts b/src/features/app/hooks/useComposerInsert.ts index 389053e5..ec787522 100644 --- a/src/features/app/hooks/useComposerInsert.ts +++ b/src/features/app/hooks/useComposerInsert.ts @@ -2,21 +2,21 @@ import { useCallback } from "react"; import type { RefObject } from "react"; type UseComposerInsertArgs = { - activeThreadId: string | null; + isEnabled: boolean; draftText: string; onDraftChange: (next: string) => void; textareaRef: RefObject; }; export function useComposerInsert({ - activeThreadId, + isEnabled, draftText, onDraftChange, textareaRef, }: UseComposerInsertArgs) { return useCallback( (insertText: string) => { - if (!activeThreadId) { + if (!isEnabled) { return; } const textarea = textareaRef.current; @@ -46,6 +46,6 @@ export function useComposerInsert({ node.dispatchEvent(new Event("select", { bubbles: true })); }); }, - [activeThreadId, draftText, onDraftChange, textareaRef], + [isEnabled, draftText, onDraftChange, textareaRef], ); } diff --git a/src/features/files/components/FilePreviewPopover.test.tsx b/src/features/files/components/FilePreviewPopover.test.tsx index 6721a524..849dfcaf 100644 --- a/src/features/files/components/FilePreviewPopover.test.tsx +++ b/src/features/files/components/FilePreviewPopover.test.tsx @@ -82,4 +82,30 @@ describe("FilePreviewPopover", () => { expect(onLineMouseUp).toHaveBeenCalledWith(1, expect.any(Object)); expect(onSelectLine).toHaveBeenCalledWith(1, expect.any(Object)); }); + + it("disables add-to-chat when insertion is not allowed", () => { + render( + , + ); + + const addButton = screen.getByRole("button", { name: "Add to chat" }); + expect(addButton.hasAttribute("disabled")).toBe(true); + }); }); diff --git a/src/features/files/components/FilePreviewPopover.tsx b/src/features/files/components/FilePreviewPopover.tsx index 75be17af..1fd9b340 100644 --- a/src/features/files/components/FilePreviewPopover.tsx +++ b/src/features/files/components/FilePreviewPopover.tsx @@ -23,6 +23,7 @@ type FilePreviewPopoverProps = { onLineMouseUp?: (index: number, event: MouseEvent) => void; onClearSelection: () => void; onAddSelection: () => void; + canInsertText?: boolean; onClose: () => void; selectionHints?: string[]; style?: CSSProperties; @@ -48,6 +49,7 @@ export function FilePreviewPopover({ onLineMouseUp, onClearSelection, onAddSelection, + canInsertText = true, onClose, selectionHints = [], style, @@ -158,7 +160,7 @@ export function FilePreviewPopover({ type="button" className="primary file-preview-action file-preview-action--add" onClick={onAddSelection} - disabled={!selection} + disabled={!selection || !canInsertText} > Add to chat diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 007bacc2..98c8fab9 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -47,6 +47,7 @@ type FileTreePanelProps = { filePanelMode: PanelTabId; onFilePanelModeChange: (mode: PanelTabId) => void; onInsertText?: (text: string) => void; + canInsertText: boolean; openTargets: OpenAppTarget[]; openAppIconById: Record; selectedOpenAppId: string; @@ -225,6 +226,7 @@ export function FileTreePanel({ filePanelMode, onFilePanelModeChange, onInsertText, + canInsertText, openTargets, openAppIconById, selectedOpenAppId, @@ -537,7 +539,13 @@ export function FileTreePanel({ ); const handleAddSelection = useCallback(() => { - if (previewKind !== "text" || !previewPath || !previewSelection || !onInsertText) { + if ( + !canInsertText || + previewKind !== "text" || + !previewPath || + !previewSelection || + !onInsertText + ) { return; } const lines = previewContent.split("\n"); @@ -551,6 +559,7 @@ export function FileTreePanel({ onInsertText(snippet); closePreview(); }, [ + canInsertText, previewContent, previewKind, previewPath, @@ -559,12 +568,22 @@ export function FileTreePanel({ closePreview, ]); - const showFileMenu = useCallback( + const showMenu = useCallback( async (event: MouseEvent, relativePath: string) => { event.preventDefault(); event.stopPropagation(); const menu = await Menu.new({ items: [ + await MenuItem.new({ + text: "Add to chat", + enabled: canInsertText, + action: async () => { + if (!canInsertText) { + return; + } + onInsertText?.(relativePath); + }, + }), await MenuItem.new({ text: "Reveal in Finder", action: async () => { @@ -577,7 +596,7 @@ export function FileTreePanel({ const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window); }, - [resolvePath], + [canInsertText, onInsertText, resolvePath], ); const renderNode = (node: FileTreeNode, depth: number) => { @@ -599,9 +618,7 @@ export function FileTreePanel({ openPreview(node.path, event.currentTarget); }} onContextMenu={(event) => { - if (!isFolder) { - void showFileMenu(event, node.path); - } + void showMenu(event, node.path); }} > {isFolder ? ( @@ -622,8 +639,12 @@ export function FileTreePanel({ className="ghost icon-button file-tree-action" onClick={(event) => { event.stopPropagation(); + if (!canInsertText) { + return; + } onInsertText?.(node.path); }} + disabled={!canInsertText} aria-label={`Mention ${node.name}`} title="Mention in chat" > @@ -717,6 +738,7 @@ export function FileTreePanel({ onLineMouseUp={handleLineMouseUp} onClearSelection={() => setPreviewSelection(null)} onAddSelection={handleAddSelection} + canInsertText={canInsertText} onClose={closePreview} selectionHints={selectionHints} style={{ diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index f1c67486..8710e233 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -392,6 +392,7 @@ type LayoutNodesOptions = { prompts: CustomPromptOption[]; files: string[]; onInsertComposerText: (text: string) => void; + canInsertComposerText: boolean; textareaRef: RefObject; composerEditorSettings: ComposerEditorSettings; composerEditorExpanded: boolean; @@ -722,6 +723,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { filePanelMode={options.filePanelMode} onFilePanelModeChange={options.onFilePanelModeChange} onInsertText={options.onInsertComposerText} + canInsertText={options.canInsertComposerText} openTargets={options.openAppTargets} openAppIconById={options.openAppIconById} selectedOpenAppId={options.selectedOpenAppId} diff --git a/src/features/workspaces/components/WorkspaceHome.tsx b/src/features/workspaces/components/WorkspaceHome.tsx index 9c6c8fa9..0b7dd9c4 100644 --- a/src/features/workspaces/components/WorkspaceHome.tsx +++ b/src/features/workspaces/components/WorkspaceHome.tsx @@ -6,6 +6,7 @@ import { useState, type CSSProperties, type KeyboardEvent, + type RefObject, } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import type { @@ -84,6 +85,7 @@ type WorkspaceHomeProps = { onDismissDictationHint: () => void; dictationTranscript: DictationTranscript | null; onDictationTranscriptHandled: (id: string) => void; + textareaRef?: RefObject; agentMdContent: string; agentMdExists: boolean; agentMdTruncated: boolean; @@ -159,6 +161,7 @@ export function WorkspaceHome({ onDismissDictationHint, dictationTranscript, onDictationTranscriptHandled, + textareaRef: textareaRefProp, agentMdContent, agentMdExists, agentMdTruncated, @@ -181,7 +184,8 @@ export function WorkspaceHome({ const iconSrc = useMemo(() => convertFileSrc(iconPath), [iconPath]); const runModeRef = useRef(null); const modelsRef = useRef(null); - const textareaRef = useRef(null); + const fallbackTextareaRef = useRef(null); + const textareaRef = textareaRefProp ?? fallbackTextareaRef; const { activeImages, attachImages,