feat(web): show disabled reasons in tooltips when buttons are disabled

- Add disabled reason tooltips for model card actions (set default, delete)
- Add disabled reason tooltips for marketplace skill card install button
- Add disabled reason display for chat input when disabled
- Add internationalization support for all disabled reasons (en/zh)
- Model card: Show specific reasons when set-default or delete buttons are disabled
- Marketplace skill card: Show specific reasons when install button is disabled
- Chat composer: Show reason text below input when input is disabled
This commit is contained in:
SiYue-ZO 2026-04-09 00:45:44 +08:00
parent 06023c79fa
commit e6f91acd4f
5 changed files with 74 additions and 6 deletions

View file

@ -36,6 +36,13 @@ export function MarketSkillCard({
}) {
const { t } = useTranslation()
const installDisabledReason = (() => {
if (installPending) return t("pages.agent.skills.marketplace_installDisabled.installing")
if (result.installed) return t("pages.agent.skills.marketplace_installDisabled.installed")
if (!canInstall) return t("pages.agent.skills.marketplace_installDisabled.cannotInstall")
return t("pages.agent.skills.marketplace_install_action")
})()
return (
<Card
className="group border-border/40 bg-card/40 hover:border-border/80 hover:bg-card relative overflow-hidden transition-all hover:shadow-md"
@ -92,6 +99,7 @@ export function MarketSkillCard({
className="shadow-sm transition-all"
disabled={!canInstall || result.installed || installPending}
onClick={onInstall}
title={installDisabledReason}
>
{installPending ? (
<IconLoader2 className="size-4 animate-spin" />

View file

@ -33,6 +33,12 @@ export function ChatComposer({
const { t } = useTranslation()
const canInput = isConnected && hasDefaultModel
const inputDisabledReason = (() => {
if (!isConnected) return t("chat.inputDisabled.notConnected")
if (!hasDefaultModel) return t("chat.inputDisabled.noModel")
return null
})()
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return
if (e.key === "Enter" && !e.shiftKey) {
@ -76,6 +82,7 @@ export function ChatComposer({
onKeyDown={handleKeyDown}
placeholder={t("chat.placeholder")}
disabled={!canInput}
title={inputDisabledReason || undefined}
className={cn(
"placeholder:text-muted-foreground/50 max-h-[200px] min-h-[60px] resize-none border-0 bg-transparent px-2 py-1 text-[15px] shadow-none transition-colors focus-visible:ring-0 focus-visible:outline-none dark:bg-transparent",
!canInput && "cursor-not-allowed",
@ -83,6 +90,11 @@ export function ChatComposer({
minRows={1}
maxRows={8}
/>
{!canInput && inputDisabledReason && (
<div className="px-3 py-1 text-xs text-muted-foreground">
{inputDisabledReason}
</div>
)}
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">

View file

@ -33,6 +33,18 @@ export function ModelCard({
const canSetDefault =
model.available && !model.is_default && !model.is_virtual
const setDefaultDisabledReason = (() => {
if (settingDefault) return t("models.action.setDefaultDisabled.setting")
if (!model.available) return t("models.action.setDefaultDisabled.unavailable")
if (model.is_default) return t("models.action.setDefaultDisabled.isDefault")
if (model.is_virtual) return t("models.action.setDefaultDisabled.isVirtual")
return t("models.action.setDefault")
})()
const deleteDisabledReason = model.is_default
? t("models.action.deleteDisabled.isDefault")
: t("models.action.delete")
return (
<div
className={[
@ -86,7 +98,7 @@ export function ModelCard({
size="icon-sm"
onClick={() => onSetDefault(model)}
disabled={settingDefault || !canSetDefault}
title={t("models.action.setDefault")}
title={setDefaultDisabledReason}
>
{settingDefault ? (
<IconLoader2 className="size-3.5 animate-spin" />
@ -110,7 +122,7 @@ export function ModelCard({
size="icon-sm"
onClick={() => onDelete(model)}
disabled={model.is_default}
title={t("models.action.delete")}
title={deleteDisabledReason}
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
>
<IconTrash className="size-3.5" />

View file

@ -55,6 +55,10 @@
"deleteSession": "Delete session",
"messagesCount": "{{count}} messages",
"noModel": "Select model",
"inputDisabled": {
"notConnected": "Gateway is not running. Start it to chat.",
"noModel": "No default model configured. Go to Models page to set one."
},
"attachImage": "Add images",
"removeImage": "Remove image",
"uploadedImage": "Uploaded image",
@ -199,7 +203,16 @@
"action": {
"edit": "Edit API key",
"setDefault": "Set as default",
"delete": "Delete model"
"delete": "Delete model",
"setDefaultDisabled": {
"setting": "Setting as default...",
"unavailable": "Cannot set unavailable model as default",
"isDefault": "Already the default model",
"isVirtual": "Cannot set virtual model as default"
},
"deleteDisabled": {
"isDefault": "Cannot delete the default model"
}
},
"defaultOnSave": {
"label": "Default Model",
@ -487,6 +500,11 @@
"version": "Installed Version",
"lines": "Line Count",
"characters": "Character Count"
},
"marketplace_installDisabled": {
"installing": "Installing...",
"installed": "Already installed",
"cannotInstall": "Cannot install: related tool is not enabled"
}
},
"tools": {

View file

@ -55,6 +55,10 @@
"deleteSession": "删除会话",
"messagesCount": "{{count}} 条消息",
"noModel": "选择模型",
"inputDisabled": {
"notConnected": "服务未运行,请先启动以进行对话。",
"noModel": "未设置默认模型,请前往模型页面进行配置。"
},
"attachImage": "添加图片",
"removeImage": "移除图片",
"uploadedImage": "已上传图片",
@ -199,7 +203,16 @@
"action": {
"edit": "编辑 API Key",
"setDefault": "设为默认",
"delete": "删除模型"
"delete": "删除模型",
"setDefaultDisabled": {
"setting": "正在设为默认...",
"unavailable": "无法将不可用的模型设为默认",
"isDefault": "该模型已是默认模型",
"isVirtual": "无法将虚拟模型设为默认"
},
"deleteDisabled": {
"isDefault": "无法删除默认模型"
}
},
"defaultOnSave": {
"label": "默认模型",
@ -487,6 +500,11 @@
"version": "已安装版本",
"lines": "行数",
"characters": "字符数"
},
"marketplace_installDisabled": {
"installing": "正在安装...",
"installed": "已安装",
"cannotInstall": "无法安装:相关工具未启用"
}
},
"tools": {