mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Add Assistant transcript message copy
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user