diff --git a/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx b/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx
new file mode 100644
index 00000000000..08eb87a2101
--- /dev/null
+++ b/apps/desktop/src/renderer/__tests__/fileChipCopyLocation.test.tsx
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ writeText: vi.fn(),
+ success: vi.fn(),
+ error: vi.fn(),
+ getAbsPath: vi.fn(),
+ context: {
+ sessionId: undefined as string | undefined,
+ workingDir: '/repo',
+ origin: { kind: 'local' } as { kind: string; deviceId?: string; remoteHostId?: string },
+ },
+}));
+
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+vi.mock('@/lib/toast', () => ({ toast: { success: mocks.success, error: mocks.error } }));
+vi.mock('@/components/chat/ChatSessionFileContext', () => ({ useChatSessionFile: () => mocks.context }));
+vi.mock('@/features/cc-agent/embeddedSessionNavigation', () => ({
+ useSidebarTargetSessionId: (id: string | undefined) => id,
+}));
+vi.mock('@/features/right-sidebar/lib/openInSidebarBrowser', () => ({
+ openUrlInSidebarBrowser: vi.fn(), pathToFileUrl: vi.fn(),
+}));
+vi.mock('@/features/right-sidebar/lib/openInSidebarFileBrowser', () => ({
+ openDirInSidebarFileBrowser: vi.fn(),
+ openExternalFileInSidebarFileBrowser: vi.fn(),
+ openFileInSidebarFileBrowser: vi.fn(),
+}));
+vi.mock('@/lib/remoteFileOpen', () => ({ copyRemoteChatFile: vi.fn(), revealRemoteChatFile: vi.fn() }));
+
+import { useFileChipContextMenu } from '../components/chat/useFileChipContextMenu';
+import type { FileLocation } from '../lib/fileLocation';
+
+function FileReference({ location, directory = false }: { location?: FileLocation; directory?: boolean }) {
+ const { onContextMenu, menu } = useFileChipContextMenu({
+ getAbsPath: mocks.getAbsPath,
+ sidebarFileBrowserKind: directory ? 'directory' : 'file',
+ location,
+ });
+ return <>{menu}>;
+}
+
+async function openMenu(location?: FileLocation, directory = false) {
+ render();
+ fireEvent.contextMenu(screen.getByRole('button', { name: 'file reference' }), { clientX: 10, clientY: 10 });
+ await screen.findByRole('menuitem', { name: 'chat.markdownRenderer.copyFilePath' });
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.context.workingDir = '/repo';
+ mocks.context.origin = { kind: 'local' };
+ mocks.getAbsPath.mockResolvedValue('/repo/src/example.ts');
+ mocks.writeText.mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true, value: { writeText: mocks.writeText },
+ });
+});
+afterEach(cleanup);
+
+describe('file chip Copy Location action', () => {
+ it('copies relative path, line and column without resolving or opening the file again', async () => {
+ await openMenu({ absPath: '/repo/src/example.ts', line: 42, column: 7 });
+ fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' }));
+ await waitFor(() => expect(mocks.success).toHaveBeenCalledWith('chat.markdownRenderer.locationCopied'));
+ expect(mocks.writeText).toHaveBeenCalledWith('src/example.ts:42:7');
+ expect(mocks.getAbsPath).not.toHaveBeenCalled();
+ });
+
+ it('retains the separate absolute-path copy action', async () => {
+ await openMenu({ absPath: '/repo/src/example.ts', line: 42 });
+ fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyFilePath' }));
+ await waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('/repo/src/example.ts'));
+ expect(mocks.success).toHaveBeenCalledWith('chat.markdownRenderer.pathCopied');
+ });
+
+ it.each([
+ [undefined, false],
+ [{ absPath: '/repo/src/example.ts' }, false],
+ [{ absPath: '/outside/example.ts', line: 42 }, false],
+ [{ absPath: '/repo/src', line: 42 }, true],
+ ] as const)('omits Copy Location for missing coordinates, outside files and directories (%j)', async (location, directory) => {
+ await openMenu(location, directory);
+ expect(screen.queryByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' })).toBeNull();
+ });
+
+ it('uses the remote source workdir, not the local clipboard host', async () => {
+ mocks.context.workingDir = 'D:\\Remote';
+ mocks.context.origin = { kind: 'device', deviceId: 'remote-device' };
+ await openMenu({ absPath: 'd:\\remote\\src\\example.ts', line: 42 });
+ fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' }));
+ await waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('src/example.ts:42'));
+ expect(mocks.getAbsPath).not.toHaveBeenCalled();
+ });
+
+ it('reports clipboard failure without reporting success', async () => {
+ mocks.writeText.mockRejectedValueOnce(new Error('Clipboard unavailable'));
+ await openMenu({ absPath: '/repo/src/example.ts', line: 42 });
+ fireEvent.click(screen.getByRole('menuitem', { name: 'chat.markdownRenderer.copyLocation' }));
+ await waitFor(() => expect(mocks.error).toHaveBeenCalledWith('chat.media.copyFailed'));
+ expect(mocks.success).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/src/renderer/__tests__/fileLocation.test.ts b/apps/desktop/src/renderer/__tests__/fileLocation.test.ts
new file mode 100644
index 00000000000..abbd32023db
--- /dev/null
+++ b/apps/desktop/src/renderer/__tests__/fileLocation.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+
+import { formatFileLocation } from '../lib/fileLocation';
+import { splitLocalLineSuffix } from '../lib/markdownTarget';
+
+describe('file reference clipboard locations', () => {
+ // These are logical paths belonging to the source machine, not the test host.
+ it.each([
+ ['/repo', '/repo/src/example.ts', 42, undefined, 'src/example.ts:42'],
+ ['/repo/', '/repo/./src/example.ts', 42, 7, 'src/example.ts:42:7'],
+ ['C:\\Repo', 'c:\\repo\\src\\example.ts', 42, 7, 'src/example.ts:42:7'],
+ ['D:/Repo/', 'D:/Repo/src/中文 文件.ts', 1, undefined, 'src/中文 文件.ts:1'],
+ ['/remote/project', '/remote/project/src/main.ts', 12, 3, 'src/main.ts:12:3'],
+ ])('formats %s / %s without using host path semantics', (workingDir, absPath, line, column, expected) => {
+ expect(formatFileLocation(workingDir, { absPath, line, column })).toBe(expected);
+ });
+
+ it.each([
+ ['', '/repo/a.ts'],
+ ['/repo', '/other/a.ts'],
+ ['/repo', '/repo-other/a.ts'],
+ ['/repo', '/repo'],
+ ['/repo', '/repo/../other/a.ts'],
+ ['/repo', 'src/a.ts'],
+ ['C:/repo', 'D:/repo/a.ts'],
+ ['C:/repo', '/repo/a.ts'],
+ ])('does not invent a relative location for %s / %s', (workingDir, absPath) => {
+ expect(formatFileLocation(workingDir, { absPath, line: 1 })).toBeNull();
+ });
+
+ it.each([undefined, 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])(
+ 'requires a valid line number: %s', (line) => {
+ expect(formatFileLocation('/repo', { absPath: '/repo/a.ts', line })).toBeNull();
+ },
+ );
+
+ it.each([0, -1, 1.5, NaN, Infinity])('rejects an invalid column: %s', (column) => {
+ expect(formatFileLocation('/repo', { absPath: '/repo/a.ts', line: 1, column })).toBeNull();
+ });
+
+ it('preserves the existing start-line semantics for a line range', () => {
+ const { href, line, column } = splitLocalLineSuffix('/repo/src/a.ts:42-50');
+ expect(formatFileLocation('/repo', { absPath: href, line, column })).toBe('src/a.ts:42');
+ });
+});
diff --git a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx
index 1bf99b28ffa..af5059350d7 100644
--- a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx
+++ b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx
@@ -945,6 +945,8 @@ function localKindFromAbsPath(absPath: string, fallback: MarkdownLocalKind): Mar
function FileTargetChip({
resolvedAbsPath,
localKind,
+ line,
+ column,
onOpen,
title,
children,
@@ -952,6 +954,8 @@ function FileTargetChip({
}: {
resolvedAbsPath: string;
localKind: MarkdownLocalKind;
+ line?: number;
+ column?: number;
onOpen: () => void | Promise;
title?: string;
children: ReactNode;
@@ -986,6 +990,7 @@ function FileTargetChip({
const sidebarTargetSessionId = useSidebarTargetSessionId(htmlWithSession);
const ctxMenu = useFileChipContextMenu({
getAbsPath: async () => resolvedAbsPath,
+ location: { absPath: resolvedAbsPath, line, column },
canOpenInBrowser: localKind !== 'directory' && isBrowserOpenablePath(resolvedAbsPath),
sidebarFileBrowserKind: localKind === 'directory' ? 'directory' : 'file',
sidebarOpenSessionId: htmlWithSession,
@@ -1080,6 +1085,8 @@ function FileTargetChip({
function ResolvedLocalLink({
resolvedAbsPath,
localKind,
+ line,
+ column,
href,
onOpen,
anchorProps,
@@ -1088,6 +1095,8 @@ function ResolvedLocalLink({
}: {
resolvedAbsPath: string;
localKind: MarkdownLocalKind;
+ line?: number;
+ column?: number;
href: string;
onOpen: () => void | Promise;
anchorProps: Record;
@@ -1105,6 +1114,7 @@ function ResolvedLocalLink({
const sidebarTargetSessionId = useSidebarTargetSessionId(htmlWithSession);
const ctxMenu = useFileChipContextMenu({
getAbsPath: () => resolvedAbsPath,
+ location: { absPath: resolvedAbsPath, line, column },
canOpenInBrowser: localKind !== 'directory' && isBrowserOpenablePath(resolvedAbsPath),
sidebarFileBrowserKind: localKind === 'directory' ? 'directory' : 'file',
sidebarOpenSessionId: htmlWithSession,
@@ -1483,6 +1493,8 @@ function MarkdownTargetLink({
activateResolvedLocalTarget(
diff --git a/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx b/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx
index a6ce0a8a661..9e16023505d 100644
--- a/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx
+++ b/apps/desktop/src/renderer/components/chat/useFileChipContextMenu.tsx
@@ -45,6 +45,7 @@ import {
import { useTranslation } from 'react-i18next';
import { toast } from '@/lib/toast';
+import { formatFileLocation, type FileLocation } from '@/lib/fileLocation';
import { mapIpcErrorToI18nKey } from '@/utils/ipcError';
import {
DropdownMenu,
@@ -101,12 +102,15 @@ export function useFileChipContextMenu({
sidebarFileBrowserKind = 'file',
sidebarOpenSessionId,
onViewSource,
+ location,
}: {
getAbsPath: () => Promise | string;
canOpenInBrowser?: boolean;
sidebarFileBrowserKind?: 'file' | 'directory';
sidebarOpenSessionId?: string;
onViewSource?: () => void | Promise;
+ /** Only supplied by resolved Markdown references; existing path-copy stays absolute. */
+ location?: FileLocation;
}): UseFileChipContextMenu {
const { t } = useTranslation();
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
@@ -123,6 +127,20 @@ export function useFileChipContextMenu({
const remoteOrigin = isRemoteFileOrigin(sessionFileCtx.origin) ? sessionFileCtx.origin : null;
const sidebarFileTargetSessionId = useSidebarTargetSessionId(sessionFileCtx.sessionId);
const sidebarBrowserTargetSessionId = useSidebarTargetSessionId(sidebarOpenSessionId);
+ const copyLocation = location && sidebarFileBrowserKind === 'file'
+ ? formatFileLocation(sessionFileCtx.workingDir, location)
+ : null;
+
+ async function handleCopyLocation(): Promise {
+ setMenuPos(null);
+ if (!copyLocation) return;
+ try {
+ await navigator.clipboard.writeText(copyLocation);
+ toast.success(t('chat.markdownRenderer.locationCopied'));
+ } catch {
+ toast.error(t('chat.media.copyFailed'));
+ }
+ }
async function handleCopyFile(): Promise {
setMenuPos(null);
@@ -372,6 +390,12 @@ export function useFileChipContextMenu({
{t('chat.markdownRenderer.copyFilePath')}
+ {copyLocation ? (
+
+
+ {t('chat.markdownRenderer.copyLocation')}
+
+ ) : null}
{remoteOrigin
diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json
index 654c41bf897..2849e879821 100644
--- a/apps/desktop/src/renderer/i18n/locales/en/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/en/common.json
@@ -8728,6 +8728,8 @@
"duplicateFiles": "Found {{count}} files with the same name, please use an absolute path",
"copyFile": "Copy",
"copyFilePath": "Copy file path",
+ "copyLocation": "Copy Location",
+ "locationCopied": "Location copied",
"openWith": "Open with",
"openWithAppFailed": "Couldn't open with the selected app",
"revealFile": "Reveal in folder",
diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json
index 9846c6a192b..79beceeb619 100644
--- a/apps/desktop/src/renderer/i18n/locales/ja/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json
@@ -8722,6 +8722,8 @@
"duplicateFiles": "同名のファイルが {{count}} 件見つかりました。絶対パスを使用してください",
"copyFile": "コピー",
"copyFilePath": "ファイルパスをコピー",
+ "copyLocation": "位置をコピー",
+ "locationCopied": "位置をコピーしました",
"openWith": "アプリで開く",
"openWithAppFailed": "選択したアプリで開けませんでした",
"revealFile": "ファイルの場所を開く",
diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json
index b3a13b89f93..8d53bc5bfa8 100644
--- a/apps/desktop/src/renderer/i18n/locales/ko/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json
@@ -8722,6 +8722,8 @@
"duplicateFiles": "동일한 이름의 파일 {{count}}개를 찾았습니다. 절대 경로를 사용해 주세요",
"copyFile": "복사",
"copyFilePath": "파일 경로 복사",
+ "copyLocation": "위치 복사",
+ "locationCopied": "위치를 복사했습니다",
"openWith": "앱으로 열기",
"openWithAppFailed": "선택한 앱으로 열 수 없습니다",
"revealFile": "파일 위치 열기",
diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
index a78ef6b66f4..ff006f34730 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json
@@ -8722,6 +8722,8 @@
"duplicateFiles": "找到 {{count}} 个同名文件,请改用绝对路径",
"copyFile": "复制",
"copyFilePath": "复制文件路径",
+ "copyLocation": "复制位置",
+ "locationCopied": "位置已复制",
"openWith": "打开方式",
"openWithAppFailed": "无法用所选应用打开",
"revealFile": "打开文件所在目录",
diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
index f0e2e9c6f5f..70cb170488f 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json
@@ -8721,6 +8721,8 @@
"duplicateFiles": "找到 {{count}} 個同名檔案,請改用絕對路徑",
"copyFile": "複製",
"copyFilePath": "複製檔案路徑",
+ "copyLocation": "複製位置",
+ "locationCopied": "位置已複製",
"openWith": "開啟方式",
"openWithAppFailed": "無法用所選應用開啟",
"revealFile": "開啟檔案所在目錄",
diff --git a/apps/desktop/src/renderer/lib/fileLocation.ts b/apps/desktop/src/renderer/lib/fileLocation.ts
new file mode 100644
index 00000000000..3903fd70d8e
--- /dev/null
+++ b/apps/desktop/src/renderer/lib/fileLocation.ts
@@ -0,0 +1,18 @@
+import { toWorkdirRel } from '../../shared/workdirPath';
+
+/** A resolved file reference; coordinates are one-based, as in Markdown targets. */
+export interface FileLocation {
+ absPath: string;
+ line?: number;
+ column?: number;
+}
+
+/** Clipboard locations use workspace-relative POSIX separators on every host. */
+export function formatFileLocation(workingDir: string, location: FileLocation): string | null {
+ const { absPath, line, column } = location;
+ if (line === undefined || !Number.isSafeInteger(line) || line < 1) return null;
+ if (column !== undefined && (!Number.isSafeInteger(column) || column < 1)) return null;
+ const relativePath = toWorkdirRel(workingDir, absPath);
+ if (!relativePath) return null;
+ return `${relativePath}:${line}${column === undefined ? '' : `:${column}`}`;
+}