mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +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:
@@ -574,6 +574,15 @@ export default function App() {
|
||||
onBranchFromMessage={(messageId) => {
|
||||
void api.branchSession({ sessionId: selectedSession.id, messageId });
|
||||
}}
|
||||
onPinMessage={(messageId, isPinned) => {
|
||||
void api.setSessionMessagePinned({ sessionId: selectedSession.id, messageId, isPinned });
|
||||
}}
|
||||
onRegenerateMessage={(messageId) => {
|
||||
void api.regenerateSessionMessage({ sessionId: selectedSession.id, messageId });
|
||||
}}
|
||||
onEditAndResendMessage={(messageId, content) => {
|
||||
void api.editAndResendSessionMessage({ sessionId: selectedSession.id, messageId, content });
|
||||
}}
|
||||
branchOriginLabel={
|
||||
selectedSession.branchOrigin
|
||||
? workspace.sessions.find((s) => s.id === selectedSession.branchOrigin!.sourceSessionId)?.title
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bookmark, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, RefreshCw, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||
import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner';
|
||||
import { MessageActions } from '@renderer/components/chat/MessageActions';
|
||||
import { MessageEditComposer } from '@renderer/components/chat/MessageEditComposer';
|
||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
} from '@shared/domain/models';
|
||||
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||
import { resolveSessionToolingSelection, type SessionRecord } from '@shared/domain/session';
|
||||
import { resolveSessionToolingSelection, type SessionBranchOriginAction, type SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
groupApprovalToolsByProvider,
|
||||
listApprovalToolDefinitions,
|
||||
@@ -65,6 +67,9 @@ interface ChatPaneProps {
|
||||
onUpdateSessionTooling?: (selection: SessionToolingSelection) => void;
|
||||
onUpdateSessionApprovalSettings?: (settings: { autoApprovedToolNames?: string[] }) => void;
|
||||
onBranchFromMessage?: (messageId: string) => void;
|
||||
onPinMessage?: (messageId: string, isPinned: boolean) => void;
|
||||
onRegenerateMessage?: (messageId: string) => void;
|
||||
onEditAndResendMessage?: (messageId: string, content: string) => void;
|
||||
branchOriginLabel?: string;
|
||||
}
|
||||
|
||||
@@ -93,6 +98,9 @@ export function ChatPane({
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
onBranchFromMessage,
|
||||
onPinMessage,
|
||||
onRegenerateMessage,
|
||||
onEditAndResendMessage,
|
||||
branchOriginLabel,
|
||||
}: ChatPaneProps) {
|
||||
const [hasComposerContent, setHasComposerContent] = useState(false);
|
||||
@@ -101,10 +109,17 @@ export function ChatPane({
|
||||
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
|
||||
const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false);
|
||||
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
|
||||
const [editingMessageId, setEditingMessageId] = useState<string>();
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||
|
||||
const isSessionBusy = session.status === 'running';
|
||||
const lastAssistantIndex = useMemo(() => {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].role === 'assistant') return i;
|
||||
}
|
||||
return -1;
|
||||
}, [session.messages]);
|
||||
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
||||
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
||||
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
||||
@@ -174,6 +189,7 @@ export function ChatPane({
|
||||
setApprovalError(undefined);
|
||||
setIsResolvingApproval(false);
|
||||
setIsUpdatingSessionModelConfig(false);
|
||||
setEditingMessageId(undefined);
|
||||
}, [session.id]);
|
||||
|
||||
function handleComposerSubmit(content: string) {
|
||||
@@ -183,6 +199,18 @@ export function ChatPane({
|
||||
void onSend(content, attachments, messageMode);
|
||||
}
|
||||
|
||||
const handleCopyMessage = useCallback((content: string) => {
|
||||
void navigator.clipboard.writeText(content);
|
||||
}, []);
|
||||
|
||||
const handleEditSave = useCallback(
|
||||
(messageId: string, content: string) => {
|
||||
setEditingMessageId(undefined);
|
||||
onEditAndResendMessage?.(messageId, content);
|
||||
},
|
||||
[onEditAndResendMessage],
|
||||
);
|
||||
|
||||
function handleDismissPlan() {
|
||||
onDismissPlanReview?.();
|
||||
}
|
||||
@@ -333,19 +361,16 @@ export function ChatPane({
|
||||
<div className="mx-auto max-w-3xl px-6 py-4">
|
||||
{/* Branch origin banner */}
|
||||
{session.branchOrigin && (
|
||||
<div className="mb-4 flex items-center gap-2.5 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]/60 px-3.5 py-2.5 text-[12px] text-[var(--color-text-secondary)]">
|
||||
<GitBranch className="size-3.5 shrink-0 text-[var(--color-accent)]" />
|
||||
<span>
|
||||
Branched from{' '}
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
{branchOriginLabel ?? 'a previous session'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<BranchOriginBanner
|
||||
action={session.branchOrigin.action}
|
||||
label={branchOriginLabel}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{session.messages.map((message, index) => {
|
||||
const isUser = message.role === 'user';
|
||||
const isEditing = editingMessageId === message.id;
|
||||
const isLastAssistant = index === lastAssistantIndex;
|
||||
const phase = getAssistantMessagePhase(session, message, index);
|
||||
const assistantContainerClass =
|
||||
phase === 'thinking'
|
||||
@@ -359,6 +384,7 @@ export function ChatPane({
|
||||
: 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]';
|
||||
const phaseLabel =
|
||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||
const showActions = !isSessionBusy && !message.pending;
|
||||
|
||||
return (
|
||||
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}>
|
||||
@@ -373,6 +399,9 @@ export function ChatPane({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||
<span>{message.authorName}</span>
|
||||
{message.isPinned && (
|
||||
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
||||
)}
|
||||
{!isUser && phaseLabel && (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
|
||||
@@ -380,61 +409,71 @@ export function ChatPane({
|
||||
{phaseLabel}
|
||||
</span>
|
||||
)}
|
||||
{onBranchFromMessage && (
|
||||
<button
|
||||
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-[var(--color-text-muted)] opacity-0 transition-all duration-150 hover:bg-[var(--color-surface-2)] hover:text-[var(--color-accent)] group-hover:opacity-100"
|
||||
onClick={() => onBranchFromMessage(message.id)}
|
||||
title={isUser
|
||||
? 'Branch from here — create a new session starting from this message'
|
||||
: 'Branch from here — create a new session continuing from this response'}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
Branch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
|
||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
|
||||
}
|
||||
>
|
||||
{/* Attachment thumbnails */}
|
||||
{isUser && message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, attIdx) =>
|
||||
isImageAttachment(att) ? (
|
||||
<img
|
||||
key={attIdx}
|
||||
alt={getAttachmentDisplayName(att)}
|
||||
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={attIdx}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
{getAttachmentDisplayName(att)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{showActions && (
|
||||
<div className="ml-auto">
|
||||
<MessageActions
|
||||
message={message}
|
||||
isLastAssistant={isLastAssistant}
|
||||
onCopy={() => handleCopyMessage(message.content)}
|
||||
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
|
||||
onBranch={() => onBranchFromMessage?.(message.id)}
|
||||
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
|
||||
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isUser && message.pending ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={message.content} />
|
||||
)}
|
||||
{message.pending && message.content && (
|
||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit mode */}
|
||||
{isEditing ? (
|
||||
<MessageEditComposer
|
||||
initialContent={message.content}
|
||||
onSave={(content) => handleEditSave(message.id, content)}
|
||||
onCancel={() => setEditingMessageId(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
isUser
|
||||
? 'text-[14px] leading-relaxed text-[var(--color-text-primary)]'
|
||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-[var(--color-text-primary)] ${assistantContainerClass}`
|
||||
}
|
||||
>
|
||||
{/* Attachment thumbnails */}
|
||||
{isUser && message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, attIdx) =>
|
||||
isImageAttachment(att) ? (
|
||||
<img
|
||||
key={attIdx}
|
||||
alt={getAttachmentDisplayName(att)}
|
||||
className="max-h-48 max-w-xs rounded-lg border border-[var(--color-border)] object-cover"
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={attIdx}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-1 text-[11px] text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
{getAttachmentDisplayName(att)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && message.pending ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-[var(--color-text-primary)]">
|
||||
{message.content}
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent content={message.content} />
|
||||
)}
|
||||
{message.pending && message.content && (
|
||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{message.pending && !message.content && <ThinkingDots />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -813,3 +852,31 @@ export function ChatPane({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Branch origin banner ───────────────────────────────────── */
|
||||
|
||||
function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) {
|
||||
const icon =
|
||||
action === 'regenerate'
|
||||
? <RefreshCw className="size-3.5 shrink-0 text-[var(--color-accent-sky)]" />
|
||||
: <GitBranch className="size-3.5 shrink-0 text-[var(--color-accent)]" />;
|
||||
|
||||
const verb =
|
||||
action === 'regenerate'
|
||||
? 'Regenerated from'
|
||||
: action === 'edit-and-resend'
|
||||
? 'Edited & resent from'
|
||||
: 'Branched from';
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-2.5 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]/60 px-3.5 py-2.5 text-[12px] text-[var(--color-text-secondary)]">
|
||||
{icon}
|
||||
<span>
|
||||
{verb}{' '}
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
{label ?? 'a previous session'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,8 +297,22 @@ function SessionItem({
|
||||
</span>
|
||||
)}
|
||||
{session.branchOrigin && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]" title="Branched session">
|
||||
<GitBranch className="size-2.5" />
|
||||
<span
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={
|
||||
session.branchOrigin.action === 'regenerate'
|
||||
? 'Regenerated response'
|
||||
: session.branchOrigin.action === 'edit-and-resend'
|
||||
? 'Edited & resent'
|
||||
: 'Branched session'
|
||||
}
|
||||
>
|
||||
{session.branchOrigin.action === 'regenerate'
|
||||
? <RefreshCw className="size-2.5" />
|
||||
: session.branchOrigin.action === 'edit-and-resend'
|
||||
? <Pencil className="size-2.5" />
|
||||
: <GitBranch className="size-2.5" />
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -587,6 +587,19 @@ body {
|
||||
animation: message-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Message action toolbar ──────────────────────────────────── */
|
||||
|
||||
@keyframes msg-actions-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px) scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.msg-actions-enter {
|
||||
animation: msg-actions-in 0.12s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
/* ── Sidebar session item ────────────────────────────────────── */
|
||||
|
||||
@keyframes session-item-in {
|
||||
@@ -622,6 +635,7 @@ body {
|
||||
.overlay-panel-enter,
|
||||
.overlay-slide-enter,
|
||||
.message-enter,
|
||||
.msg-actions-enter,
|
||||
.session-item-enter,
|
||||
.banner-slide-enter {
|
||||
animation: none;
|
||||
|
||||
Reference in New Issue
Block a user