feat: add message actions frontend UI

- Hover action toolbar on messages: copy, pin/unpin, branch, edit (user),
  regenerate (last assistant)
- Inline edit composer for user messages with save & resend flow
- Pinned message bookmark indicator next to author name
- Action-specific branch origin banners (branched, regenerated, edited)
- Sidebar branch icons distinguish branch/regenerate/edit-and-resend
- CSS animation for action toolbar entrance

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-29 23:12:34 +02:00
co-authored by Copilot
parent d88ce0f00c
commit 5f4318e761
6 changed files with 364 additions and 66 deletions
@@ -0,0 +1,113 @@
import { useState } from 'react';
import { Bookmark, Check, ClipboardCopy, GitBranch, Pencil, RefreshCw } from 'lucide-react';
import type { ChatMessageRecord } from '@shared/domain/session';
export interface MessageActionsProps {
message: ChatMessageRecord;
isLastAssistant: boolean;
onCopy: () => void;
onPin: () => void;
onBranch: () => void;
onRegenerate?: () => void;
onEdit?: () => void;
}
export function MessageActions({
message,
isLastAssistant,
onCopy,
onPin,
onBranch,
onRegenerate,
onEdit,
}: MessageActionsProps) {
const [copied, setCopied] = useState(false);
const isUser = message.role === 'user';
const isPinned = !!message.isPinned;
function handleCopy() {
onCopy();
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
return (
<div
className="msg-actions-enter flex items-center gap-0.5 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]/90 px-1 py-0.5 opacity-0 shadow-sm backdrop-blur-sm transition-opacity duration-150 group-hover:opacity-100"
role="toolbar"
aria-label="Message actions"
>
{/* Copy */}
<ActionButton
icon={copied ? <Check className="size-3 text-[var(--color-status-success)]" /> : <ClipboardCopy className="size-3" />}
label={copied ? 'Copied' : 'Copy as markdown'}
onClick={handleCopy}
/>
{/* Pin / Unpin */}
<ActionButton
icon={
<Bookmark
className={`size-3 ${isPinned ? 'fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]' : ''}`}
/>
}
label={isPinned ? 'Unpin message' : 'Pin message'}
onClick={onPin}
active={isPinned}
/>
{/* Edit (user messages only) */}
{isUser && onEdit && (
<ActionButton
icon={<Pencil className="size-3" />}
label="Edit &amp; resend"
onClick={onEdit}
/>
)}
{/* Regenerate (last assistant only) */}
{!isUser && isLastAssistant && onRegenerate && (
<ActionButton
icon={<RefreshCw className="size-3" />}
label="Regenerate response"
onClick={onRegenerate}
/>
)}
{/* Branch */}
<ActionButton
icon={<GitBranch className="size-3" />}
label={isUser ? 'Branch from this message' : 'Branch from this response'}
onClick={onBranch}
/>
</div>
);
}
/* ── Small action button ────────────────────────────────────── */
interface ActionButtonProps {
icon: React.ReactNode;
label: string;
onClick: () => void;
active?: boolean;
}
function ActionButton({ icon, label, onClick, active }: ActionButtonProps) {
return (
<button
aria-label={label}
className={`flex size-6 items-center justify-center rounded-md transition-all duration-100 ${
active
? 'text-[var(--color-accent-sky)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
}`}
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
);
}
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Check, X } from 'lucide-react';
export interface MessageEditComposerProps {
initialContent: string;
onSave: (content: string) => void;
onCancel: () => void;
}
export function MessageEditComposer({ initialContent, onSave, onCancel }: MessageEditComposerProps) {
const [content, setContent] = useState(initialContent);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const ta = textareaRef.current;
if (!ta) return;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
resizeTextarea(ta);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
const trimmed = content.trim();
if (trimmed) onSave(trimmed);
}
},
[content, onCancel, onSave],
);
function resizeTextarea(el: HTMLTextAreaElement) {
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 300)}px`;
}
const canSave = content.trim().length > 0 && content.trim() !== initialContent.trim();
return (
<div className="msg-actions-enter">
<textarea
ref={textareaRef}
className="w-full resize-none rounded-lg border border-[var(--color-border-glow)] bg-[var(--color-surface-0)] px-3 py-2 text-[14px] leading-relaxed text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-accent)]/50"
onChange={(e) => {
setContent(e.target.value);
resizeTextarea(e.target);
}}
onKeyDown={handleKeyDown}
rows={1}
value={content}
/>
<div className="mt-1.5 flex items-center gap-1.5">
<button
className="flex items-center gap-1 rounded-md bg-[var(--color-accent)] px-2.5 py-1 text-[11px] font-medium text-white transition-all duration-150 hover:bg-[var(--color-accent-hover)] disabled:cursor-not-allowed disabled:opacity-40"
disabled={!canSave}
onClick={() => onSave(content.trim())}
type="button"
>
<Check className="size-3" />
Save &amp; Resend
</button>
<button
className="flex items-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all duration-150 hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]"
onClick={onCancel}
type="button"
>
<X className="size-3" />
Cancel
</button>
<span className="ml-auto text-[10px] text-[var(--color-text-muted)]">
Ctrl+Enter to save · Esc to cancel
</span>
</div>
</div>
);
}