From d90447be15b27e7478bb6c74f65905683e033d03 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 02:36:47 -0400 Subject: [PATCH 1/5] feat(web): add copy mention and add to chat to the file explorer Signed-off-by: Yordis Prieto --- .../src/components/ComposerPromptEditor.tsx | 77 ++++++++++++++++ .../src/components/files/FileBrowserPanel.tsx | 87 +++++++++++++++++++ 2 files changed, 164 insertions(+) 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 a13749d2879bb4ad1b9c56d7646e6be9b9929796 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 02:36:49 -0400 Subject: [PATCH 2/5] fix(desktop): account for page zoom when positioning context menus Signed-off-by: Yordis Prieto --- apps/desktop/src/electron/ElectronMenu.ts | 16 +++++++++++++--- 1 file changed, 13 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, From 4d64f72a58fc3d738081aed843cbee3ba625bfb3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 02:38:24 -0400 Subject: [PATCH 3/5] docs(fork): start a ledger of divergences from upstream Signed-off-by: Yordis Prieto --- .../0001-file-explorer-mention-actions.md | 40 +++++++++++++++++++ docs/fork/README.md | 20 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 docs/fork/0001-file-explorer-mention-actions.md create mode 100644 docs/fork/README.md diff --git a/docs/fork/0001-file-explorer-mention-actions.md b/docs/fork/0001-file-explorer-mention-actions.md new file mode 100644 index 00000000000..07d863215b7 --- /dev/null +++ b/docs/fork/0001-file-explorer-mention-actions.md @@ -0,0 +1,40 @@ +# 0001: File explorer mention actions and zoom-aware context menus + +- PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1) +- Status: active + +## What this adds + +Right-clicking a file or directory in the Files panel opens a context menu +with two actions: + +- **Copy mention** copies the composer mention form, `[basename](path)`, so it + can be pasted anywhere in a prompt and render as a file pill. +- **Add to chat** appends the mention directly to the active composer. + +Two supporting changes were required to make that work end to end: + +- The composer only tokenized mentions inserted programmatically, so pasted + mention text stayed plain text. A paste plugin in `ComposerPromptEditor` now + parses pasted text with the shared inline-token grammar and inserts pill + nodes. This benefits any pasted mention, not just ones copied from the file + explorer. +- Native context menus positioned by coordinates drifted whenever the window + was zoomed (Cmd+= / Cmd+-), proportionally to the distance from the window + origin. `ElectronMenu` now converts renderer CSS pixels to window points + using the web contents zoom factor. This fixes every coordinate-positioned + context menu in the desktop app, not only the new one. + +## Touched files + +- `apps/web/src/components/files/FileBrowserPanel.tsx` +- `apps/web/src/components/ComposerPromptEditor.tsx` +- `apps/desktop/src/electron/ElectronMenu.ts` + +## Upstream considerations + +The zoom fix in `ElectronMenu.ts` is a genuine bug fix that stands alone and +is the strongest candidate to submit upstream once contributions open +(upstream is not accepting contributions at the time of writing). The mention +actions are a feature upstream may or may not want; they depend only on +public composer and tree APIs, so the rebase burden is low. diff --git a/docs/fork/README.md b/docs/fork/README.md new file mode 100644 index 00000000000..00cf2231175 --- /dev/null +++ b/docs/fork/README.md @@ -0,0 +1,20 @@ +# Fork divergence ledger + +This directory tracks everything this fork carries that is not in upstream +[pingdotgg/t3code](https://github.com/pingdotgg/t3code). One numbered entry per +divergence, newest last. When upstream ships an equivalent, update the status +here and note what replaced our version instead of deleting the entry, so the +history of what we carried and why stays reconstructable. + +## Statuses + +- `active`: carried by this fork, not in upstream. +- `submitted`: proposed to upstream, waiting on the outcome. +- `upstreamed`: merged into upstream; our patch was dropped or reconciled. +- `superseded`: upstream shipped a different solution; our patch was dropped. + +## Ledger + +| # | Divergence | PR | Status | +| ---- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------ | +| 0001 | [File explorer mention actions and zoom-aware context menus](./0001-file-explorer-mention-actions.md) | [#1](https://github.com/TrogonStack/t3code/pull/1) | active | From 1b745a079492ba01773f02940072313e26bd1ee3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 02:49:36 -0400 Subject: [PATCH 4/5] docs(fork): keep ledger entries product focused Signed-off-by: Yordis Prieto --- .../0001-file-explorer-mention-actions.md | 43 +++++++------------ 1 file changed, 16 insertions(+), 27 deletions(-) diff --git a/docs/fork/0001-file-explorer-mention-actions.md b/docs/fork/0001-file-explorer-mention-actions.md index 07d863215b7..7111d5efe6c 100644 --- a/docs/fork/0001-file-explorer-mention-actions.md +++ b/docs/fork/0001-file-explorer-mention-actions.md @@ -3,38 +3,27 @@ - PR: [TrogonStack/t3code#1](https://github.com/TrogonStack/t3code/pull/1) - Status: active -## What this adds +## What you can do now -Right-clicking a file or directory in the Files panel opens a context menu -with two actions: +- Right-click a file or directory in the Files panel and pick **Add to chat** + to drop it into the active composer as a file pill, or **Copy mention** to + put it on the clipboard and paste it wherever it belongs in a prompt. +- Pasting mention text into the composer renders the file pill. This works + for any pasted mention, including ones copied out of an earlier message, + not just ones coming from the file explorer. +- Context menus across the desktop app open at the cursor even when the + window is zoomed. Before this, any zoom level made menus drift away from + the click, worst near the right edge of the window. -- **Copy mention** copies the composer mention form, `[basename](path)`, so it - can be pasted anywhere in a prompt and render as a file pill. -- **Add to chat** appends the mention directly to the active composer. +## Why -Two supporting changes were required to make that work end to end: - -- The composer only tokenized mentions inserted programmatically, so pasted - mention text stayed plain text. A paste plugin in `ComposerPromptEditor` now - parses pasted text with the shared inline-token grammar and inserts pill - nodes. This benefits any pasted mention, not just ones copied from the file - explorer. -- Native context menus positioned by coordinates drifted whenever the window - was zoomed (Cmd+= / Cmd+-), proportionally to the distance from the window - origin. `ElectronMenu` now converts renderer CSS pixels to window points - using the web contents zoom factor. This fixes every coordinate-positioned - context menu in the desktop app, not only the new one. - -## Touched files - -- `apps/web/src/components/files/FileBrowserPanel.tsx` -- `apps/web/src/components/ComposerPromptEditor.tsx` -- `apps/desktop/src/electron/ElectronMenu.ts` +Attaching files to a prompt required typing @-mentions by hand even though +the file explorer already has the file in front of you. Browsing and +prompting should be one flow. ## Upstream considerations -The zoom fix in `ElectronMenu.ts` is a genuine bug fix that stands alone and -is the strongest candidate to submit upstream once contributions open -(upstream is not accepting contributions at the time of writing). The mention +The zoomed context menu drift is a genuine upstream bug and that part is the +strongest candidate to submit once upstream opens contributions. The mention actions are a feature upstream may or may not want; they depend only on public composer and tree APIs, so the rebase burden is low. From 67a8426087d82cc16cdea0875b873415aa2b606e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 16 Jul 2026 02:50:40 -0400 Subject: [PATCH 5/5] docs(fork): document the product-focused entry format Signed-off-by: Yordis Prieto --- docs/fork/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/fork/README.md b/docs/fork/README.md index 00cf2231175..d73a005d602 100644 --- a/docs/fork/README.md +++ b/docs/fork/README.md @@ -6,6 +6,20 @@ divergence, newest last. When upstream ships an equivalent, update the status here and note what replaced our version instead of deleting the entry, so the history of what we carried and why stays reconstructable. +## Writing an entry + +Entries are product focused. Describe what someone can do now and why we +wanted it, not how it was built. No touched-file lists, component names, or +implementation details; the linked PR already carries all of that, and code +detail in the ledger goes stale the moment the code moves. + +Each entry uses these sections: + +- **What you can do now**: the user-visible capabilities, as bullets. +- **Why**: the product rationale for carrying the divergence. +- **Upstream considerations**: whether and how we would submit it upstream, + and anything that affects the rebase burden. + ## Statuses - `active`: carried by this fork, not in upstream.