[codex] fix follow-up suggestions layout (#2836)

* fix follow-up suggestions layout

* fix agent chat welcome layout transition

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
DanielWalnut 2026-05-10 15:10:44 +08:00 committed by GitHub
parent 08ee7adeba
commit dfa4eb0c1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 101 additions and 74 deletions

View File

@ -2,7 +2,7 @@
import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
@ -14,7 +14,6 @@ import { InputBox } from "@/components/workspace/input-box";
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 { ThreadTitle } from "@/components/workspace/thread-title";
@ -34,7 +33,6 @@ import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter();
const { agent_name } = useParams<{
@ -45,6 +43,10 @@ export default function AgentChatPage() {
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// `isNewThread` gates history/token-usage fetches until the backend creates
// the thread. `isWelcomeMode` controls only the centered welcome layout, so
// it can flip immediately on submit without triggering eager history loads.
const [isWelcomeMode, setIsWelcomeMode] = useState(isNewThread);
const [settings, setSettings] = useThreadSettings(threadId);
const [localSettings, setLocalSettings] = useLocalSettings();
const { tokenUsageEnabled } = useModels();
@ -55,6 +57,11 @@ export default function AgentChatPage() {
const backendTokenUsage = threadTokenUsageToTokenUsage(threadTokenUsage.data);
const { showNotification } = useNotification();
useEffect(() => {
setIsWelcomeMode(isNewThread);
}, [isNewThread]);
const {
thread,
pendingUsageMessages,
@ -66,6 +73,9 @@ export default function AgentChatPage() {
threadId: isNewThread ? undefined : threadId,
context: { ...settings.context, agent_name: agent_name },
isMock,
onSend: () => {
setIsWelcomeMode(false);
},
onStart: (createdThreadId) => {
setThreadId(createdThreadId);
setIsNewThread(false);
@ -105,13 +115,10 @@ export default function AgentChatPage() {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const tokenUsageInlineMode = tokenUsageEnabled
? localSettings.tokenUsage.inlineMode
: "off";
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
return (
<ThreadContext.Provider value={{ thread }}>
@ -120,7 +127,7 @@ export default function AgentChatPage() {
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
isNewThread
isWelcomeMode
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
@ -165,12 +172,12 @@ export default function AgentChatPage() {
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
className={cn("size-full", !isWelcomeMode && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
@ -178,33 +185,51 @@ export default function AgentChatPage() {
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
isWelcomeMode && "-translate-y-[calc(50vh-96px)]",
isWelcomeMode
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
{hasTodos && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"right-0 bottom-0 left-0",
isWelcomeMode ? "absolute" : "relative",
)}
>
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
</div>
</div>
</div>
)}
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isWelcomeMode={isNewThread}
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isNewThread}
autoFocus={isWelcomeMode}
status={
thread.error
? "error"
@ -214,13 +239,12 @@ export default function AgentChatPage() {
}
context={settings.context}
extraHeader={
isNewThread && (
isWelcomeMode && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>

View File

@ -14,7 +14,6 @@ import { InputBox } from "@/components/workspace/input-box";
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 { ThreadTitle } from "@/components/workspace/thread-title";
@ -33,7 +32,6 @@ import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
// `isNewThread` tracks whether the backend has the thread yet — gates the
@ -119,13 +117,10 @@ export default function ChatPage() {
await thread.stop();
}, [thread]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const tokenUsageInlineMode = tokenUsageEnabled
? localSettings.tokenUsage.inlineMode
: "off";
const hasTodos = (thread.values.todos?.length ?? 0) > 0;
return (
<ThreadContext.Provider value={{ thread, isMock }}>
@ -159,19 +154,24 @@ export default function ChatPage() {
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className={cn("size-full", !isWelcomeMode && "pt-10")}
threadId={threadId}
thread={thread}
paddingBottom={messageListPaddingBottom}
paddingBottom={MESSAGE_LIST_DEFAULT_PADDING_BOTTOM}
hasMoreHistory={hasMoreHistory}
loadMoreHistory={loadMoreHistory}
isHistoryLoading={isHistoryLoading}
tokenUsageInlineMode={tokenUsageInlineMode}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"right-0 bottom-0 left-0 z-30 flex justify-center px-4",
isWelcomeMode ? "absolute" : "relative shrink-0 pb-4",
)}
>
<div
className={cn(
"relative w-full",
@ -181,20 +181,33 @@ export default function ChatPage() {
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
{hasTodos && (
<div
className={cn(
"right-0 left-0 z-0",
isWelcomeMode ? "absolute -top-4" : "relative",
)}
>
<div
className={cn(
"right-0 bottom-0 left-0",
isWelcomeMode ? "absolute" : "relative",
)}
>
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={false}
/>
</div>
</div>
</div>
)}
{mountedRef.current ? (
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
className={cn(
"bg-background/5 w-full",
isWelcomeMode && "-translate-y-4",
)}
isWelcomeMode={isWelcomeMode}
threadId={threadId}
autoFocus={isWelcomeMode}
@ -216,7 +229,6 @@ export default function ChatPage() {
onContextChange={(context) =>
setSettings("context", context)
}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
/>
@ -224,7 +236,8 @@ export default function ChatPage() {
<div
aria-hidden="true"
className={cn(
"bg-background/5 h-32 w-full -translate-y-4 rounded-2xl",
"bg-background/5 h-32 w-full rounded-2xl",
isWelcomeMode && "-translate-y-4",
)}
/>
)}

View File

@ -110,7 +110,6 @@ export function InputBox({
threadId,
initialValue,
onContextChange,
onFollowupsVisibilityChange,
onSubmit,
onStop,
...props
@ -143,7 +142,6 @@ export function InputBox({
reasoning_effort?: "minimal" | "low" | "medium" | "high";
},
) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
}) {
@ -350,24 +348,10 @@ export function InputBox({
!followupsHidden &&
(followupsLoading || followups.length > 0);
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
useEffect(() => {
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
}, [onFollowupsVisibilityChange]);
useEffect(() => {
followupsVisibilityChangeRef.current?.(showFollowups);
}, [showFollowups]);
useEffect(() => {
messagesRef.current = thread.messages;
}, [thread.messages]);
useEffect(() => {
return () => followupsVisibilityChangeRef.current?.(false);
}, []);
useEffect(() => {
const streaming = status === "streaming";
const wasStreaming = wasStreamingRef.current;
@ -442,26 +426,33 @@ export function InputBox({
}, [context.model_name, disabled, isMock, status, threadId]);
return (
<div ref={promptRootRef} className="relative flex flex-col gap-4">
<div
ref={promptRootRef}
className={cn(
"relative flex flex-col",
isWelcomeMode ? "gap-4" : "gap-2",
)}
>
{showFollowups && (
<div className="flex items-center justify-center pb-2">
<div className="flex items-center justify-center pb-1">
<div className="flex items-center gap-2">
{followupsLoading ? (
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-2 text-xs backdrop-blur-sm">
<div className="text-muted-foreground bg-background/80 rounded-full border px-4 py-1.5 text-xs backdrop-blur-sm">
{t.inputBox.followupLoading}
</div>
) : (
<Suggestions className="min-h-16 w-fit items-start">
<Suggestions className="w-fit items-center">
{followups.map((s) => (
<Suggestion
key={s}
className="py-1.5"
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"
className="text-muted-foreground h-auto cursor-pointer rounded-full px-2.5 py-1.5 text-xs font-normal"
variant="outline"
size="sm"
type="button"

View File

@ -44,8 +44,7 @@ import {
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 24;
const LOAD_MORE_HISTORY_THROTTLE_MS = 1200;