feat(site): improve edit-message UX with dedicated button and confirmation (#23172)

This commit is contained in:
Danielle Maywood
2026-03-17 17:39:28 +00:00
committed by GitHub
parent 497e1e6589
commit 41d12b8aa3
6 changed files with 263 additions and 35 deletions
+25 -7
View File
@@ -21,6 +21,7 @@ import {
CheckIcon,
ImageIcon,
MicIcon,
PencilIcon,
Square,
XIcon,
} from "lucide-react";
@@ -520,6 +521,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
) {
return;
}
onSend(text);
if (!isMobileViewport()) {
internalRef.current?.focus();
@@ -572,10 +574,20 @@ export const AgentChatInput = memo<AgentChatInputProps>(
}
};
const sendButtonLabel = editingQueuedMessageID !== null ? "Save" : "Send";
const sendButtonLabel =
editingQueuedMessageID !== null
? "Save"
: isEditingHistoryMessage
? "Save Edit"
: "Send";
const content = (
<div className="mx-auto w-full max-w-3xl pb-0 sm:pb-4">
<div
className={cn(
"mx-auto w-full max-w-3xl pb-0 sm:pb-4",
isEditingHistoryMessage && "pt-1",
)}
>
{queuedMessages.length > 0 && (
<QueuedMessagesList
messages={queuedMessages}
@@ -600,6 +612,8 @@ export const AgentChatInput = memo<AgentChatInputProps>(
className={cn(
"rounded-2xl border border-border-default/80 bg-surface-secondary/45 p-1 shadow-sm has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-content-link/40",
isDragging && "ring-2 ring-content-link/40",
isEditingHistoryMessage &&
"shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
)}
onKeyDown={handleKeyDown}
onDragOver={onAttach ? handleDragOver : undefined}
@@ -623,10 +637,12 @@ export const AgentChatInput = memo<AgentChatInputProps>(
</div>
)}
{isEditingHistoryMessage && editingQueuedMessageID === null && (
<div className="flex items-center justify-between border-b border-border-default/70 px-3 py-1.5">
<span className="flex items-center gap-1.5 text-sm text-content-secondary">
{isLoading && <Spinner className="h-3.5 w-3.5" loading />}
{isLoading ? "Saving edit..." : "Editing message"}
<div className="flex items-center justify-between border-b border-border-warning/50 px-3 py-1.5">
<span className="flex items-center gap-1.5 text-xs font-medium text-content-warning">
<PencilIcon className="h-3.5 w-3.5" />
{isLoading
? "Saving edit..."
: "Editing message \u2014 all subsequent messages will be deleted"}
</span>
<Button
type="button"
@@ -635,7 +651,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
aria-label="Cancel editing"
onClick={onCancelHistoryEdit}
disabled={isLoading}
className="size-6 rounded text-content-secondary hover:text-content-primary"
className="size-6 rounded text-content-warning hover:text-content-primary"
>
<XIcon className="h-3.5 w-3.5" />
</Button>
@@ -662,8 +678,10 @@ export const AgentChatInput = memo<AgentChatInputProps>(
disabled={isDisabled || isLoading}
autoFocus
/>
<div className="flex items-center justify-between gap-2 px-2.5 pb-1.5">
<div className="flex min-w-0 items-center gap-2">
{" "}
<ModelSelector
value={selectedModel}
onValueChange={onModelChange}
+5 -1
View File
@@ -130,7 +130,11 @@ export function useConversationEditingState(deps: {
setEditingMessageId(null);
setDraftBeforeHistoryEdit(null);
setEditingFileBlocks([]);
}, [draftBeforeHistoryEdit, inputValueRef]);
chatInputRef.current?.clear();
if (draftBeforeHistoryEdit) {
chatInputRef.current?.insertText(draftBeforeHistoryEdit);
}
}, [draftBeforeHistoryEdit, inputValueRef, chatInputRef]);
// -- Queue editing state --
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
@@ -2268,6 +2268,76 @@ describe("useChatStore", () => {
expect(clearChatErrorReason).toHaveBeenCalledWith(chatID);
});
});
it("removes stale messages when refetched set is smaller (edit truncation)", async () => {
immediateAnimationFrame();
const chatID = "chat-edit-truncation";
const msg1 = makeMessage(chatID, 1, "user", "first");
const msg2 = makeMessage(chatID, 2, "assistant", "second");
const msg3 = makeMessage(chatID, 3, "user", "third");
const mockSocket = createMockSocket();
vi.mocked(watchChat).mockReturnValue(mockSocket as never);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const setChatErrorReason = vi.fn();
const clearChatErrorReason = vi.fn();
const noQueued: TypesGen.ChatQueuedMessage[] = [];
const initialMessages = [msg1, msg2, msg3];
const initialOptions = {
chatID,
chatMessages: initialMessages,
chatRecord: makeChat(chatID),
chatMessagesData: {
messages: initialMessages,
queued_messages: noQueued,
has_more: false,
},
chatQueuedMessages: noQueued,
setChatErrorReason,
clearChatErrorReason,
};
const { result, rerender } = renderHook(
(options: Parameters<typeof useChatStore>[0]) => {
const { store } = useChatStore(options);
return {
orderedMessageIDs: useChatSelector(store, selectOrderedMessageIDs),
};
},
{ initialProps: initialOptions, wrapper },
);
// All three messages should be in the store.
await waitFor(() => {
expect(result.current.orderedMessageIDs).toEqual([1, 2, 3]);
});
// Simulate a post-edit refetch that only returns the first
// message (server truncated messages 2 and 3).
rerender({
...initialOptions,
chatMessages: [msg1],
chatMessagesData: {
messages: [msg1],
queued_messages: [],
has_more: false,
},
});
// Messages 2 and 3 should be removed — replaceMessages should
// have been used instead of upsert because the store contained
// IDs not present in the fetched set.
await waitFor(() => {
expect(result.current.orderedMessageIDs).toEqual([1]);
});
});
});
describe("updateSidebarChat via stream events", () => {
@@ -559,9 +559,22 @@ export const useChatStore = (
// of replacing the entire map. This preserves any messages the
// WebSocket delivered via upsertDurableMessage that haven't
// appeared in a REST page yet.
//
// However, if the fetched set is missing message IDs the store
// already has (e.g. after an edit truncation), a full replace
// is needed because upsert can only add/update, not remove.
if (chatMessages) {
for (const message of chatMessages) {
store.upsertDurableMessage(message);
const fetchedIDs = new Set(chatMessages.map((m) => m.id));
const storeSnap = store.getSnapshot();
const hasStaleEntries = storeSnap.orderedMessageIDs.some(
(id) => !fetchedIDs.has(id),
);
if (hasStaleEntries) {
store.replaceMessages(chatMessages);
} else {
for (const message of chatMessages) {
store.upsertDurableMessage(message);
}
}
}
}, [chatID, chatMessages, store]);
@@ -12,7 +12,12 @@ import { WebSearchSources } from "components/ai-elements/tool";
import { Button } from "components/Button/Button";
import { FileReferenceChip } from "components/ChatMessageInput/FileReferenceNode";
import { Spinner } from "components/Spinner/Spinner";
import { ChevronDownIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ChevronDownIcon, PencilIcon } from "lucide-react";
import {
type FC,
Fragment,
@@ -296,6 +301,7 @@ const ChatMessageItem = memo<{
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
isAfterEditingMessage?: boolean;
// When true, renders a gradient overlay inside the bubble
// that fades text out toward the bottom. Used by the sticky
// overlay to indicate truncated content.
@@ -308,6 +314,7 @@ const ChatMessageItem = memo<{
onEditUserMessage,
editingMessageId,
savingMessageId,
isAfterEditingMessage = false,
fadeFromBottom = false,
urlTransform,
}) => {
@@ -369,18 +376,20 @@ const ChatMessageItem = memo<{
);
return (
<>
<div
className={cn(
isAfterEditingMessage && "opacity-40 pointer-events-none",
"transition-opacity duration-200",
)}
>
<ConversationItem {...conversationItemProps}>
{isUser ? (
<Message className="my-2 w-full max-w-none">
<MessageContent
className={cn(
"rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
onEditUserMessage &&
!isSavingMessage &&
"cursor-pointer [&:hover:not(:has(button:hover))]:bg-surface-tertiary",
"group/msg rounded-lg border border-solid border-border-default bg-surface-secondary px-3 py-2 font-sans shadow-sm transition-shadow",
editingMessageId === message.id &&
"ring-2 ring-content-link/40",
"border-surface-secondary shadow-[0_0_0_2px_hsla(var(--border-warning),0.6)]",
isSavingMessage && "ring-2 ring-content-secondary/40",
fadeFromBottom && "relative overflow-hidden",
)}
@@ -389,22 +398,6 @@ const ChatMessageItem = memo<{
? { maxHeight: "var(--clip-h, none)" }
: undefined
}
onClick={
onEditUserMessage && !isSavingMessage
? () => {
const fileBlocks = parsed.blocks.filter(
(b): b is Extract<RenderBlock, { type: "file" }> =>
b.type === "file" &&
b.media_type.startsWith("image/"),
);
onEditUserMessage(
message.id,
parsed.markdown || "",
fileBlocks.length > 0 ? fileBlocks : undefined,
);
}
: undefined
}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-start gap-2">
@@ -431,6 +424,37 @@ const ChatMessageItem = memo<{
loading
/>
)}
{onEditUserMessage && !isSavingMessage && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="mt-0.5 inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md border-none bg-transparent p-0 text-content-secondary opacity-0 transition-opacity hover:bg-surface-tertiary hover:text-content-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link group-hover/msg:opacity-100"
aria-label="Edit message"
onClick={() => {
const fileBlocks = parsed.blocks.filter(
(
b,
): b is Extract<
RenderBlock,
{ type: "file" }
> =>
b.type === "file" &&
b.media_type.startsWith("image/"),
);
onEditUserMessage(
message.id,
parsed.markdown || "",
fileBlocks.length > 0 ? fileBlocks : undefined,
);
}}
>
<PencilIcon className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Edit message</TooltipContent>
</Tooltip>
)}
</div>
{(() => {
const imageBlocks = parsed.blocks.filter(
@@ -508,7 +532,7 @@ const ChatMessageItem = memo<{
onClose={() => setPreviewImage(null)}
/>
)}
</>
</div>
);
},
);
@@ -605,12 +629,14 @@ const StickyUserMessage: FC<{
) => void;
editingMessageId?: number | null;
savingMessageId?: number | null;
isAfterEditingMessage?: boolean;
}> = ({
message,
parsed,
onEditUserMessage,
editingMessageId,
savingMessageId,
isAfterEditingMessage = false,
}) => {
const [isStuck, setIsStuck] = useState(false);
const [isReady, setIsReady] = useState(false);
@@ -787,6 +813,7 @@ const StickyUserMessage: FC<{
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
/>
</div>
@@ -831,6 +858,7 @@ const StickyUserMessage: FC<{
onEditUserMessage={handleEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={isAfterEditingMessage}
fadeFromBottom
/>
</div>
@@ -889,6 +917,24 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
const isUsageLimitError = detailError?.kind === "usage-limit";
const showUsageAction = onOpenAnalytics !== undefined && isUsageLimitError;
// Build a set of message IDs that appear after the message
// currently being edited so they can be visually faded.
const afterEditingMessageIds = new Set<number>();
if (editingMessageId != null) {
let found = false;
for (const section of parsedSections) {
for (const entry of section.entries) {
if (entry.message.id === editingMessageId) {
found = true;
continue;
}
if (found) {
afterEditingMessageIds.add(entry.message.id);
}
}
}
}
return (
<div className="mx-auto w-full max-w-3xl py-6">
{isEmpty && !hasStreamOutput ? (
@@ -924,6 +970,9 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
onEditUserMessage={onEditUserMessage}
editingMessageId={editingMessageId}
savingMessageId={savingMessageId}
isAfterEditingMessage={afterEditingMessageIds.has(
message.id,
)}
/>
) : (
<ChatMessageItem
@@ -932,9 +981,12 @@ export const ConversationTimeline: FC<ConversationTimelineProps> = ({
parsed={parsed}
savingMessageId={savingMessageId}
urlTransform={urlTransform}
isAfterEditingMessage={afterEditingMessageIds.has(
message.id,
)}
/>
),
)}
)}{" "}
{shouldRenderStreamInLastSection &&
sectionIdx === parsedSections.length - 1 && (
<StreamingOutput
@@ -332,6 +332,77 @@ export const LoadingSidebarCollapsed: Story = {
),
};
// ---------------------------------------------------------------------------
// Helpers for seeding stores with messages
// ---------------------------------------------------------------------------
const buildMessage = (
id: number,
role: TypesGen.ChatMessageRole,
text: string,
): TypesGen.ChatMessage => ({
id,
chat_id: AGENT_ID,
created_at: new Date(Date.now() - (10 - id) * 60_000).toISOString(),
role,
content: [{ type: "text", text }],
});
const buildStoreWithMessages = (
msgs: TypesGen.ChatMessage[],
status: TypesGen.ChatStatus = "completed",
) => {
const store = createChatStore();
store.replaceMessages(msgs);
store.setChatStatus(status);
return store;
};
// ---------------------------------------------------------------------------
// Editing flow stories
// ---------------------------------------------------------------------------
const editingMessages = [
buildMessage(1, "user", "Say hi back"),
buildMessage(2, "assistant", "Hi!"),
buildMessage(3, "user", "Now tell me a joke"),
buildMessage(
4,
"assistant",
"Why did the developer quit? Because they didn't get arrays.",
),
buildMessage(5, "user", "That was terrible, try again"),
];
/** Editing a message in the middle of the conversation — shows the warning
* border on the edited message, faded subsequent messages, and the editing
* banner + outline on the chat input. */
export const EditingMessage: Story = {
args: {
store: buildStoreWithMessages(editingMessages),
editing: {
...defaultEditing,
editingMessageId: 3,
editorInitialValue: "Now tell me a joke",
},
},
};
/** The saving state while an edit is in progress — shows the pending
* indicator on the message being saved. */
export const EditingSaving: Story = {
args: {
store: buildStoreWithMessages(editingMessages),
editing: {
...defaultEditing,
editingMessageId: 3,
editorInitialValue: "Now tell me a better joke",
},
pendingEditMessageId: 3,
isSubmissionPending: true,
},
};
// ---------------------------------------------------------------------------
// AgentDetailNotFoundView stories
// ---------------------------------------------------------------------------