From 7cefe29f3f58f6d47e08e6dbf001c0d4624270d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:13:10 +1000 Subject: [PATCH 1/5] fix(web): file link chips use the shared file context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown file links carried their own right-click menu with a private reveal action and no Open with submenu, diverging from the changed-files tree, diff panel, and file browser. Build their menu from useFileContextMenu instead: reveal and the Open with editor submenu now appear beside the chip's own preview, preferred-editor, browser, and copy actions, and reveal works in remote mode where the old one was hidden. Absolute environment-host paths resolve directly so links outside the workspace keep their actions. The chip already renders "Open in ", so the shared menu's default-app Open folds into Open with as "Default Application" — one primary open row plus alternatives instead of three competing open items. Open with selections now dispatch their child ids, and bare display paths like "ChatView.tsx" resolve through the workspace index before an action runs so they target the indexed file rather than /. Document the convention in docs/internals/file-context-menus.md and define file chip and file context menu in the glossary. --- apps/web/src/components/ChatMarkdown.tsx | 181 +++++++++-------------- apps/web/src/fileContextMenu.test.ts | 89 ++++++++++- apps/web/src/fileContextMenu.ts | 47 ++++-- docs/internals/file-context-menus.md | 53 +++++++ docs/internals/glossary.md | 7 + 5 files changed, 241 insertions(+), 136 deletions(-) create mode 100644 docs/internals/file-context-menus.md diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f48c573aa8bc..923ced75d7de 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -107,10 +107,6 @@ import { MediaActions, type MediaActionSource } from "./media/MediaActions"; import { resolveProtocolRelativeMediaUrl } from "./media/mediaContent"; import { FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; -import { - revealInFileExplorerLabelForKind, - revealInFileExplorerLabelForOs, -} from "./preview/fileExplorerLabel"; import { resolveExternalWebLinkHost, showExternalLinkContextMenu, @@ -124,11 +120,7 @@ import { ScrollArea } from "./ui/scroll-area"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { recordVisitForThread } from "../browserHistoryStore"; -import { - PreferredEditorEnvironmentRequiredError, - useOpenInPreferredEditor, - usePreferredEditor, -} from "../editorPreferences"; +import { useOpenInPreferredEditor, usePreferredEditor } from "../editorPreferences"; import { openInEditorMenuLabel } from "../editorLabels"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; @@ -158,13 +150,13 @@ import { type MarkdownFileLinkMeta, } from "../markdown-links"; import { readLocalApi } from "../localApi"; +import { useFileContextMenu, type FileContextMenuTarget } from "../fileContextMenu"; import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; import { cn } from "../lib/utils"; import { useRemoteOpenResolution, type RemoteOpenMode } from "../remoteOpen"; import { useRightPanelStore } from "../rightPanelStore"; import { readThreadShell, useProjects } from "../state/entities"; import { serverEnvironment } from "../state/server"; -import { shellEnvironment } from "../state/shell"; import { assetEnvironment } from "../state/assets"; import { usePreparedConnection } from "../state/session"; import { previewEnvironment } from "../state/preview"; @@ -186,7 +178,7 @@ import { import { useOpenLink } from "../browser/useOpenLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; +import { isAbsolutePath } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -1149,10 +1141,16 @@ interface MarkdownFileLinkProps { openInEditorMenuLabel: string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; - onReveal?: (() => Promise>) | undefined; - /** Platform-specific menu label ("Reveal in Finder", ...); required for the - reveal item to show. */ - revealLabel?: string | undefined; + /** Shared file-menu machinery for reveal and the Open with submenu. */ + fileMenu?: ReturnType | undefined; + /** Position-stripped path the shared menu acts on; required for it to show. */ + menuPath?: string | undefined; + /** Workspace root the shared menu resolves menuPath against. */ + menuWorkspaceRoot?: string | undefined; + /** Resolves a bare display path (e.g. `ChatView.tsx`) to the indexed + workspace path before a shared action runs on it. */ + resolveMenuPath?: ((filePath: string) => Promise) | undefined; + className?: string | undefined; } const MARKDOWN_FILE_LINK_CLASS_NAME = "chat-markdown-file-link"; @@ -1888,8 +1886,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ openInEditorMenuLabel, onOpenInBrowser, onOpenMedia, - onReveal, - revealLabel, + fileMenu, + menuPath, + menuWorkspaceRoot, + resolveMenuPath, + className, }: MarkdownFileLinkProps) { const handleOpenInEditor = useCallback(() => { if (!onOpen) { @@ -1979,44 +1980,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ })(); }, [onOpenInBrowser, targetPath]); - const handleRevealInFileManager = useCallback(() => { - if (!onReveal) { - return; - } - void (async () => { - try { - const result = await onReveal(); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - result.cause, - ); - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } catch (cause) { - reportMarkdownActionFailure( - { operation: "reveal-file-in-file-manager", target: targetPath }, - cause, - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to reveal file", - description: cause instanceof Error ? cause.message : "An error occurred.", - }), - ); - } - })(); - }, [onReveal, targetPath]); - const handleCopy = useCallback( (value: string, title: string) => { if (typeof window === "undefined" || !navigator.clipboard?.writeText) { @@ -2061,26 +2024,51 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const api = readLocalApi(); if (!api) return; + const menuTarget: FileContextMenuTarget | undefined = + fileMenu && menuPath + ? { + environmentId: threadRef?.environmentId ?? null, + filePath: menuPath, + workspaceRoot: menuWorkspaceRoot, + } + : undefined; + // The chip already renders "Open in ", so the default-app Open + // folds into the shared Open with submenu instead of a third open row. + const sharedItems = menuTarget + ? (fileMenu?.buildItems(menuTarget, { hasPrimaryOpenItem: onOpen !== undefined }) ?? []) + : []; + try { const clicked = await api.contextMenu.show( [ ...(onOpenMedia ? ([{ id: "preview-media", label: "Preview media" }] as const) : []), - ...(onOpen ? ([{ id: "open", label: openInEditorMenuLabel }] as const) : []), + ...(onOpen ? ([{ id: "open-editor", label: openInEditorMenuLabel }] as const) : []), + ...sharedItems, ...(onOpenInBrowser ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) : []), - ...(onReveal && revealLabel ? ([{ id: "reveal", label: revealLabel }] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, - ] as const, + ], position, ); + if (clicked === null) return; + // "Open with" selections report the child id ("editor:" or the + // nested "open"), which is not a top-level item. + const sharedClicked = sharedItems + .flatMap((item) => [item, ...(item.children ?? [])]) + .find((item) => item.id === clicked); + if (menuTarget && fileMenu && sharedClicked) { + const filePath = (await resolveMenuPath?.(menuTarget.filePath)) ?? menuTarget.filePath; + await fileMenu.activate(sharedClicked.id, { ...menuTarget, filePath }); + return; + } if (clicked === "preview-media") { onOpenMedia?.(); return; } - if (clicked === "open") { + if (clicked === "open-editor") { handleOpenInEditor(); return; } @@ -2088,10 +2076,6 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ handleOpenInBrowser(); return; } - if (clicked === "reveal") { - handleRevealInFileManager(); - return; - } if (clicked === "copy-relative") { handleCopy(displayPath, "Relative path"); return; @@ -2108,17 +2092,19 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [ displayPath, + fileMenu, handleCopy, handleOpenInBrowser, handleOpenInEditor, - handleRevealInFileManager, + menuPath, + menuWorkspaceRoot, onOpenInBrowser, onOpenMedia, onOpen, - onReveal, openInEditorMenuLabel, - revealLabel, + resolveMenuPath, targetPath, + threadRef, ], ); @@ -2228,8 +2214,11 @@ function areMarkdownFileLinkPropsEqual( previous.openInEditorMenuLabel === next.openInEditorMenuLabel && previous.onOpenInBrowser === next.onOpenInBrowser && previous.onOpenMedia === next.onOpenMedia && - previous.onReveal === next.onReveal && - previous.revealLabel === next.revealLabel + previous.fileMenu === next.fileMenu && + previous.menuPath === next.menuPath && + previous.menuWorkspaceRoot === next.menuWorkspaceRoot && + previous.resolveMenuPath === next.resolveMenuPath && + previous.className === next.className ); } @@ -2323,37 +2312,11 @@ function useChatMarkdownState({ ); const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const projects = useProjects(); - const availableEditors = serverConfig?.availableEditors ?? []; + const availableEditors = useMemo(() => serverConfig?.availableEditors ?? [], [serverConfig]); const [preferredEditor] = usePreferredEditor(availableEditors); const preferredEditorMenuLabel = openInEditorMenuLabel(preferredEditor); const openInPreferredEditor = useOpenInPreferredEditor(environmentId, availableEditors); - const openInEditor = useAtomCommand(shellEnvironment.openInEditor, { - reportFailure: false, - }); - const revealInFileManagerLabel = - environmentId !== null && - serverConfig?.shellRevealInFileManager === true && - serverConfig.availableEditors.includes("file-manager") - ? serverConfig.shellRevealInFileManagerKind === undefined - ? revealInFileExplorerLabelForOs(serverConfig.environment.platform.os) - : revealInFileExplorerLabelForKind(serverConfig.shellRevealInFileManagerKind) - : undefined; - const revealFileInFileManager = useCallback( - (filePath: string) => { - if (environmentId === null) { - return Promise.resolve( - AsyncResult.failure( - Cause.fail(new PreferredEditorEnvironmentRequiredError({ targetPath: filePath })), - ), - ); - } - return openInEditor({ - environmentId, - input: { cwd: filePath, editor: "file-manager", reveal: true }, - }); - }, - [environmentId, openInEditor], - ); + const fileMenu = useFileContextMenu(environmentId); const diffThemeName = resolveDiffThemeName(resolvedTheme); const markdownFileLinkMetaByHref = useMemo(() => { const metaByHref = new Map< @@ -2545,17 +2508,6 @@ function useChatMarkdownState({ }, [cwd, findWorkspaceBasenameMatch, threadRef], ); - const revealMarkdownFileInFileManager = useCallback( - async (fileLinkMeta: MarkdownFileLinkMeta) => { - const workspaceRelativePath = fileLinkMeta.workspaceRelativePath; - const match = workspaceRelativePath - ? await findWorkspaceBasenameMatch(workspaceRelativePath) - : null; - const filePath = match && cwd ? resolvePathLinkTarget(match, cwd) : fileLinkMeta.filePath; - return revealFileInFileManager(filePath); - }, - [cwd, findWorkspaceBasenameMatch, revealFileInFileManager], - ); const fileLinkChip = useCallback( (fileLinkMeta: MarkdownFileLinkMeta, copyMarkdown: string, mediaSource?: string) => { const parentSuffix = fileLinkParentSuffixByPath.get( @@ -2601,12 +2553,10 @@ function useChatMarkdownState({ : undefined } openInEditorMenuLabel={preferredEditorMenuLabel} - onReveal={ - canUseShellActions && revealInFileManagerLabel !== undefined - ? () => revealMarkdownFileInFileManager(fileLinkMeta) - : undefined - } - revealLabel={revealInFileManagerLabel} + fileMenu={fileMenu} + menuPath={fileLinkMeta.workspaceRelativePath ?? fileLinkMeta.filePath} + menuWorkspaceRoot={cwd} + resolveMenuPath={findWorkspaceBasenameMatch} onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && @@ -2619,15 +2569,16 @@ function useChatMarkdownState({ }, [ canUseShellActions, + cwd, fileLinkParentSuffixByPath, + fileMenu, + findWorkspaceBasenameMatch, openFileInPanel, openInPreferredEditor, openMarkdownFileInPreview, openMarkdownMedia, preferredEditorMenuLabel, resolvedTheme, - revealInFileManagerLabel, - revealMarkdownFileInFileManager, threadRef, ], ); diff --git a/apps/web/src/fileContextMenu.test.ts b/apps/web/src/fileContextMenu.test.ts index 02cae8b5dabe..9dbc4bafa154 100644 --- a/apps/web/src/fileContextMenu.test.ts +++ b/apps/web/src/fileContextMenu.test.ts @@ -10,12 +10,6 @@ const BASE_TARGET = { workspaceRoot: "/workspace/project", }; -const EMPTY_CAPABILITIES = { - revealLabel: undefined, - canOpenDefault: false, - editorIds: [], -}; - describe("resolveFileContextMenuAbsolutePath", () => { it("joins workspace-relative diff paths onto the workspace root", () => { expect(resolveFileContextMenuAbsolutePath(BASE_TARGET)).toBe("/workspace/project/src/index.ts"); @@ -43,15 +37,58 @@ describe("resolveFileContextMenuAbsolutePath", () => { ).toBeNull(); }); - it("rejects absolute paths without a workspace root, matching diff path resolution", () => { + it("passes absolute environment-host paths through unchanged", () => { expect( resolveFileContextMenuAbsolutePath({ ...BASE_TARGET, - workspaceRoot: undefined, filePath: "/absolute/src/index.ts", }), + ).toBe("/absolute/src/index.ts"); + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "C:\\temp\\report.pdf", + }), + ).toBe("C:\\temp\\report.pdf"); + }); + + it("returns null for relative paths without a workspace root, matching diff path resolution", () => { + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "src/index.ts", + }), + ).toBeNull(); + }); + + it("does not treat drive-relative Windows paths as absolute", () => { + // C:notes.md is relative to the current directory on C:, so it cannot + // pass through as an environment-host path or resolve to a workspace file. + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + workspaceRoot: undefined, + filePath: "C:notes.md", + }), + ).toBeNull(); + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + filePath: "C:src\\index.ts", + }), ).toBeNull(); }); + + it("passes UNC environment-host paths through unchanged", () => { + expect( + resolveFileContextMenuAbsolutePath({ + ...BASE_TARGET, + filePath: "\\\\server\\share\\report.pdf", + }), + ).toBe("\\\\server\\share\\report.pdf"); + }); }); describe("buildFileContextMenuItems", () => { @@ -87,6 +124,42 @@ describe("buildFileContextMenuItems", () => { expect(items[0]).toMatchObject({ label: "Reveal in File Explorer" }); }); + it("folds the default-app open into Open with when the caller has its own open row", () => { + const items = buildFileContextMenuItems({ + hasAbsolutePath: true, + hasPrimaryOpenItem: true, + capabilities: { + revealLabel: "Reveal in Finder", + canOpenDefault: true, + editorIds: ["vscode", "cursor", "file-manager"], + }, + }); + + expect(items.map((item) => item.id)).toEqual(["reveal-in-folder", "open-with"]); + const openWith = items[1]; + NodeAssert.ok(openWith); + expect(openWith.children?.map((child) => child.id)).toEqual([ + "open", + "editor:vscode", + "editor:cursor", + ]); + expect(openWith.children?.[0]).toMatchObject({ label: "Default Application" }); + }); + + it("keeps the top-level open when no submenu exists to fold it into", () => { + const items = buildFileContextMenuItems({ + hasAbsolutePath: true, + hasPrimaryOpenItem: true, + capabilities: { + revealLabel: "Reveal in Finder", + canOpenDefault: true, + editorIds: ["file-manager"], + }, + }); + + expect(items.map((item) => item.id)).toEqual(["open", "reveal-in-folder"]); + }); + it("offers nothing when the path cannot be resolved", () => { expect( buildFileContextMenuItems({ diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 05425f205955..6ab7f4b6233d 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -22,7 +22,7 @@ import { readLocalApi } from "./localApi"; import { serverEnvironment } from "./state/server"; import { shellEnvironment } from "./state/shell"; import { useAtomCommand } from "./state/use-atom-command"; -import { resolvePathLinkTarget } from "./terminal-links"; +import { isAbsolutePath, resolvePathLinkTarget } from "./terminal-links"; import { toastManager } from "./components/ui/toast"; import { useAtomValue } from "@effect/atom-react"; @@ -35,13 +35,16 @@ export type FileContextMenuAction = export interface FileContextMenuTarget { readonly environmentId: EnvironmentId | null; - /** Repo- or workspace-relative file path, as shown in diffs. */ + /** + * Repo- or workspace-relative path (as shown in diffs), or an absolute + * environment-host path (as carried by markdown file links pointing outside + * the workspace). + */ readonly filePath: string; readonly workspaceRoot: string | undefined; readonly repositoryRoot?: string | undefined; } -/** Absolute path on the environment host, or null when it cannot be resolved. */ /** * Absolute path on the environment host for a diff-style target, resolving * repo-relative paths through the workspace root like every other diff @@ -49,6 +52,11 @@ export interface FileContextMenuTarget { * treat as "no file actions available". */ export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget): string | null { + // Markdown file links can point outside the workspace with an absolute + // environment-host path; there is nothing to resolve. isAbsolutePath needs + // a separator after a drive colon, so drive-relative C:notes.md is treated + // as workspace-relative, and UNC shares pass through. + if (isAbsolutePath(target.filePath)) return target.filePath; const workspaceFilePath = resolveDiffPathForWorkspace({ filePath: target.filePath, workspaceRoot: target.workspaceRoot, @@ -56,9 +64,7 @@ export function resolveFileContextMenuAbsolutePath(target: FileContextMenuTarget }); if (workspaceFilePath === null) return null; if (target.workspaceRoot === undefined) { - return workspaceFilePath.startsWith("/") || /^[a-zA-Z]:/.test(workspaceFilePath) - ? workspaceFilePath - : null; + return isAbsolutePath(workspaceFilePath) ? workspaceFilePath : null; } return resolvePathLinkTarget(workspaceFilePath, target.workspaceRoot); } @@ -79,11 +85,20 @@ export interface FileContextMenuCapabilities { export function buildFileContextMenuItems(input: { readonly hasAbsolutePath: boolean; readonly capabilities: FileContextMenuCapabilities; + /** + * Set when the caller already renders its own top-level open row (the file + * chip's "Open in "). The default-app Open folds into "Open with" + * so the menu keeps one primary open action plus alternatives. + */ + readonly hasPrimaryOpenItem?: boolean; }): readonly ContextMenuItem[] { // Without a resolvable absolute path nothing here can act on the file. if (!input.hasAbsolutePath) return []; const items: ContextMenuItem[] = []; - if (input.capabilities.canOpenDefault) { + const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); + const nestDefaultOpen = + input.capabilities.canOpenDefault && input.hasPrimaryOpenItem === true && editorIds.length > 0; + if (input.capabilities.canOpenDefault && !nestDefaultOpen) { items.push({ id: "open", label: "Open", icon: "pencil" }); } if (input.capabilities.revealLabel !== undefined) { @@ -93,15 +108,17 @@ export function buildFileContextMenuItems(input: { icon: "folder-tree", }); } - const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); if (editorIds.length > 0) { items.push({ id: "open-with", label: "Open with", - children: editorIds.map((editorId) => ({ - id: `editor:${editorId}` as FileContextMenuAction, - label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, - })), + children: [ + ...(nestDefaultOpen ? ([{ id: "open", label: "Default Application" }] as const) : []), + ...editorIds.map((editorId) => ({ + id: `editor:${editorId}` as FileContextMenuAction, + label: EDITOR_LABEL_BY_ID.get(editorId) ?? editorId, + })), + ], }); } return items; @@ -181,10 +198,14 @@ export function useFileContextMenu(environmentId: EnvironmentId | null) { }; return { - buildItems: (target: FileContextMenuTarget) => + buildItems: ( + target: FileContextMenuTarget, + options?: { readonly hasPrimaryOpenItem?: boolean }, + ) => buildFileContextMenuItems({ hasAbsolutePath: resolveFileContextMenuAbsolutePath(target) !== null, capabilities, + ...options, }), capabilities, activate, diff --git a/docs/internals/file-context-menus.md b/docs/internals/file-context-menus.md new file mode 100644 index 000000000000..b86064d54041 --- /dev/null +++ b/docs/internals/file-context-menus.md @@ -0,0 +1,53 @@ +# File context menus + +> For maintainers. Using T3 Code? See [docs/user](../user/). + +Every surface that shows a workspace file — as a chip, a row, or a tree node — must offer the +shared file context menu on right-click. The menu is how a user bridges from the app to their own +tools, so a file surface without it dead-ends: the user can see the file but cannot reach Finder, +Explorer, or their editor. + +## The rule + +A file surface's context menu must include, when the environment supports them: + +- **Show in Finder / Explorer / Files** — reveals the file on the environment host, server-side, + so it works over local, remote, and tunnel connections. +- **Open** — opens the file with the host's default application. When a surface already renders + its own primary open row (a markdown chip's "Open in \"), this folds into the Open + with submenu as "Default Application" so the menu keeps one top-level open action. +- **Open with** — a submenu of the environment's detected editors, with remote deep links when + this client is not on the environment's machine. + +The menu may add surface-specific items around the shared ones (a diff panel adds diff actions, a +markdown chip adds preview and copy actions), but the shared items are never dropped and never +reimplemented with different behavior. + +## The mechanism + +`apps/web/src/fileContextMenu.ts` owns the menu. `useFileContextMenu(environmentId)` resolves +capabilities once per environment (detected editors, the server's `shellRevealInFileManager` +gate and its reveal wording, remote-open mode), and returns `buildItems` and `activate`. +`useFileContextMenuHandler(environmentId)` wraps them into an `onContextMenu` callback. + +To cover a new surface: + +1. Build a `FileContextMenuTarget`: an absolute environment-host path or a repo/workspace-relative + path, plus the `workspaceRoot` (and `repositoryRoot` for repo-relative paths inside a nested + worktree) needed to resolve it. +2. Wire the surface's right-click (and click-with-no-other-primary-action, for chips that are not + links) to `useFileContextMenuHandler` or, when the surface has extra items of its own, call + `buildItems` and `activate` around them the way `MarkdownFileLink` does. +3. Do not fork the actions. If a surface needs a variation, change `fileContextMenu.ts` so every + surface benefits. + +## Covered surfaces + +- Chat changed-files tree and activity rows (`ChangedFilesTree`, `MessagesTimeline`). +- Diff panel file headers (`DiffPanel`). +- Workspace file browser (`FileBrowserPanel`). +- Markdown file-link chips in messages and previews (`MarkdownFileLink` in `ChatMarkdown`). + +Not covered yet, tracked as follow-ups: composer file-mention chips (they live inside the Lexical +editor and need environment context threaded into the decorator) and mobile, which has no +right-click; long-press there should eventually map to the same menu. diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 019c9d53023a..6c0aa0f6f6e0 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -64,3 +64,10 @@ Terms whose meaning matters across T3 Code. Architecture and lifecycle constrain | Attachment inventory | The ordered image records shown as thumbnails above the prose, including images with no inline references. | See [composer context references](./composer-context-references.md) for the contract and lifecycle. + +## File surfaces + +| Term | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| File chip | An inline, clickable file path rendered as a pill in chat markdown, activity, or the composer. Not every file surface is a chip. | +| File context menu | The shared right-click menu for any shown workspace file: reveal in the file manager and an Open with submenu. See [file context menus](./file-context-menus.md). | From d3dfba56e31b5269f05b40facfdc3da6fca903f0 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 14:13:55 +1000 Subject: [PATCH 2/5] fix(web): open the diff file menu from anywhere on the header Right-clicking a diff header's metadata or blank area produced no menu because the handler only looked for a data-title node in the click path. Fall back to the enclosing data-diffs-header's filename like the click handler does. --- apps/web/src/components/DiffPanel.tsx | 11 ++++++++++- apps/web/src/fileContextMenu.test.ts | 7 +++++-- apps/web/src/fileContextMenu.ts | 9 ++++++--- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 41cc4c82fa0f..49d44d15eebb 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1063,7 +1063,16 @@ export default function DiffPanel({ (node): node is HTMLElement => node instanceof HTMLElement && node.hasAttribute("data-title"), ); - const filePath = title?.textContent?.trim(); + // Metadata and blank header areas have no data-title in + // the click path; fall back to the enclosing header's + // filename like onClickCapture does. + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const filePath = ( + title?.textContent ?? header?.querySelector("[data-title]")?.textContent + )?.trim(); if (!filePath) return; event.preventDefault(); onFileContextMenu( diff --git a/apps/web/src/fileContextMenu.test.ts b/apps/web/src/fileContextMenu.test.ts index 9dbc4bafa154..5c14dafb47a0 100644 --- a/apps/web/src/fileContextMenu.test.ts +++ b/apps/web/src/fileContextMenu.test.ts @@ -146,7 +146,10 @@ describe("buildFileContextMenuItems", () => { expect(openWith.children?.[0]).toMatchObject({ label: "Default Application" }); }); - it("keeps the top-level open when no submenu exists to fold it into", () => { + it("drops the default-app open entirely when the caller's row already covers it", () => { + // With no editors detected, the caller's "Open in editor" opens the + // default app through preferred-editor resolution, so a shared Open row + // would be a duplicate. const items = buildFileContextMenuItems({ hasAbsolutePath: true, hasPrimaryOpenItem: true, @@ -157,7 +160,7 @@ describe("buildFileContextMenuItems", () => { }, }); - expect(items.map((item) => item.id)).toEqual(["open", "reveal-in-folder"]); + expect(items.map((item) => item.id)).toEqual(["reveal-in-folder"]); }); it("offers nothing when the path cannot be resolved", () => { diff --git a/apps/web/src/fileContextMenu.ts b/apps/web/src/fileContextMenu.ts index 6ab7f4b6233d..6bb674e3224d 100644 --- a/apps/web/src/fileContextMenu.ts +++ b/apps/web/src/fileContextMenu.ts @@ -96,9 +96,12 @@ export function buildFileContextMenuItems(input: { if (!input.hasAbsolutePath) return []; const items: ContextMenuItem[] = []; const editorIds = input.capabilities.editorIds.filter((id) => id !== "file-manager"); - const nestDefaultOpen = - input.capabilities.canOpenDefault && input.hasPrimaryOpenItem === true && editorIds.length > 0; - if (input.capabilities.canOpenDefault && !nestDefaultOpen) { + // With no submenu to fold into, the caller's own open row already reaches + // the default app through preferred-editor resolution — a second Open row + // would do the same thing. + const hasOwnOpen = input.hasPrimaryOpenItem === true; + const nestDefaultOpen = hasOwnOpen && input.capabilities.canOpenDefault && editorIds.length > 0; + if (input.capabilities.canOpenDefault && !hasOwnOpen) { items.push({ id: "open", label: "Open", icon: "pencil" }); } if (input.capabilities.revealLabel !== undefined) { From 8fa228b6153e831ff7feab74bd695251773a651f Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 15:21:09 +1000 Subject: [PATCH 3/5] fix(web): bind changed-file actions to the painted thread While the list paints a held snapshot of the previous thread, the changed-files card mixed the old row's files with the route thread's workspace and repository roots. Resolve the section's thread and project from the painted thread key so the file menu and expansion state follow the rows on screen. --- .../src/components/chat/MessagesTimeline.tsx | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 20b8438fbc1c..48891ed89f0b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,10 +154,8 @@ import { } from "./SnapShotAttachmentDetails"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; -import { useAtomValue } from "@effect/atom-react"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThread } from "../../state/entities"; -import { serverEnvironment } from "../../state/server"; import { CHAT_TIMELINE_ANCHOR_OFFSET, readTimelinePosition, @@ -269,6 +267,9 @@ interface TimelineRowSharedState { listRef: React.RefObject; timestampFormat: TimestampFormat; routeThreadKey: string; + // The thread whose rows are currently painted — differs from routeThreadKey + // while a held snapshot of the previous thread fills the loading gap. + paintedThreadKey: string; threadRef: ScopedThreadRef | null; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; @@ -1129,6 +1130,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, timestampFormat, routeThreadKey, + paintedThreadKey: listIdentityKey, // Keep Markdown callbacks memoized during unrelated activity updates. threadRef: citationThreadRef, markdownCwd, @@ -1165,6 +1167,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ listRef, timestampFormat, routeThreadKey, + listIdentityKey, citationThreadRef, markdownCwd, resolvedTheme, @@ -2377,7 +2380,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -3343,12 +3346,12 @@ function WorkGroupToggleTimelineRow({ * so toggling re-renders only this component — not the entire list. */ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ turnSummary, - routeThreadKey, + threadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary | undefined; - routeThreadKey: string; + threadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { @@ -3360,7 +3363,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( @@ -3372,33 +3375,37 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( function AssistantChangedFilesSectionInner({ turnSummary, checkpointFiles, - routeThreadKey, + threadKey, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary; checkpointFiles: TurnDiffSummary["files"]; - routeThreadKey: string; + threadKey: string; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const ctx = use(TimelineRowCtx); const persistedExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], + (store) => store.threadChangedFilesExpandedById[threadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const allDirectoriesExpanded = persistedExpanded ?? false; - const thread = useThread(ctx.threadRef); - const activeProject = useProject( + // Rows can belong to a held snapshot of the previous thread while the list + // settles; bind file actions and expansion state to the painted thread. + const paintedThreadRef = useMemo( + () => parseScopedThreadKey(threadKey) ?? ctx.threadRef, + [threadKey, ctx.threadRef], + ); + const thread = useThread(paintedThreadRef); + const project = useProject( thread && thread.projectId ? { environmentId: thread.environmentId, projectId: thread.projectId } : null, ); - const serverConfig = useAtomValue( - serverEnvironment.configValueAtom(ctx.activeThreadEnvironmentId), - ); - const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); + const environmentId = paintedThreadRef?.environmentId ?? ctx.activeThreadEnvironmentId; + const onFileContextMenu = useFileContextMenuHandler(environmentId); return ( - setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) + setExpanded(threadKey, turnSummary.turnId, !allDirectoriesExpanded) } onOpenTurnDiff={onOpenTurnDiff} onFileContextMenu={(filePath, event) => onFileContextMenu( { - environmentId: ctx.activeThreadEnvironmentId, + environmentId, filePath, - workspaceRoot: ctx.workspaceRoot, + workspaceRoot: project?.workspaceRoot ?? ctx.workspaceRoot, repositoryRoot: - thread?.worktreePath == null - ? activeProject?.repositoryIdentity?.rootPath - : undefined, + thread?.worktreePath == null ? project?.repositoryIdentity?.rootPath : undefined, }, event, ) From f49e3ebdca019b70f4b794919f54357010a7419a Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Wed, 16 Sep 2026 15:25:01 +1000 Subject: [PATCH 4/5] fix(web): resolve diff file menus against the effective diff cwd When the worktree cwd is rejected, the branch diff preview retries at the environment cwd, but the context menu still resolved files against the rejected root. Bind the menu's workspace root to the cwd the rendered diff came from. --- apps/web/src/components/DiffPanel.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 49d44d15eebb..58072f317717 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1079,7 +1079,12 @@ export default function DiffPanel({ { environmentId: activeThread?.environmentId ?? null, filePath, - workspaceRoot: activeCwd, + // The branch preview can retry at the environment cwd + // when the worktree is rejected; resolve files against + // the cwd the rendered diff actually came from. + workspaceRoot: selectedTurn + ? activeCwd + : (branchDiffPreview.data?.cwd ?? activeCwd), repositoryRoot: activeRepositoryRoot, }, event, From 4746466055a6692cd800ef935017885877f5e14f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:23:54 +1000 Subject: [PATCH 5/5] fix(web): resolve changed files against the painted worktree --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 48891ed89f0b..c9219a44fa96 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3422,7 +3422,7 @@ function AssistantChangedFilesSectionInner({ { environmentId, filePath, - workspaceRoot: project?.workspaceRoot ?? ctx.workspaceRoot, + workspaceRoot: thread?.worktreePath ?? project?.workspaceRoot ?? ctx.workspaceRoot, repositoryRoot: thread?.worktreePath == null ? project?.repositoryIdentity?.rootPath : undefined, },