mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat(site): improve edit-message UX with dedicated button and confirmation (#23172)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user