feat: edit/delete user message

This commit is contained in:
NaccOll 2025-09-04 11:03:53 +08:00
parent c792662ac9
commit 7922443e94
7 changed files with 290 additions and 556 deletions

View file

@ -317,7 +317,6 @@ describe("Checkpoint functionality", () => {
},
]
mockCheckpointService.getDiff.mockResolvedValue(mockChanges)
mockCheckpointService.getCheckpoints = vi.fn(() => ["commit1", "commit2"])
await checkpointDiff(mockTask, {
ts: 4,
commitHash: "commit1",
@ -343,7 +342,6 @@ describe("Checkpoint functionality", () => {
},
]
mockCheckpointService.getDiff.mockResolvedValue(mockChanges)
mockCheckpointService.getCheckpoints = vi.fn(() => ["commit1", "commit2"])
await checkpointDiff(mockTask, {
ts: 4,

View file

@ -271,15 +271,16 @@ export async function checkpointDiff(task: Task, { ts, previousCommitHash, commi
TelemetryService.instance.captureCheckpointDiffed(task.taskId)
let prevHash = commitHash
let nextHash: string | undefined
let nextHash: string | undefined = undefined
const checkpoints = typeof service.getCheckpoints === "function" ? service.getCheckpoints() : []
const idx = checkpoints.indexOf(commitHash)
if (idx !== -1 && idx < checkpoints.length - 1) {
nextHash = checkpoints[idx + 1]
} else {
nextHash = undefined
if (mode !== "full") {
const checkpoints = task.clineMessages.filter(({ say }) => say === "checkpoint_saved").map(({ text }) => text!)
const idx = checkpoints.indexOf(commitHash)
if (idx !== -1 && idx < checkpoints.length - 1) {
nextHash = checkpoints[idx + 1]
} else {
nextHash = undefined
}
}
try {

View file

@ -110,9 +110,9 @@ export const webviewMessageHandler = async (
const { messageIndex } = findMessageIndices(messageTs, currentCline)
if (messageIndex !== -1) {
// Find the last checkpoint before this message
const checkpoints = currentCline.clineMessages
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
.sort((a, b) => b.ts - a.ts)
const checkpoints = currentCline.clineMessages.filter(
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
)
hasCheckpoint = checkpoints.length > 0
} else {
@ -153,19 +153,19 @@ export const webviewMessageHandler = async (
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
if (restoreCheckpoint) {
// Find the last checkpoint before this message
const checkpoints = currentCline.clineMessages
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
.sort((a, b) => b.ts - a.ts)
const checkpoints = currentCline.clineMessages.filter(
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
)
const lastCheckpoint = checkpoints[0]
const nextCheckpoint = checkpoints[0]
if (lastCheckpoint && lastCheckpoint.text) {
if (nextCheckpoint && nextCheckpoint.text) {
await handleCheckpointRestoreOperation({
provider,
currentCline,
messageTs: targetMessage.ts!,
messageIndex,
checkpoint: { hash: lastCheckpoint.text },
checkpoint: { hash: nextCheckpoint.text },
operation: "delete",
})
} else {
@ -221,9 +221,9 @@ export const webviewMessageHandler = async (
const { messageIndex } = findMessageIndices(messageTs, currentCline)
if (messageIndex !== -1) {
// Find the last checkpoint before this message
const checkpoints = currentCline.clineMessages
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
.sort((a, b) => b.ts - a.ts)
const checkpoints = currentCline.clineMessages.filter(
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
)
hasCheckpoint = checkpoints.length > 0
} else {
@ -274,19 +274,19 @@ export const webviewMessageHandler = async (
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
if (restoreCheckpoint) {
// Find the last checkpoint before this message
const checkpoints = currentCline.clineMessages
.filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs)
.sort((a, b) => b.ts - a.ts)
const checkpoints = currentCline.clineMessages.filter(
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
)
const lastCheckpoint = checkpoints[0]
const nextCheckpoint = checkpoints[0]
if (lastCheckpoint && lastCheckpoint.text) {
if (nextCheckpoint && nextCheckpoint.text) {
await handleCheckpointRestoreOperation({
provider,
currentCline,
messageTs: targetMessage.ts!,
messageIndex,
checkpoint: { hash: lastCheckpoint.text },
checkpoint: { hash: nextCheckpoint.text },
operation: "edit",
editData: {
editedContent,

View file

@ -46,6 +46,7 @@ import { appendImages } from "@src/utils/imageUtils"
import { McpExecution } from "./McpExecution"
import { ChatTextArea } from "./ChatTextArea"
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
import { useSelectedModel } from "../ui/hooks/useSelectedModel"
interface ChatRowProps {
message: ClineMessage
@ -115,8 +116,8 @@ export const ChatRowContent = ({
}: ChatRowContentProps) => {
const { t } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration } = useExtensionState()
const { info: model } = useSelectedModel(apiConfiguration)
const [reasoningCollapsed, setReasoningCollapsed] = useState(true)
const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false)
const [showCopySuccess, setShowCopySuccess] = useState(false)
@ -1184,7 +1185,7 @@ export const ChatRowContent = ({
setSelectedImages={setEditImages}
onSend={handleSaveEdit}
onSelectImages={handleSelectImages}
shouldDisableImages={false}
shouldDisableImages={!model?.supportsImages}
mode={editMode}
setMode={setEditMode}
modeShortcutText=""

View file

@ -1,7 +1,7 @@
import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import DynamicTextArea from "react-textarea-autosize"
import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react"
import { VolumeX, Image, WandSparkles, SendHorizontal, MessageSquareX } from "lucide-react"
import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions"
import { WebviewMessage } from "@roo/WebviewMessage"
@ -31,7 +31,6 @@ import ContextMenu from "./ContextMenu"
import { IndexingStatusBadge } from "./IndexingStatusBadge"
import { SlashCommandsPopover } from "./SlashCommandsPopover"
import { usePromptHistory } from "./hooks/usePromptHistory"
import { EditModeControls } from "./EditModeControls"
interface ChatTextAreaProps {
inputValue: string
@ -58,7 +57,6 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{
inputValue,
setInputValue,
sendingDisabled,
selectApiConfigDisabled,
placeholderText,
selectedImages,
@ -897,261 +895,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
[setMode],
)
// Helper function to render mode selector
const renderModeSelector = () => (
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={handleModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
)
// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}, [])
// Helper function to render non-edit mode controls
const renderNonEditModeControls = () => (
<div className={cn("flex", "justify-between", "items-center", "mt-auto")}>
<div className={cn("flex", "items-center", "gap-1", "min-w-0")}>
<div className="shrink-0">{renderModeSelector()}</div>
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
<ApiConfigSelector
value={currentConfigId}
displayName={displayName}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
listApiConfigMeta={listApiConfigMeta || []}
pinnedApiConfigs={pinnedApiConfigs}
togglePinnedApiConfig={togglePinnedApiConfig}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<SlashCommandsPopover />
<IndexingStatusBadge />
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
// Helper function to render the text area section
const renderTextAreaSection = () => (
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
data-testid="highlight-layer"
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
"px-[8px]",
"pr-9",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={false}
onClick={handleEnhancePrompt}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
{!isEditMode && (
<div className="absolute bottom-1 right-1 z-30">
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={false}
onClick={onSend}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
)}
{!inputValue && !isEditMode && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
style={{
bottom: "0.25rem",
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
)
return (
<div
className={cn(
@ -1160,12 +908,12 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"flex-col",
"gap-1",
"bg-editor-background",
isEditMode ? "px-0" : "px-1.5",
"px-1.5",
"pb-1",
"outline-none",
"border",
"border-none",
isEditMode ? "w-full" : "w-[calc(100%-16px)]",
"w-[calc(100%-16px)]",
"ml-auto",
"mr-auto",
"box-border",
@ -1228,23 +976,189 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</div>
)}
{renderTextAreaSection()}
</div>
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
data-testid="highlight-layer"
className={cn(
"absolute",
"inset-0",
"pointer-events-none",
"whitespace-pre-wrap",
"break-words",
"text-transparent",
"overflow-hidden",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
"px-[8px]",
"py-1.5",
"pr-9",
"z-10",
"forced-color-adjust-none",
)}
style={{
color: "transparent",
}}
/>
<DynamicTextArea
ref={(el) => {
if (typeof ref === "function") {
ref(el)
} else if (ref) {
ref.current = el
}
textAreaRef.current = el
}}
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
onPaste={handlePaste}
onSelect={updateCursorPosition}
onMouseUp={updateCursorPosition}
onHeightChange={(height) => {
if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) {
setTextAreaBaseHeight(height)
}
{isEditMode && (
<EditModeControls
mode={mode}
onModeChange={handleModeChange}
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
onCancel={onCancel}
onSend={onSend}
onSelectImages={onSelectImages}
sendingDisabled={sendingDisabled}
shouldDisableImages={shouldDisableImages}
/>
)}
onHeightChange?.(height)
}}
placeholder={placeholderText}
minRows={3}
maxRows={15}
autoFocus={true}
className={cn(
"w-full",
"text-vscode-input-foreground",
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
"cursor-text",
"py-1.5 px-2",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
isDraggingOver
? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]"
: "bg-vscode-input-background",
"transition-background-color duration-150 ease-in-out",
"will-change-background-color",
"min-h-[90px]",
"box-border",
"rounded",
"resize-none",
"overflow-x-hidden",
"overflow-y-auto",
"pr-9",
"flex-none flex-grow",
"z-[2]",
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
/>
<div className="absolute top-1 right-1 z-30">
<StandardTooltip content={t("chat:enhancePrompt")}>
<button
aria-label={t("chat:enhancePrompt")}
disabled={false}
onClick={handleEnhancePrompt}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<WandSparkles className={cn("w-4 h-4", isEnhancingPrompt && "animate-spin")} />
</button>
</StandardTooltip>
</div>
<div className="absolute bottom-1 right-1 z-30">
{isEditMode && (
<StandardTooltip content={t("chat:cancel.title")}>
<button
aria-label={t("chat:cancel.title")}
disabled={false}
onClick={onCancel}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<MessageSquareX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
<StandardTooltip content={t("chat:sendMessage")}>
<button
aria-label={t("chat:sendMessage")}
disabled={false}
onClick={onSend}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
{!inputValue && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height"
style={{
bottom: "0.25rem",
color: "color-mix(in oklab, var(--vscode-input-foreground) 50%, transparent)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
</div>
</div>
{selectedImages.length > 0 && (
@ -1259,7 +1173,80 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
{!isEditMode && renderNonEditModeControls()}
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<div className="max-w-32">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={handleModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
<div className="max-w-32">
<ApiConfigSelector
value={currentConfigId}
displayName={displayName}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
listApiConfigMeta={listApiConfigMeta || []}
pinnedApiConfigs={pinnedApiConfigs}
togglePinnedApiConfig={togglePinnedApiConfig}
/>
</div>
</div>
<div className="flex items-center gap-0.5">
{isTtsPlaying && (
<StandardTooltip content={t("chat:stopTts")}>
<button
aria-label={t("chat:stopTts")}
onClick={() => vscode.postMessage({ type: "stopTts" })}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
"cursor-pointer",
)}>
<VolumeX className="w-4 h-4" />
</button>
</StandardTooltip>
)}
{!isEditMode ? <SlashCommandsPopover /> : null}
{!isEditMode ? <IndexingStatusBadge /> : null}
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"text-vscode-foreground opacity-85",
"transition-all duration-150",
"hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
"mr-1",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
</div>
)
},

View file

@ -1,115 +0,0 @@
import React from "react"
import { Mode } from "@roo/modes"
import { Button, StandardTooltip } from "@/components/ui"
import { Image, SendHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { ModeSelector } from "./ModeSelector"
import { useAppTranslation } from "@/i18n/TranslationContext"
interface EditModeControlsProps {
mode: Mode
onModeChange: (value: Mode) => void
modeShortcutText: string
customModes: any
customModePrompts: any
onCancel?: () => void
onSend: () => void
onSelectImages: () => void
sendingDisabled: boolean
shouldDisableImages: boolean
}
export const EditModeControls: React.FC<EditModeControlsProps> = ({
mode,
onModeChange,
modeShortcutText,
customModes,
customModePrompts,
onCancel,
onSend,
onSelectImages,
sendingDisabled,
shouldDisableImages,
}) => {
const { t } = useAppTranslation()
return (
<div
className={cn(
"flex",
"items-center",
"justify-between",
"absolute",
"bottom-2",
"left-2",
"right-2",
"z-30",
)}>
<div className={cn("flex", "items-center", "gap-1", "flex-1", "min-w-0")}>
<div className="shrink-0">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={onModeChange}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0", "ml-2")}>
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={sendingDisabled}
className="text-xs bg-vscode-toolbar-hoverBackground hover:bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground">
Cancel
</Button>
<StandardTooltip content={t("chat:addImages")}>
<button
aria-label={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={!shouldDisableImages ? onSelectImages : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!shouldDisableImages && "cursor-pointer",
shouldDisableImages &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<Image className="w-4 h-4" />
</button>
</StandardTooltip>
<StandardTooltip content={t("chat:save.tooltip")}>
<button
aria-label={t("chat:save.tooltip")}
disabled={sendingDisabled}
onClick={!sendingDisabled ? onSend : undefined}
className={cn(
"relative inline-flex items-center justify-center",
"bg-transparent border-none p-1.5",
"rounded-md min-w-[28px] min-h-[28px]",
"opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground",
"transition-all duration-150",
"hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)]",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder",
"active:bg-[rgba(255,255,255,0.1)]",
!sendingDisabled && "cursor-pointer",
sendingDisabled &&
"opacity-40 cursor-not-allowed grayscale-[30%] hover:bg-transparent hover:border-[rgba(255,255,255,0.08)] active:bg-transparent",
)}>
<SendHorizontal className="w-4 h-4" />
</button>
</StandardTooltip>
</div>
</div>
)
}

View file

@ -1,138 +0,0 @@
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { EditModeControls } from "../EditModeControls"
import { Mode } from "@roo/modes"
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock the UI components
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, disabled, ...props }: any) => (
<button onClick={onClick} disabled={disabled} {...props}>
{children}
</button>
),
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
}))
// Mock ModeSelector
vi.mock("../ModeSelector", () => ({
default: ({ value, onChange, title }: any) => (
<select value={value} onChange={(e) => onChange(e.target.value)} title={title}>
<option value="code">Code</option>
<option value="architect">Architect</option>
</select>
),
}))
describe("EditModeControls", () => {
const defaultProps = {
mode: "code" as Mode,
onModeChange: vi.fn(),
modeShortcutText: "Ctrl+M",
customModes: [],
customModePrompts: {},
onCancel: vi.fn(),
onSend: vi.fn(),
onSelectImages: vi.fn(),
sendingDisabled: false,
shouldDisableImages: false,
}
beforeEach(() => {
vi.clearAllMocks()
})
it("renders all controls correctly", () => {
render(<EditModeControls {...defaultProps} />)
// Check for mode selector
expect(screen.getByTitle("chat:selectMode")).toBeInTheDocument()
// Check for Cancel button
expect(screen.getByText("Cancel")).toBeInTheDocument()
// Check for image button
expect(screen.getByTitle("chat:addImages")).toBeInTheDocument()
// Check for send button
expect(screen.getByTitle("chat:save.tooltip")).toBeInTheDocument()
})
it("calls onCancel when Cancel button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const cancelButton = screen.getByText("Cancel")
fireEvent.click(cancelButton)
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
})
it("calls onSend when send button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).toHaveBeenCalledTimes(1)
})
it("calls onSelectImages when image button is clicked", () => {
render(<EditModeControls {...defaultProps} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).toHaveBeenCalledTimes(1)
})
it("disables buttons when sendingDisabled is true", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const cancelButton = screen.getByText("Cancel")
const sendButton = screen.getByLabelText("chat:save.tooltip")
expect(cancelButton).toBeDisabled()
expect(sendButton).toBeDisabled()
})
it("disables image button when shouldDisableImages is true", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
expect(imageButton).toBeDisabled()
})
it("does not call onSelectImages when image button is disabled", () => {
render(<EditModeControls {...defaultProps} shouldDisableImages={true} />)
const imageButton = screen.getByLabelText("chat:addImages")
fireEvent.click(imageButton)
expect(defaultProps.onSelectImages).not.toHaveBeenCalled()
})
it("does not call onSend when send button is disabled", () => {
render(<EditModeControls {...defaultProps} sendingDisabled={true} />)
const sendButton = screen.getByLabelText("chat:save.tooltip")
fireEvent.click(sendButton)
expect(defaultProps.onSend).not.toHaveBeenCalled()
})
it("calls onModeChange when mode is changed", () => {
render(<EditModeControls {...defaultProps} />)
const modeSelector = screen.getByTitle("chat:selectMode")
fireEvent.change(modeSelector, { target: { value: "architect" } })
expect(defaultProps.onModeChange).toHaveBeenCalledWith("architect")
})
})