diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index a95f8ea8e997..bd9105e8b849 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,3 +1,5 @@
+import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
+import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
import { environmentSession } from "../../state/session";
import { NativeStackScreenOptions } from "../../native/StackHeader";
@@ -783,7 +785,13 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
title: "Copy path",
icon: "doc.on.doc",
inline: false,
- onPress: () => copyTextWithHaptic(relativePath),
+ onPress: () =>
+ copyTextWithHaptic(
+ formatFilePathForClipboard(
+ relativePath,
+ resolvePathLinkTarget(relativePath, cwd ?? ""),
+ ),
+ ),
} as const,
]),
fileData?.contents != null
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 46d9fccac17f..2b45fc547afc 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -230,7 +230,7 @@ import {
} from "../files/filePath";
import { waitForThreadShellReady } from "./threadForkNavigation";
import { resolveUserMessageIntentBadge } from "./userMessageIntentBadge";
-import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu";
+import { fileChipClipboardPath, fileChipMenu, resolveFileChipTarget } from "./fileChipMenu";
import { useFileChipShare } from "./useFileChipShare";
import {
MarkdownImageAvailableWidthContext,
@@ -1869,13 +1869,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
onFileContextMenuAction: (href, actionId) => {
const target = resolveFileChipTarget(href, props.workspaceRoot);
if (!target) return;
- switch (actionId as FileChipAction) {
+ switch (actionId) {
case "copy-full-path":
- if (target.fullPath) copyTextWithHaptic(target.fullPath);
- return;
- case "copy-relative-path":
- if (target.relativePath) copyTextWithHaptic(target.relativePath);
+ case "copy-relative-path": {
+ const clipboardPath = fileChipClipboardPath(target, actionId);
+ if (clipboardPath !== undefined) copyTextWithHaptic(clipboardPath);
return;
+ }
case "open-file":
onMarkdownLinkPress(href);
return;
diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts
index 1627a72da5de..999c403964b3 100644
--- a/apps/mobile/src/features/threads/fileChipMenu.test.ts
+++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts
@@ -1,7 +1,23 @@
import { ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu";
+import {
+ fileChipClipboardPath,
+ fileChipMenu,
+ fileChipShareSource,
+ resolveFileChipTarget,
+} from "./fileChipMenu";
+
+it("copies native paths from a relative file chip in a Windows workspace", () => {
+ const target = resolveFileChipTarget("reports/Device Users.csv", "C:/repo");
+ if (!target) throw new Error("File-chip target unavailable");
+ expect(fileChipClipboardPath(target, "copy-full-path")).toBe(
+ String.raw`C:\repo\reports\Device Users.csv`,
+ );
+ expect(fileChipClipboardPath(target, "copy-relative-path")).toBe(
+ String.raw`reports\Device Users.csv`,
+ );
+});
describe("resolveFileChipTarget", () => {
it("resolves a workspace-relative link to both paths", () => {
diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts
index d55d3e4b5964..d020e5f542a3 100644
--- a/apps/mobile/src/features/threads/fileChipMenu.ts
+++ b/apps/mobile/src/features/threads/fileChipMenu.ts
@@ -1,3 +1,4 @@
+import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
import { fileBasename } from "@t3tools/shared/path";
import type { ThreadId } from "@t3tools/contracts";
import { resolveMarkdownLinkPresentation } from "../../lib/markdownLinks";
@@ -10,13 +11,19 @@ import {
resolveWorkspaceRelativeFilePath,
} from "../files/filePath";
-export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save";
-
export interface FileChipTarget {
readonly fullPath?: string;
readonly relativePath?: string;
}
+export function fileChipClipboardPath(
+ target: FileChipTarget,
+ action: "copy-full-path" | "copy-relative-path",
+): string | undefined {
+ const path = action === "copy-full-path" ? target.fullPath : target.relativePath;
+ return path === undefined ? undefined : formatFilePathForClipboard(path, target.fullPath);
+}
+
export function resolveFileChipTarget(
href: string,
workspaceRoot: string | null | undefined,
diff --git a/apps/mobile/src/lib/mediaActions.test.ts b/apps/mobile/src/lib/mediaActions.test.ts
index e270f723d8d6..6670587b1ca2 100644
--- a/apps/mobile/src/lib/mediaActions.test.ts
+++ b/apps/mobile/src/lib/mediaActions.test.ts
@@ -5,6 +5,7 @@ import {
type AuthSessionState,
} from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
+import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
import { beforeEach, expect, it, vi } from "vite-plus/test";
const state = vi.hoisted(() => ({
@@ -96,6 +97,56 @@ beforeEach(() => {
state.copy.mockReset();
});
+it.each([
+ [
+ "C:/repo/assets/shot.png",
+ "C:/repo",
+ String.raw`C:\repo\assets\shot.png`,
+ String.raw`assets\shot.png`,
+ ],
+ [
+ String.raw`\\server\share\repo/assets/clip.mp4`,
+ String.raw`\\server\share\repo`,
+ String.raw`\\server\share\repo\assets\clip.mp4`,
+ String.raw`assets\clip.mp4`,
+ ],
+] as const)("copies full and relative media paths for %s", (path, root, full, relative) => {
+ const media = useMediaActions({
+ environmentId,
+ threadId,
+ resource: { _tag: "media-file", threadId, path },
+ reference: mediaFileReference(path, root),
+ name: path.split(/[\\/]/).at(-1)!,
+ mimeType: path.endsWith(".mp4") ? "video/mp4" : "image/png",
+ });
+ for (const [actionId, expected] of [
+ ["copy-full-path", full],
+ ["copy-relative-path", relative],
+ ] as const) {
+ const action = media.actions.find(({ id }) => id === actionId);
+ if (!action) throw new Error(`Missing media action: ${actionId}`);
+ action.run();
+ expect(state.copy).toHaveBeenLastCalledWith(expected);
+ }
+ expect(state.refresh).not.toHaveBeenCalled();
+ expect(state.download).not.toHaveBeenCalled();
+});
+
+it("preserves media URLs when copying", () => {
+ const url = "https://cdn.test/assets/media?path=C:/repo/media";
+ const media = useMediaActions({
+ uri: url,
+ name: "media",
+ mimeType: "video/mp4",
+ reference: { kind: "url", url },
+ });
+ const action = media.actions.find(({ id }) => id === "copy-url");
+ if (!action) throw new Error("Copy URL action missing");
+ action.run();
+ expect(state.copy).toHaveBeenCalledWith(url);
+ expect(state.refresh).not.toHaveBeenCalled();
+});
+
it.each(["workspace-file", "media-file"] as const)(
"waits for the %s grant before enabling host menu actions",
(_tag) => {
diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts
index a0a9a678205f..dde7e2fe6b5b 100644
--- a/apps/mobile/src/state/mediaActions.ts
+++ b/apps/mobile/src/state/mediaActions.ts
@@ -2,7 +2,10 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime
import * as Dialog from "../components/ui/Dialog";
import type { MediaActionsSource } from "../lib/mediaActionsSource";
import { useNavigation } from "@react-navigation/native";
-import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
+import {
+ formatFilePathForClipboard,
+ type MediaActionId,
+} from "@t3tools/client-runtime/media-actions";
import {
AuthFilesystemReadScope,
type EnvironmentId,
@@ -108,16 +111,17 @@ export function useMediaActions(source: MediaActionsSource | undefined, onOpenFi
{
id: "copy-full-path" as const,
title: "Copy full path",
- run: () => copyTextWithHaptic(reference.path),
+ run: () => copyTextWithHaptic(formatFilePathForClipboard(reference.path)),
},
]
: []),
- ...(relativePath
+ ...(relativePath && reference?.kind === "file"
? [
{
id: "copy-relative-path" as const,
title: "Copy relative path",
- run: () => copyTextWithHaptic(relativePath),
+ run: () =>
+ copyTextWithHaptic(formatFilePathForClipboard(relativePath, reference.path)),
},
]
: []),
diff --git a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx
index 0c0a20593305..51ce200a53e6 100644
--- a/apps/web/src/components/BranchToolbar.panel.browser.test.tsx
+++ b/apps/web/src/components/BranchToolbar.panel.browser.test.tsx
@@ -132,6 +132,7 @@ it.each([
["local", null, "/tmp/project"],
["worktree", null, null],
["worktree", "/tmp/worktree", "/tmp/worktree"],
+ ["worktree", "C:/repo/worktree", String.raw`C:\repo\worktree`],
] as const)(
"copies only an existing workspace path with mode %s and worktree %s",
async (envMode, worktreePath, copiedPath) => {
diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx
index 4d6fbef50a6e..d27dc428ac3e 100644
--- a/apps/web/src/components/BranchToolbar.tsx
+++ b/apps/web/src/components/BranchToolbar.tsx
@@ -1,4 +1,5 @@
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
+import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
import { readLocalApi } from "../localApi";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { ComposerContextLabel } from "./ComposerContextLabel";
@@ -118,13 +119,14 @@ function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string)
})
.then((action) => {
if (action !== "copy-path") return;
- void writeTextToClipboard(workspacePath, "workspace path").then(
+ const clipboardPath = formatFilePathForClipboard(workspacePath);
+ void writeTextToClipboard(clipboardPath, "workspace path").then(
(didCopy) => {
if (didCopy) {
toastManager.add({
type: "success",
title: "Path copied",
- description: workspacePath,
+ description: clipboardPath,
});
}
},
diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx
index 361b114250c4..05a900387724 100644
--- a/apps/web/src/components/ChatMarkdown.browser.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx
@@ -11,6 +11,8 @@ import "../index.css";
import { useTheme } from "../hooks/useTheme";
import { applyThemeColorPreview, getStandardThemeColors } from "../themePalette";
+import { readLocalApi } from "../localApi";
+import { toastManager } from "./ui/toast";
import { filesystemEnvironment } from "../state/filesystem";
import { useRightPanelStore } from "../rightPanelStore";
import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting";
@@ -1262,6 +1264,60 @@ describe("ChatMarkdown thread find", () => {
);
});
+it.each([
+ {
+ name: "Windows drive paths on a Mac client",
+ platform: "MacIntel",
+ cwd: "C:/repo",
+ href: "C:/repo/reports/Device Users.csv:12:3",
+ full: String.raw`C:\repo\reports\Device Users.csv:12:3`,
+ relative: String.raw`reports\Device Users.csv:12:3`,
+ },
+ {
+ name: "UNC paths on a Mac client",
+ platform: "MacIntel",
+ cwd: String.raw`\\server\share\repo`,
+ href: "file://server/share/repo/reports/report.csv",
+ full: String.raw`\\server\share\repo\reports\report.csv`,
+ relative: String.raw`reports\report.csv`,
+ },
+ {
+ name: "POSIX paths on a Windows client",
+ platform: "Win32",
+ cwd: "/home/repo",
+ href: "/home/repo/reports/report.csv",
+ full: "/home/repo/reports/report.csv",
+ relative: "reports/report.csv",
+ },
+])("copies $name and shows the copied text", async ({ platform, cwd, href, full, relative }) => {
+ vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
+ vi.stubGlobal("ClipboardItem", undefined);
+ const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
+ const toast = vi.spyOn(toastManager, "add");
+ const api = readLocalApi();
+ if (!api) throw new Error("Local API unavailable");
+ const choose = vi.spyOn(api.contextMenu, "show");
+ const container = await renderText(`[File](<${href}>)`, {
+ cwd,
+ environmentId: EnvironmentId.make("env-file-copy"),
+ });
+ const link = container.querySelector(".chat-markdown-file-link");
+ if (!link) throw new Error("File link unavailable");
+ for (const [action, expected] of [
+ ["copy-full", full],
+ ["copy-relative", relative],
+ ] as const) {
+ choose.mockResolvedValue(action);
+ await act(async () => {
+ link.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
+ });
+ expect(writeText).toHaveBeenLastCalledWith(expected);
+ expect(toast).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: "success", description: expected }),
+ );
+ }
+});
+
describe("ChatMarkdown math", () => {
const renderMath = async (
text: string,
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 000a3617953e..baf23d2b4cec 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -1,3 +1,4 @@
+import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
import { MarkdownFindContext, findRevealRef } from "./chat/markdownFindContext";
import {
buildFileLinkParentSuffixByPath,
@@ -2119,7 +2120,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
return;
}
- const value = resolveValue();
+ const value = resolveValue().then((text) => formatFilePathForClipboard(text, targetPath));
// Start the write before workspace lookup finishes so Safari retains the user gesture.
const write =
typeof navigator.clipboard.write === "function" && typeof ClipboardItem !== "undefined"
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 43fd030f8e18..49a84f9900d8 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1,3 +1,5 @@
+import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions";
+import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
import { ThreadFind, ThreadFindCanvas, type ThreadFindControls } from "./chat/ThreadFindProvider";
import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar";
import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner";
@@ -3941,12 +3943,16 @@ export default function ChatView(props: ChatViewProps) {
return;
}
- void navigator.clipboard.writeText(relativePath).then(
+ const clipboardPath = formatFilePathForClipboard(
+ relativePath,
+ resolvePathLinkTarget(relativePath, gitCwd ?? activeProjectCwd ?? ""),
+ );
+ void navigator.clipboard.writeText(clipboardPath).then(
() => {
toastManager.add({
type: "success",
title: "Path copied",
- description: relativePath,
+ description: clipboardPath,
});
},
(error) => {
diff --git a/apps/web/src/components/media/MediaActions.test.tsx b/apps/web/src/components/media/MediaActions.test.tsx
index bcf2d64db418..1b12a92e74c9 100644
--- a/apps/web/src/components/media/MediaActions.test.tsx
+++ b/apps/web/src/components/media/MediaActions.test.tsx
@@ -5,6 +5,7 @@ import {
type AuthSessionState,
} from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
+import { mediaFileReference } from "@t3tools/client-runtime/media-reference";
import { createElement, isValidElement, type ReactNode } from "react";
import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
@@ -24,6 +25,7 @@ const state = vi.hoisted(() => ({
showMenu: vi.fn(),
openFile: vi.fn(),
clipboard: vi.fn(),
+ copyText: vi.fn(),
menuFinished: null as (() => void) | null,
}));
@@ -33,7 +35,7 @@ vi.mock("react", async (importOriginal) => ({
useRef: (current: A) => ({ current }),
useState: (initial: A) => [initial, () => {}],
}));
-vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn() }));
+vi.mock("../../hooks/useCopyToClipboard", () => ({ writeTextToClipboard: state.copyText }));
vi.mock("../../localApi", () => ({
readLocalApi: () => ({ contextMenu: { show: state.showMenu } }),
}));
@@ -128,6 +130,7 @@ beforeEach(() => {
state.png.mockReset().mockResolvedValue(new Blob(["png"], { type: "image/png" }));
state.showMenu.mockReset().mockResolvedValue(null);
state.openFile.mockReset();
+ state.copyText.mockReset().mockResolvedValue(true);
state.menuFinished = null;
class TestClipboardItem {
constructor(readonly items: Record>) {}
@@ -141,6 +144,56 @@ beforeEach(() => {
afterEach(() => vi.unstubAllGlobals());
+it.each([
+ [
+ "image",
+ "C:/repo/assets/shot.png",
+ "C:/repo",
+ String.raw`C:\repo\assets\shot.png`,
+ String.raw`assets\shot.png`,
+ ],
+ [
+ "video",
+ String.raw`\\server\share\repo/assets/clip.mp4`,
+ String.raw`\\server\share\repo`,
+ String.raw`\\server\share\repo\assets\clip.mp4`,
+ String.raw`assets\clip.mp4`,
+ ],
+] as const)(
+ "copies full and relative paths for %s %s",
+ async (kind, path, root, full, relative) => {
+ const source: MediaActionSource = {
+ ...hostSource(),
+ kind,
+ reference: mediaFileReference(path, root),
+ };
+ for (const [action, expected] of [
+ ["copy-full-path", full],
+ ["copy-relative-path", relative],
+ ] as const) {
+ const completed = deferred();
+ state.menuFinished = () => completed.resolve();
+ state.showMenu.mockResolvedValue(action);
+ openMenu(source);
+ await completed.promise;
+ expect(state.copyText).toHaveBeenLastCalledWith(expected, "file path");
+ }
+ expect(state.mint).not.toHaveBeenCalled();
+ expect(state.download).not.toHaveBeenCalled();
+ },
+);
+
+it("preserves media URLs when copying", async () => {
+ const url = "https://cdn.test/assets/clip.mp4?path=C:/repo/media";
+ const completed = deferred();
+ state.menuFinished = () => completed.resolve();
+ state.showMenu.mockResolvedValue("copy-url");
+ openMenu({ ...hostSource(), kind: "video", src: url, reference: { kind: "url", url } });
+ await completed.promise;
+ expect(state.copyText).toHaveBeenCalledWith(url, "URL");
+ expect(state.mint).not.toHaveBeenCalled();
+});
+
it.each(["workspace-file", "media-file"] as const)(
"waits for the %s grant before enabling host menu actions",
(_tag) => {
diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx
index 45b273e21305..314f7a6435d2 100644
--- a/apps/web/src/components/media/MediaActions.tsx
+++ b/apps/web/src/components/media/MediaActions.tsx
@@ -1,4 +1,7 @@
-import type { MediaActionId } from "@t3tools/client-runtime/media-actions";
+import {
+ formatFilePathForClipboard,
+ type MediaActionId,
+} from "@t3tools/client-runtime/media-actions";
import {
mediaReferenceFileName,
type MediaReference,
@@ -166,7 +169,9 @@ export function MediaActions({
? reference.url
: undefined;
if (text !== undefined) {
- await writeTextToClipboard(text, reference?.kind === "file" ? "file path" : "URL");
+ const clipboardText =
+ reference?.kind === "file" ? formatFilePathForClipboard(text, reference.path) : text;
+ await writeTextToClipboard(clipboardText, reference?.kind === "file" ? "file path" : "URL");
toastManager.add({
type: "success",
title: action === "copy-url" ? "URL copied" : "Path copied",
diff --git a/packages/client-runtime/src/mediaActions.ts b/packages/client-runtime/src/mediaActions.ts
index fda507b17bb1..85bdcf67812b 100644
--- a/packages/client-runtime/src/mediaActions.ts
+++ b/packages/client-runtime/src/mediaActions.ts
@@ -1,3 +1,10 @@
+import { isWindowsAbsolutePath } from "@t3tools/shared/path";
+
+// The file host can use a different path style from the client.
+export function formatFilePathForClipboard(path: string, hostPath = path): string {
+ return isWindowsAbsolutePath(hostPath) ? path.replaceAll("/", "\\") : path;
+}
+
export type MediaActionId =
| "copy-full-path"
| "copy-relative-path"