refactor(ai): drop floating chat panel and simplify timeline layout

This commit is contained in:
DIYgod 2025-11-06 09:09:06 +08:00
parent f6a8691e3a
commit ae97aa2b1a
No known key found for this signature in database
19 changed files with 204 additions and 482 deletions

View File

@ -15,7 +15,6 @@ import { atom, useAtomValue } from "jotai"
import { getFeature } from "~/hooks/biz/useFeature"
export interface WebAISettings extends AISettings {
panelStyle: AIChatPanelStyle
showSplineButton: boolean
}
@ -111,7 +110,6 @@ export const isServerShortcut = (shortcut: AIShortcut) => !!shortcut.defaultProm
export const createDefaultSettings = (): WebAISettings => ({
...defaultAISettings,
shortcuts: normalizeShortcuts(defaultAISettings.shortcuts),
panelStyle: AIChatPanelStyle.Fixed,
showSplineButton: true,
})
@ -137,18 +135,6 @@ export const syncServerShortcuts = (
setAISetting("shortcuts", mergedShortcuts)
}
////////// AI Panel Style
export enum AIChatPanelStyle {
Fixed = "fixed",
Floating = "floating",
}
export const useAIChatPanelStyle = () => useAISettingKey("panelStyle")
export const setAIChatPanelStyle = (style: AIChatPanelStyle) => {
setAISetting("panelStyle", style)
}
export const getAIChatPanelStyle = () => getAISettings().panelStyle
// Floating panel state atoms
interface FloatingPanelState {
width: number
@ -224,5 +210,4 @@ export const removeMCPService = (id: string) => {
//// Enhance Init Ai Settings
export const initializeDefaultAISettings = () => {
initializeDefaultSettings()
if (getAISettings().panelStyle === AIChatPanelStyle.Fixed) setAIPanelVisibility(true)
}

View File

@ -177,26 +177,6 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
)}
<div className="flex items-center gap-2">
{aiEnabled && showAskAIButton && hasContent && onAskAI && (
<MotionButtonBase
onClick={onAskAI}
className={cn(
"flex h-7 items-center gap-1.5 rounded-lg px-3 text-sm font-medium",
"bg-gradient-to-r from-purple-500/10 to-blue-500/10",
"border border-purple-200/30 dark:border-purple-800/30",
"text-purple-600 dark:text-purple-400",
"hover:from-purple-500/20 hover:to-blue-500/20",
"hover:border-purple-300/50 dark:hover:border-purple-700/50",
"transition-all duration-200",
"backdrop-blur-sm",
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
)}
>
<i className="i-mingcute-ai-line text-base" />
<span>Ask AI</span>
</MotionButtonBase>
)}
{showCopyButton && hasContent && (
<CopyButton
value={content}
@ -210,6 +190,25 @@ export const AISummaryCardBase: React.FC<AISummaryCardBaseProps> = ({
)}
/>
)}
{aiEnabled && showAskAIButton && hasContent && onAskAI && (
<MotionButtonBase
onClick={onAskAI}
className={cn(
"flex h-7 items-center gap-1.5 rounded-lg px-3 text-sm font-medium",
"bg-gradient-to-r from-purple-500/10 to-blue-500/10",
"border border-purple-200/30 dark:border-purple-800/30",
"text-purple-600 dark:text-purple-400",
"hover:from-purple-500/20 hover:to-blue-500/20",
"hover:border-purple-300/50 dark:hover:border-purple-700/50",
"transition-all duration-200",
"backdrop-blur-sm",
"sm:duration-300 sm:group-hover:translate-y-0",
)}
>
<i className="i-mingcute-ai-line text-base" />
<span>Ask AI</span>
</MotionButtonBase>
)}
</div>
</div>

View File

@ -1,16 +1,11 @@
import { getView } from "@follow/constants"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export const useShowEntryDetailsColumn = () => {
const { view } = useRouteParamsSelector((s) => ({
view: s.view,
}))
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
return (
!getView(view).wideMode && (aiPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible)
)
return !getView(view).wideMode
}

View File

