| author | |
| committer | |
| log | 4cfec8c128084a0abde67853d10e996f5b2aac05 |
| tree | 073bab4704348888185841bd3ea33aeba1455ec1 |
| parent | 1bd2fa25e2214e0318a8a0d4cd4c4c513ddd800e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 33 | 33 | ||
| 34 | const makeWindow = (zoomFactor = 1): Electron.BrowserWindow => | ||
| 35 | ({ | ||
| 36 | id: 7, | ||
| 37 | webContents: { getZoomFactor: () => zoomFactor }, | ||
| 38 | }) as unknown as Electron.BrowserWindow; | ||
| 39 | |||
| 34 | describe("ElectronMenu", () => { | 40 | describe("ElectronMenu", () => { |
| 35 | beforeEach(() => { | 41 | beforeEach(() => { |
| 36 | buildFromTemplateMock.mockReset(); | 42 | buildFromTemplateMock.mockReset(); |
| ... | @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { | ... | @@ -70,7 +76,7 @@ describe("ElectronMenu", () => { |
| 70 | 76 | ||
| 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", () => { |
| 81 | 87 | ||
| 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 | })); |
| 89 | 97 | ||
| 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 | }); |
| 96 | 104 | ||
| 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 | } |
| 96 | 96 | ||
| 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. | ||
| 97 | const normalizePosition = ( | 100 | const 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 | ); | ||
| 104 | 111 | ||
| 105 | export const make = Effect.gen(function* () { | 112 | export 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* () { |
| 214 | 221 | ||
| 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"; |
| 59 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | ||
| 58 | import { | 60 | import { |
| 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 | } |
| 1119 | 1121 | ||
| 1122 | function 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 | |||
| 1120 | function ComposerSurroundSelectionPlugin(props: { | 1215 | function 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( |
| 390 | export interface ChatComposerHandle { | 390 | export 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 @@ |
| 1 | import type { | ||
| 2 | ContextMenuItem as TreeContextMenuItem, | ||
| 3 | ContextMenuOpenContext as TreeContextMenuOpenContext, | ||
| 4 | } from "@pierre/trees"; | ||
| 1 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; | 5 | import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; |
| 2 | import { FileTree, useFileTree } from "@pierre/trees/react"; | 6 | import { FileTree, useFileTree } from "@pierre/trees/react"; |
| 7 | import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; | ||
| 3 | import { RefreshCw, Search } from "lucide-react"; | 8 | import { RefreshCw, Search } from "lucide-react"; |
| 4 | import { useEffect, useMemo, useRef } from "react"; | 9 | import { useEffect, useMemo, useRef } from "react"; |
| 5 | 10 | ||
| 11 | import { toastManager } from "~/components/ui/toast"; | ||
| 12 | import { useComposerHandleContext } from "~/composerHandleContext"; | ||
| 13 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | ||
| 6 | import { useTheme } from "~/hooks/useTheme"; | 14 | import { useTheme } from "~/hooks/useTheme"; |
| 7 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 16 | import { readLocalApi } from "~/localApi"; | ||
| 8 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; | 17 | import { T3_PIERRE_ICONS } from "~/pierre-icons"; |
| 9 | 18 | ||
| 10 | import { useProjectEntriesQuery } from "./projectFilesQueryState"; | 19 | import { 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[]>([]); |
| 51 | 61 | ||
| 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, |