diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index e2e585851..9a92fcb2d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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` diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index 189423306..1a5e0590f 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.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 = (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 | 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 (
{/* User message - compact bubble */} -
-

{props.message.content}

- -
+ + + - - - -
+
+
+

{props.message.content}

+ +
+
+
+
@@ -522,9 +549,9 @@ export const MessageItem: Component = (props) => {