fix(desktop): keep ai chat input within viewport

This commit is contained in:
DIYgod 2026-03-24 13:03:55 +08:00
parent 99081f8e7f
commit 14c77295ac
5 changed files with 83 additions and 10 deletions

View File

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest"
import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles"
describe("getBottomPanelContainerStyle", () => {
it("returns no extra transform after the chat has messages", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: true,
visualOffsetY: "clamp(-10vh, -8vh, -6vh)",
}),
).toBeUndefined()
})
it("uses the base centered transform when no visual offset is provided", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: false,
}),
).toEqual({
transform: "translateY(calc(100% + 1rem))",
})
})
it("merges the visual offset into the centered transform", () => {
expect(
getBottomPanelContainerStyle({
centerInputOnEmpty: true,
hasMessages: false,
visualOffsetY: "clamp(-10vh, -8vh, -6vh)",
}),
).toEqual({
transform: "translateY(calc(100% + 1rem + clamp(-10vh, -8vh, -6vh)))",
})
})
})

View File

@ -0,0 +1,32 @@
import type { CSSProperties } from "react"
const EMPTY_BOTTOM_PANEL_BASE_OFFSET = "100% + 1rem"
const formatVisualOffsetY = (visualOffsetY: string | number) =>
typeof visualOffsetY === "number" ? `${visualOffsetY}px` : visualOffsetY
interface BottomPanelContainerStyleOptions {
centerInputOnEmpty?: boolean
hasMessages: boolean
visualOffsetY?: string | number
}
export const getBottomPanelContainerStyle = ({
centerInputOnEmpty,
hasMessages,
visualOffsetY,
}: BottomPanelContainerStyleOptions): CSSProperties | undefined => {
if (!centerInputOnEmpty || hasMessages) {
return undefined
}
if (visualOffsetY == null) {
return {
transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET}))`,
}
}
return {
transform: `translateY(calc(${EMPTY_BOTTOM_PANEL_BASE_OFFSET} + ${formatVisualOffsetY(visualOffsetY)}))`,
}
}

View File

@ -10,10 +10,12 @@ import { RateLimitNotice } from "~/modules/ai-chat/components/layouts/RateLimitN
import type { ShortcutData } from "../../editor"
import { useSendAIShortcut } from "../../hooks/useSendAIShortcut"
import { getBottomPanelContainerStyle } from "./ChatBottomPanel.styles"
interface ChatBottomPanelProps {
hasMessages: boolean
centerInputOnEmpty?: boolean
visualOffsetY?: string | number
shouldShowInterruptionNotice: boolean
rateLimitMessage: string | null
isRateLimited: boolean
@ -27,6 +29,7 @@ interface ChatBottomPanelProps {
export const ChatBottomPanel = ({
hasMessages,
centerInputOnEmpty,
visualOffsetY,
shouldShowInterruptionNotice,
rateLimitMessage,
isRateLimited,
@ -39,6 +42,11 @@ export const ChatBottomPanel = ({
const panelRef = useRef<HTMLDivElement | null>(null)
const t = useI18n()
const { sendAIShortcut } = useSendAIShortcut()
const containerStyle = getBottomPanelContainerStyle({
centerInputOnEmpty,
hasMessages,
visualOffsetY,
})
useLayoutEffect(() => {
const element = panelRef.current
@ -83,10 +91,9 @@ export const ChatBottomPanel = ({
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
"inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
centerInputOnEmpty &&
!hasMessages &&
"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200",
centerInputOnEmpty && !hasMessages && "bottom-1/2 duration-200",
)}
style={containerStyle}
>
{shouldShowInterruptionNotice && (
<m.div

View File

@ -54,7 +54,7 @@ import { ChatBottomPanel } from "./ChatBottomPanel"
import { ChatMessageContainer } from "./ChatMessageContainer"
const draftMessages = new Map<string, EditorState>()
const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
const ChatInterfaceContent = ({ centerInputOnEmpty, visualOffsetY }: ChatInterfaceProps) => {
const hasMessages = useHasMessages()
const status = useChatStatus()
const chatActions = useChatActions()
@ -288,6 +288,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
<ChatBottomPanel
hasMessages={hasMessages}
centerInputOnEmpty={centerInputOnEmpty}
visualOffsetY={visualOffsetY}
shouldShowInterruptionNotice={shouldShowInterruptionNotice}
rateLimitMessage={rateLimitMessage}
isRateLimited={isRateLimited}

View File

@ -1,5 +1,3 @@
import { cn } from "@follow/utils"
import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { ChatPageHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
@ -7,10 +5,7 @@ import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterfac
export const Component = () => {
return (
<div
className={cn(
"relative flex h-screen w-full flex-col",
"[&_[data-testid=chat-input-container]]:translate-y-32 [&_[data-testid=welcome-screen-header]]:-translate-y-24",
)}
className="relative flex h-screen w-full flex-col [&_[data-testid=welcome-screen-header]]:-translate-y-24"
style={{ "--ai-chat-layout-width": "65rem" } as React.CSSProperties}
>
<AIChatRoot>