diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a0352b3..a53649b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index c3dec7a..5010c45 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -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(); const transcriptRef = useRef(null); const composerRef = useRef(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({
{/* Branch origin banner */} {session.branchOrigin && ( -
- - - Branched from{' '} - - {branchOriginLabel ?? 'a previous session'} - - -
+ )}
{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 (
@@ -373,6 +399,9 @@ export function ChatPane({
{message.authorName} + {message.isPinned && ( + + )} {!isUser && phaseLabel && ( )} - {onBranchFromMessage && ( - - )} -
-
- {/* Attachment thumbnails */} - {isUser && message.attachments && message.attachments.length > 0 && ( -
- {message.attachments.map((att, attIdx) => - isImageAttachment(att) ? ( - {getAttachmentDisplayName(att)} - ) : ( -
- - {getAttachmentDisplayName(att)} -
- ), - )} + {showActions && ( +
+ 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} + />
)} - {!isUser && message.pending ? ( -
- {message.content} -
- ) : ( - - )} - {message.pending && message.content && ( - - )}
+ + {/* Edit mode */} + {isEditing ? ( + handleEditSave(message.id, content)} + onCancel={() => setEditingMessageId(undefined)} + /> + ) : ( +
+ {/* Attachment thumbnails */} + {isUser && message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((att, attIdx) => + isImageAttachment(att) ? ( + {getAttachmentDisplayName(att)} + ) : ( +
+ + {getAttachmentDisplayName(att)} +
+ ), + )} +
+ )} + {!isUser && message.pending ? ( +
+ {message.content} +
+ ) : ( + + )} + {message.pending && message.content && ( + + )} +
+ )} {message.pending && !message.content && }
@@ -813,3 +852,31 @@ export function ChatPane({
); } + +/* ── Branch origin banner ───────────────────────────────────── */ + +function BranchOriginBanner({ action, label }: { action?: SessionBranchOriginAction; label?: string }) { + const icon = + action === 'regenerate' + ? + : ; + + const verb = + action === 'regenerate' + ? 'Regenerated from' + : action === 'edit-and-resend' + ? 'Edited & resent from' + : 'Branched from'; + + return ( +
+ {icon} + + {verb}{' '} + + {label ?? 'a previous session'} + + +
+ ); +} diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 90df016..ad22a65 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -297,8 +297,22 @@ function SessionItem({ )} {session.branchOrigin && ( - - + + {session.branchOrigin.action === 'regenerate' + ? + : session.branchOrigin.action === 'edit-and-resend' + ? + : + } )} diff --git a/src/renderer/components/chat/MessageActions.tsx b/src/renderer/components/chat/MessageActions.tsx new file mode 100644 index 0000000..d476815 --- /dev/null +++ b/src/renderer/components/chat/MessageActions.tsx @@ -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 ( +
+ {/* Copy */} + : } + label={copied ? 'Copied' : 'Copy as markdown'} + onClick={handleCopy} + /> + + {/* Pin / Unpin */} + + } + label={isPinned ? 'Unpin message' : 'Pin message'} + onClick={onPin} + active={isPinned} + /> + + {/* Edit (user messages only) */} + {isUser && onEdit && ( + } + label="Edit & resend" + onClick={onEdit} + /> + )} + + {/* Regenerate (last assistant only) */} + {!isUser && isLastAssistant && onRegenerate && ( + } + label="Regenerate response" + onClick={onRegenerate} + /> + )} + + {/* Branch */} + } + label={isUser ? 'Branch from this message' : 'Branch from this response'} + onClick={onBranch} + /> +
+ ); +} + +/* ── Small action button ────────────────────────────────────── */ + +interface ActionButtonProps { + icon: React.ReactNode; + label: string; + onClick: () => void; + active?: boolean; +} + +function ActionButton({ icon, label, onClick, active }: ActionButtonProps) { + return ( + + ); +} diff --git a/src/renderer/components/chat/MessageEditComposer.tsx b/src/renderer/components/chat/MessageEditComposer.tsx new file mode 100644 index 0000000..1f8e256 --- /dev/null +++ b/src/renderer/components/chat/MessageEditComposer.tsx @@ -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(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 ( +
+