authorgravatar for yordis.prieto@gmail.comYordis Prieto <yordis.prieto@gmail.com> 2026-07-18 07:05:26-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-18 13:05:26+02:00
log4cfec8c128084a0abde67853d10e996f5b2aac05
tree073bab4704348888185841bd3ea33aeba1455ec1
parent1bd2fa25e2214e0318a8a0d4cd4c4c513ddd800e
signature Signed by PGP key B5690EEEBB952194

feat: file explorer mention actions and zoom-aware context menus (#4054)

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

5 files changed, 227 insertions(+), 9 deletions(-)

apps/desktop/src/electron/ElectronMenu.test.ts+12-2
...@@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe(...@@ -31,6 +31,12 @@ const TestLayer = ElectronMenu.layer.pipe(
31 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),31 Layer.provide(Layer.succeed(HostProcessPlatform, "linux")),
32);32);
3333
34const makeWindow = (zoomFactor = 1): Electron.BrowserWindow =>
35 ({
36 id: 7,
37 webContents: { getZoomFactor: () => zoomFactor },
38 }) as unknown as Electron.BrowserWindow;
39
34describe("ElectronMenu", () => {40describe("ElectronMenu", () => {
35 beforeEach(() => {41 beforeEach(() => {
36 buildFromTemplateMock.mockReset();42 buildFromTemplateMock.mockReset();
...@@ -70,7 +76,7 @@ describe("ElectronMenu", () => {...@@ -70,7 +76,7 @@ describe("ElectronMenu", () => {
7076
71 const electronMenu = yield* ElectronMenu.ElectronMenu;77 const electronMenu = yield* ElectronMenu.ElectronMenu;
72 const selectedItemId = yield* electronMenu.showContextMenu({78 const selectedItemId = yield* electronMenu.showContextMenu({
73 window: {} as Electron.BrowserWindow,79 window: makeWindow(),
74 items: [{ id: "copy", label: "Copy" }],80 items: [{ id: "copy", label: "Copy" }],
75 position: Option.none(),81 position: Option.none(),
76 });82 });
...@@ -81,20 +87,24 @@ describe("ElectronMenu", () => {...@@ -81,20 +87,24 @@ describe("ElectronMenu", () => {
8187
82 it.effect("resolves with none when the menu closes without a click", () =>88 it.effect("resolves with none when the menu closes without a click", () =>
83 Effect.gen(function* () {89 Effect.gen(function* () {
90 let popupOptions: Electron.PopupOptions | undefined;
84 buildFromTemplateMock.mockImplementation(() => ({91 buildFromTemplateMock.mockImplementation(() => ({
85 popup: (options: Electron.PopupOptions) => {92 popup: (options: Electron.PopupOptions) => {
93 popupOptions = options;
86 options.callback?.();94 options.callback?.();
87 },95 },
88 }));96 }));
8997
90 const electronMenu = yield* ElectronMenu.ElectronMenu;98 const electronMenu = yield* ElectronMenu.ElectronMenu;
91 const selectedItemId = yield* electronMenu.showContextMenu({99 const selectedItemId = yield* electronMenu.showContextMenu({
92 window: {} as Electron.BrowserWindow,100 window: makeWindow(2),
93 items: [{ id: "copy", label: "Copy" }],101 items: [{ id: "copy", label: "Copy" }],
94 position: Option.some({ x: 10.8, y: 20.2 }),102 position: Option.some({ x: 10.8, y: 20.2 }),
95 });103 });
96104
97 assert.isTrue(Option.isNone(selectedItemId));105 assert.isTrue(Option.isNone(selectedItemId));
106 assert.equal(popupOptions?.x, 21);
107 assert.equal(popupOptions?.y, 40);
98 assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {108 assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], {
99 label: "Copy",109 label: "Copy",
100 enabled: true,110 enabled: true,
apps/desktop/src/electron/ElectronMenu.ts+13-3
...@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM...@@ -94,13 +94,20 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
94 return normalizedItems;94 return normalizedItems;
95}95}
9696
97// Renderer positions arrive in CSS pixels; popup() expects window points, so
98// page zoom must be factored in or menus drift proportionally to their
99// distance from the window origin.
97const normalizePosition = (100const normalizePosition = (
98 position: Option.Option<ElectronMenuPosition>,101 position: Option.Option<ElectronMenuPosition>,
102 zoomFactor: number,
99): Option.Option<ElectronMenuPosition> =>103): Option.Option<ElectronMenuPosition> =>
100 Option.filter(104 Option.filter(
101 position,105 position,
102 ({ x, y }) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0,106 ({ x, y }) =>
103 ).pipe(Option.map(({ x, y }) => ({ x: Math.floor(x), y: Math.floor(y) })));107 Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && Number.isFinite(zoomFactor),
108 ).pipe(
109 Option.map(({ x, y }) => ({ x: Math.floor(x * zoomFactor), y: Math.floor(y * zoomFactor) })),
110 );
104111
105export const make = Effect.gen(function* () {112export const make = Effect.gen(function* () {
106 const platform = yield* HostProcessPlatform;113 const platform = yield* HostProcessPlatform;
...@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {...@@ -214,7 +221,10 @@ export const make = Effect.gen(function* () {
214221
215 try {222 try {
216 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));223 const menu = Electron.Menu.buildFromTemplate(buildTemplate(normalizedItems, complete));
217 const popupPosition = normalizePosition(input.position);224 const popupPosition = normalizePosition(
225 input.position,
226 input.window.webContents.getZoomFactor(),
227 );
218 const popupOptions = Option.match(popupPosition, {228 const popupOptions = Option.match(popupPosition, {
219 onNone: (): Electron.PopupOptions => ({229 onNone: (): Electron.PopupOptions => ({
220 window: input.window,230 window: input.window,
apps/web/src/components/ComposerPromptEditor.tsx+96
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 KEY_TAB_COMMAND,27 KEY_TAB_COMMAND,
28 COMMAND_PRIORITY_HIGH,28 COMMAND_PRIORITY_HIGH,
29 KEY_BACKSPACE_COMMAND,29 KEY_BACKSPACE_COMMAND,
30 PASTE_COMMAND,
30 $getRoot,31 $getRoot,
31 HISTORY_MERGE_TAG,32 HISTORY_MERGE_TAG,
32 DecoratorNode,33 DecoratorNode,
...@@ -55,6 +56,7 @@ import {...@@ -55,6 +56,7 @@ import {
55 expandCollapsedComposerCursor,56 expandCollapsedComposerCursor,
56 isCollapsedCursorAdjacentToInlineToken,57 isCollapsedCursorAdjacentToInlineToken,
57} from "~/composer-logic";58} from "~/composer-logic";
59import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
58import {60import {
59 selectionTouchesMentionBoundary,61 selectionTouchesMentionBoundary,
60 splitPromptIntoComposerSegments,62 splitPromptIntoComposerSegments,
...@@ -1117,6 +1119,99 @@ function ComposerInlineTokenBackspacePlugin() {...@@ -1117,6 +1119,99 @@ function ComposerInlineTokenBackspacePlugin() {
1117 return null;1119 return null;
1118}1120}
11191121
1122function ComposerInlineTokenPastePlugin() {
1123 const [editor] = useLexicalComposerContext();
1124
1125 useEffect(() => {
1126 return editor.registerCommand(
1127 PASTE_COMMAND,
1128 (event) => {
1129 if (!(event instanceof ClipboardEvent) || event.clipboardData === null) {
1130 return false;
1131 }
1132 if (event.clipboardData.files.length > 0) {
1133 return false;
1134 }
1135 const text = event.clipboardData.getData("text/plain");
1136 if (text.length === 0) {
1137 return false;
1138 }
1139 // Token grammar requires trailing whitespace; a virtual newline lets a
1140 // mention at the very end of the pasted text still parse.
1141 const mentions = collectComposerInlineTokens(`${text}\n`).filter(
1142 (token) => token.type === "mention" && token.end <= text.length,
1143 );
1144 if (mentions.length === 0) {
1145 return false;
1146 }
1147 let inserted = false;
1148 editor.update(() => {
1149 const selection = $getSelection();
1150 if (!$isRangeSelection(selection)) {
1151 return;
1152 }
1153 const nodes: LexicalNode[] = [];
1154 const appendText = (value: string) => {
1155 const lines = value.split("\n");
1156 for (let index = 0; index < lines.length; index += 1) {
1157 const line = lines[index] ?? "";
1158 if (line.length > 0) {
1159 nodes.push($createTextNode(line));
1160 }
1161 if (index < lines.length - 1) {
1162 nodes.push($createLineBreakNode());
1163 }
1164 }
1165 };
1166 const firstMention = mentions[0];
1167 if (firstMention && firstMention.start === 0) {
1168 const startPoint = selection.isBackward() ? selection.focus : selection.anchor;
1169 const insertionOffset = getExpandedAbsoluteOffsetForPoint(
1170 startPoint.getNode(),
1171 startPoint.offset,
1172 );
1173 const precedingChar = $getRoot()
1174 .getTextContent()
1175 .slice(insertionOffset - 1, insertionOffset);
1176 if (precedingChar.length > 0 && !/\s/.test(precedingChar)) {
1177 nodes.push($createTextNode(" "));
1178 }
1179 }
1180 let cursor = 0;
1181 for (const mention of mentions) {
1182 if (mention.start < cursor) {
1183 continue;
1184 }
1185 if (mention.start > cursor) {
1186 appendText(text.slice(cursor, mention.start));
1187 }
1188 nodes.push($createComposerMentionNode(mention.value));
1189 cursor = mention.end;
1190 }
1191 if (cursor < text.length) {
1192 appendText(text.slice(cursor));
1193 } else {
1194 // Keep the serialized prompt valid: mention tokens need trailing
1195 // whitespace, so a paste ending in a mention gets the same
1196 // trailing space the autocomplete inserts.
1197 nodes.push($createTextNode(" "));
1198 }
1199 selection.insertNodes(nodes);
1200 inserted = true;
1201 });
1202 if (!inserted) {
1203 return false;
1204 }
1205 event.preventDefault();
1206 return true;
1207 },
1208 COMMAND_PRIORITY_HIGH,
1209 );
1210 }, [editor]);
1211
1212 return null;
1213}
1214
1120function ComposerSurroundSelectionPlugin(props: {1215function ComposerSurroundSelectionPlugin(props: {
1121 terminalContexts: ReadonlyArray<TerminalContextDraft>;1216 terminalContexts: ReadonlyArray<TerminalContextDraft>;
1122 skills: ReadonlyArray<ServerProviderSkill>;1217 skills: ReadonlyArray<ServerProviderSkill>;
...@@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({...@@ -1634,6 +1729,7 @@ function ComposerPromptEditorInner({
1634 <ComposerInlineTokenArrowPlugin />1729 <ComposerInlineTokenArrowPlugin />
1635 <ComposerInlineTokenSelectionNormalizePlugin />1730 <ComposerInlineTokenSelectionNormalizePlugin />
1636 <ComposerInlineTokenBackspacePlugin />1731 <ComposerInlineTokenBackspacePlugin />
1732 <ComposerInlineTokenPastePlugin />
1637 <HistoryPlugin />1733 <HistoryPlugin />
1638 </div>1734 </div>
1639 </ComposerTerminalContextActionsContext>1735 </ComposerTerminalContextActionsContext>
apps/web/src/components/chat/ChatComposer.tsx+10-4
...@@ -390,7 +390,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(...@@ -390,7 +390,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
390export interface ChatComposerHandle {390export interface ChatComposerHandle {
391 focusAtEnd: () => void;391 focusAtEnd: () => void;
392 focusAt: (cursor: number) => void;392 focusAt: (cursor: number) => void;
393 insertTextAtEnd: (text: string) => boolean;393 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => boolean;
394 openModelPicker: () => void;394 openModelPicker: () => void;
395 toggleModelPicker: () => void;395 toggleModelPicker: () => void;
396 isModelPickerOpen: () => boolean;396 isModelPickerOpen: () => boolean;
...@@ -1931,7 +1931,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1931,7 +1931,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1931 focusAt: (cursor: number) => {1931 focusAt: (cursor: number) => {
1932 composerEditorRef.current?.focusAt(cursor);1932 composerEditorRef.current?.focusAt(cursor);
1933 },1933 },
1934 insertTextAtEnd: (text: string) => {1934 insertTextAtEnd: (text: string, options?: { ensureLeadingBoundary?: boolean }) => {
1935 if (1935 if (
1936 text.length === 0 ||1936 text.length === 0 ||
1937 isConnecting ||1937 isConnecting ||
...@@ -1942,8 +1942,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -1942,8 +1942,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
1942 ) {1942 ) {
1943 return false;1943 return false;
1944 }1944 }
1945 const rangeEnd = promptRef.current.length;1945 const prompt = promptRef.current;
1946 return applyPromptReplacement(rangeEnd, rangeEnd, text);1946 const needsLeadingSpace =
1947 (options?.ensureLeadingBoundary ?? false) && prompt.length > 0 && !/\s$/.test(prompt);
1948 return applyPromptReplacement(
1949 prompt.length,
1950 prompt.length,
1951 needsLeadingSpace ? ` ${text}` : text,
1952 );
1947 },1953 },
1948 openModelPicker: () => {1954 openModelPicker: () => {
1949 setIsComposerModelPickerOpen(true);1955 setIsComposerModelPickerOpen(true);
apps/web/src/components/files/FileBrowserPanel.tsx+96
...@@ -1,10 +1,19 @@...@@ -1,10 +1,19 @@
1import type {
2 ContextMenuItem as TreeContextMenuItem,
3 ContextMenuOpenContext as TreeContextMenuOpenContext,
4} from "@pierre/trees";
1import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";5import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts";
2import { FileTree, useFileTree } from "@pierre/trees/react";6import { FileTree, useFileTree } from "@pierre/trees/react";
7import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger";
3import { RefreshCw, Search } from "lucide-react";8import { RefreshCw, Search } from "lucide-react";
4import { useEffect, useMemo, useRef } from "react";9import { useEffect, useMemo, useRef } from "react";
510
11import { toastManager } from "~/components/ui/toast";
12import { useComposerHandleContext } from "~/composerHandleContext";
13import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
6import { useTheme } from "~/hooks/useTheme";14import { useTheme } from "~/hooks/useTheme";
7import { cn } from "~/lib/utils";15import { cn } from "~/lib/utils";
16import { readLocalApi } from "~/localApi";
8import { T3_PIERRE_ICONS } from "~/pierre-icons";17import { T3_PIERRE_ICONS } from "~/pierre-icons";
918
10import { useProjectEntriesQuery } from "./projectFilesQueryState";19import { useProjectEntriesQuery } from "./projectFilesQueryState";
...@@ -39,6 +48,7 @@ export default function FileBrowserPanel({...@@ -39,6 +48,7 @@ export default function FileBrowserPanel({
39 onOpenFile,48 onOpenFile,
40}: FileBrowserPanelProps) {49}: FileBrowserPanelProps) {
41 const { resolvedTheme } = useTheme();50 const { resolvedTheme } = useTheme();
51 const composerRef = useComposerHandleContext();
42 const entriesQuery = useProjectEntriesQuery(environmentId, cwd);52 const entriesQuery = useProjectEntriesQuery(environmentId, cwd);
43 const entries = entriesQuery.data?.entries ?? [];53 const entries = entriesQuery.data?.entries ?? [];
44 const entryKinds = useMemo(54 const entryKinds = useMemo(
...@@ -49,7 +59,93 @@ export default function FileBrowserPanel({...@@ -49,7 +59,93 @@ export default function FileBrowserPanel({
49 const treePaths = useMemo(() => entries.map(treePath), [entries]);59 const treePaths = useMemo(() => entries.map(treePath), [entries]);
50 const previousTreePathsRef = useRef<readonly string[]>([]);60 const previousTreePathsRef = useRef<readonly string[]>([]);
5161
62 // The tree renders rows in shadow DOM and its anchor rect is unreliable, so
63 // capture the right-click position ourselves; contextmenu is a composed
64 // event, so a capture-phase listener sees it with viewport coordinates.
65 const contextMenuPointerRef = useRef<{ x: number; y: number; at: number } | null>(null);
66 useEffect(() => {
67 const capturePointer = (event: MouseEvent) => {
68 contextMenuPointerRef.current = { x: event.clientX, y: event.clientY, at: event.timeStamp };
69 };
70 document.addEventListener("contextmenu", capturePointer, true);
71 return () => document.removeEventListener("contextmenu", capturePointer, true);
72 }, []);
73
74 const showEntryContextMenu = async (
75 item: TreeContextMenuItem,
76 context: TreeContextMenuOpenContext,
77 ) => {
78 const api = readLocalApi();
79 if (!api) {
80 context.close();
81 return;
82 }
83 const relativePath = item.path.replace(/\/$/, "");
84 const mention = serializeComposerFileLink(relativePath);
85 const pointer = contextMenuPointerRef.current;
86 const pointerIsFresh = pointer !== null && performance.now() - pointer.at < 1000;
87 const anchorRect = context.anchorElement.getBoundingClientRect();
88 const position = pointerIsFresh
89 ? { x: pointer.x, y: pointer.y }
90 : { x: anchorRect.left, y: anchorRect.bottom };
91 try {
92 const clicked = await api.contextMenu.show(
93 [
94 { id: "copy-mention", label: "Copy mention" },
95 { id: "add-to-chat", label: "Add to chat" },
96 ],
97 position,
98 );
99 if (clicked === "copy-mention") {
100 try {
101 await writeTextToClipboard(mention);
102 toastManager.add({ type: "success", title: "Mention copied", description: relativePath });
103 } catch (error) {
104 toastManager.add({
105 type: "error",
106 title: "Failed to copy mention",
107 description: error instanceof Error ? error.message : "An error occurred.",
108 });
109 }
110 return;
111 }
112 if (clicked === "add-to-chat") {
113 const composer = composerRef?.current;
114 if (!composer) {
115 toastManager.add({
116 type: "error",
117 title: "Unable to add to chat",
118 description: "Open a chat for this project and try again.",
119 });
120 return;
121 }
122 const inserted = composer.insertTextAtEnd(`${mention} `, { ensureLeadingBoundary: true });
123 if (!inserted) {
124 toastManager.add({
125 type: "error",
126 title: "Unable to add to chat",
127 description: "The chat isn't ready to accept input right now.",
128 });
129 }
130 }
131 } finally {
132 context.close();
133 }
134 };
135 const showEntryContextMenuRef = useRef(showEntryContextMenu);
136 useEffect(() => {
137 showEntryContextMenuRef.current = showEntryContextMenu;
138 });
139
52 const { model } = useFileTree({140 const { model } = useFileTree({
141 composition: {
142 contextMenu: {
143 triggerMode: "right-click",
144 onOpen: (item, context) => {
145 void showEntryContextMenuRef.current(item, context);
146 },
147 },
148 },
53 density: "compact",149 density: "compact",
54 fileTreeSearchMode: "hide-non-matches",150 fileTreeSearchMode: "hide-non-matches",
55 flattenEmptyDirectories: true,151 flattenEmptyDirectories: true,