mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-30 14:37: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:
@@ -574,6 +574,15 @@ export default function App() {
|
|||||||
onBranchFromMessage={(messageId) => {
|
onBranchFromMessage={(messageId) => {
|
||||||
void api.branchSession({ sessionId: selectedSession.id, 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={
|
branchOriginLabel={
|
||||||
selectedSession.branchOrigin
|
selectedSession.branchOrigin
|
||||||
? workspace.sessions.find((s) => s.id === selectedSession.branchOrigin!.sourceSessionId)?.title
|
? workspace.sessions.find((s) => s.id === selectedSession.branchOrigin!.sourceSessionId)?.title
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, ShieldAlert, Square, User, X } from 'lucide-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 { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||||
import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner';
|
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 { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||||
@@ -26,7 +28,7 @@ import {
|
|||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
import { type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
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 {
|
import {
|
||||||
groupApprovalToolsByProvider,
|
groupApprovalToolsByProvider,
|
||||||
listApprovalToolDefinitions,
|
listApprovalToolDefinitions,
|
||||||
@@ -65,6 +67,9 @@ interface ChatPaneProps {
|
|||||||
onUpdateSessionTooling?: (selection: SessionToolingSelection) => void;
|
onUpdateSessionTooling?: (selection: SessionToolingSelection) => void;
|
||||||
onUpdateSessionApprovalSettings?: (settings: { autoApprovedToolNames?: string[] }) => void;
|
onUpdateSessionApprovalSettings?: (settings: { autoApprovedToolNames?: string[] }) => void;
|
||||||
onBranchFromMessage?: (messageId: string) => void;
|
onBranchFromMessage?: (messageId: string) => void;
|
||||||
|
onPinMessage?: (messageId: string, isPinned: boolean) => void;
|
||||||
|
onRegenerateMessage?: (messageId: string) => void;
|
||||||
|
onEditAndResendMessage?: (messageId: string, content: string) => void;
|
||||||
branchOriginLabel?: string;
|
branchOriginLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +98,9 @@ export function ChatPane({
|
|||||||
onUpdateSessionTooling,
|
onUpdateSessionTooling,
|
||||||
onUpdateSessionApprovalSettings,
|
onUpdateSessionApprovalSettings,
|
||||||
onBranchFromMessage,
|
onBranchFromMessage,
|
||||||
|
onPinMessage,
|
||||||
|
onRegenerateMessage,
|
||||||
|
onEditAndResendMessage,
|
||||||
branchOriginLabel,
|
branchOriginLabel,
|
||||||
}: ChatPaneProps) {
|
}: ChatPaneProps) {
|
||||||
const [hasComposerContent, setHasComposerContent] = useState(false);
|
const [hasComposerContent, setHasComposerContent] = useState(false);
|
||||||
@@ -101,10 +109,17 @@ export function ChatPane({
|
|||||||
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
|
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
|
||||||
const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false);
|
const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false);
|
||||||
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
|
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
|
||||||
|
const [editingMessageId, setEditingMessageId] = useState<string>();
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||||
|
|
||||||
const isSessionBusy = session.status === 'running';
|
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 pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
||||||
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
||||||
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
||||||
@@ -174,6 +189,7 @@ export function ChatPane({
|
|||||||
setApprovalError(undefined);
|
setApprovalError(undefined);
|
||||||
setIsResolvingApproval(false);
|
setIsResolvingApproval(false);
|
||||||
setIsUpdatingSessionModelConfig(false);
|
setIsUpdatingSessionModelConfig(false);
|
||||||
|
setEditingMessageId(undefined);
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
|
|
||||||
function handleComposerSubmit(content: string) {
|
function handleComposerSubmit(content: string) {
|
||||||
@@ -183,6 +199,18 @@ export function ChatPane({
|
|||||||
void onSend(content, attachments, messageMode);
|
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() {
|
function handleDismissPlan() {
|
||||||
onDismissPlanReview?.();
|
onDismissPlanReview?.();
|
||||||
}
|
}
|
||||||
@@ -333,19 +361,16 @@ export function ChatPane({
|
|||||||
<div className="mx-auto max-w-3xl px-6 py-4">
|
<div className="mx-auto max-w-3xl px-6 py-4">
|
||||||
{/* Branch origin banner */}
|
{/* Branch origin banner */}
|
||||||
{session.branchOrigin && (
|
{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)]">
|
<BranchOriginBanner
|
||||||
<GitBranch className="size-3.5 shrink-0 text-[var(--color-accent)]" />
|
action={session.branchOrigin.action}
|
||||||
<span>
|
label={branchOriginLabel}
|
||||||
Branched from{' '}
|
/>
|
||||||
<span className="font-medium text-[var(--color-text-primary)]">
|
|
||||||
{branchOriginLabel ?? 'a previous session'}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{session.messages.map((message, index) => {
|
{session.messages.map((message, index) => {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
const isEditing = editingMessageId === message.id;
|
||||||
|
const isLastAssistant = index === lastAssistantIndex;
|
||||||
const phase = getAssistantMessagePhase(session, message, index);
|
const phase = getAssistantMessagePhase(session, message, index);
|
||||||
const assistantContainerClass =
|
const assistantContainerClass =
|
||||||
phase === 'thinking'
|
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)]';
|
: 'border-[var(--color-status-success)]/20 bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]';
|
||||||
const phaseLabel =
|
const phaseLabel =
|
||||||
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
phase === 'thinking' ? 'Thinking' : phase === 'final' ? 'Final' : undefined;
|
||||||
|
const showActions = !isSessionBusy && !message.pending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-enter group py-3" data-message-id={message.id} key={message.id}>
|
<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="min-w-0 flex-1">
|
||||||
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
<div className="mb-1 flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-secondary)]">
|
||||||
<span>{message.authorName}</span>
|
<span>{message.authorName}</span>
|
||||||
|
{message.isPinned && (
|
||||||
|
<Bookmark className="size-3 fill-[var(--color-accent-sky)] text-[var(--color-accent-sky)]" />
|
||||||
|
)}
|
||||||
{!isUser && phaseLabel && (
|
{!isUser && phaseLabel && (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] ${assistantBadgeClass}`}
|
||||||
@@ -380,20 +409,29 @@ export function ChatPane({
|
|||||||
{phaseLabel}
|
{phaseLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{onBranchFromMessage && (
|
{showActions && (
|
||||||
<button
|
<div className="ml-auto">
|
||||||
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"
|
<MessageActions
|
||||||
onClick={() => onBranchFromMessage(message.id)}
|
message={message}
|
||||||
title={isUser
|
isLastAssistant={isLastAssistant}
|
||||||
? 'Branch from here — create a new session starting from this message'
|
onCopy={() => handleCopyMessage(message.content)}
|
||||||
: 'Branch from here — create a new session continuing from this response'}
|
onPin={() => onPinMessage?.(message.id, !message.isPinned)}
|
||||||
type="button"
|
onBranch={() => onBranchFromMessage?.(message.id)}
|
||||||
>
|
onRegenerate={onRegenerateMessage ? () => onRegenerateMessage(message.id) : undefined}
|
||||||
<GitBranch className="size-3" />
|
onEdit={onEditAndResendMessage && isUser ? () => setEditingMessageId(message.id) : undefined}
|
||||||
Branch
|
/>
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit mode */}
|
||||||
|
{isEditing ? (
|
||||||
|
<MessageEditComposer
|
||||||
|
initialContent={message.content}
|
||||||
|
onSave={(content) => handleEditSave(message.id, content)}
|
||||||
|
onCancel={() => setEditingMessageId(undefined)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
isUser
|
isUser
|
||||||
@@ -435,6 +473,7 @@ export function ChatPane({
|
|||||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-[var(--color-accent)]" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{message.pending && !message.content && <ThinkingDots />}
|
{message.pending && !message.content && <ThinkingDots />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -813,3 +852,31 @@ export function ChatPane({
|
|||||||
</div>
|
</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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{session.branchOrigin && (
|
{session.branchOrigin && (
|
||||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]" title="Branched session">
|
<span
|
||||||
<GitBranch className="size-2.5" />
|
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>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-[10px] text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]">
|
<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;
|
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 ────────────────────────────────────── */
|
/* ── Sidebar session item ────────────────────────────────────── */
|
||||||
|
|
||||||
@keyframes session-item-in {
|
@keyframes session-item-in {
|
||||||
@@ -622,6 +635,7 @@ body {
|
|||||||
.overlay-panel-enter,
|
.overlay-panel-enter,
|
||||||
.overlay-slide-enter,
|
.overlay-slide-enter,
|
||||||
.message-enter,
|
.message-enter,
|
||||||
|
.msg-actions-enter,
|
||||||
.session-item-enter,
|
.session-item-enter,
|
||||||
.banner-slide-enter {
|
.banner-slide-enter {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user