From 146b0a1150e627932f77526c5983158e12129b99 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 13:08:46 -0400 Subject: [PATCH 1/4] feat: file explorer mention actions and zoom-aware context menus Signed-off-by: Yordis Prieto --- apps/desktop/src/electron/ElectronMenu.ts | 16 +++- .../src/components/ComposerPromptEditor.tsx | 77 ++++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 87 +++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) 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..cd5cb409e95 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,80 @@ 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; + } + event.preventDefault(); + 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()); + } + } + }; + 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); + }); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor]); + + return null; +} + function ComposerSurroundSelectionPlugin(props: { terminalContexts: ReadonlyArray; skills: ReadonlyArray; @@ -1634,6 +1710,7 @@ function ComposerPromptEditorInner({ + diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 694341ee19a..7d48f0a28d6 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,84 @@ 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 inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false; + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "Open a chat for this project and try again.", + }); + } + } + } 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, From 718e283baf132d61533108d55500233cf6839e40 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 13:25:50 -0400 Subject: [PATCH 2/4] fix(web): keep mentions tokenizable at text boundaries and stop paste from swallowing clipboard Signed-off-by: Yordis Prieto --- .../src/components/ComposerPromptEditor.tsx | 20 ++++++++++++++++++- .../src/components/files/FileBrowserPanel.tsx | 15 ++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index cd5cb409e95..74a6e60746c 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1144,7 +1144,7 @@ function ComposerInlineTokenPastePlugin() { if (mentions.length === 0) { return false; } - event.preventDefault(); + let inserted = false; editor.update(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { @@ -1163,6 +1163,19 @@ function ComposerInlineTokenPastePlugin() { } } }; + const firstMention = mentions[0]; + if (firstMention && firstMention.start === 0) { + const anchorOffset = getExpandedAbsoluteOffsetForPoint( + selection.anchor.getNode(), + selection.anchor.offset, + ); + const precedingChar = $getRoot() + .getTextContent() + .slice(anchorOffset - 1, anchorOffset); + if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { + nodes.push($createTextNode(" ")); + } + } let cursor = 0; for (const mention of mentions) { if (mention.start < cursor) { @@ -1183,7 +1196,12 @@ function ComposerInlineTokenPastePlugin() { nodes.push($createTextNode(" ")); } selection.insertNodes(nodes); + inserted = true; }); + if (!inserted) { + return false; + } + event.preventDefault(); return true; }, COMMAND_PRIORITY_HIGH, diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 7d48f0a28d6..20152f40fcb 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -110,13 +110,24 @@ export default function FileBrowserPanel({ return; } if (clicked === "add-to-chat") { - const inserted = composerRef?.current?.insertTextAtEnd(`${mention} `) ?? false; - if (!inserted) { + 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 currentValue = composer.readSnapshot().value; + const needsLeadingSpace = currentValue.length > 0 && !/\s$/.test(currentValue); + const inserted = composer.insertTextAtEnd(`${needsLeadingSpace ? " " : ""}${mention} `); + if (!inserted) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: "The chat isn't ready to accept input right now.", + }); } } } finally { From f7b6b19b8c9bd61eb3b17d443116d1dc52e937bc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 13:34:52 -0400 Subject: [PATCH 3/4] fix(web): anchor mention boundary checks to the actual insertion point Signed-off-by: Yordis Prieto --- apps/web/src/components/ComposerPromptEditor.tsx | 9 +++++---- apps/web/src/components/chat/ChatComposer.tsx | 14 ++++++++++---- apps/web/src/components/files/FileBrowserPanel.tsx | 4 +--- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 74a6e60746c..8652bfdf5ba 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1165,13 +1165,14 @@ function ComposerInlineTokenPastePlugin() { }; const firstMention = mentions[0]; if (firstMention && firstMention.start === 0) { - const anchorOffset = getExpandedAbsoluteOffsetForPoint( - selection.anchor.getNode(), - selection.anchor.offset, + const startPoint = selection.isBackward() ? selection.focus : selection.anchor; + const insertionOffset = getExpandedAbsoluteOffsetForPoint( + startPoint.getNode(), + startPoint.offset, ); const precedingChar = $getRoot() .getTextContent() - .slice(anchorOffset - 1, anchorOffset); + .slice(insertionOffset - 1, insertionOffset); if (precedingChar.length > 0 && !/\s/.test(precedingChar)) { nodes.push($createTextNode(" ")); } 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 20152f40fcb..f95022c3424 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -119,9 +119,7 @@ export default function FileBrowserPanel({ }); return; } - const currentValue = composer.readSnapshot().value; - const needsLeadingSpace = currentValue.length > 0 && !/\s$/.test(currentValue); - const inserted = composer.insertTextAtEnd(`${needsLeadingSpace ? " " : ""}${mention} `); + const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true }); if (!inserted) { toastManager.add({ type: "error", From a9caa6f60f1d414fb756292d79c2e0d53f4808bb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 14:39:34 -0400 Subject: [PATCH 4/4] test(desktop): supply zoom-aware window mocks for context menu popups Signed-off-by: Yordis Prieto --- apps/desktop/src/electron/ElectronMenu.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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,