feat(ai): implement fixed and floating chat panel styles

- Introduced a new enum for AI chat panel styles, allowing users to toggle between fixed and floating layouts.
- Added state management for the floating panel dimensions and position, ensuring it remains anchored during window resizing.
- Updated various components to utilize the new panel style settings, enhancing the user experience with improved layout flexibility.
- Implemented UI elements for toggling between fixed and floating modes in the chat interface.

These changes collectively enhance the functionality and usability of the AI chat interface, providing users with more control over their layout preferences.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-08-12 01:27:57 +08:00
parent 3d577699fc
commit ab537447e8
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
13 changed files with 281 additions and 58 deletions

View File

@ -19,9 +19,38 @@ export const {
export const aiServerSyncWhiteListKeys = []
// Local Setting for ai
const aiChatPinnedAtom = atom<boolean>(true)
export const useAIChatPinned = () => useAtomValue(aiChatPinnedAtom)
export const setAIChatPinned = (pinned: boolean) => {
jotaiStore.set(aiChatPinnedAtom, pinned)
export enum AIChatPanelStyle {
Fixed = "fixed",
Floating = "floating",
}
export const getAIChatPinned = () => jotaiStore.get(aiChatPinnedAtom)
const aiChatPanelStyleAtom = atom<AIChatPanelStyle>(AIChatPanelStyle.Floating)
export const useAIChatPanelStyle = () => useAtomValue(aiChatPanelStyleAtom)
export const setAIChatPanelStyle = (style: AIChatPanelStyle) => {
jotaiStore.set(aiChatPanelStyleAtom, style)
}
export const getAIChatPanelStyle = () => jotaiStore.get(aiChatPanelStyleAtom)
// Floating panel state atoms
interface FloatingPanelState {
width: number
height: number
x: number
y: number
}
const defaultFloatingPanelState: FloatingPanelState = {
width: 500,
height: 600,
x: window.innerWidth - 520, // 20px margin from right
y: window.innerHeight - 620, // 20px margin from bottom
}
const floatingPanelStateAtom = atom<FloatingPanelState>(defaultFloatingPanelState)
export const useFloatingPanelState = () => useAtomValue(floatingPanelStateAtom)
export const setFloatingPanelState = (state: Partial<FloatingPanelState>) => {
const currentState = jotaiStore.get(floatingPanelStateAtom)
jotaiStore.set(floatingPanelStateAtom, { ...currentState, ...state })
}
export const getFloatingPanelState = () => jotaiStore.get(floatingPanelStateAtom)

View File

@ -21,7 +21,6 @@ import {
setReadabilityStatus,
useEntryIsInReadability,
} from "~/atoms/readability"
import { useAIChatPinned } from "~/atoms/settings/ai"
import { useIntegrationSettingValue } from "~/atoms/settings/integration"
import { useShowSourceContent } from "~/atoms/source-content"
import { ipcServices } from "~/lib/client"
@ -31,7 +30,6 @@ import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding
import type { FollowCommandId } from "~/modules/command/types"
import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks"
import { useFeature } from "./useFeature"
import { useRouteParams } from "./useRouteParams"
export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => {
@ -249,8 +247,7 @@ export const useEntryActions = ({
const isShowAISummaryOnce = useShowAISummaryOnce()
const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation)
const isShowAITranslationOnce = useShowAITranslationOnce()
const isShowAIChatPinned = useAIChatPinned()
const aiEnabled = useFeature("ai")
const runCmdFn = useRunCommandFn()
const hasEntry = !!entry
@ -441,14 +438,6 @@ export const useEntryActions = ({
entryId,
}),
new EntryActionMenuItem({
id: COMMAND_ID.global.toggleAIChatPinned,
onClick: runCmdFn(COMMAND_ID.global.toggleAIChatPinned, [{ entryId }]),
entryId,
active: isShowAIChatPinned,
hide: !aiEnabled,
}),
// Custom Integration with sub-menu
...(() => {
const customIntegrations = integrationSettings.customIntegration || []
@ -513,8 +502,7 @@ export const useEntryActions = ({
isCollection,
compact,
isEntryInReadability,
isShowAIChatPinned,
aiEnabled,
integrationSettings.customIntegration,
integrationSettings.enableCustomIntegration,
])

View File

@ -54,8 +54,7 @@ export const ChatHeader = () => {
/>
<div className="relative z-10 flex h-full items-center justify-between px-4">
{/* Left side - Title */}
<div className="mr-2 min-w-0 flex-1">
<div className="mr-2 flex min-w-0 flex-1 items-center">
<EditableTitle
title={currentTitle}
onSave={handleTitleSave}

View File

@ -3,6 +3,7 @@ import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { AIChatPanelStyle, setAIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { RelativeDay } from "~/components/ui/datetime"
import {
DropdownMenu,
@ -23,6 +24,7 @@ import { downloadMarkdown, exportChatToMarkdown } from "~/modules/ai-chat/utils/
export const ChatMoreDropdown = () => {
const currentTitle = useCurrentTitle()
const currentChatId = useCurrentChatId()
const panelStyle = useAIChatPanelStyle()
const chatActions = useChatActions()
const { t } = useTranslation("ai")
@ -91,6 +93,12 @@ export const ChatMoreDropdown = () => {
}
}, [chatActions, currentTitle, t])
const handleToggleMode = useCallback(() => {
const newStyle =
panelStyle === AIChatPanelStyle.Fixed ? AIChatPanelStyle.Floating : AIChatPanelStyle.Fixed
setAIChatPanelStyle(newStyle)
}, [panelStyle])
return (
<DropdownMenu onOpenChange={handleDropdownOpen}>
<DropdownMenuTrigger asChild>
@ -169,6 +177,19 @@ export const ChatMoreDropdown = () => {
<i className="i-mgc-download-2-cute-re mr-2 size-4" />
<span>Export Chat</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleMode}>
<i
className={`mr-2 size-4 ${panelStyle === AIChatPanelStyle.Fixed ? "i-mingcute-rectangle-vertical-line" : "i-mingcute-pin-line"}`}
/>
<span>
{panelStyle === AIChatPanelStyle.Fixed
? "Switch to Floating Panel"
: "Switch to Fixed Panel"}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -0,0 +1,23 @@
import { cn } from "@follow/utils"
import type { FC } from "react"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
export interface AIChatFixedPanelProps
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
export const AIChatFixedPanel: FC<AIChatFixedPanelProps> = ({ className, ...props }) => {
return (
<Focusable
scope={HotkeyScope.AIChat}
className={cn("bg-background relative flex h-full flex-col overflow-hidden", className)}
{...props}
>
<ChatHeader />
<ChatInterface />
</Focusable>
)
}

View File

@ -0,0 +1,110 @@
import { Spring } from "@follow/components/constants/spring.js"
import { cn, computeAdjustedTopLeftPosition } from "@follow/utils"
import { m } from "motion/react"
import type { ResizeDirection } from "re-resizable"
import { Resizable } from "re-resizable"
import type { FC } from "react"
import { useEffect, useRef } from "react"
import {
getFloatingPanelState,
setFloatingPanelState,
useFloatingPanelState,
} from "~/atoms/settings/ai"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
export interface AIChatFloatingPanelProps
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
export const AIChatFloatingPanel: FC<AIChatFloatingPanelProps> = ({ className, ...props }) => {
const floatingState = useFloatingPanelState()
// Preserve right/bottom margins to keep panel anchored to bottom-right on window resize
const rightBottomMarginRef = useRef({ right: 0, bottom: 0 })
useEffect(() => {
rightBottomMarginRef.current = {
right: Math.max(0, window.innerWidth - (floatingState.x + floatingState.width)),
bottom: Math.max(0, window.innerHeight - (floatingState.y + floatingState.height)),
}
}, [floatingState.x, floatingState.y, floatingState.width, floatingState.height])
const handleResize = useRef(
(_event: MouseEvent | TouchEvent, direction: ResizeDirection, ref: HTMLElement) => {
const prev = getFloatingPanelState()
const newWidth = ref.offsetWidth
const newHeight = ref.offsetHeight
const { x, y } = computeAdjustedTopLeftPosition(
{ x: prev.x, y: prev.y, width: prev.width, height: prev.height },
{ width: newWidth, height: newHeight },
direction,
)
setFloatingPanelState({ width: newWidth, height: newHeight, x, y })
},
).current
// Keep floating panel anchored to bottom-right on window resize
useEffect(() => {
const handleWindowResize = () => {
const { right, bottom } = rightBottomMarginRef.current
const newX = Math.max(0, window.innerWidth - floatingState.width - right)
const newY = Math.max(0, window.innerHeight - floatingState.height - bottom)
setFloatingPanelState({ x: newX, y: newY })
}
window.addEventListener("resize", handleWindowResize)
return () => window.removeEventListener("resize", handleWindowResize)
}, [floatingState.width, floatingState.height])
const chatContent = (
<Focusable
scope={HotkeyScope.AIChat}
className={cn(
"bg-background relative flex h-full flex-col overflow-hidden rounded-lg border shadow-xl",
className,
)}
{...props}
>
<ChatHeader />
<ChatInterface />
</Focusable>
)
return (
<m.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={Spring.presets.smooth}
className="fixed z-50"
style={{ left: floatingState.x, top: floatingState.y }}
>
<div className="relative">
<Resizable
size={{ width: floatingState.width, height: floatingState.height }}
onResize={handleResize}
onResizeStop={handleResize}
minWidth={500}
minHeight={600}
maxWidth={600}
maxHeight={Math.floor(window.innerHeight * 0.85)}
enable={{
top: true,
right: true,
bottom: true,
left: true,
topRight: true,
bottomRight: true,
bottomLeft: true,
topLeft: true,
}}
>
{chatContent}
</Resizable>
</div>
</m.div>
)
}

View File

@ -1,21 +1,18 @@
import type { FC } from "react"
import { Focusable } from "~/components/common/Focusable"
import { HotkeyScope } from "~/constants"
import { ChatHeader } from "~/modules/ai-chat/components/layouts/ChatHeader"
import { ChatInterface } from "~/modules/ai-chat/components/layouts/ChatInterface"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
export const AIChatLayout: FC<
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
> = ({ ...props }) => {
return (
<Focusable
scope={HotkeyScope.AIChat}
className="bg-background relative flex h-full flex-col overflow-hidden"
{...props}
>
<ChatHeader />
<ChatInterface />
</Focusable>
)
import { AIChatFixedPanel } from "./AIChatFixedPanel"
import { AIChatFloatingPanel } from "./AIChatFloatingPanel"
export interface AIChatLayoutProps
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
export const AIChatLayout: FC<AIChatLayoutProps> = ({ ...props }) => {
const panelStyle = useAIChatPanelStyle()
if (panelStyle === AIChatPanelStyle.Floating) {
return <AIChatFloatingPanel {...props} />
}
return <AIChatFixedPanel {...props} />
}

View File

@ -7,7 +7,7 @@ import { useMemo } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
import { useAIChatPinned } from "~/atoms/settings/ai"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
@ -102,7 +102,8 @@ const Grid = ({ entryId }) => {
const wideMode = !!(settingWideMode && entryId)
const feedColumnTempShow = useTimelineColumnTempShow()
const feedColumnShow = useTimelineColumnShow()
const aiPinned = useAIChatPinned()
const panelStyle = useAIChatPanelStyle()
const aiPinned = panelStyle === AIChatPanelStyle.Fixed
const shouldHeaderPaddingLeft = feedColumnTempShow && !feedColumnShow && settingWideMode
const { isDragging, position, separatorProps, separatorCursor } = useResizable({

View File

@ -19,7 +19,6 @@ const MAIN_ACTIONS = [
COMMAND_ID.entry.toggleAISummary,
COMMAND_ID.entry.readability,
COMMAND_ID.global.toggleAIChatPinned,
COMMAND_ID.entry.viewSourceContent,
COMMAND_ID.entry.share,

View File

@ -8,6 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
@ -21,6 +22,7 @@ import { EntryColumn } from "./index"
const AIEntryLayoutImpl = () => {
const { entryId } = useParams()
const navigate = useNavigateEntry()
const panelStyle = useAIChatPanelStyle()
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
@ -130,7 +132,7 @@ const AIEntryLayoutImpl = () => {
return (
<div className="relative flex min-w-0 grow">
<div className="h-full flex-1 border-r">
<div className={cn("h-full flex-1", panelStyle === AIChatPanelStyle.Fixed && "border-r")}>
<AppLayoutGridContainerProvider>
<div className="relative h-full">
{/* Entry list - always rendered to prevent animation */}
@ -175,20 +177,29 @@ const AIEntryLayoutImpl = () => {
</div>
</AppLayoutGridContainerProvider>
</div>
<PanelSplitter
{...separatorProps}
cursor={separatorCursor}
isDragging={isDragging}
onDoubleClick={() => {
setUISetting("aiColWidth", defaultUISettings.aiColWidth)
setPosition(defaultUISettings.aiColWidth)
}}
/>
<AIChatLayout
style={
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
}
/>
{/* Fixed panel layout */}
{panelStyle === AIChatPanelStyle.Fixed && (
<>
<PanelSplitter
{...separatorProps}
cursor={separatorCursor}
isDragging={isDragging}
onDoubleClick={() => {
setUISetting("aiColWidth", defaultUISettings.aiColWidth)
setPosition(defaultUISettings.aiColWidth)
}}
/>
<AIChatLayout
style={
{ width: position, "--ai-chat-layout-width": `${position}px` } as React.CSSProperties
}
/>
</>
)}
{/* Floating panel - renders outside layout flow */}
{panelStyle === AIChatPanelStyle.Floating && <AIChatLayout />}
</div>
)
}

View File

@ -9,7 +9,7 @@ import { cn } from "@follow/utils/utils"
import { useStore } from "jotai"
import { memo, useEffect, useMemo, useState } from "react"
import { useAIChatPinned } from "~/atoms/settings/ai"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useFeature } from "~/hooks/biz/useFeature"
@ -51,7 +51,8 @@ const BackTopIndicator: Component = memo(({ className }) => {
const scrollElement = useScrollViewElement()
const aiEnabled = useFeature("ai")
const isAiPanelOpen = useAIChatPinned()
const panelStyle = useAIChatPanelStyle()
const isAiPanelOpen = panelStyle === AIChatPanelStyle.Fixed
return (
<span

View File

@ -8,5 +8,6 @@ export * from "./jotai"
export * from "./noop"
export * from "./path-parser"
export * from "./react"
export * from "./resize"
export * from "./url-for-video"
export * from "./utils"

View File

@ -0,0 +1,43 @@
export interface ViewportSize {
width: number
height: number
}
export interface RectLike {
x: number
y: number
width: number
height: number
}
/**
* Compute new top-left position when resizing from top and/or left so that the
* opposite corner remains visually anchored. The result is clamped to viewport.
*/
export function computeAdjustedTopLeftPosition(
previous: RectLike,
newSize: { width: number; height: number },
direction: string,
viewport?: ViewportSize,
): { x: number; y: number } {
viewport ??= { width: window.innerWidth, height: window.innerHeight }
const resizedFromLeft = /left/i.test(direction)
const resizedFromTop = /top/i.test(direction)
let newX = previous.x
let newY = previous.y
if (resizedFromLeft) {
newX = previous.x - (newSize.width - previous.width)
}
if (resizedFromTop) {
newY = previous.y - (newSize.height - previous.height)
}
const maxX = Math.max(0, viewport.width - newSize.width)
const maxY = Math.max(0, viewport.height - newSize.height)
newX = Math.min(Math.max(0, newX), maxX)
newY = Math.min(Math.max(0, newY), maxY)
return { x: newX, y: newY }
}