Skip to content
Open
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: 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