Add Assistant transcript message copy

This commit is contained in:
rcourtman
2026-06-07 16:29:11 +01:00
parent 5c4b54251d
commit b4a492c4bd
3 changed files with 152 additions and 63 deletions
@@ -16,7 +16,9 @@
## Purpose
Own Pulse Assistant and Patrol backend runtime behavior, AI orchestration,
runtime cost control, and shared AI transport surfaces.
runtime cost control, shared AI transport surfaces, and browser-visible
Assistant transcript actions that define what visible operator/model text can
leave the transcript without exposing hidden provider/tool metadata.
## Canonical Files
@@ -1309,6 +1311,16 @@ runtime cost control, and shared AI transport surfaces.
the keypress so the App-level Assistant drawer Escape guard does not also
close the whole drawer. Delete remains a named row action instead of a
mouse-only affordance.
Message-level copy is part of the same OpenCode-aligned message action
surface, but Pulse must keep it browser-safe and role-neutral rather than
implying unsupported message-level fork/revert semantics. The referenced
OpenCode source in `packages/tui/src/routes/session/dialog-message.tsx`
exposes `message.copy` by collecting non-synthetic text parts for the
selected message. Pulse adapts that by allowing visible user and completed
assistant transcript rows to copy their own visible text through the shared
clipboard fallback helper; hidden reasoning, raw tool input/output, provider
envelopes, and scoped handoff metadata remain excluded unless the operator
explicitly opens a raw Details surface.
Assistant session rename is part of that same source-backed session
workflow. The referenced OpenCode source in
`packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx`
@@ -38,6 +38,7 @@ import type {
} from './types';
import { AI_CHAT_ASSISTANT_MESSAGE_LABEL } from '@/utils/aiChatPresentation';
import { formatAIModelRouteLabel } from '@/utils/aiProviderPresentation';
import { copyToClipboard } from '@/utils/clipboard';
interface MessageItemProps {
message: ChatMessage;
@@ -418,64 +419,90 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
}
});
// Copy-to-clipboard for a completed assistant answer.
// Copy-to-clipboard for completed transcript messages.
const [copied, setCopied] = createSignal(false);
const copyableMessageText = () => getAssistantAnswerText(props.message);
const canCopy = () => !props.message.isStreaming && !!copyableMessageText();
const copyMessage = async () => {
const text = copyableMessageText();
try {
await navigator.clipboard?.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard can be unavailable (permissions / insecure context); fail quietly.
let copiedResetTimer: ReturnType<typeof setTimeout> | undefined;
const copyButtonLabel = () => (copied() ? 'Copied message' : 'Copy message');
const copyableMessageText = () => {
if (isUser()) {
const text = props.message.content || '';
return text.trim() ? text : '';
}
return getAssistantAnswerText(props.message);
};
const canCopy = () => !props.message.isStreaming && !!copyableMessageText();
const copyMessage = async (event?: MouseEvent) => {
event?.stopPropagation();
const text = copyableMessageText();
if (!text) return;
const ok = await copyToClipboard(text);
if (!ok) return;
setCopied(true);
if (copiedResetTimer) clearTimeout(copiedResetTimer);
copiedResetTimer = setTimeout(() => setCopied(false), 1500);
};
onCleanup(() => {
if (copiedResetTimer) clearTimeout(copiedResetTimer);
});
return (
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
{/* User message - compact bubble */}
<Show when={isUser()}>
<div
class={`max-w-[85%] px-4 py-2.5 rounded-md rounded-br-sm shadow-sm ${
isQueuedUserMessage()
? 'border border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-100'
: 'bg-blue-600 text-white'
}`}
>
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
<Show when={isQueuedUserMessage()}>
<div
class="mt-1.5 flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-medium text-blue-700 dark:text-blue-300"
role="status"
<div class="group flex max-w-[85%] items-start justify-end gap-2">
<Show when={canCopy()}>
<button
type="button"
onClick={(event) => void copyMessage(event)}
aria-label={copyButtonLabel()}
title={copyButtonLabel()}
class="mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-subtle bg-surface text-muted opacity-0 shadow-sm transition-opacity hover:text-base-content focus:opacity-100 group-hover:opacity-100"
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
<span>{queuedStatusLabel()}</span>
<Show when={props.onEditQueued}>
<button
type="button"
onClick={() => props.onEditQueued?.()}
aria-label="Edit queued follow-up"
title="Edit queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<PencilIcon class="h-3 w-3" aria-hidden="true" />
</button>
<Show when={copied()} fallback={<CopyIcon class="h-3.5 w-3.5" aria-hidden="true" />}>
<CheckIcon class="h-3.5 w-3.5 text-emerald-500" aria-hidden="true" />
</Show>
<Show when={props.onCancelQueued}>
<button
type="button"
onClick={() => props.onCancelQueued?.()}
aria-label="Remove queued follow-up"
title="Remove queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<XIcon class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
</div>
</button>
</Show>
<div
class={`min-w-0 px-4 py-2.5 rounded-md rounded-br-sm shadow-sm ${
isQueuedUserMessage()
? 'border border-blue-200 bg-blue-50 text-blue-950 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-100'
: 'bg-blue-600 text-white'
}`}
>
<p class="text-sm whitespace-pre-wrap">{props.message.content}</p>
<Show when={isQueuedUserMessage()}>
<div
class="mt-1.5 flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-medium text-blue-700 dark:text-blue-300"
role="status"
>
<ClockIcon class="h-3 w-3" aria-hidden="true" />
<span>{queuedStatusLabel()}</span>
<Show when={props.onEditQueued}>
<button
type="button"
onClick={() => props.onEditQueued?.()}
aria-label="Edit queued follow-up"
title="Edit queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<PencilIcon class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
<Show when={props.onCancelQueued}>
<button
type="button"
onClick={() => props.onCancelQueued?.()}
aria-label="Remove queued follow-up"
title="Remove queued follow-up"
class="inline-flex h-5 w-5 items-center justify-center rounded text-blue-700 transition-colors hover:bg-blue-100 hover:text-blue-950 focus:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500/30 dark:text-blue-200 dark:hover:bg-blue-900/60"
>
<XIcon class="h-3 w-3" aria-hidden="true" />
</button>
</Show>
</div>
</Show>
</div>
</div>
</Show>
@@ -522,9 +549,9 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
<Show when={canCopy()}>
<button
type="button"
onClick={copyMessage}
aria-label={copied() ? 'Copied' : 'Copy message'}
title={copied() ? 'Copied' : 'Copy message'}
onClick={(event) => void copyMessage(event)}
aria-label={copyButtonLabel()}
title={copyButtonLabel()}
class="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md border border-border-subtle bg-surface text-muted opacity-0 shadow-sm transition-opacity hover:text-base-content focus:opacity-100 group-hover:opacity-100"
>
<Show
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render, screen, fireEvent } from '@solidjs/testing-library';
import { cleanup, render, screen, fireEvent, waitFor } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { MessageItem } from '../MessageItem';
import type { ChatMessage, PendingApproval, PendingQuestion, StreamDisplayEvent } from '../types';
@@ -100,6 +100,7 @@ vi.mock('../../aiChatUtils', () => ({
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});
@@ -336,7 +337,7 @@ describe('MessageItem', () => {
});
describe('copy button', () => {
it('copies the message content when clicked', () => {
it('copies the assistant message content when clicked', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
@@ -352,7 +353,62 @@ describe('MessageItem', () => {
const copy = screen.getByRole('button', { name: /copy message/i });
fireEvent.click(copy);
expect(writeText).toHaveBeenCalledWith('The cluster is healthy.');
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('The cluster is healthy.');
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Copied message' })).toBeInTheDocument();
});
});
it('copies user message content from the transcript row', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
render(() => (
<MessageItem
message={makeMessage({ role: 'user', content: 'how many devices in this host?' })}
{...makeHandlers()}
/>
));
const copy = screen.getByRole('button', { name: /copy message/i });
fireEvent.click(copy);
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('how many devices in this host?');
});
expect(screen.getByText('how many devices in this host?')).toBeInTheDocument();
});
it('uses the fallback clipboard path when direct clipboard writes fail', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('blocked'));
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(document, 'execCommand', {
value: execCommand,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
});
render(() => (
<MessageItem
message={makeMessage({ role: 'assistant', content: 'Fallback copy content.' })}
{...makeHandlers()}
/>
));
fireEvent.click(screen.getByRole('button', { name: /copy message/i }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('Fallback copy content.');
expect(execCommand).toHaveBeenCalledWith('copy');
expect(screen.getByRole('button', { name: 'Copied message' })).toBeInTheDocument();
});
});
it('does not show a copy button while streaming', () => {
@@ -855,9 +911,7 @@ describe('MessageItem', () => {
expect(screen.getByText('Reading current Pulse inventory.')).toBeInTheDocument();
expect(screen.getByText('3 devices found')).toBeInTheDocument();
expect(
screen.getByText('OpenRouter is starting the response.'),
).toBeInTheDocument();
expect(screen.getByText('OpenRouter is starting the response.')).toBeInTheDocument();
expect(screen.queryByText(/pulse_query/)).not.toBeInTheDocument();
});
@@ -880,9 +934,7 @@ describe('MessageItem', () => {
));
expect(screen.getByText('Partial answer')).toBeInTheDocument();
expect(
screen.queryByText('OpenRouter is starting the response.'),
).not.toBeInTheDocument();
expect(screen.queryByText('OpenRouter is starting the response.')).not.toBeInTheDocument();
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
});
@@ -909,9 +961,7 @@ describe('MessageItem', () => {
/>
));
expect(
screen.getByText('OpenRouter is starting the response.'),
).toBeInTheDocument();
expect(screen.getByText('OpenRouter is starting the response.')).toBeInTheDocument();
expect(screen.getByText('Partial answer')).toBeInTheDocument();
expect(screen.queryByText('Thinking...')).not.toBeInTheDocument();
});