fix(webui): nudge model setup on blocked submit

This commit is contained in:
Xubin Ren
2026-09-02 16:25:07 +08:00
parent 6b07c91aa2
commit 94d62f86a0
4 changed files with 52 additions and 3 deletions
@@ -90,6 +90,7 @@ interface ModelPresetBadgeProps {
provider?: string | null; provider?: string | null;
providerLabel?: string | null; providerLabel?: string | null;
needsSetup?: boolean; needsSetup?: boolean;
attentionRequest?: number;
fallbackModelName?: string | null; fallbackModelName?: string | null;
isHero: boolean; isHero: boolean;
onClick?: () => void; onClick?: () => void;
@@ -106,6 +107,7 @@ export function ModelPresetBadge({
provider, provider,
providerLabel, providerLabel,
needsSetup = false, needsSetup = false,
attentionRequest = 0,
fallbackModelName, fallbackModelName,
isHero, isHero,
onClick, onClick,
@@ -272,11 +274,13 @@ export function ModelPresetBadge({
const pill = ( const pill = (
<PresetPill <PresetPill
key={needsSetup ? attentionRequest : undefined}
label={displayLabel} label={displayLabel}
modelDetail={displayModelDetail} modelDetail={displayModelDetail}
provider={displayProvider} provider={displayProvider}
providerLabel={fallbackModelName ? null : providerLabel} providerLabel={fallbackModelName ? null : providerLabel}
needsSetup={needsSetup} needsSetup={needsSetup}
needsAttention={needsSetup && attentionRequest > 0}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
fallbackFromLabel={fallbackModelName ? label : null} fallbackFromLabel={fallbackModelName ? label : null}
isHero={isHero} isHero={isHero}
@@ -493,6 +497,7 @@ function PresetPill({
provider, provider,
providerLabel, providerLabel,
needsSetup = false, needsSetup = false,
needsAttention = false,
fallbackModelName, fallbackModelName,
fallbackFromLabel, fallbackFromLabel,
isHero, isHero,
@@ -504,6 +509,7 @@ function PresetPill({
provider?: string | null; provider?: string | null;
providerLabel?: string | null; providerLabel?: string | null;
needsSetup?: boolean; needsSetup?: boolean;
needsAttention?: boolean;
fallbackModelName?: string | null; fallbackModelName?: string | null;
fallbackFromLabel?: string | null; fallbackFromLabel?: string | null;
isHero: boolean; isHero: boolean;
@@ -542,6 +548,7 @@ function PresetPill({
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45", "transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]", isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
needsSetup && "composer-model-pill-setup", needsSetup && "composer-model-pill-setup",
needsAttention && "composer-model-pill-setup-attention",
offset !== undefined && "composer-model-pill-dock", offset !== undefined && "composer-model-pill-dock",
)} )}
style={scale === undefined ? undefined : { style={scale === undefined ? undefined : {
@@ -1036,6 +1036,7 @@ export function ThreadComposer({
} | null>(null); } | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [sendPending, setSendPending] = useState(false); const [sendPending, setSendPending] = useState(false);
const [modelSetupAttentionRequest, setModelSetupAttentionRequest] = useState(0);
const interactionDisabled = !!disabled || sendPending; const interactionDisabled = !!disabled || sendPending;
const [voiceErrorFading, setVoiceErrorFading] = useState(false); const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
@@ -2008,7 +2009,9 @@ export function ThreadComposer({
const submit = useCallback(() => { const submit = useCallback(() => {
if (modelNeedsSetup) { if (modelNeedsSetup) {
onModelBadgeClick?.(); if (hasComposerContent) {
setModelSetupAttentionRequest((request) => request + 1);
}
return; return;
} }
if (!canSend) return; if (!canSend) return;
@@ -2112,11 +2115,11 @@ export function ThreadComposer({
clear, clear,
clearComposerText, clearComposerText,
hasTouchPrimaryPointer, hasTouchPrimaryPointer,
hasComposerContent,
handleStop, handleStop,
isStreaming, isStreaming,
maxTextBytes, maxTextBytes,
modelNeedsSetup, modelNeedsSetup,
onModelBadgeClick,
onSend, onSend,
onStop, onStop,
onQuotedContextChange, onQuotedContextChange,
@@ -2546,6 +2549,7 @@ export function ThreadComposer({
provider={modelProvider} provider={modelProvider}
providerLabel={modelProviderLabel} providerLabel={modelProviderLabel}
needsSetup={modelNeedsSetup} needsSetup={modelNeedsSetup}
attentionRequest={modelSetupAttentionRequest}
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
isHero={isHero} isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined} onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
+31
View File
@@ -765,6 +765,33 @@
0 2px 5px rgb(0 0 0 / 0.16); 0 2px 5px rgb(0 0 0 / 0.16);
} }
@keyframes composer-model-pill-setup-attention {
0%,
100% {
transform: translateX(0);
}
22% {
transform: translateX(-3px);
}
46% {
transform: translateX(3px);
}
68% {
transform: translateX(-2px);
}
86% {
transform: translateX(1px);
}
}
.composer-model-pill-setup-attention {
animation: composer-model-pill-setup-attention 280ms cubic-bezier(0.22, 0.8, 0.32, 1);
}
@keyframes composer-model-pill-viewport-enter { @keyframes composer-model-pill-viewport-enter {
from { from {
transform: scale(0.9074); transform: scale(0.9074);
@@ -817,6 +844,10 @@
transition: none; transition: none;
} }
.composer-model-pill-setup-attention {
animation: none;
}
.composer-model-pill-track[data-settling="true"], .composer-model-pill-track[data-settling="true"],
.composer-model-pill-dock { .composer-model-pill-dock {
transition: none; transition: none;
+8 -1
View File
@@ -895,11 +895,18 @@ describe("ThreadShell", () => {
expect(client.sendMessage).not.toHaveBeenCalled(); expect(client.sendMessage).not.toHaveBeenCalled();
onOpenModelSettings.mockClear(); onOpenModelSettings.mockClear();
const firstSetupPill = badge.querySelector('[data-needs-setup="true"]');
fireEvent.keyDown(input, { key: "Enter", code: "Enter" }); fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(onOpenModelSettings).toHaveBeenCalledTimes(1); const secondSetupPill = badge.querySelector('[data-needs-setup="true"]');
expect(onOpenModelSettings).not.toHaveBeenCalled();
expect(secondSetupPill).not.toBe(firstSetupPill);
expect(secondSetupPill).toHaveClass("composer-model-pill-setup-attention");
expect(input).toHaveValue("hello"); expect(input).toHaveValue("hello");
expect(client.sendMessage).not.toHaveBeenCalled(); expect(client.sendMessage).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
expect(badge.querySelector('[data-needs-setup="true"]')).not.toBe(secondSetupPill);
}); });
it("keeps image generation controls out of the composer", async () => { it("keeps image generation controls out of the composer", async () => {