diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 3dc218d8252..58870bbab1d 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe( Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), ); +const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => + ({ + id: 7, + webContents: { getZoomFactor: () => zoomFactor }, + }) as unknown as Electron.BrowserWindow; + describe("ElectronMenu", () => { beforeEach(() => { buildFromTemplateMock.mockReset(); @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(), items: [{ id: "copy", label: "Copy" }], position: Option.none(), }); @@ -81,20 +87,24 @@ describe("ElectronMenu", () => { it.effect("resolves with none when the menu closes without a click", () => Effect.gen(function* () { + let popupOptions: Electron.PopupOptions | undefined; buildFromTemplateMock.mockImplementation(() => ({ popup: (options: Electron.PopupOptions) => { + popupOptions = options; options.callback?.(); }, })); const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ - window: {} as Electron.BrowserWindow, + window: makeWindow(2), items: [{ id: "copy", label: "Copy" }], position: Option.some({ x: 10.8, y: 20.2 }), }); assert.isTrue(Option.isNone(selectedItemId)); + assert.equal(popupOptions?.x, 21); + assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", enabled: true, diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 09fb5d1807d..4d3e5a1c241 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM return normalizedItems; } +// Renderer positions arrive in CSS pixels; popup() expects window points, so +// page zoom must be factored in or menus drift proportionally to their +// distance from the window origin. const normalizePosition = ( position: Option.Option, + zoomFactor: number, ): Option.Option => Option.filter( position, - ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0, - ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) }))); + ({ x, y }) => + Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor), + ).pipe( + Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })), + ); export const make = Effect.gen(function* () { const platform = yield* HostProcessPlatform; @@ -214,7 +221,10 @@ export const make = Effect.gen(function* () { try { const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete)); - const popupPosition = normalizePosition(input.position); + const popupPosition = normalizePosition( + input.position, + input.window.webContents.getZoomFactor(), + ); const popupOptions = Option.match(popupPosition, { onNone: (): Electron.PopupOptions => ({ window: input.window, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 18579cda6d3..8652bfdf5ba 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -27,6 +27,7 @@ import { KEY_TAB_COMMAND, COMMAND_PRIORITY_HIGH, KEY_BACKSPACE_COMMAND, + PASTE_COMMAND, $getRoot, HISTORY_MERGE_TAG, DecoratorNode, @@ -55,6 +56,7 @@ import { expandCollapsedComposerCursor, isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, @@ -1117,6 +1119,99 @@ function ComposerInlineTokenBackspacePlugin() { return null; } +function ComposerInlineTokenPastePlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + PASTE_COMMAND, + (event) => { + if (!(event instanceof ClipboardEvent) || event.clipboardData === null) { + return false; + } + if (event.clipboardData.files.length > 0) { + return false; + } + const text = event.clipboardData.getData("text/plain"); + if (text.length === 0) { + return false; + } + // Token grammar requires trailing whitespace; a virtual newline lets a + // mention at the very end of the pasted text still parse. + const mentions = collectComposerInlineTokens(`${text}\n`).filter( + (token) => token.type === "mention" && token.end <= text.length, + ); + if (mentions.length === 0) { + return false; + } + let inserted = false; + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + const nodes: LexicalNode[] = []; + const appendText = (value: string) => { + const lines = value.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (line.length > 0) { + nodes.push($createTextNode(line)); + } + if (index < lines.length - 1) { + nodes.push($createLineBreakNode()); + } + } + }; + const firstMention = mentions[0]; + if (firstMention && firstMention.start === 0) { + const startPoint = selection.isBackward() ? selection.focus : selection.anchor; + const insertionOffset = getExpandedAbsoluteOffsetForPoint( + startPoint.getNode(), + startPoint.offset, + ); + const precedingChar = $getRoot() + .getTextContent() + .slice(insertionOffset - 1, insertionOffset); + if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { + nodes.push($createTextNode(" ")); + } + } + let cursor = 0; + for (const mention of mentions) { + if (mention.start < cursor) { + continue; + } + if (mention.start > cursor) { + appendText(text.slice(cursor, mention.start)); + } + nodes.push($createComposerMentionNode(mention.value)); + cursor = mention.end; + } + if (cursor < text.length) { + appendText(text.slice(cursor)); + } else { + // Keep the serialized prompt valid: mention tokens need trailing + // whitespace, so a paste ending in a mention gets the same + // trailing space the autocomplete inserts. + nodes.push($createTextNode(" ")); + } + selection.insertNodes(nodes); + inserted = true; + }); + if (!inserted) { + return false; + } + event.preventDefault(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor]); + + return null; +} + function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; @@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..d02aa519cdf 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -389,7 +389,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - insertTextAtEnd: (text: string) => boolean; + insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean; openModelPicker: () => void; toggleModelPicker: () => void; isModelPickerOpen: () => boolean; @@ -1918,7 +1918,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, - insertTextAtEnd: (text: string) => { + insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => { if ( text.length === 0 || isConnecting || @@ -1928,8 +1928,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return false; } - const rangeEnd = promptRef.current.length; - return applyPromptReplacement(rangeEnd, rangeEnd, text); + const prompt = promptRef.current; + const needsLeadingSpace = + (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt); + return applyPromptReplacement( + prompt.length, + prompt.length, + needsLeadingSpace ? ` ${text}` : text, + ); }, openModelPicker: () => { setIsComposerModelPickerOpen(true); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 694341ee19a..f95022c3424 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,10 +1,19 @@ +import type { + ContextMenuItem as TreeContextMenuItem, + ContextMenuOpenContext as TreeContextMenuOpenContext, +} from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree } from "@pierre/trees/react"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { RefreshCw, Search } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; +import { toastManager } from "~/components/ui/toast"; +import { useComposerHandleContext } from "~/composerHandleContext"; +import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -39,6 +48,7 @@ export default function FileBrowserPanel({ onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); + const composerRef = useComposerHandleContext(); const entriesQuery = useProjectEntriesQuery(environmentId, cwd); const entries = entriesQuery.data?.entries ?? []; const entryKinds = useMemo( @@ -49,7 +59,93 @@ export default function FileBrowserPanel({ const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + // The tree renders rows in shadow DOM and its anchor rect is unreliable, so + // capture the right-click position ourselves; contextmenu is a composed + // event, so a capture-phase listener sees it with viewport coordinates. + const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null); + useEffect(() => { + const capturePointer = (event: MouseEvent) => { + contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp }; + }; + document.addEventListener("contextmenu", capturePointer, true); + return () => document.removeEventListener("contextmenu", capturePointer, true); + }, []); + + const showEntryContextMenu = async ( + item: TreeContextMenuItem, + context: TreeContextMenuOpenContext, + ) => { + const api = readLocalApi(); + if (!api) { + context.close(); + return; + } + const relativePath = item.path.replace(/\/$/, ""); + const mention = serializeComposerFileLink(relativePath); + const pointer = contextMenuPointerRef.current; + const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000; + const anchorRect = context.anchorElement.getBoundingClientRect(); + const position = pointerIsFresh + ? { x: pointer.x, y: pointer.y } + : { x: anchorRect.left, y: anchorRect.bottom }; + try { + const clicked = await api.contextMenu.show( + [ + { id: "copy-mention", label: "Copy mention" }, + { id: "add-to-chat", label: "Add to chat" }, + ], + position, + ); + if (clicked === "copy-mention") { + try { + await writeTextToClipboard(mention); + toastManager.add({ type: "success", title: "Mention copied", description: relativePath }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to copy mention", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return; + } + if (clicked === "add-to-chat") { + const composer = composerRef?.current; + if (!composer) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "Open a chat for this project and try again.", + }); + return; + } + const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The chat isn't ready to accept input right now.", + }); + } + } + } finally { + context.close(); + } + }; + const showEntryContextMenuRef = useRef(showEntryContextMenu); + useEffect(() => { + showEntryContextMenuRef.current = showEntryContextMenu; + }); + const { model } = useFileTree({ + composition: { + contextMenu: { + triggerMode: "right-click", + onOpen: (item, context) => { + void showEntryContextMenuRef.current(item, context); + }, + }, + }, density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true,