mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Add copy-to-clipboard button on Assistant answers
Every modern chat surfaces a copy affordance; Pulse had none. Add a hover-revealed copy button (top-right of the assistant card, which already carries the group-hover hook) that copies the answer's markdown and flips to a 'Copied' check for 1.5s. Shown only for completed answers with content, so it never appears mid-stream or on error-only turns. Clipboard failures (insecure context / permissions) fail quietly. Verified by component tests (renders + copies exact content, hidden while streaming and when empty). Live screenshot skipped: the configured OpenRouter default is out of credits and can't produce an answer to copy right now.
This commit is contained in:
parent
6bbbf185bb
commit
1e303cf507
2 changed files with 86 additions and 1 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, Show, For, Switch, Match, createMemo } from 'solid-js';
|
||||
import { Component, Show, For, Switch, Match, createMemo, createSignal } from 'solid-js';
|
||||
import { renderMarkdown } from '../aiChatUtils';
|
||||
import { ThinkingBlock } from './ThinkingBlock';
|
||||
import { ToolExecutionBlock } from './ToolExecutionBlock';
|
||||
|
|
@ -61,6 +61,20 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
props.message.isStreaming &&
|
||||
(!props.message.pendingTools || props.message.pendingTools.length === 0);
|
||||
|
||||
// Copy-to-clipboard for a completed assistant answer.
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
const canCopy = () => !props.message.isStreaming && !!props.message.content?.trim();
|
||||
const copyMessage = async () => {
|
||||
const text = props.message.content || '';
|
||||
try {
|
||||
await navigator.clipboard?.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard can be unavailable (permissions / insecure context); fail quietly.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={`${isUser() ? 'flex justify-end' : ''} mb-4`}>
|
||||
{/* User message - compact bubble */}
|
||||
|
|
@ -74,6 +88,36 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
|||
<Show when={!isUser()}>
|
||||
<div class="w-full pl-2 pr-2">
|
||||
<div class="group relative bg-surface-alt rounded-md border border-border p-5 shadow-sm transition-all hover:border-border">
|
||||
{/* Copy answer - hover-revealed, top-right */}
|
||||
<Show when={canCopy()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyMessage}
|
||||
aria-label={copied() ? 'Copied' : 'Copy message'}
|
||||
title={copied() ? 'Copied' : 'Copy message'}
|
||||
class="absolute right-2 top-2 z-10 inline-flex items-center gap-1 rounded-md border border-border-subtle bg-surface px-1.5 py-1 text-[11px] text-muted opacity-0 shadow-sm transition-opacity hover:text-base-content focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Show
|
||||
when={copied()}
|
||||
fallback={
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.8"
|
||||
d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m11.25 0a9.06 9.06 0 00-1.5-.124H15.375"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</Show>
|
||||
{copied() ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Assistant indicator */}
|
||||
<div class="flex items-center gap-2.5 mb-3">
|
||||
<div class="w-6 h-6 rounded-md border border-border-subtle shadow-sm flex items-center justify-center shrink-0">
|
||||
|
|
|
|||
|
|
@ -175,6 +175,47 @@ describe('MessageItem', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('copy button', () => {
|
||||
it('copies the message content when clicked', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', content: 'The cluster is healthy.' })}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
|
||||
const copy = screen.getByRole('button', { name: /copy message/i });
|
||||
fireEvent.click(copy);
|
||||
expect(writeText).toHaveBeenCalledWith('The cluster is healthy.');
|
||||
});
|
||||
|
||||
it('does not show a copy button while streaming', () => {
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', content: 'partial', isStreaming: true })}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByRole('button', { name: /copy message/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show a copy button when there is no content', () => {
|
||||
render(() => (
|
||||
<MessageItem
|
||||
message={makeMessage({ role: 'assistant', content: '', error: 'failed' })}
|
||||
{...makeHandlers()}
|
||||
/>
|
||||
));
|
||||
expect(screen.queryByRole('button', { name: /copy message/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant message rendering', () => {
|
||||
it('renders assistant indicator with label', () => {
|
||||
render(() => (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue