fix(ui): avoid follow-up suggestion overlap (#1777)

* fix(ui): avoid follow-up suggestion overlap

* fix(ui): address followup review feedback

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Admire 2026-04-03 15:48:41 +08:00 committed by GitHub
parent 48565664e0
commit 9735d73b83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 111 additions and 39 deletions

View File

@ -2,7 +2,7 @@
import { BotIcon, PlusSquare } from "lucide-react"; import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -11,7 +11,11 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
@ -28,6 +32,7 @@ import { cn } from "@/lib/utils";
export default function AgentChatPage() { export default function AgentChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_name } = useParams<{ const { agent_name } = useParams<{
@ -81,6 +86,11 @@ export default function AgentChatPage() {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
@ -128,6 +138,7 @@ export default function AgentChatPage() {
className={cn("size-full", !isNewThread && "pt-10")} className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom}
/> />
</div> </div>
@ -173,6 +184,7 @@ export default function AgentChatPage() {
} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
@ -11,7 +11,11 @@ import {
} from "@/components/workspace/chats"; } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages"; import {
MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
@ -27,6 +31,7 @@ import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat(); const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId); const [settings, setSettings] = useThreadSettings(threadId);
useSpecificChatMode(); useSpecificChatMode();
@ -70,6 +75,11 @@ export default function ChatPage() {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
return ( return (
<ThreadContext.Provider value={{ thread, isMock }}> <ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
@ -97,6 +107,7 @@ export default function ChatPage() {
className={cn("size-full", !isNewThread && "pt-10")} className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom}
/> />
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
@ -141,6 +152,7 @@ export default function ChatPage() {
isUploading isUploading
} }
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
/> />

View File

@ -109,6 +109,7 @@ export function InputBox({
threadId, threadId,
initialValue, initialValue,
onContextChange, onContextChange,
onFollowupsVisibilityChange,
onSubmit, onSubmit,
onStop, onStop,
...props ...props
@ -136,6 +137,7 @@ export function InputBox({
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void; onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void; onStop?: () => void;
}) { }) {
@ -186,6 +188,8 @@ export function InputBox({
return models.find((m) => m.name === context.model_name) ?? models[0]; return models.find((m) => m.name === context.model_name) ?? models[0];
}, [context.model_name, models]); }, [context.model_name, models]);
const resolvedModelName = selectedModel?.name;
const supportThinking = useMemo( const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false, () => selectedModel?.supports_thinking ?? false,
[selectedModel], [selectedModel],
@ -253,9 +257,33 @@ export function InputBox({
setFollowups([]); setFollowups([]);
setFollowupsHidden(false); setFollowupsHidden(false);
setFollowupsLoading(false); setFollowupsLoading(false);
// Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state.
if (resolvedModelName && context.model_name !== resolvedModelName) {
onContextChange?.({
...context,
model_name: resolvedModelName,
mode: getResolvedMode(
context.mode,
selectedModel?.supports_thinking ?? false,
),
});
setTimeout(() => onSubmit?.(message), 0);
return;
}
onSubmit?.(message); onSubmit?.(message);
}, },
[onSubmit, onStop, status], [
context,
onContextChange,
onSubmit,
onStop,
resolvedModelName,
selectedModel?.supports_thinking,
status,
],
); );
const requestFormSubmit = useCallback(() => { const requestFormSubmit = useCallback(() => {
@ -309,6 +337,26 @@ export function InputBox({
setTimeout(() => requestFormSubmit(), 0); setTimeout(() => requestFormSubmit(), 0);
}, [pendingSuggestion, requestFormSubmit, textInput]); }, [pendingSuggestion, requestFormSubmit, textInput]);
const showFollowups =
!disabled &&
!isNewThread &&
!followupsHidden &&
(followupsLoading || followups.length > 0);
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
useEffect(() => {
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
}, [onFollowupsVisibilityChange]);
useEffect(() => {
followupsVisibilityChangeRef.current?.(showFollowups);
}, [showFollowups]);
useEffect(() => {
return () => followupsVisibilityChangeRef.current?.(false);
}, []);
useEffect(() => { useEffect(() => {
const streaming = status === "streaming"; const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current; const wasStreaming = wasStreamingRef.current;
@ -769,40 +817,37 @@ export function InputBox({
)} )}
</PromptInput> </PromptInput>
{!disabled && {showFollowups && (
!isNewThread && <div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center">
!followupsHidden && <div className="flex items-center gap-2">
(followupsLoading || followups.length > 0) && ( {followupsLoading ? (
<div className="absolute -top-20 right-0 left-0 z-20 flex items-center justify-center"> <div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
<div className="flex items-center gap-2"> {t.inputBox.followupLoading}
{followupsLoading ? ( </div>
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm"> ) : (
{t.inputBox.followupLoading} <Suggestions className="min-h-16 w-fit items-start">
</div> {followups.map((s) => (
) : ( <Suggestion
<Suggestions className="min-h-16 w-fit items-start"> key={s}
{followups.map((s) => ( suggestion={s}
<Suggestion onClick={() => handleFollowupClick(s)}
key={s} />
suggestion={s} ))}
onClick={() => handleFollowupClick(s)} <Button
/> aria-label={t.common.close}
))} className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal"
<Button variant="outline"
aria-label={t.common.close} size="sm"
className="text-muted-foreground cursor-pointer rounded-full px-3 text-xs font-normal" type="button"
variant="outline" onClick={() => setFollowupsHidden(true)}
size="sm" >
type="button" <XIcon className="size-4" />
onClick={() => setFollowupsHidden(true)} </Button>
> </Suggestions>
<XIcon className="size-4" /> )}
</Button>
</Suggestions>
)}
</div>
</div> </div>
)} </div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>

View File

@ -29,11 +29,14 @@ import { MessageListItem } from "./message-list-item";
import { MessageListSkeleton } from "./skeleton"; import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card"; import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({ export function MessageList({
className, className,
threadId, threadId,
thread, thread,
paddingBottom = 160, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
}: { }: {
className?: string; className?: string;
threadId: string; threadId: string;