mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 22:17:13 +02:00
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:
@@ -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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user