mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user