mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor(site): simplify AgentChatInput into a controlled component (#22426)
This commit is contained in:
@@ -16,7 +16,9 @@ const meta: Meta<typeof AgentChatInput> = {
|
||||
component: AgentChatInput,
|
||||
args: {
|
||||
onSend: fn(),
|
||||
onChange: fn(),
|
||||
onModelChange: fn(),
|
||||
value: "",
|
||||
isDisabled: false,
|
||||
isLoading: false,
|
||||
selectedModel: defaultModelOptions[0].id,
|
||||
@@ -36,37 +38,32 @@ export const Default: Story = {};
|
||||
export const DisablesSendUntilInput: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByPlaceholderText("Type a message...");
|
||||
const sendButton = canvas.getByRole("button", { name: "Send" });
|
||||
|
||||
expect(sendButton).toBeDisabled();
|
||||
await userEvent.type(input, "Write tests");
|
||||
expect(sendButton).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
export const SendsAndClearsInput: Story = {
|
||||
args: {
|
||||
onSend: fn().mockResolvedValue(undefined),
|
||||
onSend: fn(),
|
||||
value: "Run focused tests",
|
||||
},
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByPlaceholderText("Type a message...");
|
||||
|
||||
await userEvent.type(input, "Run focused tests");
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.onSend).toHaveBeenCalledWith("Run focused tests", undefined);
|
||||
expect(args.onSend).toHaveBeenCalledWith("Run focused tests");
|
||||
});
|
||||
expect(input).toHaveValue("");
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledInput: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
initialValue: "Should not send",
|
||||
value: "Should not send",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -78,7 +75,7 @@ export const NoModelOptions: Story = {
|
||||
args: {
|
||||
isDisabled: false,
|
||||
hasModelOptions: false,
|
||||
initialValue: "Model required",
|
||||
value: "Model required",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -90,7 +87,7 @@ export const LoadingSpinner: Story = {
|
||||
args: {
|
||||
isDisabled: true,
|
||||
isLoading: true,
|
||||
initialValue: "Sending...",
|
||||
value: "Sending...",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -101,3 +98,18 @@ export const LoadingSpinner: Story = {
|
||||
expect(sendButton.querySelector(".animate-spin")).toBeTruthy();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadingDisablesSend: Story = {
|
||||
args: {
|
||||
isDisabled: false,
|
||||
isLoading: true,
|
||||
value: "Another message",
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const sendButton = canvas.getByRole("button", { name: "Send" });
|
||||
// The send button should be disabled while a previous send is
|
||||
// in-flight, even though the textarea has content.
|
||||
expect(sendButton).toBeDisabled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,15 +16,9 @@ import {
|
||||
Square,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { memo, type ReactNode, useRef } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatProviderLabel } from "./modelOptions";
|
||||
import { QueuedMessagesList } from "./QueuedMessagesList";
|
||||
|
||||
@@ -41,16 +35,13 @@ export interface AgentContextUsage {
|
||||
}
|
||||
|
||||
interface AgentChatInputProps {
|
||||
onSend: (message: string, editedMessageID?: number) => Promise<void>;
|
||||
onSend: (message: string) => void;
|
||||
placeholder?: string;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
// Optional initial value for the textarea (e.g. restored from
|
||||
// localStorage on the create page).
|
||||
initialValue?: string;
|
||||
// Fires whenever the textarea value changes, useful for persisting
|
||||
// the draft externally.
|
||||
onInputChange?: (value: string) => void;
|
||||
// Controlled input value. The parent owns the text state.
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
// Model selector.
|
||||
selectedModel: string;
|
||||
onModelChange: (value: string) => void;
|
||||
@@ -71,6 +62,13 @@ interface AgentChatInputProps {
|
||||
queuedMessages?: readonly ChatQueuedMessage[];
|
||||
onDeleteQueuedMessage?: (id: number) => Promise<void> | void;
|
||||
onPromoteQueuedMessage?: (id: number) => Promise<void> | void;
|
||||
// Queue editing state, owned by the parent.
|
||||
editingQueuedMessageID?: number | null;
|
||||
onStartQueueEdit?: (id: number, text: string) => void;
|
||||
onCancelQueueEdit?: () => void;
|
||||
// History editing state, owned by the parent.
|
||||
isEditingHistoryMessage?: boolean;
|
||||
onCancelHistoryEdit?: () => void;
|
||||
|
||||
// Optional context-usage summary shown to the left of the send button.
|
||||
// Pass `null` to render fallback values (e.g. when limit is unknown).
|
||||
@@ -79,13 +77,6 @@ interface AgentChatInputProps {
|
||||
// When true the entire input sticks to the bottom of the scroll
|
||||
// container (used in the detail page).
|
||||
sticky?: boolean;
|
||||
// External edit request — when set, replaces the input text and
|
||||
// focuses the textarea. Use a unique `key` to allow re-editing the
|
||||
// same text.
|
||||
editRequest?: { text: string; messageId?: number; key: number } | null;
|
||||
// Called when the user cancels or completes a history edit so the
|
||||
// parent can clear the editing highlight.
|
||||
onEditCleared?: () => void;
|
||||
}
|
||||
|
||||
const hasFiniteTokenValue = (value: number | undefined): value is number =>
|
||||
@@ -144,7 +135,6 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
|
||||
const hasPercent = percentUsed !== null;
|
||||
const percentLabel =
|
||||
percentUsed === null ? "--" : `${Math.round(percentUsed)}%`;
|
||||
const indicatorLabel = null;
|
||||
const clampedPercent = hasPercent
|
||||
? Math.min(Math.max(percentUsed, 0), 100)
|
||||
: 100;
|
||||
@@ -158,14 +148,13 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
|
||||
className="relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 outline-none transition-colors hover:bg-surface-secondary/60 focus-visible:ring-2 focus-visible:ring-content-link/40"
|
||||
>
|
||||
<svg
|
||||
className={`h-5 w-5 -rotate-90 ${toneClassName}`}
|
||||
className={cn("h-5 w-5 -rotate-90", toneClassName)}
|
||||
viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -191,12 +180,7 @@ const ContextUsageIndicator = memo<{ usage: AgentContextUsage | null }>(
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
{indicatorLabel !== null && (
|
||||
<span className="pointer-events-none absolute text-[7px] font-semibold tabular-nums text-content-secondary">
|
||||
{indicatorLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<div className="text-xs text-content-primary">
|
||||
@@ -224,8 +208,8 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
placeholder = "Type a message...",
|
||||
isDisabled,
|
||||
isLoading,
|
||||
initialValue = "",
|
||||
onInputChange,
|
||||
value,
|
||||
onChange,
|
||||
selectedModel,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
@@ -240,209 +224,62 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
queuedMessages = [],
|
||||
onDeleteQueuedMessage,
|
||||
onPromoteQueuedMessage,
|
||||
editingQueuedMessageID = null,
|
||||
onStartQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
isEditingHistoryMessage = false,
|
||||
onCancelHistoryEdit,
|
||||
contextUsage,
|
||||
sticky = false,
|
||||
editRequest = null,
|
||||
onEditCleared,
|
||||
}) => {
|
||||
const [input, setInput] = useState(initialValue);
|
||||
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Handle external edit requests (e.g. clicking a historical
|
||||
// user message's edit icon).
|
||||
const lastEditKeyRef = useRef<number | null>(null);
|
||||
const [isEditingHistoryMessage, setIsEditingHistoryMessage] =
|
||||
useState(false);
|
||||
const [editingHistoryMessageID, setEditingHistoryMessageID] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
useEffect(() => {
|
||||
if (!editRequest || editRequest.key === lastEditKeyRef.current) {
|
||||
const canSend =
|
||||
!isDisabled && !isLoading && hasModelOptions && !!value.trim();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSend) {
|
||||
return;
|
||||
}
|
||||
lastEditKeyRef.current = editRequest.key;
|
||||
setDraftBeforeHistoryEdit((current) =>
|
||||
isEditingHistoryMessage ? current : input,
|
||||
);
|
||||
setIsEditingHistoryMessage(true);
|
||||
setEditingHistoryMessageID(editRequest.messageId ?? null);
|
||||
setInput(editRequest.text);
|
||||
onInputChange?.(editRequest.text);
|
||||
onSend(value.trim());
|
||||
textareaRef.current?.focus();
|
||||
}, [editRequest, input, isEditingHistoryMessage, onInputChange]);
|
||||
};
|
||||
|
||||
const handleCancelHistoryEdit = useCallback(() => {
|
||||
if (!isEditingHistoryMessage) {
|
||||
return;
|
||||
}
|
||||
const restored = draftBeforeHistoryEdit ?? "";
|
||||
setIsEditingHistoryMessage(false);
|
||||
setEditingHistoryMessageID(null);
|
||||
setDraftBeforeHistoryEdit(null);
|
||||
setInput(restored);
|
||||
onInputChange?.(restored);
|
||||
onEditCleared?.();
|
||||
textareaRef.current?.focus();
|
||||
}, [
|
||||
draftBeforeHistoryEdit,
|
||||
isEditingHistoryMessage,
|
||||
onEditCleared,
|
||||
onInputChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingQueuedMessageID === null) {
|
||||
return;
|
||||
}
|
||||
const stillQueued = queuedMessages.some(
|
||||
(message) => message.id === editingQueuedMessageID,
|
||||
);
|
||||
if (stillQueued) {
|
||||
return;
|
||||
}
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
}, [editingQueuedMessageID, queuedMessages]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text || isDisabled || !hasModelOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queueEditID = editingQueuedMessageID;
|
||||
const editedMessageID =
|
||||
isEditingHistoryMessage && editingHistoryMessageID !== null
|
||||
? editingHistoryMessageID
|
||||
: undefined;
|
||||
// Capture the raw input before clearing so we can restore
|
||||
// it if the request fails.
|
||||
const capturedInput = input;
|
||||
|
||||
// Clear the input and editing state immediately so the
|
||||
// user can start typing their next message without waiting
|
||||
// for the network round-trip.
|
||||
setInput("");
|
||||
onInputChange?.("");
|
||||
if (queueEditID !== null) {
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
}
|
||||
if (isEditingHistoryMessage) {
|
||||
setIsEditingHistoryMessage(false);
|
||||
setEditingHistoryMessageID(null);
|
||||
setDraftBeforeHistoryEdit(null);
|
||||
onEditCleared?.();
|
||||
}
|
||||
|
||||
try {
|
||||
await onSend(capturedInput, editedMessageID);
|
||||
if (queueEditID !== null && onDeleteQueuedMessage) {
|
||||
await onDeleteQueuedMessage(queueEditID);
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (editingQueuedMessageID !== null) {
|
||||
e.preventDefault();
|
||||
onCancelQueueEdit?.();
|
||||
} else if (isEditingHistoryMessage) {
|
||||
e.preventDefault();
|
||||
onCancelHistoryEdit?.();
|
||||
} else if (isStreaming && onInterrupt && !isInterruptPending) {
|
||||
e.preventDefault();
|
||||
onInterrupt();
|
||||
}
|
||||
} catch {
|
||||
// Restore the input so the user can retry.
|
||||
setInput(capturedInput);
|
||||
onInputChange?.(capturedInput);
|
||||
} finally {
|
||||
// Re-focus the textarea so the user can keep typing.
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
editingQueuedMessageID,
|
||||
editingHistoryMessageID,
|
||||
hasModelOptions,
|
||||
input,
|
||||
isDisabled,
|
||||
isEditingHistoryMessage,
|
||||
onDeleteQueuedMessage,
|
||||
onEditCleared,
|
||||
onInputChange,
|
||||
onSend,
|
||||
]);
|
||||
|
||||
const handleStartQueueEdit = useCallback(
|
||||
(id: number, text: string) => {
|
||||
setDraftBeforeQueueEdit((current) =>
|
||||
editingQueuedMessageID === null ? input : current,
|
||||
);
|
||||
setEditingQueuedMessageID(id);
|
||||
setInput(text);
|
||||
onInputChange?.(text);
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[editingQueuedMessageID, input, onInputChange],
|
||||
);
|
||||
|
||||
const handleCancelQueueEdit = useCallback(() => {
|
||||
if (editingQueuedMessageID === null) {
|
||||
return;
|
||||
}
|
||||
const restored = draftBeforeQueueEdit ?? "";
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
setInput(restored);
|
||||
onInputChange?.(restored);
|
||||
textareaRef.current?.focus();
|
||||
}, [draftBeforeQueueEdit, editingQueuedMessageID, onInputChange]);
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// If the input is empty and there are queued messages,
|
||||
// promote the first one instead of submitting.
|
||||
if (
|
||||
!value.trim() &&
|
||||
!isDisabled &&
|
||||
!isLoading &&
|
||||
queuedMessages.length > 0 &&
|
||||
onPromoteQueuedMessage
|
||||
) {
|
||||
void onPromoteQueuedMessage(queuedMessages[0].id);
|
||||
return;
|
||||
}
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const sendButtonLabel =
|
||||
isStreaming && editingQueuedMessageID === null ? "Queue message" : "Send";
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
if (editingQueuedMessageID !== null) {
|
||||
e.preventDefault();
|
||||
handleCancelQueueEdit();
|
||||
} else if (isEditingHistoryMessage) {
|
||||
e.preventDefault();
|
||||
handleCancelHistoryEdit();
|
||||
} else if (isStreaming && onInterrupt && !isInterruptPending) {
|
||||
e.preventDefault();
|
||||
onInterrupt();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// If the input is empty and there are queued messages,
|
||||
// promote the first one instead of submitting.
|
||||
if (
|
||||
!input.trim() &&
|
||||
queuedMessages.length > 0 &&
|
||||
onPromoteQueuedMessage
|
||||
) {
|
||||
void onPromoteQueuedMessage(queuedMessages[0].id);
|
||||
return;
|
||||
}
|
||||
void handleSubmit();
|
||||
}
|
||||
},
|
||||
[
|
||||
editingQueuedMessageID,
|
||||
handleCancelHistoryEdit,
|
||||
handleCancelQueueEdit,
|
||||
handleSubmit,
|
||||
input,
|
||||
isEditingHistoryMessage,
|
||||
isInterruptPending,
|
||||
isStreaming,
|
||||
onInterrupt,
|
||||
onPromoteQueuedMessage,
|
||||
queuedMessages,
|
||||
],
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="mx-auto w-full max-w-3xl pb-4">
|
||||
{queuedMessages.length > 0 && (
|
||||
@@ -450,17 +287,17 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
messages={queuedMessages}
|
||||
onDelete={(id) => {
|
||||
if (id === editingQueuedMessageID) {
|
||||
handleCancelQueueEdit();
|
||||
onCancelQueueEdit?.();
|
||||
}
|
||||
void onDeleteQueuedMessage?.(id);
|
||||
}}
|
||||
onPromote={(id) => {
|
||||
if (id === editingQueuedMessageID) {
|
||||
handleCancelQueueEdit();
|
||||
onCancelQueueEdit?.();
|
||||
}
|
||||
void onPromoteQueuedMessage?.(id);
|
||||
}}
|
||||
onEdit={handleStartQueueEdit}
|
||||
onEdit={onStartQueueEdit}
|
||||
editingMessageID={editingQueuedMessageID}
|
||||
className="mb-2"
|
||||
/>
|
||||
@@ -475,7 +312,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={handleCancelQueueEdit}
|
||||
onClick={onCancelQueueEdit}
|
||||
className="h-7 px-2 text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
Cancel
|
||||
@@ -495,7 +332,7 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
aria-label="Cancel editing"
|
||||
onClick={handleCancelHistoryEdit}
|
||||
onClick={onCancelHistoryEdit}
|
||||
disabled={isLoading}
|
||||
className="size-6 rounded text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
@@ -508,11 +345,8 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
aria-label="Chat message"
|
||||
className="min-h-[120px] w-full resize-none border-none bg-transparent px-3 py-2 font-sans text-[15px] leading-6 text-content-primary outline-none placeholder:text-content-secondary disabled:cursor-not-allowed disabled:opacity-70"
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
onInputChange?.(e.target.value);
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
minRows={4}
|
||||
@@ -528,7 +362,6 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
formatProviderLabel={formatProviderLabel}
|
||||
dropdownSide="top"
|
||||
dropdownAlign="center"
|
||||
className=""
|
||||
/>
|
||||
{leftActions}
|
||||
{inputStatusText && (
|
||||
@@ -557,9 +390,8 @@ export const AgentChatInput = memo<AgentChatInputProps>(
|
||||
size="icon"
|
||||
variant="default"
|
||||
className="size-7 rounded-full transition-colors [&>svg]:!size-6 flex items-center justify-center"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={isDisabled || !hasModelOptions || !input.trim()}
|
||||
title={sendButtonLabel}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="animate-spin" />
|
||||
|
||||
@@ -187,7 +187,7 @@ const AgentDetailTimeline: FC<AgentDetailTimelineProps> = ({
|
||||
interface AgentDetailInputProps {
|
||||
store: ChatStoreHandle;
|
||||
compressionThreshold: number | undefined;
|
||||
onSend: (message: string, editedMessageID?: number) => Promise<void>;
|
||||
onSend: (message: string) => void;
|
||||
onDeleteQueuedMessage: (id: number) => Promise<void>;
|
||||
onPromoteQueuedMessage: (id: number) => Promise<void>;
|
||||
onInterrupt: () => void;
|
||||
@@ -201,8 +201,15 @@ interface AgentDetailInputProps {
|
||||
modelSelectorPlaceholder: string;
|
||||
inputStatusText: string | null;
|
||||
modelCatalogStatusMessage: string | null;
|
||||
editRequest?: { text: string; messageId?: number; key: number } | null;
|
||||
onEditCleared?: () => void;
|
||||
// Controlled input value and editing state, owned by the
|
||||
// conversation component.
|
||||
inputValue: string;
|
||||
onInputChange: (value: string) => void;
|
||||
editingQueuedMessageID: number | null;
|
||||
onStartQueueEdit: (id: number, text: string) => void;
|
||||
onCancelQueueEdit: () => void;
|
||||
isEditingHistoryMessage: boolean;
|
||||
onCancelHistoryEdit: () => void;
|
||||
}
|
||||
|
||||
const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
@@ -222,8 +229,13 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
modelSelectorPlaceholder,
|
||||
inputStatusText,
|
||||
modelCatalogStatusMessage,
|
||||
editRequest,
|
||||
onEditCleared,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
editingQueuedMessageID,
|
||||
onStartQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
isEditingHistoryMessage,
|
||||
onCancelHistoryEdit,
|
||||
}) => {
|
||||
const messagesByID = useChatSelector(store, selectMessagesByID);
|
||||
const orderedMessageIDs = useChatSelector(store, selectOrderedMessageIDs);
|
||||
@@ -251,9 +263,16 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
return (
|
||||
<AgentChatInput
|
||||
onSend={onSend}
|
||||
value={inputValue}
|
||||
onChange={onInputChange}
|
||||
queuedMessages={queuedMessages}
|
||||
onDeleteQueuedMessage={onDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={onPromoteQueuedMessage}
|
||||
editingQueuedMessageID={editingQueuedMessageID}
|
||||
onStartQueueEdit={onStartQueueEdit}
|
||||
onCancelQueueEdit={onCancelQueueEdit}
|
||||
isEditingHistoryMessage={isEditingHistoryMessage}
|
||||
onCancelHistoryEdit={onCancelHistoryEdit}
|
||||
isDisabled={isInputDisabled}
|
||||
isLoading={isSendPending}
|
||||
isStreaming={isStreaming}
|
||||
@@ -267,8 +286,6 @@ const AgentDetailInput: FC<AgentDetailInputProps> = ({
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
editRequest={editRequest}
|
||||
onEditCleared={onEditCleared}
|
||||
sticky
|
||||
/>
|
||||
);
|
||||
@@ -317,22 +334,88 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
|
||||
modelCatalogStatusMessage,
|
||||
savingMessageId,
|
||||
}) => {
|
||||
const [editRequest, setEditRequest] = useState<{
|
||||
text: string;
|
||||
messageId: number;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
// -- History editing state --
|
||||
const [editingMessageId, setEditingMessageId] = useState<number | null>(null);
|
||||
const [draftBeforeHistoryEdit, setDraftBeforeHistoryEdit] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleEditUserMessage = useCallback(
|
||||
(messageId: number, text: string) => {
|
||||
setEditRequest({ text, messageId, key: Date.now() });
|
||||
setDraftBeforeHistoryEdit((prev) =>
|
||||
editingMessageId !== null ? prev : inputValue,
|
||||
);
|
||||
setEditingMessageId(messageId);
|
||||
setInputValue(text);
|
||||
},
|
||||
[],
|
||||
[editingMessageId, inputValue],
|
||||
);
|
||||
|
||||
const handleEditCleared = useCallback(() => {
|
||||
setEditRequest(null);
|
||||
}, []);
|
||||
const handleCancelHistoryEdit = useCallback(() => {
|
||||
setInputValue(draftBeforeHistoryEdit ?? "");
|
||||
setEditingMessageId(null);
|
||||
setDraftBeforeHistoryEdit(null);
|
||||
}, [draftBeforeHistoryEdit]);
|
||||
|
||||
// -- Queue editing state --
|
||||
const [editingQueuedMessageID, setEditingQueuedMessageID] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [draftBeforeQueueEdit, setDraftBeforeQueueEdit] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleStartQueueEdit = useCallback(
|
||||
(id: number, text: string) => {
|
||||
setDraftBeforeQueueEdit((prev) =>
|
||||
editingQueuedMessageID === null ? inputValue : prev,
|
||||
);
|
||||
setEditingQueuedMessageID(id);
|
||||
setInputValue(text);
|
||||
},
|
||||
[editingQueuedMessageID, inputValue],
|
||||
);
|
||||
|
||||
const handleCancelQueueEdit = useCallback(() => {
|
||||
setInputValue(draftBeforeQueueEdit ?? "");
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
}, [draftBeforeQueueEdit]);
|
||||
|
||||
// Wraps the parent onSend to clear local input/editing state
|
||||
// and handle queue-edit deletion.
|
||||
const handleSendFromInput = useCallback(
|
||||
(message: string) => {
|
||||
const editedMessageID =
|
||||
editingMessageId !== null ? editingMessageId : undefined;
|
||||
const queueEditID = editingQueuedMessageID;
|
||||
|
||||
// Clear input and editing state optimistically.
|
||||
setInputValue("");
|
||||
if (editingMessageId !== null) {
|
||||
setEditingMessageId(null);
|
||||
setDraftBeforeHistoryEdit(null);
|
||||
}
|
||||
if (queueEditID !== null) {
|
||||
setEditingQueuedMessageID(null);
|
||||
setDraftBeforeQueueEdit(null);
|
||||
}
|
||||
|
||||
void onSend(message, editedMessageID)
|
||||
.then(() => {
|
||||
if (queueEditID !== null) {
|
||||
void onDeleteQueuedMessage(queueEditID);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Restore input so the user can retry.
|
||||
setInputValue(message);
|
||||
});
|
||||
},
|
||||
[editingMessageId, editingQueuedMessageID, onDeleteQueuedMessage, onSend],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -341,13 +424,13 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
|
||||
chatID={chatID}
|
||||
persistedErrorReason={persistedErrorReason}
|
||||
onEditUserMessage={handleEditUserMessage}
|
||||
editingMessageId={editRequest?.messageId ?? null}
|
||||
editingMessageId={editingMessageId}
|
||||
savingMessageId={savingMessageId}
|
||||
/>
|
||||
<AgentDetailInput
|
||||
store={store}
|
||||
compressionThreshold={compressionThreshold}
|
||||
onSend={onSend}
|
||||
onSend={handleSendFromInput}
|
||||
onDeleteQueuedMessage={onDeleteQueuedMessage}
|
||||
onPromoteQueuedMessage={onPromoteQueuedMessage}
|
||||
onInterrupt={onInterrupt}
|
||||
@@ -361,8 +444,13 @@ const AgentDetailConversation: FC<AgentDetailConversationProps> = ({
|
||||
modelSelectorPlaceholder={modelSelectorPlaceholder}
|
||||
inputStatusText={inputStatusText}
|
||||
modelCatalogStatusMessage={modelCatalogStatusMessage}
|
||||
editRequest={editRequest}
|
||||
onEditCleared={handleEditCleared}
|
||||
inputValue={inputValue}
|
||||
onInputChange={setInputValue}
|
||||
editingQueuedMessageID={editingQueuedMessageID}
|
||||
onStartQueueEdit={handleStartQueueEdit}
|
||||
onCancelQueueEdit={handleCancelQueueEdit}
|
||||
isEditingHistoryMessage={editingMessageId !== null}
|
||||
onCancelHistoryEdit={handleCancelHistoryEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -812,7 +900,9 @@ const AgentDetail: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<AgentChatInput
|
||||
onSend={handleSend}
|
||||
onSend={() => {}}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
isDisabled={isInputDisabled}
|
||||
isLoading={false}
|
||||
selectedModel={selectedModel}
|
||||
|
||||
@@ -520,24 +520,24 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
canManageChatModelConfigs,
|
||||
topBarActionsRef,
|
||||
}) => {
|
||||
const initialInput = useMemo(() => {
|
||||
const [inputValue, setInputValue] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return localStorage.getItem(emptyInputStorageKey) ?? "";
|
||||
}, []);
|
||||
const initialSystemPrompt = useMemo(() => {
|
||||
});
|
||||
const initialSystemPrompt = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return localStorage.getItem(systemPromptStorageKey) ?? "";
|
||||
}, []);
|
||||
const initialLastModelConfigID = useMemo(() => {
|
||||
};
|
||||
const [initialLastModelConfigID] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
return localStorage.getItem(lastModelConfigIDStorageKey) ?? "";
|
||||
}, []);
|
||||
});
|
||||
const modelIDByConfigID = useMemo(() => {
|
||||
const optionIDByRef = new Map<string, string>();
|
||||
for (const option of modelOptions) {
|
||||
@@ -671,6 +671,7 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
};
|
||||
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setInputValue(value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(emptyInputStorageKey, value);
|
||||
}
|
||||
@@ -700,8 +701,8 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (message: string) => {
|
||||
await onCreateChat({
|
||||
(message: string) => {
|
||||
void onCreateChat({
|
||||
message,
|
||||
workspaceId: selectedWorkspaceIdRef.current ?? undefined,
|
||||
model: selectedModelRef.current || undefined,
|
||||
@@ -741,8 +742,8 @@ export const AgentsEmptyState: FC<AgentsEmptyStateProps> = ({
|
||||
placeholder="Ask Coder to build, fix bugs, or explore your project..."
|
||||
isDisabled={isCreating}
|
||||
isLoading={isCreating}
|
||||
initialValue={initialInput}
|
||||
onInputChange={handleInputChange}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
selectedModel={selectedModel}
|
||||
onModelChange={handleModelChange}
|
||||
modelOptions={modelOptions}
|
||||
|
||||
Reference in New Issue
Block a user