Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1213,7 +1213,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
return;
}

void navigator.clipboard.writeText(value).then(
void writeTextToClipboard(value).then(
() => {
toastManager.add({
type: "success",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import {
type Thread,
type TurnDiffSummary,
} from "../types";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
import { useTheme } from "../hooks/useTheme";
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
import { isCommandPaletteOpen } from "../commandPaletteBus";
Expand Down Expand Up @@ -3607,7 +3608,7 @@ function ChatViewContent(props: ChatViewProps) {
return;
}

void navigator.clipboard.writeText(relativePath).then(
void writeTextToClipboard(relativePath).then(
() => {
toastManager.add({
type: "success",
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { openCommandPalette } from "../commandPaletteBus";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { useClientSettings } from "../hooks/useSettings";
import { useCopyThreadTranscript } from "../hooks/useCopyThreadTranscript";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useNowMinute } from "../hooks/useNowMinute";
Expand Down Expand Up @@ -1788,6 +1789,7 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si
);
},
});
const copyThreadTranscript = useCopyThreadTranscript();
const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
const newThreadContext = useHandleNewThread();
const openAddProjectCommandPalette = useCallback(
Expand Down Expand Up @@ -3153,6 +3155,9 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si
case "mark-unread":
markThreadUnread(threadKey, thread.latestTurn?.completedAt);
return;
case "copy-transcript":
await copyThreadTranscript(threadRef);
return;
case "copy-path":
if (!threadWorkspacePath) {
toastManager.add(
Expand Down Expand Up @@ -3247,6 +3252,7 @@ export default function Sidebar({ projectScopeKey, onProjectScopeKeyChange }: Si
copyBranchToClipboard,
copyPathToClipboard,
copyThreadIdToClipboard,
copyThreadTranscript,
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/preview/PreviewView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "~/browserHistoryStore";
import { type ComposerImageAttachment, useComposerDraftStore } from "~/composerDraftStore";
import { previewAnnotationScreenshotFile } from "~/lib/previewAnnotation";
import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
import { ensureLocalApi } from "~/localApi";
import {
rememberPreviewUrl,
Expand Down Expand Up @@ -324,7 +325,7 @@ export function PreviewView({
return;
}

void navigator.clipboard.writeText(artifact.path).then(
void writeTextToClipboard(artifact.path).then(
() => {
pathCopied = true;
updateRecordingToast();
Expand Down Expand Up @@ -461,7 +462,7 @@ export function PreviewView({
return;
}

void navigator.clipboard.writeText(artifact.path).then(
void writeTextToClipboard(artifact.path).then(
() => {
pathCopied = true;
updateScreenshotToast();
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/threadActionMenu.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,15 @@ describe("buildThreadActionMenuItems", () => {
...baseState,
supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false },
}),
).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "archive", "delete"]);
).toEqual([
"rename",
"mark-unread",
"copy-transcript",
"copy-path",
"copy-thread-id",
"archive",
"delete",
]);
});

it("includes branch items only for threads with a branch", () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/threadActionMenu.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type ThreadActionMenuId =
| "rename"
| "regenerate-title"
| "mark-unread"
| "copy-transcript"
| "copy-path"
| "copy-branch"
| "copy-thread-id"
Expand Down Expand Up @@ -102,6 +103,7 @@ export function buildThreadActionMenuItems(
]
: []),
{ id: "mark-unread", label: "Mark unread" },
{ id: "copy-transcript", label: "Copy transcript", icon: "copy" },
{ id: "copy-path", label: "Copy path", icon: "copy" },
...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []),
{ id: "copy-thread-id", label: "Copy thread ID", icon: "copy" },
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/hooks/useCopyThreadTranscript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import type { ScopedThreadRef } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { useCallback } from "react";

import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { buildThreadTranscript } from "../lib/threadTranscript";
import { threadSnapshotCommands } from "../state/threads";
import { useAtomCommand } from "../state/use-atom-command";
import {
clipboardWriteEpoch,
ensureClipboardEpochTracking,
useCopyToClipboard,
} from "./useCopyToClipboard";

// Shared across hook instances (sidebar and chat header dispatch through
// separate instances): only the most recent copy request may touch the
// clipboard. The clipboard epoch cannot order two pending transcript fetches
// (neither has written yet), so this id covers transcript-vs-transcript while
// the epoch covers transcript-vs-everything-else.
let latestRequestId = 0;

function transcriptFailureToast(description: string) {
toastManager.add(
stackedThreadToast({ type: "error", title: "Failed to copy transcript", description }),
);
}

/**
* Copies a thread's conversation to the clipboard as markdown. Fetches a full
* snapshot on demand instead of reading cached detail state, which only holds
* a turn window (or nothing at all for a thread that was never opened).
*/
export function useCopyThreadTranscript() {
const fetchSnapshot = useAtomCommand(threadSnapshotCommands.fetchFull, {
reportFailure: false,
});
const { copyToClipboard } = useCopyToClipboard<{ messageCount: number }>({
target: "transcript",
onCopy: ({ messageCount }) => {
toastManager.add({
type: "success",
title: "Transcript copied",
description: `${messageCount} message${messageCount === 1 ? "" : "s"}`,
});
},
onError: (error) => {
transcriptFailureToast(error.message);
},
});

return useCallback(
async (threadRef: ScopedThreadRef) => {
ensureClipboardEpochTracking();
const requestId = ++latestRequestId;
const epochAtRequest = clipboardWriteEpoch();
const result = await fetchSnapshot({
environmentId: threadRef.environmentId,
input: { threadId: threadRef.threadId },
});
// Superseded while fetching — by a newer transcript copy or by anything
// else the user copied — so that write owns the clipboard now. Drop this
// result without writing or toasting.
if (requestId !== latestRequestId || clipboardWriteEpoch() !== epochAtRequest) {
return;
}
if (result._tag === "Failure" && isAtomCommandInterrupted(result)) {
return;
}
const snapshot = result._tag === "Failure" ? null : Option.getOrNull(result.value);
if (snapshot === null) {
const error = result._tag === "Failure" ? squashAtomCommandFailure(result) : null;
transcriptFailureToast(
error instanceof Error
? error.message
: "Could not load the conversation from the server.",
);
return;
}
const transcript = buildThreadTranscript(snapshot.thread.title, snapshot.thread.messages);
if (transcript.messageCount === 0) {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Nothing to copy",
description: "This thread has no messages yet.",
}),
);
return;
}
copyToClipboard(transcript.text, { messageCount: transcript.messageCount });
},
[copyToClipboard, fetchSnapshot],
);
}
79 changes: 36 additions & 43 deletions apps/web/src/hooks/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import {
ClipboardApiUnavailableError,
ClipboardWriteError,
clipboardWriteEpoch,
ensureClipboardEpochTracking,
writeTextToClipboard,
} from "./useCopyToClipboard";

describe("writeTextToClipboard", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
// Tests run in a node environment; stub the browser globals the module reads.
function stubClipboardWriteText(implementation: () => Promise<void>) {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText: implementation } });
}

it("reports unavailable clipboard support with structural context", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
afterEach(() => {
vi.unstubAllGlobals();
});

const error = await writeTextToClipboard("plan contents", "plan").then(
() => undefined,
(cause: unknown) => cause,
);
describe("clipboardWriteEpoch", () => {
it("advances on a successful write and not on a failed one", async () => {
stubClipboardWriteText(() => Promise.resolve());
const before = clipboardWriteEpoch();
await writeTextToClipboard("hello");
expect(clipboardWriteEpoch()).toBe(before + 1);

expect(error).toBeInstanceOf(ClipboardApiUnavailableError);
expect(error).toMatchObject({
target: "plan",
});
expect((error as Error).message).not.toContain("plan contents");
stubClipboardWriteText(() => Promise.reject(new Error("denied")));
await expect(writeTextToClipboard("blocked")).rejects.toThrow();
expect(clipboardWriteEpoch()).toBe(before + 1);
});

it("preserves the exact clipboard failure without exposing copied contents", async () => {
const cause = new Error("browser clipboard failure");
const writeText = vi.fn().mockRejectedValue(cause);
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });

const error = await writeTextToClipboard("secret clipboard contents", "error-message").then(
() => undefined,
(failure: unknown) => failure,
);

expect(writeText).toHaveBeenCalledWith("secret clipboard contents");
expect(error).toBeInstanceOf(ClipboardWriteError);
expect(error).toMatchObject({
target: "error-message",
cause,
});
expect((error as Error).message).not.toContain("secret clipboard contents");
it("does not advance for an empty value, which is never written", async () => {
stubClipboardWriteText(() => Promise.resolve());
const before = clipboardWriteEpoch();
await writeTextToClipboard("");
expect(clipboardWriteEpoch()).toBe(before);
});

it("keeps empty values as a no-op when clipboard support is available", async () => {
const writeText = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });

await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
expect(writeText).not.toHaveBeenCalled();
it("advances on DOM copy events once tracking is installed", () => {
const documentStub = new EventTarget();
vi.stubGlobal("document", documentStub);
ensureClipboardEpochTracking();
const before = clipboardWriteEpoch();
documentStub.dispatchEvent(new Event("copy"));
expect(clipboardWriteEpoch()).toBe(before + 1);

// Installing again must not double-count.
ensureClipboardEpochTracking();
documentStub.dispatchEvent(new Event("copy"));
expect(clipboardWriteEpoch()).toBe(before + 2);
});
});
28 changes: 28 additions & 0 deletions apps/web/src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,33 @@ export class ClipboardReadError extends Schema.TaggedErrorClass<ClipboardReadErr
}
}

// Monotonic count of clipboard writes observable from inside the app: every
// successful write through this module, plus any DOM copy (Cmd+C on a
// selection, copy-on-selection handlers) once tracking is installed. An
// asynchronous copy captures the epoch when it starts and drops its result if
// the epoch moved, so a slow fetch can never stomp something copied later.
let clipboardWriteCount = 0;

export function clipboardWriteEpoch(): number {
return clipboardWriteCount;
}

let copyEventTracked = false;

export function ensureClipboardEpochTracking(): void {
if (copyEventTracked || typeof document === "undefined") {
return;
}
copyEventTracked = true;
document.addEventListener(
"copy",
() => {
clipboardWriteCount += 1;
},
true,
);
}

export async function writeTextToClipboard(value: string, target = "text") {
if (
typeof window === "undefined" ||
Expand All @@ -62,6 +89,7 @@ export async function writeTextToClipboard(value: string, target = "text") {

try {
await navigator.clipboard.writeText(value);
clipboardWriteCount += 1;
return true;
} catch (cause) {
throw new ClipboardWriteError({
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/hooks/useThreadActionMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "../state/entities";
import { readLocalApi } from "../localApi";
import { useUiStateStore } from "../uiStateStore";
import { useCopyThreadTranscript } from "./useCopyThreadTranscript";
import { useCopyToClipboard } from "./useCopyToClipboard";
import { useNewThreadHandler } from "./useHandleNewThread";
import { useClientSettings } from "./useSettings";
Expand Down Expand Up @@ -98,6 +99,7 @@ export function useThreadActionMenu(input: {
},
onError: (error) => failureToast("Failed to copy branch", error),
});
const copyThreadTranscript = useCopyThreadTranscript();
const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({
onCopy: ({ threadId }) => {
toastManager.add({ type: "success", title: "Thread ID copied", description: threadId });
Expand Down Expand Up @@ -233,6 +235,9 @@ export function useThreadActionMenu(input: {
case "mark-unread":
markThreadUnread(scopedThreadKey(threadRef), thread.latestTurn?.completedAt);
return;
case "copy-transcript":
await copyThreadTranscript(threadRef);
return;
case "copy-path": {
const workspacePath = thread.worktreePath ?? projectCwd;
if (!workspacePath) {
Expand Down Expand Up @@ -318,6 +323,7 @@ export function useThreadActionMenu(input: {
copyBranchToClipboard,
copyPathToClipboard,
copyThreadIdToClipboard,
copyThreadTranscript,
deleteThread,
handleNewThread,
markThreadUnread,
Expand Down
Loading
Loading