@ -5,7 +5,7 @@ import type { ReactNode } from "react"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { AIChatPanelStyle, setAIPanelVisibility, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import { useTimelineSummaryAutoContext } from "~/modules/ai-chat/hooks/useTimelineSummaryAutoContext"
import {
useBlockActions,
@ -25,12 +25,14 @@ import { TaskReportDropdown } from "./TaskReportDropdown"
// Base header layout with shared logic inside
const ChatHeaderLayout = ({
renderActions,
isFixedMode = false,
}: {
renderActions: (ctx: {
onNewChatClick: () => void
currentTitle: string | undefined
displayTitle: string | undefined
}) => ReactNode
isFixedMode?: boolean
}) => {
const hasMessages = useHasMessages()
const currentTitle = useCurrentTitle()
@ -57,8 +59,6 @@ const ChatHeaderLayout = ({
blockActions.clearBlocks({ keepSpecialTypes: true })
}, [chatActions, blockActions, shouldDisableTimelineSummary])
const isFloating = useAIChatPanelStyle() === AIChatPanelStyle.Floating
const { isScrolledBeyondThreshold } = useAIRootState()
const isScrolledBeyondThresholdValue = useAtomValue(isScrolledBeyondThreshold)
@ -66,12 +66,12 @@ const ChatHeaderLayout = ({
<div
className={cn(
"absolute inset-x-0 top-0 z-[1] border-b border-transparent duration-200",
!isFloating && "bg-background data-[scrolled-beyond-threshold=true]:border-b-border",
isFixedMode && "bg-background data-[scrolled-beyond-threshold=true]:border-b-border",
)}
data-scrolled-beyond-threshold={isScrolledBeyondThresholdValue}
>
<div className="h-top-header">
{isFloating && (
{!isFixedMode && (
<div
className="absolute inset-0 bg-background/70 backdrop-blur-background"
style={{
@ -108,12 +108,12 @@ const ChatHeaderLayout = ({
)
}
export const ChatHeader = () => {
const panelStyle = useAIChatPanelStyle()
export const ChatHeader = ({ isFixedMode = false }: { isFixedMode?: boolean }) => {
const { t } = useTranslation("ai")
return (
<ChatHeaderLayout
isFixedMode={isFixedMode}
renderActions={({ onNewChatClick }) => (
<>
<ActionButton tooltip={t("common.new_chat")} onClick={onNewChatClick}>
@ -130,7 +130,7 @@ export const ChatHeader = () => {
}
/>
{panelStyle === AIChatPanelStyle.Floating && (
{!isFixedMode && (
<>
<div className="h-5 w-px bg-border" />
<ActionButton tooltip="Close" onClick={() => setAIPanelVisibility(false)}>

View File

@ -411,8 +411,7 @@ const ChatInterfaceContent = ({ centerInputOnEmpty }: ChatInterfaceProps) => {
data-testid="chat-input-container"
className={cn(
"absolute z-10 mx-auto duration-500 ease-in-out",
hasMessages && "inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
!hasMessages && "inset-x-0 bottom-0 max-w-3xl px-4 pb-4 duration-200",
"inset-x-0 bottom-0 max-w-4xl px-4 pb-4",
centerInputOnEmpty &&
!hasMessages &&
"bottom-1/2 translate-y-[calc(100%+1rem)] duration-200",

View File

@ -1,18 +1,11 @@
import type { ReactNode } from "react"
import { useCallback, useRef, useState } from "react"
import { useCallback, useState } from "react"
import { useTranslation } from "react-i18next"
import {
AIChatPanelStyle,
setAIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
} from "~/atoms/settings/ai"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
@ -23,13 +16,10 @@ import { generateAndUpdateChatTitle } from "../../utils/titleGeneration"
export const ChatMoreDropdown = ({
triggerElement,
asChild = true,
canToggleMode = true,
}: {
triggerElement: ReactNode
asChild?: boolean
canToggleMode?: boolean
}) => {
const panelStyle = useAIChatPanelStyle()
const settingModalPresent = useSettingModal()
const chatActions = useChatActions()
const currentChatId = useCurrentChatId()
@ -38,16 +28,6 @@ export const ChatMoreDropdown = ({
const [isGenerating, setIsGenerating] = useState(false)
const { t } = useTranslation("ai")
const handleToggleMode = useCallback(() => {
const newStyle =
panelStyle === AIChatPanelStyle.Fixed ? AIChatPanelStyle.Floating : AIChatPanelStyle.Fixed
setAIChatPanelStyle(newStyle)
}, [panelStyle])
const handleCloseSidebar = useRef(() => {
setAIPanelVisibility(false)
}).current
const handleGenerateTitle = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
@ -81,37 +61,10 @@ export const ChatMoreDropdown = ({
<span>{isGenerating ? t("common.generating_title") : t("common.generate_title")}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{canToggleMode && (
<>
<DropdownMenuItem onClick={handleToggleMode}>
<i
className={`mr-2 size-4 ${panelStyle === AIChatPanelStyle.Fixed ? "i-mingcute-rectangle-vertical-line" : "i-mingcute-layout-right-line"}`}
/>
<span>
{panelStyle === AIChatPanelStyle.Fixed
? "Switch to Floating Panel"
: "Switch to Fixed Panel"}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => settingModalPresent("ai")}>
<i className="i-mgc-settings-1-cute-re mr-2 size-4" />
<span>AI Settings</span>
</DropdownMenuItem>
{panelStyle !== AIChatPanelStyle.Floating && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCloseSidebar}>
<i className="i-mgc-close-cute-re mr-2 size-4" />
<span>Close Sidebar</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)

View File

@ -2,12 +2,7 @@ import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focus
import { useEffect } from "react"
import { tinykeys } from "tinykeys"
import {
AIChatPanelStyle,
getAIPanelVisibility,
getAISettings,
setAIPanelVisibility,
} from "~/atoms/settings/ai"
import { getAIPanelVisibility, setAIPanelVisibility } from "~/atoms/settings/ai"
import { FocusablePresets } from "~/components/common/Focusable"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
@ -24,14 +19,14 @@ export const useAIShortcut = () => {
"$mod+n": (e) => {
e.preventDefault()
// New chat
const { view, entryId } = getRouteParams()
if (isTimelineSummaryAutoContext({ view, entryId })) {
const { entryId } = getRouteParams()
if (isTimelineSummaryAutoContext({ entryId })) {
chatActions.setTimelineSummaryManualOverride(true)
}
chatActions.newChat()
},
"$mod+w": (e) => {
if (getAISettings().panelStyle === AIChatPanelStyle.Floating && getAIPanelVisibility()) {
if (getAIPanelVisibility()) {
e.preventDefault()
// close AI chat
setAIPanelVisibility(false)

View File

@ -106,7 +106,7 @@ export const useAutoTimelineSummaryShortcut = () => {
})
const previousContextKeyRef = useRef<string | null>(null)
const isAllTimeline = isTimelineSummaryAutoContext({ view, entryId })
const isAllTimeline = isTimelineSummaryAutoContext({ entryId })
const defaultShortcut = useMemo(() => {
const shortcuts = aiSettings.shortcuts ?? []

View File

@ -1,14 +1,12 @@
import { FeedViewType } from "@follow/constants"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import type { BizRouteParams } from "~/hooks/biz/useRouteParams"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
export type TimelineSummaryContextParams = Pick<BizRouteParams, "view" | "entryId">
export type TimelineSummaryContextParams = Pick<BizRouteParams, "entryId">
export const isTimelineSummaryAutoContext = ({ view, entryId }: TimelineSummaryContextParams) => {
return view === FeedViewType.All && (!entryId || entryId === ROUTE_ENTRY_PENDING)
export const isTimelineSummaryAutoContext = ({ entryId }: TimelineSummaryContextParams) => {
return !entryId || entryId === ROUTE_ENTRY_PENDING
}
export const useTimelineSummaryAutoContext = () =>
useRouteParamsSelector(({ view, entryId }) => isTimelineSummaryAutoContext({ view, entryId }))
useRouteParamsSelector(({ entryId }) => isTimelineSummaryAutoContext({ entryId }))

View File

@ -5,19 +5,17 @@ import { defaultUISettings } from "@follow/shared/settings/defaults"
import { cn } from "@follow/utils"
import { isSafari } from "@follow/utils/utils"
import { AnimatePresence } from "motion/react"
import type { TransitionEvent } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import type { CSSProperties } from "react"
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
import { useResizable } from "react-resizable-layout"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { getUISettings, setUISetting, useUISettingKey } from "~/atoms/settings/ui"
import { m } from "~/components/common/Motion"
import { ROUTE_ENTRY_PENDING } from "~/constants"
import { useFeature } from "~/hooks/biz/useFeature"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useShowEntryDetailsColumn } from "~/hooks/biz/useShowEntryDetailsColumn"
import { AIChatRoot } from "~/modules/ai-chat/components/layouts/AIChatRoot"
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
import { AIChatFixedPanel } from "~/modules/app-layout/ai/AIChatFixedPanel"
import { AIIndicator } from "~/modules/app-layout/ai/AISplineButton"
import { EntryContentPlaceholder } from "~/modules/app-layout/entry-content/EntryContentPlaceholder"
import { EntryColumn } from "~/modules/entry-column"
@ -26,61 +24,29 @@ import { AIEntryHeader } from "~/modules/entry-content/components/entry-header"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { MainViewHotkeysProvider } from "~/providers/main-view-hotkeys-provider"
/**
* AIEnhancedTimelineLayout Component
*
* An advanced timeline layout that integrates AI chat functionality with entry browsing.
* This layout provides:
* - Entry list display with animated content overlay
* - Integrated AI chat panel (Fixed/Floating modes)
* - Dynamic subscription column toggling
* - Smooth animations for entry transitions
* - Resizable AI panel with persistent settings
*
* Layout Structure:
* ```
* AIEnhancedTimelineLayout
* Main Content Area
* EntryColumn (entry list)
* AIEntryHeader (animated overlay)
* EntryContent (animated overlay)
* AI Chat Panel (resizable, optional)
* Fixed Panel (docked to right)
* Floating Panel (draggable overlay)
* Subscription Column Toggler
* ```
*
* @component
* @example
* // Used in AI-enabled timeline routes
* // Provides enhanced timeline experience with AI assistance
*/
const AIEnhancedTimelineLayoutImpl = () => {
const aiPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const aiSidebarVisible = aiPanelStyle === AIChatPanelStyle.Fixed && isAIPanelVisible
const MIN_ENTRY_WIDTH = isSafari() ? 356 : 300
const { view, entryId } = useRouteParamsSelector((s) => ({
view: s.view,
entryId: s.entryId,
const AIEnhancedTimelineLayoutImpl = () => {
const { view, entryId } = useRouteParamsSelector((state) => ({
view: state.view,
entryId: state.entryId,
}))
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
const showEntryDetailsColumn = useShowEntryDetailsColumn()
const hasSelectedEntry = Boolean(realEntryId)
const layoutContainerRef = useRef<HTMLDivElement>(null)
const hasMountedRef = useRef(false)
// Delay the details column until the timeline width animation finishes
const [shouldRenderDetailsColumn, setShouldRenderDetailsColumn] = useState(showEntryDetailsColumn)
const feedColumnWidth = useUISettingKey("feedColWidth")
// Timeline column resizable configuration (used when entry details column is visible)
const entryColumnInitialWidth = useMemo(() => getUISettings().entryColWidth, [])
const timelineMaxWidth = useMemo(() => {
if (typeof window === "undefined") return 600
return Math.max((window.innerWidth - feedColumnWidth) / 2, 600)
}, [feedColumnWidth])
const entryColumnInitialWidth = useMemo(() => getUISettings().entryColWidth, [])
const timelineStartDragPosition = useRef(0)
const {
position: timelineColumnWidth,
separatorProps: timelineSeparatorProps,
@ -89,7 +55,7 @@ const AIEnhancedTimelineLayoutImpl = () => {
setPosition: setTimelineColumnWidth,
} = useResizable({
axis: "x",
min: isSafari() ? 356 : 300,
min: MIN_ENTRY_WIDTH,
max: timelineMaxWidth,
initial: entryColumnInitialWidth,
containerRef: layoutContainerRef as React.RefObject<HTMLElement>,
@ -99,12 +65,10 @@ const AIEnhancedTimelineLayoutImpl = () => {
onResizeEnd({ position }) {
if (position === timelineStartDragPosition.current) return
setUISetting("entryColWidth", position)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
},
})
// AI chat resizable panel configuration
const isAllView = view === FeedViewType.All
const widthRange: [number, number] = isAllView ? [500, timelineMaxWidth] : [300, timelineMaxWidth]
const [minWidth, maxWidth] = widthRange
@ -116,14 +80,11 @@ const AIEnhancedTimelineLayoutImpl = () => {
const resolvePreferredWidth = useCallback(() => {
const ui = getUISettings()
const preferredWidth = ui.aiColWidth ?? defaultUISettings.aiColWidth
return clampWidth(preferredWidth)
const preferred = ui.aiColWidth ?? defaultUISettings.aiColWidth
return clampWidth(preferred)
}, [clampWidth])
// Calculate initial width depending on view
const initialAiWidth = useMemo(() => resolvePreferredWidth(), [resolvePreferredWidth])
const aiPanelStartDragPosition = useRef(0)
const {
position: aiPanelWidth,
separatorProps: aiSeparatorProps,
@ -134,145 +95,181 @@ const AIEnhancedTimelineLayoutImpl = () => {
axis: "x",
min: minWidth,
max: maxWidth,
initial: initialAiWidth,
initial: resolvePreferredWidth(),
reverse: true,
containerRef: layoutContainerRef as React.RefObject<HTMLElement>,
onResizeStart({ position }) {
aiPanelStartDragPosition.current = position
},
onResizeEnd({ position }) {
if (position === aiPanelStartDragPosition.current) return
// Persist per-view width
setUISetting("aiColWidth", position)
// TODO: Remove this after useMeasure can get bounds in time
window.dispatchEvent(new Event("resize"))
},
})
// When view changes, switch to the saved width for that view
useEffect(() => {
const width = resolvePreferredWidth()
setAiPanelWidth(width)
// Trigger layout update
window.dispatchEvent(new Event("resize"))
}, [resolvePreferredWidth, setAiPanelWidth])
const handleTimelineTransitionEnd = useCallback(
(event: TransitionEvent<HTMLDivElement>) => {
if (
event.propertyName !== "width" ||
event.target !== event.currentTarget ||
!showEntryDetailsColumn
) {
return
const showEntryContentOnRight = showEntryDetailsColumn && hasSelectedEntry
const shouldShowFixedAI = !showEntryContentOnRight
const showEntryContentOnLeft = !showEntryDetailsColumn && hasSelectedEntry
const shouldRenderRightColumn = showEntryDetailsColumn || shouldShowFixedAI
const shouldShowEntryBorder = showEntryDetailsColumn || shouldShowFixedAI
const entryColumnStyle: CSSProperties = showEntryDetailsColumn
? {
flexBasis: timelineColumnWidth,
minWidth: MIN_ENTRY_WIDTH,
}
: {
minWidth: MIN_ENTRY_WIDTH,
}
setShouldRenderDetailsColumn(true)
},
[showEntryDetailsColumn],
)
useEffect(() => {
if (!showEntryDetailsColumn) {
setShouldRenderDetailsColumn(false)
}
}, [showEntryDetailsColumn])
const rightColumnStyle: CSSProperties = showEntryDetailsColumn
? {
minWidth: 0,
}
: {
width: aiPanelWidth,
minWidth: 0,
flexBasis: aiPanelWidth,
}
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true
setShouldRenderDetailsColumn(showEntryDetailsColumn)
return
}
}, [showEntryDetailsColumn])
const resetTimelineWidth = useCallback(() => {
setUISetting("entryColWidth", defaultUISettings.entryColWidth)
setTimelineColumnWidth(defaultUISettings.entryColWidth)
window.dispatchEvent(new Event("resize"))
}, [setTimelineColumnWidth])
const timelineSection = useMemo(
() => (
const resetAiWidth = useCallback(() => {
const resetWidth = clampWidth(defaultUISettings.aiColWidth)
setUISetting("aiColWidth", resetWidth)
setAiPanelWidth(resetWidth)
window.dispatchEvent(new Event("resize"))
}, [clampWidth, setAiPanelWidth])
const splitter =
shouldRenderRightColumn && showEntryDetailsColumn ? (
<PanelSplitter
{...timelineSeparatorProps}
cursor={timelineSeparatorCursor}
isDragging={isTimelineDragging}
onDoubleClick={resetTimelineWidth}
/>
) : shouldShowFixedAI ? (
<PanelSplitter
{...aiSeparatorProps}
cursor={aiSeparatorCursor}
isDragging={isAiPanelDragging}
onDoubleClick={resetAiWidth}
/>
) : null
return (
<div
className={cn(
"relative h-full min-w-0 grow",
isAllView ? "flex flex-col overflow-y-auto scroll-smooth" : "flex",
)}
>
<div
className={cn(
"relative h-full min-w-0",
isAllView ? "min-h-full w-full flex-none snap-start snap-always" : "flex-1",
isAllView ? "min-h-full w-full flex-none" : "flex-1",
)}
>
<AppLayoutGridContainerProvider>
<div className="relative h-full">
<div ref={layoutContainerRef} className="flex h-full min-w-0">
<div
className={cn(
"absolute inset-y-0 left-0 min-w-[300px] transition-[width] duration-200 ease-out",
showEntryDetailsColumn && "border-r will-change-[width]",
isTimelineDragging && "transition-none",
aiPanelStyle === AIChatPanelStyle.Fixed && !showEntryDetailsColumn && "border-r",
"relative flex h-full flex-col overflow-hidden",
shouldShowEntryBorder && "border-r",
showEntryDetailsColumn
? "flex-none transition-[flex-basis] duration-200 ease-out will-change-[flex-basis]"
: "min-w-0 flex-1",
showEntryDetailsColumn && isTimelineDragging && "transition-none",
)}
onTransitionEnd={handleTimelineTransitionEnd}
style={{ width: showEntryDetailsColumn ? timelineColumnWidth : "100%" }}
style={entryColumnStyle}
>
<div className="relative h-full">
{/* Entry list - always rendered to prevent animation */}
<EntryColumn key="entry-list" />
{!showEntryDetailsColumn && (
<>
<AnimatePresence>
{realEntryId && (
<EntryColumn />
{showEntryContentOnLeft && (
<>
<AnimatePresence>
{realEntryId && (
<m.div
key="entry-header"
className="absolute inset-x-0 top-0 z-10"
initial={{ translateY: "-50px", opacity: 0 }}
animate={{ translateY: 0, opacity: 1 }}
exit={{ translateY: "-50px", opacity: 0 }}
transition={Spring.smooth(0.3)}
>
<AIEntryHeader entryId={realEntryId} />
</m.div>
)}
</AnimatePresence>
<AnimatePresence>
{realEntryId && (
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<m.div
className="absolute inset-x-0 top-0 z-10"
initial={{ translateY: "-50px", opacity: 0 }}
animate={{ translateY: 0, opacity: 1 }}
exit={{ translateY: "-50px", opacity: 0 }}
key="entry-content"
lcpOptimization
initial={{ translateY: "50px", opacity: 0, scale: 0.98 }}
animate={{ translateY: 0, opacity: 1, scale: 1 }}
exit={{ translateY: "50px", opacity: 0, scale: 0.98 }}
transition={Spring.smooth(0.3)}
className="pointer-events-auto relative flex h-0 flex-1 flex-col bg-theme-background"
>
<AIEntryHeader entryId={realEntryId} />
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>
)}
</AnimatePresence>
{/* Entry content overlay with exit animation */}
<AnimatePresence>
{realEntryId && (
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<m.div
lcpOptimization
initial={{ translateY: "50px", opacity: 0, scale: 0.98 }}
animate={{ translateY: 0, opacity: 1, scale: 1 }}
exit={{ translateY: "50px", opacity: 0, scale: 0.98 }}
transition={Spring.smooth(0.3)}
className="pointer-events-auto relative flex h-0 flex-1 flex-col bg-theme-background"
>
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>
</div>
)}
</AnimatePresence>
</>
)}
</div>
</div>
)}
</AnimatePresence>
</>
)}
</div>
{showEntryDetailsColumn && shouldRenderDetailsColumn && (
{shouldRenderRightColumn && (
<>
<div className="absolute inset-y-0 z-[2]" style={{ left: timelineColumnWidth }}>
<PanelSplitter
{...timelineSeparatorProps}
cursor={timelineSeparatorCursor}
isDragging={isTimelineDragging}
onDoubleClick={() => {
setUISetting("entryColWidth", defaultUISettings.entryColWidth)
setTimelineColumnWidth(defaultUISettings.entryColWidth)
window.dispatchEvent(new Event("resize"))
}}
/>
</div>
{splitter}
<div
className="absolute inset-y-0 right-0 flex min-w-0 flex-1 flex-col overflow-hidden bg-theme-background"
style={{ left: timelineColumnWidth }}
className={cn(
"relative flex h-full min-w-0 flex-col overflow-hidden bg-theme-background",
showEntryDetailsColumn ? "flex-1" : "flex-none",
)}
style={rightColumnStyle}
>
{realEntryId ? (
<>
{showEntryContentOnRight && realEntryId ? (
<div className="flex h-full flex-col overflow-hidden">
<div className="absolute inset-x-0 top-0 z-10">
<AIEntryHeader entryId={realEntryId} />
</div>
<div className="pointer-events-none absolute inset-0 z-[9] flex flex-col overflow-hidden">
<div className="pointer-events-auto relative flex h-0 flex-1 flex-col bg-theme-background">
<EntryContent entryId={realEntryId} className="h-full" />
</div>
<div className="flex h-0 flex-1 flex-col overflow-hidden">
<EntryContent entryId={realEntryId} className="h-full" />
</div>
</>
</div>
) : shouldShowFixedAI ? (
<div className="flex h-full flex-1 items-center justify-center">
<AIChatFixedPanel
key="ai-chat-layout"
style={
{
width: showEntryDetailsColumn ? "100%" : aiPanelWidth,
"--ai-chat-layout-width": showEntryDetailsColumn
? "100%"
: `${aiPanelWidth}px`,
} as CSSProperties
}
/>
</div>
) : (
<div className="flex flex-1 items-center justify-center px-8">
<EntryContentPlaceholder />
@ -284,93 +281,15 @@ const AIEnhancedTimelineLayoutImpl = () => {
</div>
</AppLayoutGridContainerProvider>
</div>
),
[
aiPanelStyle,
handleTimelineTransitionEnd,
isAllView,
isTimelineDragging,
realEntryId,
setTimelineColumnWidth,
shouldRenderDetailsColumn,
showEntryDetailsColumn,
timelineColumnWidth,
timelineSeparatorCursor,
timelineSeparatorProps,
],
)
const aiChatLayoutNode = useMemo(
() => (
<div className={cn(isAllView && "size-full flex-none snap-start snap-always border-b")}>
<AIChatLayout
key="ai-chat-layout"
style={
{
width: isAllView ? "100%" : aiPanelWidth,
"--ai-chat-layout-width": `${aiPanelWidth}px`,
} as React.CSSProperties
}
/>
{isAllView && (
<div className="center absolute inset-x-0 bottom-0 z-10">
<i className="i-mingcute-down-line size-4 shrink-0 text-text-secondary" />
</div>
)}
</div>
),
[aiPanelWidth, isAllView],
)
return (
<div
ref={layoutContainerRef}
className={cn(
"relative h-full min-w-0 grow",
isAllView ? "flex snap-y snap-mandatory flex-col overflow-y-auto scroll-smooth" : "flex",
)}
>
{isAllView ? (
<>
{aiChatLayoutNode}
{timelineSection}
</>
) : (
<>
{timelineSection}
{/* Fixed panel layout */}
{aiSidebarVisible && (
<>
<PanelSplitter
{...aiSeparatorProps}
cursor={aiSeparatorCursor}
isDragging={isAiPanelDragging}
onDoubleClick={() => {
const resetWidth = clampWidth(defaultUISettings.aiColWidth)
setUISetting("aiColWidth", resetWidth)
setAiPanelWidth(resetWidth)
window.dispatchEvent(new Event("resize"))
}}
/>
{aiChatLayoutNode}
</>
)}
{/* Floating panel - renders outside layout flow */}
{aiPanelStyle === AIChatPanelStyle.Floating && <AIChatLayout key="ai-chat-layout" />}
</>
)}
{!shouldShowFixedAI && <AIIndicator />}
</div>
)
}
export const AIEnhancedTimelineLayout = memo(function AIEnhancedTimelineLayout() {
const aiEnabled = useFeature("ai")
return (
<AIChatRoot wrapFocusable={false}>
<AIEnhancedTimelineLayoutImpl />
{aiEnabled && <AIIndicator />}
<MainViewHotkeysProvider />
</AIChatRoot>
)

View File

@ -17,7 +17,7 @@ export const AIChatFixedPanel: FC<AIChatFixedPanelProps> = ({ className, ...prop
className={cn("relative flex h-full flex-col overflow-hidden bg-background", className)}
{...props}
>
<ChatHeader />
<ChatHeader isFixedMode />
<ChatInterface />
</Focusable>
)

View File

@ -1,42 +0,0 @@
import { FeedViewType } from "@follow/constants"
import type { FC } from "react"
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { AIChatFixedPanel } from "./AIChatFixedPanel"
import { AIChatFloatingPanel } from "./AIChatFloatingPanel"
export interface AIChatLayoutProps
extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
/**
* AIChatLayout Component
*
* A dynamic layout component for AI chat functionality that switches between
* different panel styles based on user preferences.
*
* Supported Layouts:
* - Fixed Panel: Chat interface integrated as a fixed sidebar/panel
* - Floating Panel: Chat interface as a draggable floating window
*
* The layout style is controlled by the AIChatPanelStyle setting and automatically
* renders the appropriate panel implementation.
*
* @component
* @param props - Standard div HTML attributes passed to the selected panel
* @example
* // Renders based on user's AI chat panel style preference
* <AIChatLayout className="custom-class" />
*/
export const AIChatLayout: FC<AIChatLayoutProps> = ({ ...props }) => {
const panelStyle = useAIChatPanelStyle()
const { isAllView } = useRouteParamsSelector((s) => ({
isAllView: s.view === FeedViewType.All,
}))
if (panelStyle === AIChatPanelStyle.Floating && !isAllView) {
return <AIChatFloatingPanel {...props} />
}
return <AIChatFixedPanel {...props} />
}

View File

@ -3,18 +3,14 @@ import { clsx } from "@follow/utils"
import { AnimatePresence, m } from "motion/react"
import type { FC } from "react"
import { setAIPanelVisibility, useAIPanelVisibility, useAISettingKey } from "~/atoms/settings/ai"
import { setAIPanelVisibility, useAISettingKey } from "~/atoms/settings/ai"
import { AISpline } from "~/modules/ai-chat/components/3d-models/AISpline"
import { AISmartSidebar } from "~/modules/ai-chat/components/layouts/AISmartSidebar"
export const AIIndicator: FC = () => {
const isVisible = useAIPanelVisibility()
const showSplineButton = useAISettingKey("showSplineButton")
import { AIChatFloatingPanel } from "./AIChatFloatingPanel"
// Only show the spline button when:
// 1. Panel style is floating
// 2. Panel is currently not visible
const shouldShow = !isVisible
export const AIIndicator: FC = () => {
const showSplineButton = useAISettingKey("showSplineButton")
const handleClick = () => {
setAIPanelVisibility(true)
@ -22,8 +18,8 @@ export const AIIndicator: FC = () => {
return (
<AnimatePresence>
{shouldShow && !showSplineButton && <AISmartSidebar />}
{shouldShow && showSplineButton && (
{!showSplineButton && <AISmartSidebar />}
{showSplineButton && (
<m.button
key="ai-spline-button"
initial={{ scale: 0, opacity: 0 }}
@ -46,6 +42,7 @@ export const AIIndicator: FC = () => {
<AISpline />
</m.button>
)}
<AIChatFloatingPanel />
</AnimatePresence>
)
}

View File

@ -4,12 +4,7 @@ import { useTranslation } from "react-i18next"
import { useShowAISummary } from "~/atoms/ai-summary"
import { useEntryIsInReadabilitySuccess } from "~/atoms/readability"
import {
AIChatPanelStyle,
setAIPanelVisibility,
useAIChatPanelStyle,
useAIPanelVisibility,
} from "~/atoms/settings/ai"
import { setAIPanelVisibility } from "~/atoms/settings/ai"
import { useActionLanguage } from "~/atoms/settings/general"
import { AISummaryCardBase } from "~/components/ui/ai-summary-card"
@ -21,10 +16,6 @@ export function AISummary({ entryId }: { entryId: string }) {
const actionLanguage = useActionLanguage()
// AI Chat panel state
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const summary = usePrefetchSummary({
actionLanguage,
entryId,
@ -32,13 +23,6 @@ export function AISummary({ entryId }: { entryId: string }) {
enabled: showAISummary,
})
// Show Ask AI button when:
// 1. Panel style is floating AND panel is not visible
// 2. OR panel style is fixed (since fixed panel can be toggled)
const shouldShowAskAI =
(aiChatPanelStyle === AIChatPanelStyle.Floating && !isAIPanelVisible) ||
aiChatPanelStyle === AIChatPanelStyle.Fixed
const handleAskAI = () => {
setAIPanelVisibility(true)
}
@ -53,7 +37,7 @@ export function AISummary({ entryId }: { entryId: string }) {
isLoading={summary.isLoading}
className="my-8"
title={t("entry_content.ai_summary")}
showAskAIButton={shouldShowAskAI}
showAskAIButton={true}
onAskAI={handleAskAI}
error={summary.error}
/>

View File

@ -8,10 +8,8 @@ import { cn } from "@follow/utils/utils"
import { useStore } from "jotai"
import { memo, useEffect, useMemo, useState } from "react"
import { AIChatPanelStyle, setAIPanelVisibility, 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"
import { useWrappedElement, useWrappedElementSize } from "~/providers/wrapped-element-provider"
const useReadPercent = () => {
@ -48,10 +46,6 @@ const useReadPercent = () => {
const BackTopIndicator: Component = memo(({ className }) => {
const [readPercent] = useReadPercent()
const scrollElement = useScrollViewElement()
const aiEnabled = useFeature("ai")
const panelStyle = useAIChatPanelStyle()
const isAiPanelOpen = panelStyle === AIChatPanelStyle.Fixed
return (
<span
@ -60,23 +54,12 @@ const BackTopIndicator: Component = memo(({ className }) => {
className,
)}
>
<div className="flex items-center gap-2 tabular-nums">
<CircleProgress percent={readPercent!} size={14} strokeWidth={2} />
<span>{readPercent}%</span>
<br />
</div>
{aiEnabled && !isAiPanelOpen && (
<MotionButtonBase
onClick={() => {
setAIPanelVisibility(true)
}}
className={cn(
"mt-1 flex flex-nowrap items-center gap-2 text-sm opacity-50 transition-all duration-500 hover:opacity-100",
)}
>
<i className="i-mgc-ai-cute-re" />
<span>Ask AI</span>
</MotionButtonBase>
{!!readPercent && (
<div className="flex items-center gap-2 tabular-nums">
<CircleProgress percent={readPercent!} size={14} strokeWidth={2} />
<span>{readPercent}%</span>
<br />
</div>
)}
<MotionButtonBase
onClick={() => {

View File

@ -7,7 +7,6 @@ import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
@ -65,11 +64,6 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
removeBlock(BlockSliceAction.SPECIAL_TYPES.selectedText)
}, [removeBlock])
const aiChatPanelStyle = useAIChatPanelStyle()
const isAIPanelVisible = useAIPanelVisibility()
const shouldShowAISummary = aiChatPanelStyle === AIChatPanelStyle.Floating || !isAIPanelVisible
if (!entry) return null
return (
@ -87,7 +81,7 @@ export const ArticleLayout: React.FC<EntryLayoutProps> = ({
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-6 max-w-full cursor-auto text-[0.94rem]">
{shouldShowAISummary && <AISummary entryId={entryId} />}
<AISummary entryId={entryId} />
<ErrorBoundary fallback={EntryRenderError}>
<ReadabilityNotice entryId={entryId} />
{showTranscript ? (

View File

@ -7,7 +7,6 @@ import { createDefineSettingItem } from "../helper/builder"
import { createSettingBuilder } from "../helper/setting-builder"
import { MCPServicesSection } from "./ai/mcp/MCPServicesSection"
import { UserMemorySection } from "./ai/memory"
import { PanelStyleSection } from "./ai/PanelStyleSection"
import { PersonalizePromptSection } from "./ai/PersonalizePromptSection"
import { AIShortcutsSection } from "./ai/shortcuts/AIShortcutsSection"
import { TaskSchedulingSection } from "./ai/tasks"
@ -33,7 +32,6 @@ export const SettingAI = () => {
value: t("features.title"),
},
PanelStyleSection,
defineSettingItem("showSplineButton", {
label: t("settings.showSplineButton.label"),
description: t("settings.showSplineButton.description"),

View File

@ -1,34 +0,0 @@
import { useTranslation } from "react-i18next"
import { AIChatPanelStyle, setAIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { SettingTabbedSegment } from "../../control"
export const PanelStyleSection = () => {
const { t } = useTranslation("ai")
const panelStyle = useAIChatPanelStyle()
return (
<SettingTabbedSegment
key="panel-style"
label={t("settings.panel_style.label")}
description={t("settings.panel_style.description")}
value={panelStyle}
values={[
{
value: AIChatPanelStyle.Fixed,
label: t("settings.panel_style.fixed"),
icon: <i className="i-mingcute-rectangle-vertical-line" />,
},
{
value: AIChatPanelStyle.Floating,
label: t("settings.panel_style.floating"),
icon: <i className="i-mingcute-layout-right-line" />,
},
]}
onValueChanged={(value) => {
setAIChatPanelStyle(value as AIChatPanelStyle)
}}
/>
)
}

View File

@ -1,5 +1,4 @@
export { UserMemorySection } from "./memory/UserMemorySection"
export { PanelStyleSection } from "./PanelStyleSection"
export { PersonalizePromptSection } from "./PersonalizePromptSection"
export { AIShortcutsSection } from "./shortcuts"
export { UsageAnalysisSection } from "./usage"