This commit is contained in:
Will Li 2025-07-10 20:26:35 -07:00
parent 5edab52884
commit 4604211ffa
19 changed files with 385 additions and 325 deletions

View file

@ -785,6 +785,369 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const placeholderBottomText = `\n(${t("chat:addContext")}${shouldDisableImages ? `, ${t("chat:dragFiles")}` : `, ${t("chat:dragFilesImages")}`})`
// Common mode selector handler
const handleModeChange = useCallback(
(value: Mode) => {
setMode(value)
vscode.postMessage({ type: "mode", text: value })
},
[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 render edit mode controls
const renderEditModeControls = () => (
<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">{renderModeSelector()}</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>
<IconButton
iconClass="codicon-device-camera"
title={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={onSelectImages}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
<IconButton
iconClass="codicon-edit"
title={t("chat:save.tooltip")}
disabled={sendingDisabled}
onClick={onSend}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
</div>
)
// Helper function to get API config dropdown options
const getApiConfigOptions = useMemo(() => {
const pinnedConfigs = (listApiConfigMeta || [])
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name,
type: DropdownOptionType.ITEM,
pinned: true,
}))
.sort((a, b) => a.label.localeCompare(b.label))
const unpinnedConfigs = (listApiConfigMeta || [])
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name,
type: DropdownOptionType.ITEM,
pinned: false,
}))
.sort((a, b) => a.label.localeCompare(b.label))
const hasPinnedAndUnpinned = pinnedConfigs.length > 0 && unpinnedConfigs.length > 0
return [
...pinnedConfigs,
...(hasPinnedAndUnpinned
? [
{
value: "sep-pinned",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
]
: []),
...unpinnedConfigs,
{
value: "sep-2",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
{
value: "settingsButtonClicked",
label: t("chat:edit"),
type: DropdownOptionType.ACTION,
},
]
}, [listApiConfigMeta, pinnedApiConfigs, t])
// Helper function to handle API config change
const handleApiConfigChange = useCallback((value: string) => {
if (value === "settingsButtonClicked") {
vscode.postMessage({
type: "loadApiConfiguration",
text: value,
values: { section: "providers" },
})
} else {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}
}, [])
// Helper function to render API config item
const renderApiConfigItem = useCallback(
({ type, value, label, pinned }: any) => {
if (type !== DropdownOptionType.ITEM) {
return label
}
const config = listApiConfigMeta?.find((c) => c.id === value)
const isCurrentConfig = config?.name === currentApiConfigName
return (
<div className="flex justify-between gap-2 w-full h-5">
<div
className={cn("truncate min-w-0 overflow-hidden", {
"font-medium": isCurrentConfig,
})}>
{label}
</div>
<div className="flex justify-end w-10 flex-shrink-0">
<div
className={cn("size-5 p-1", {
"block group-hover:hidden": !pinned,
hidden: !isCurrentConfig,
})}>
<Check className="size-3" />
</div>
<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
togglePinnedApiConfig(value)
vscode.postMessage({
type: "toggleApiConfigPin",
text: value,
})
}}
className={cn("size-5", {
"hidden group-hover:flex": !pinned,
"bg-accent": pinned,
})}>
<Pin className="size-3 p-0.5 opacity-50" />
</Button>
</StandardTooltip>
</div>
</div>
)
},
[listApiConfigMeta, currentApiConfigName, t, togglePinnedApiConfig],
)
// 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")}>
<SelectDropdown
value={currentConfigId}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
disableSearch={false}
placeholder={displayName}
options={getApiConfigOptions}
onChange={handleApiConfigChange}
triggerClassName="w-full text-ellipsis overflow-hidden"
itemClassName="group"
renderItem={renderApiConfigItem}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
<IndexingStatusDot />
<IconButton
iconClass="codicon-device-camera"
title={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={onSelectImages}
className="mr-1"
/>
</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}
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",
"py-2",
"px-[9px]",
"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()}
/>
{isTtsPlaying && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
onClick={() => vscode.postMessage({ type: "stopTts" })}>
<VolumeX className="size-4" />
</Button>
)}
<div className="absolute top-1 right-1 z-30">
<IconButton
iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
title={t("chat:enhancePrompt")}
disabled={sendingDisabled}
isLoading={isEnhancingPrompt}
onClick={handleEnhancePrompt}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
{!isEditMode && (
<div className="absolute bottom-1 right-1 z-30">
<IconButton
iconClass="codicon-send"
title={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={onSend}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
)}
{!inputValue && !isEditMode && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8"
style={{
bottom: "0.25rem",
color: "var(--vscode-tab-inactiveForeground)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
)
return (
<div
className={cn(
@ -859,160 +1222,11 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
</div>
)}
<div
className={cn(
"relative",
"flex-1",
"flex",
"flex-col-reverse",
"min-h-0",
"overflow-hidden",
"rounded",
)}>
<div
ref={highlightLayerRef}
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",
"py-2",
"px-[9px]",
"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",
"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()}
/>
{isTtsPlaying && (
<Button
variant="ghost"
size="icon"
className="absolute top-0 right-0 opacity-25 hover:opacity-100 z-10"
onClick={() => vscode.postMessage({ type: "stopTts" })}>
<VolumeX className="size-4" />
</Button>
)}
<div className="absolute top-1 right-1 z-30">
<IconButton
iconClass={isEnhancingPrompt ? "codicon-loading" : "codicon-sparkle"}
title={t("chat:enhancePrompt")}
disabled={sendingDisabled}
isLoading={isEnhancingPrompt}
onClick={handleEnhancePrompt}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
{isEditMode ? (
<div className="absolute bottom-1 right-1 z-30">
<IconButton
iconClass="codicon-edit"
title={t("chat:save.tooltip")}
disabled={sendingDisabled}
onClick={onSend}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
) : (
<div className="absolute bottom-1 right-1 z-30">
<IconButton
iconClass="codicon-send"
title={t("chat:sendMessage")}
disabled={sendingDisabled}
onClick={onSend}
className="opacity-60 hover:opacity-100 text-vscode-descriptionForeground hover:text-vscode-foreground"
/>
</div>
)}
{!inputValue && (
<div
className="absolute left-2 z-30 pr-9 flex items-center h-8"
style={{
bottom: "0.25rem",
color: "var(--vscode-tab-inactiveForeground)",
userSelect: "none",
pointerEvents: "none",
}}>
{placeholderBottomText}
</div>
)}
</div>
{renderTextAreaSection()}
</div>
{isEditMode && renderEditModeControls()}
</div>
{selectedImages.length > 0 && (
@ -1027,161 +1241,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
/>
)}
<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">
<ModeSelector
value={mode}
title={t("chat:selectMode")}
onChange={(value) => {
setMode(value)
vscode.postMessage({ type: "mode", text: value })
}}
triggerClassName="w-full"
modeShortcutText={modeShortcutText}
customModes={customModes}
customModePrompts={customModePrompts}
/>
</div>
<div className={cn("flex-1", "min-w-0", "overflow-hidden")}>
<SelectDropdown
value={currentConfigId}
disabled={selectApiConfigDisabled}
title={t("chat:selectApiConfig")}
disableSearch={false}
placeholder={displayName}
options={[
// Pinned items first.
...(listApiConfigMeta || [])
.filter((config) => pinnedApiConfigs && pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name, // Keep name for comparison with currentApiConfigName.
type: DropdownOptionType.ITEM,
pinned: true,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
// If we have pinned items and unpinned items, add a separator.
...(pinnedApiConfigs &&
Object.keys(pinnedApiConfigs).length > 0 &&
(listApiConfigMeta || []).some((config) => !pinnedApiConfigs[config.id])
? [
{
value: "sep-pinned",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
]
: []),
// Unpinned items sorted alphabetically.
...(listApiConfigMeta || [])
.filter((config) => !pinnedApiConfigs || !pinnedApiConfigs[config.id])
.map((config) => ({
value: config.id,
label: config.name,
name: config.name, // Keep name for comparison with currentApiConfigName.
type: DropdownOptionType.ITEM,
pinned: false,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
{
value: "sep-2",
label: t("chat:separator"),
type: DropdownOptionType.SEPARATOR,
},
{
value: "settingsButtonClicked",
label: t("chat:edit"),
type: DropdownOptionType.ACTION,
},
]}
onChange={(value) => {
if (value === "settingsButtonClicked") {
vscode.postMessage({
type: "loadApiConfiguration",
text: value,
values: { section: "providers" },
})
} else {
vscode.postMessage({ type: "loadApiConfigurationById", text: value })
}
}}
triggerClassName="w-full text-ellipsis overflow-hidden"
itemClassName="group"
renderItem={({ type, value, label, pinned }) => {
if (type !== DropdownOptionType.ITEM) {
return label
}
const config = listApiConfigMeta?.find((c) => c.id === value)
const isCurrentConfig = config?.name === currentApiConfigName
return (
<div className="flex justify-between gap-2 w-full h-5">
<div
className={cn("truncate min-w-0 overflow-hidden", {
"font-medium": isCurrentConfig,
})}>
{label}
</div>
<div className="flex justify-end w-10 flex-shrink-0">
<div
className={cn("size-5 p-1", {
"block group-hover:hidden": !pinned,
hidden: !isCurrentConfig,
})}>
<Check className="size-3" />
</div>
<StandardTooltip content={pinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
togglePinnedApiConfig(value)
vscode.postMessage({
type: "toggleApiConfigPin",
text: value,
})
}}
className={cn("size-5", {
"hidden group-hover:flex": !pinned,
"bg-accent": pinned,
})}>
<Pin className="size-3 p-0.5 opacity-50" />
</Button>
</StandardTooltip>
</div>
</div>
)
}}
/>
</div>
</div>
<div className={cn("flex", "items-center", "gap-0.5", "shrink-0")}>
{isEditMode && (
<Button
variant="secondary"
size="sm"
onClick={onCancel}
disabled={sendingDisabled}
className="mr-2 text-xs">
Cancel
</Button>
)}
<IndexingStatusDot />
<IconButton
iconClass="codicon-device-camera"
title={t("chat:addImages")}
disabled={shouldDisableImages}
onClick={onSelectImages}
className="mr-1"
/>
</div>
</div>
{!isEditMode && renderNonEditModeControls()}
</div>
)
},

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Desar",
"tooltip": "Desa els canvis del fitxer"
"tooltip": "Desa els canvis del missatge"
},
"reject": {
"title": "Rebutjar",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Speichern",
"tooltip": "Dateiänderungen speichern"
"tooltip": "Nachrichtenänderungen speichern"
},
"reject": {
"title": "Ablehnen",

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Save",
"tooltip": "Save the file changes"
"tooltip": "Save the message changes"
},
"tokenProgress": {
"availableSpace": "Available space: {{amount}} tokens",

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Guardar",
"tooltip": "Guardar los cambios del archivo"
"tooltip": "Guardar los cambios del mensaje"
},
"tokenProgress": {
"availableSpace": "Espacio disponible: {{amount}} tokens",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Enregistrer",
"tooltip": "Sauvegarder les modifications du fichier"
"tooltip": "Enregistrer les modifications du message"
},
"reject": {
"title": "Rejeter",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "सहेजें",
"tooltip": "फ़ाइल परिवर्तन सहेजें"
"tooltip": "संदेश के बदलाव सहेजें"
},
"reject": {
"title": "अस्वीकार करें",

View file

@ -45,7 +45,7 @@
},
"save": {
"title": "Simpan",
"tooltip": "Simpan perubahan file"
"tooltip": "Simpan perubahan pesan"
},
"tokenProgress": {
"availableSpace": "Ruang tersedia: {{amount}} token",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Salva",
"tooltip": "Salva le modifiche al file"
"tooltip": "Salva le modifiche del messaggio"
},
"reject": {
"title": "Rifiuta",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "保存",
"tooltip": "ファイル変更を保存"
"tooltip": "メッセージの変更を保存"
},
"reject": {
"title": "拒否",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "저장",
"tooltip": "파일 변경사항 저장"
"tooltip": "메시지 변경사항 저장"
},
"reject": {
"title": "거부",

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Opslaan",
"tooltip": "Bestandswijzigingen opslaan"
"tooltip": "Berichtwijzigingen opslaan"
},
"tokenProgress": {
"availableSpace": "Beschikbare ruimte: {{amount}} tokens",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Zapisz",
"tooltip": "Zapisz zmiany w pliku"
"tooltip": "Zapisz zmiany wiadomości"
},
"reject": {
"title": "Odrzuć",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Salvar",
"tooltip": "Salvar as alterações do arquivo"
"tooltip": "Salvar as alterações da mensagem"
},
"reject": {
"title": "Rejeitar",

View file

@ -39,7 +39,7 @@
},
"save": {
"title": "Сохранить",
"tooltip": "Сохранить изменения в файле"
"tooltip": "Сохранить изменения сообщения"
},
"tokenProgress": {
"availableSpace": "Доступно места: {{amount}} токенов",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Kaydet",
"tooltip": "Dosya değişikliklerini kaydet"
"tooltip": "Mesaj değişikliklerini kaydet"
},
"reject": {
"title": "Reddet",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "Lưu",
"tooltip": "Lưu các thay đổi tệp"
"tooltip": "Lưu các thay đổi tin nhắn"
},
"reject": {
"title": "Từ chối",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "保存",
"tooltip": "保存文件更改"
"tooltip": "保存消息更改"
},
"reject": {
"title": "拒绝",

View file

@ -44,7 +44,7 @@
},
"save": {
"title": "儲存",
"tooltip": "儲存檔案變更"
"tooltip": "儲存訊息變更"
},
"reject": {
"title": "拒絕",