feat: inline edit UX — edit message in-place with Save/Cancel

Pivot from bottom-ChatInput editing to inline editing. When clicking
edit, the message bubble transforms into a textarea with Save and
Cancel buttons. Enter saves, Escape cancels.

- MessageBubble: add isEditing mode with inline textarea, auto-resize,
  focus, and keyboard shortcuts (Enter=save, Escape=cancel)
- MessageTimeline: pass editingMessageId, onSaveEdit, onCancelEdit
- ChatView: add handleSaveEdit (truncate + send), wire to timeline
- ChatInput: remove edit indicator bar, editingMessageId prop, and
  edit-mode focus effect
This commit is contained in:
Taylor Ho
2026-04-13 19:23:26 -10:00
parent 47d1154d4e
commit 5eef9496a0
4 changed files with 185 additions and 120 deletions
+2 -26
View File
@@ -1,6 +1,6 @@
import { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { Pencil, X } from "lucide-react";
import { X } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AcpProvider } from "@/shared/api/acp";
import type { Persona } from "@/shared/types/agents";
@@ -64,10 +64,6 @@ interface ChatInputProps {
}) => void;
contextTokens?: number;
contextLimit?: number;
/** Message ID currently being edited (non-null = edit mode active). */
editingMessageId?: string | null;
/** Cancel edit mode — clears editing state and restores input. */
onCancelEdit?: () => void;
}
export function ChatInput({
@@ -98,8 +94,6 @@ export function ChatInput({
onCreateProject,
contextTokens = 0,
contextLimit = 0,
editingMessageId = null,
onCancelEdit,
}: ChatInputProps) {
const { t } = useTranslation("chat");
const [text, setTextRaw] = useState(initialValue);
@@ -196,8 +190,7 @@ export function ChatInput({
return () => observer.disconnect();
}, []);
// Focus textarea on mount and when entering edit mode
useEffect(() => textareaRef.current?.focus(), [editingMessageId]);
useEffect(() => textareaRef.current?.focus(), []);
const handleSend = useCallback(() => {
if (!canSend) {
@@ -423,23 +416,6 @@ export function ChatInput({
</div>
)}
{editingMessageId && (
<div className="mb-2 flex items-center gap-2 rounded-lg bg-brand/10 px-3 py-1.5">
<Pencil className="size-3 shrink-0 text-brand" />
<span className="flex-1 truncate text-xs text-brand">
{t("edit.label")}
</span>
<button
type="button"
onClick={onCancelEdit}
className="shrink-0 rounded p-0.5 text-brand/60 hover:text-brand"
aria-label={t("edit.cancel")}
>
<X className="size-3.5" />
</button>
</div>
)}
<PopoverAnchor asChild>
<textarea
ref={textareaRef}
+23 -25
View File
@@ -386,28 +386,6 @@ export function ChatView({
deferredSend.current = { text, attachments };
return;
}
// Edit mode: truncate from the edited message onward, then send.
// Must run before the queue check — edited sends are never queued.
if (editingMessageId) {
// Stop any active generation so the truncated history is clean
if (chatState !== "idle") {
stopStreaming();
}
const allMessages =
useChatStore.getState().messagesBySession[activeSessionId] ?? [];
const editIndex = allMessages.findIndex(
(m) => m.id === editingMessageId,
);
if (editIndex !== -1) {
useChatStore
.getState()
.setMessages(activeSessionId, allMessages.slice(0, editIndex));
}
useChatStore.getState().setEditingMessageId(activeSessionId, null);
sendMessage(text, undefined, attachments);
return;
}
// Queue if agent is busy and no message already queued
if (chatState !== "idle" && !queue.queuedMessage) {
queue.enqueue(text, personaId, attachments);
@@ -425,11 +403,30 @@ export function ChatView({
activeSessionId,
chatState,
queue,
editingMessageId,
stopStreaming,
],
);
/** Save an inline edit: truncate history from the edited message onward, then send the new text. */
const handleSaveEdit = useCallback(
(messageId: string, text: string) => {
if (chatState !== "idle") {
stopStreaming();
}
const allMessages =
useChatStore.getState().messagesBySession[activeSessionId] ?? [];
const editIndex = allMessages.findIndex((m) => m.id === messageId);
if (editIndex !== -1) {
useChatStore
.getState()
.setMessages(activeSessionId, allMessages.slice(0, editIndex));
}
useChatStore.getState().setEditingMessageId(activeSessionId, null);
sendMessage(text);
},
[activeSessionId, chatState, sendMessage, stopStreaming],
);
useEffect(() => {
if (deferredSend.current && selectedPersona) {
const { text, attachments } = deferredSend.current;
@@ -495,6 +492,9 @@ export function ChatView({
onScrollTargetHandled={handleScrollTargetHandled}
onRetryMessage={retryMessage}
onEditMessage={editMessage}
editingMessageId={editingMessageId}
onSaveEdit={handleSaveEdit}
onCancelEdit={cancelEdit}
/>
)}
@@ -547,8 +547,6 @@ export function ChatView({
}
contextTokens={tokenState.accumulatedTotal}
contextLimit={tokenState.contextLimit}
editingMessageId={editingMessageId}
onCancelEdit={cancelEdit}
/>
</div>
+151 -69
View File
@@ -1,4 +1,4 @@
import { useState, memo } from "react";
import { useState, useRef, useEffect, useLayoutEffect, useCallback, memo } from "react";
import { useTranslation } from "react-i18next";
import {
Copy,
@@ -30,6 +30,7 @@ import {
ReasoningTrigger,
ReasoningContent,
} from "@/shared/ui/ai-elements/reasoning";
import { Button } from "@/shared/ui/button";
import { ToolChainCards, type ToolChainItem } from "./ToolChainCards";
import { ClickableImage } from "./ClickableImage";
import { useArtifactLinkHandler } from "@/features/chat/hooks/useArtifactLinkHandler";
@@ -89,6 +90,9 @@ interface MessageBubbleProps {
onCopy?: () => void;
onRetryMessage?: (messageId: string) => void;
onEditMessage?: (messageId: string) => void;
isEditing?: boolean;
onSaveEdit?: (messageId: string, text: string) => void;
onCancelEdit?: () => void;
}
interface ContentSection {
@@ -306,6 +310,9 @@ export const MessageBubble = memo(function MessageBubble({
isStreaming,
onRetryMessage,
onEditMessage,
isEditing = false,
onSaveEdit,
onCancelEdit,
}: MessageBubbleProps) {
const { t } = useTranslation(["chat", "common"]);
const { formatDate } = useLocaleFormatting();
@@ -323,6 +330,47 @@ export const MessageBubble = memo(function MessageBubble({
.map((c) => c.text)
.join("\n");
// Inline edit state
const [editText, setEditText] = useState(textContent);
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
// Reset edit text when entering edit mode
useEffect(() => {
if (isEditing) {
setEditText(textContent);
}
}, [isEditing, textContent]);
// Auto-resize + focus the inline edit textarea
useLayoutEffect(() => {
const ta = editTextareaRef.current;
if (isEditing && ta) {
ta.style.height = "auto";
ta.style.height = `${Math.min(ta.scrollHeight, 300)}px`;
ta.focus();
}
}, [isEditing, editText]);
const handleEditSave = useCallback(() => {
const trimmed = editText.trim();
if (trimmed && onSaveEdit) {
onSaveEdit(message.id, trimmed);
}
}, [editText, message.id, onSaveEdit]);
const handleEditKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancelEdit?.();
} else if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleEditSave();
}
},
[handleEditSave, onCancelEdit],
);
if (role === "system") {
return (
<div className="flex justify-center px-4 py-2">
@@ -403,76 +451,110 @@ export const MessageBubble = memo(function MessageBubble({
</div>
) : null}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated link handler */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
<div
className="w-full min-w-0 text-[13px] leading-relaxed"
onClick={handleContentClick}
>
{messageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{messageAttachments.map((attachment) => (
<MessageAttachmentRow
key={`${attachment.type}-${attachment.path ?? attachment.name}`}
attachment={attachment}
/>
))}
{isEditing ? (
<div className="w-full min-w-0">
<textarea
ref={editTextareaRef}
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleEditKeyDown}
rows={1}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-[13px] leading-relaxed text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="mt-1.5 flex items-center gap-2">
<Button
size="sm"
variant="default"
onClick={handleEditSave}
disabled={!editText.trim()}
className="h-7 px-3 text-xs"
>
{t("common:actions.save")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={onCancelEdit}
className="h-7 px-3 text-xs"
>
{t("common:actions.cancel")}
</Button>
</div>
</div>
) : (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: delegated link handler */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: delegated link handler */}
<div
className="w-full min-w-0 text-[13px] leading-relaxed"
onClick={handleContentClick}
>
{messageAttachments.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{messageAttachments.map((attachment) => (
<MessageAttachmentRow
key={`${attachment.type}-${attachment.path ?? attachment.name}`}
attachment={attachment}
/>
))}
</div>
)}
{groupContentSections(content).map((section, sectionIdx) => {
if (section.type === "toolChain") {
const toolItems = section.items as ToolChainItem[];
return <ToolChainCards key={section.key} toolItems={toolItems} />;
}
const block = section.items[0] as MessageContent;
return (
<div key={`${message.id}-${section.key}`}>
{renderContentBlock(
block,
sectionIdx,
{
defaultImageAlt: t("message.defaultImageAlt"),
redactedThinking: t("message.redactedThinking"),
},
isStreaming,
isUser,
)}
</div>
);
})}
{pathNotice && (
<p className="mt-2 text-xs text-destructive" role="status">
{pathNotice}
</p>
)}
</div>
)}
{groupContentSections(content).map((section, sectionIdx) => {
if (section.type === "toolChain") {
const toolItems = section.items as ToolChainItem[];
return <ToolChainCards key={section.key} toolItems={toolItems} />;
}
const block = section.items[0] as MessageContent;
return (
<div key={`${message.id}-${section.key}`}>
{renderContentBlock(
block,
sectionIdx,
{
defaultImageAlt: t("message.defaultImageAlt"),
redactedThinking: t("message.redactedThinking"),
},
isStreaming,
isUser,
)}
</div>
);
})}
{pathNotice && (
<p className="mt-2 text-xs text-destructive" role="status">
{pathNotice}
</p>
)}
</div>
{/* Hover actions + timestamp */}
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
{textContent && <CopyAction text={textContent} />}
{onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && (
<MessageAction
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
<span className="px-1 text-[10px] text-muted-foreground">
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</MessageActions>
{/* Hover actions + timestamp */}
<MessageActions className="opacity-0 transition-opacity duration-150 group-hover:opacity-100">
{textContent && <CopyAction text={textContent} />}
{onRetryMessage && (
<MessageAction
tooltip={t("common:actions.retry")}
onClick={() => onRetryMessage(message.id)}
>
<RotateCcw className="size-3.5" />
</MessageAction>
)}
{isUser && onEditMessage && !isEditing && (
<MessageAction
tooltip={t("common:actions.edit")}
onClick={() => onEditMessage(message.id)}
>
<Pencil className="size-3.5" />
</MessageAction>
)}
<span className="px-1 text-[10px] text-muted-foreground">
{formatDate(created, {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</MessageActions>
</>
)}
</div>
</div>
);
@@ -13,6 +13,9 @@ interface MessageTimelineProps {
onScrollTargetHandled?: (messageId: string) => void;
onRetryMessage?: (messageId: string) => void;
onEditMessage?: (messageId: string) => void;
editingMessageId?: string | null;
onSaveEdit?: (messageId: string, text: string) => void;
onCancelEdit?: () => void;
className?: string;
}
@@ -57,6 +60,9 @@ export function MessageTimeline({
onScrollTargetHandled,
onRetryMessage,
onEditMessage,
editingMessageId = null,
onSaveEdit,
onCancelEdit,
className,
}: MessageTimelineProps) {
const { t } = useTranslation("chat");
@@ -212,6 +218,9 @@ export function MessageTimeline({
onEditMessage={
message.role === "user" ? onEditMessage : undefined
}
isEditing={message.id === editingMessageId}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
/>
</div>
);