Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"tailwind-merge": "^3.4.0",
"unified": "^11.0.5",
"zustand": "^5.0.11"
},
"devDependencies": {
Expand Down
225 changes: 209 additions & 16 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const PROJECT_ID = "project-1" as ProjectId;
const NOW_ISO = "2026-03-04T12:00:00.000Z";
const BASE_TIME_MS = Date.parse(NOW_ISO);
const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1";

interface WsRequestEnvelope {
id: string;
Expand Down Expand Up @@ -86,7 +87,7 @@ const ATTACHMENT_VIEWPORT_MATRIX = [
{ name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },
] as const satisfies readonly ViewportSpec[];

interface UserRowMeasurement {
interface MessageRowMeasurement {
measuredRowHeightPx: number;
timelineWidthMeasuredPx: number;
renderedInVirtualizedRegion: boolean;
Expand All @@ -95,7 +96,10 @@ interface UserRowMeasurement {
interface MountedChatView {
[Symbol.asyncDispose]: () => Promise<void>;
cleanup: () => Promise<void>;
measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
measureMessageRow: (
targetMessageId: MessageId,
role: "user" | "assistant",
) => Promise<MessageRowMeasurement>;
setViewport: (viewport: ViewportSpec) => Promise<void>;
router: ReturnType<typeof getRouter>;
}
Expand Down Expand Up @@ -278,6 +282,29 @@ function createSnapshotForTargetUser(options: {
};
}

function createSnapshotForTargetAssistantMessage(options: {
targetAssistantId: MessageId;
targetText: string;
}): OrchestrationReadModel {
const snapshot = createSnapshotForTargetUser({
targetMessageId: "msg-user-assistant-copy-target" as MessageId,
targetText: "assistant copy target",
});

return {
...snapshot,
threads: snapshot.threads.map((thread) =>
Object.assign({}, thread, {
messages: thread.messages.map((message) =>
message.id === options.targetAssistantId
? Object.assign({}, message, { text: options.targetText })
: message,
),
}),
),
};
}

function buildFixture(snapshot: OrchestrationReadModel): TestFixture {
return {
snapshot,
Expand Down Expand Up @@ -619,6 +646,25 @@ async function waitForSendButton(): Promise<HTMLButtonElement> {
);
}

function installClipboardWriteTextSpy() {
const writeText = vi.fn<(value: string) => Promise<void>>().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
return writeText;
}

function setAssistantResponseCopyFormat(format: "markdown" | "plain-text"): void {
localStorage.setItem(
CLIENT_SETTINGS_STORAGE_KEY,
JSON.stringify({
...DEFAULT_CLIENT_SETTINGS,
assistantResponseCopyFormat: format,
}),
);
}

async function waitForInteractionModeButton(
expectedLabel: "Chat" | "Plan",
): Promise<HTMLButtonElement> {
Expand Down Expand Up @@ -704,12 +750,13 @@ async function waitForImagesToLoad(scope: ParentNode): Promise<void> {
await waitForLayout();
}

async function measureUserRow(options: {
async function measureMessageRow(options: {
host: HTMLElement;
targetMessageId: MessageId;
}): Promise<UserRowMeasurement> {
const { host, targetMessageId } = options;
const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="user"]`;
role: "user" | "assistant";
}): Promise<MessageRowMeasurement> {
const { host, targetMessageId, role } = options;
const rowSelector = `[data-message-id="${targetMessageId}"][data-message-role="${role}"]`;

const scrollContainer = await waitForElement(
() => host.querySelector<HTMLDivElement>("div.overflow-y-auto.overscroll-y-contain"),
Expand All @@ -723,7 +770,7 @@ async function measureUserRow(options: {
scrollContainer.dispatchEvent(new Event("scroll"));
await waitForLayout();
row = host.querySelector<HTMLElement>(rowSelector);
expect(row, "Unable to locate targeted user message row.").toBeTruthy();
expect(row, `Unable to locate targeted ${role} message row.`).toBeTruthy();
},
{
timeout: 8_000,
Expand Down Expand Up @@ -752,12 +799,14 @@ async function measureUserRow(options: {
scrollContainer.dispatchEvent(new Event("scroll"));
await nextFrame();
const measuredRow = host.querySelector<HTMLElement>(rowSelector);
expect(measuredRow, "Unable to measure targeted user row height.").toBeTruthy();
expect(measuredRow, `Unable to measure targeted ${role} row height.`).toBeTruthy();
timelineWidthMeasuredPx = timelineRoot.getBoundingClientRect().width;
measuredRowHeightPx = measuredRow!.getBoundingClientRect().height;
renderedInVirtualizedRegion = measuredRow!.closest("[data-index]") instanceof HTMLElement;
expect(timelineWidthMeasuredPx, "Unable to measure timeline width.").toBeGreaterThan(0);
expect(measuredRowHeightPx, "Unable to measure targeted user row height.").toBeGreaterThan(0);
expect(measuredRowHeightPx, `Unable to measure targeted ${role} row height.`).toBeGreaterThan(
0,
);
},
{
timeout: 4_000,
Expand Down Expand Up @@ -810,7 +859,8 @@ async function mountChatView(options: {
return {
[Symbol.asyncDispose]: cleanup,
cleanup,
measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),
measureMessageRow: async (targetMessageId: MessageId, role: "user" | "assistant") =>
measureMessageRow({ host, targetMessageId, role }),
setViewport: async (viewport: ViewportSpec) => {
await setViewport(viewport);
await waitForProductionStyles();
Expand All @@ -823,14 +873,14 @@ async function measureUserRowAtViewport(options: {
snapshot: OrchestrationReadModel;
targetMessageId: MessageId;
viewport: ViewportSpec;
}): Promise<UserRowMeasurement> {
}): Promise<MessageRowMeasurement> {
const mounted = await mountChatView({
viewport: options.viewport,
snapshot: options.snapshot,
});

try {
return await mounted.measureUserRow(options.targetMessageId);
return await mounted.measureMessageRow(options.targetMessageId, "user");
} finally {
await mounted.cleanup();
}
Expand Down Expand Up @@ -897,7 +947,7 @@ describe("ChatView timeline estimator parity (full app)", () => {

try {
const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
await mounted.measureUserRow(targetMessageId);
await mounted.measureMessageRow(targetMessageId, "user");

expect(renderedInVirtualizedRegion).toBe(true);

Expand Down Expand Up @@ -928,12 +978,12 @@ describe("ChatView timeline estimator parity (full app)", () => {

try {
const measurements: Array<
UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }
MessageRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number }
> = [];

for (const viewport of TEXT_VIEWPORT_MATRIX) {
await mounted.setViewport(viewport);
const measurement = await mounted.measureUserRow(targetMessageId);
const measurement = await mounted.measureMessageRow(targetMessageId, "user");
const estimatedHeightPx = estimateTimelineMessageHeight(
{ role: "user", text: userText, attachments: [] },
{ timelineWidthPx: measurement.timelineWidthMeasuredPx },
Expand Down Expand Up @@ -1017,7 +1067,7 @@ describe("ChatView timeline estimator parity (full app)", () => {

try {
const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
await mounted.measureUserRow(targetMessageId);
await mounted.measureMessageRow(targetMessageId, "user");

expect(renderedInVirtualizedRegion).toBe(true);

Expand Down Expand Up @@ -1228,6 +1278,149 @@ describe("ChatView timeline estimator parity (full app)", () => {
}
});

it("copies the raw assistant markdown by default", async () => {
const assistantMessageId = "msg-assistant-21" as MessageId;
const assistantText = [
"# Copy me",
"",
"Paragraph with [docs](https://example.com/docs).",
"",
"```ts",
"const value = 1;",
"```",
].join("\n");
const writeText = installClipboardWriteTextSpy();
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetAssistantMessage({
targetAssistantId: assistantMessageId,
targetText: assistantText,
}),
});

try {
const assistantRow = await waitForElement(
() =>
document.querySelector<HTMLElement>(
`[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`,
),
"Unable to find assistant response row.",
);
const copyButton = await waitForElement(
() => assistantRow.querySelector<HTMLButtonElement>('button[aria-label="Copy response"]'),
"Unable to find assistant copy button.",
);

copyButton.click();

await vi.waitFor(
() => {
expect(writeText).toHaveBeenCalledWith(assistantText);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("copies assistant responses as plain text when the setting is enabled", async () => {
const assistantMessageId = "msg-assistant-21" as MessageId;
const assistantText = [
"# Copy me",
"",
"Paragraph with [docs](https://example.com/docs).",
"",
"```ts",
"const value = 1;",
"```",
].join("\n");
const writeText = installClipboardWriteTextSpy();
setAssistantResponseCopyFormat("plain-text");
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetAssistantMessage({
targetAssistantId: assistantMessageId,
targetText: assistantText,
}),
});

try {
const assistantRow = await waitForElement(
() =>
document.querySelector<HTMLElement>(
`[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`,
),
"Unable to find assistant response row.",
);
const copyButton = await waitForElement(
() => assistantRow.querySelector<HTMLButtonElement>('button[aria-label="Copy response"]'),
"Unable to find assistant copy button.",
);

copyButton.click();

await vi.waitFor(
() => {
expect(writeText).toHaveBeenCalledWith(
["Copy me", "", "Paragraph with docs.", "", "const value = 1;"].join("\n"),
);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("keeps markdown code-block copy scoped to the code block", async () => {
const assistantMessageId = "msg-assistant-21" as MessageId;
const assistantText = [
"# Copy me",
"",
"Paragraph with [docs](https://example.com/docs).",
"",
"```ts",
"const value = 1;",
"```",
].join("\n");
const writeText = installClipboardWriteTextSpy();
setAssistantResponseCopyFormat("plain-text");
const mounted = await mountChatView({
viewport: DEFAULT_VIEWPORT,
snapshot: createSnapshotForTargetAssistantMessage({
targetAssistantId: assistantMessageId,
targetText: assistantText,
}),
});

try {
const assistantRow = await waitForElement(
() =>
document.querySelector<HTMLElement>(
`[data-message-id="${assistantMessageId}"][data-message-role="assistant"]`,
),
"Unable to find assistant response row.",
);
const codeCopyButton = await waitForElement(
() => assistantRow.querySelector<HTMLButtonElement>('button[aria-label="Copy code"]'),
"Unable to find code-block copy button.",
);

codeCopyButton.click();

await vi.waitFor(
() => {
expect(writeText).toHaveBeenCalled();
expect(writeText.mock.calls.at(-1)?.[0].trim()).toBe("const value = 1;");
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await mounted.cleanup();
}
});

it("runs project scripts from local draft threads at the project cwd", async () => {
useComposerDraftStore.setState({
draftThreadsByThreadId: {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
(store) => store.setStickyModelSelection,
);
const timestampFormat = settings.timestampFormat;
const assistantResponseCopyFormat = settings.assistantResponseCopyFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
strict: false,
Expand Down Expand Up @@ -3634,6 +3635,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onImageExpand={onExpandTimelineImage}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
assistantResponseCopyFormat={assistantResponseCopyFormat}
timestampFormat={timestampFormat}
workspaceRoot={activeProject?.cwd ?? undefined}
/>
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/components/chat/MessageCopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,23 @@ import { CopyIcon, CheckIcon } from "lucide-react";
import { Button } from "../ui/button";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";

export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
export const MessageCopyButton = memo(function MessageCopyButton({
text,
label = "Copy message",
}: {
text: string | (() => string);
label?: string;
}) {
const { copyToClipboard, isCopied } = useCopyToClipboard();

return (
<Button
type="button"
size="xs"
variant="outline"
onClick={() => copyToClipboard(text)}
title="Copy message"
onClick={() => copyToClipboard(typeof text === "function" ? text() : text)}
title={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label}
aria-label={isCopied ? label.replace(/^Copy\s+/i, "Copied ") : label}
>
{isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
</Button>
Expand Down
Loading