feat(desktop-settings): focus ai sections when opened from chat

This commit is contained in:
DIYgod 2025-10-20 17:06:55 +08:00
parent b064d3385e
commit e9de114ebe
No known key found for this signature in database
9 changed files with 173 additions and 27 deletions

View File

@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
import { useAISettingValue } from "~/atoms/settings/ai"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai"
import { useCreateAIShortcutModal } from "~/modules/settings/tabs/ai/shortcuts/hooks"
import { useMainEntryId } from "../../hooks/useMainEntryId"
@ -44,7 +45,7 @@ export const ChatShortcutsRow: React.FC<ChatShortcutsRowProps> = ({ onSelect })
const handleAddShortcut = useCreateAIShortcutModal()
const handleCustomize = useCallback(() => {
showSettings("ai")
showSettings({ tab: "ai", section: AI_SETTING_SECTION_IDS.shortcuts })
nextFrame(() => {
handleAddShortcut()
})

View File

@ -24,6 +24,7 @@ import {
} from "~/modules/ai-chat-session/query"
import { AITaskModal, useCanCreateNewAITask } from "~/modules/ai-task"
import { useSettingModal } from "~/modules/settings/modal/use-setting-modal-hack"
import { AI_SETTING_SECTION_IDS } from "~/modules/settings/tabs/ai"
import { AIPersistService } from "../../services"
@ -114,7 +115,7 @@ export const TaskReportDropdown = ({ triggerElement, asChild = true }: TaskRepor
toast.error("Please remove an existing task before creating a new one.")
return
}
showSettings("ai")
showSettings({ tab: "ai", section: AI_SETTING_SECTION_IDS.tasks })
nextFrame(() => {
present({
title: "New AI Task",

View File

@ -30,6 +30,7 @@ export type SettingItem<T, K extends keyof T = keyof T> = {
type SectionSettingItem = {
type: "title"
value?: string
id?: string
} & SharedSettingItem
type ActionSettingItem = {
@ -78,7 +79,13 @@ export const createSettingBuilder =
!isEmptySection
if (isValidTitle) {
return <SettingSectionTitle key={index} title={assertSetting.value} />
return (
<SettingSectionTitle
key={index}
title={assertSetting.value}
sectionId={assertSetting.id}
/>
)
}
if ("type" in assertSetting && assertSetting.type === "title") {
return null

View File

@ -2,13 +2,23 @@ import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { cn } from "@follow/utils"
import { repository } from "@pkg"
import type { FC } from "react"
import { Suspense, useDeferredValue, useLayoutEffect, useState } from "react"
import {
Suspense,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { Trans } from "react-i18next"
import { useLoaderData } from "react-router"
import { ModalClose } from "~/components/ui/modal/stacked/components"
import { SettingsTitle } from "~/modules/settings/title"
import { SettingSectionHighlightContext } from "../section"
import { getSettingPages } from "../settings-glob"
import type { SettingPageConfig } from "../utils"
import { useSettingTab } from "./context"
@ -16,23 +26,37 @@ import { SettingModalLayout } from "./layout"
export const SettingModalContent: FC<{
initialTab?: string
}> = ({ initialTab }) => {
initialSection?: string
}> = ({ initialTab, initialSection }) => {
const pages = getSettingPages()
const resolvedInitialTab = initialTab && initialTab in pages ? initialTab : undefined
return (
<SettingModalLayout
initialTab={initialTab ? (initialTab in pages ? initialTab : undefined) : undefined}
>
<Content />
<SettingModalLayout initialTab={resolvedInitialTab}>
<Content initialTab={resolvedInitialTab} initialSection={initialSection} />
</SettingModalLayout>
)
}
const Content = () => {
const Content: FC<{
initialTab?: string
initialSection?: string
}> = ({ initialTab, initialSection }) => {
const key = useDeferredValue(useSettingTab() || "general")
const pages = getSettingPages()
const { Component, loader } = pages[key]
const [scroller, setScroller] = useState<HTMLDivElement | null>(null)
const sectionRefs = useRef(new Map<string, HTMLElement>())
const pendingSectionRef = useRef<string | null>(initialSection ?? null)
const hasAppliedInitialSectionRef = useRef(false)
const [highlightedSectionId, setHighlightedSectionId] = useState<string | undefined>()
useEffect(() => {
pendingSectionRef.current = initialSection ?? null
hasAppliedInitialSectionRef.current = false
setHighlightedSectionId(undefined)
}, [initialSection])
useLayoutEffect(() => {
if (scroller) {
@ -40,6 +64,61 @@ const Content = () => {
}
}, [key])
const scrollToSection = useCallback((sectionId: string) => {
if (!sectionId) return false
const element = sectionRefs.current.get(sectionId)
if (!element) return false
element.scrollIntoView({
behavior: "smooth",
block: "start",
})
setHighlightedSectionId(sectionId)
return true
}, [])
const registerSection = useCallback(
(sectionId: string, element: HTMLElement | null) => {
if (!sectionId) return
if (element) {
sectionRefs.current.set(sectionId, element)
if (pendingSectionRef.current === sectionId) {
const handled = scrollToSection(sectionId)
if (handled) {
pendingSectionRef.current = null
hasAppliedInitialSectionRef.current = true
}
}
} else {
sectionRefs.current.delete(sectionId)
}
},
[scrollToSection],
)
useEffect(() => {
if (!initialSection || hasAppliedInitialSectionRef.current) return
if (initialTab && key !== initialTab) return
const handled = scrollToSection(initialSection)
if (handled) {
hasAppliedInitialSectionRef.current = true
pendingSectionRef.current = null
} else {
pendingSectionRef.current = initialSection
}
}, [initialSection, initialTab, key, scrollToSection])
const highlightContextValue = useMemo(
() => ({
highlightedSectionId: highlightedSectionId ?? undefined,
registerSection,
}),
[highlightedSectionId, registerSection],
)
const config = (useLoaderData() || loader || {}) as SettingPageConfig
if (!Component) return null
@ -56,7 +135,9 @@ const Content = () => {
config.viewportClassName,
)}
>
<Component />
<SettingSectionHighlightContext value={highlightContextValue}>
<Component />
</SettingSectionHighlightContext>
<div className="h-16" />
<p className="absolute inset-x-0 bottom-4 flex items-center justify-center gap-1 text-xs opacity-80">

View File

@ -1,5 +1,6 @@
// HACK: Use expose the navigate function in the window object, avoid to import `router` circular issue.
import type { SettingModalOptions } from "./useSettingModal"
const showSettings = (args?: any) => window.router.showSettings.call(null, args)
const showSettings = (args?: SettingModalOptions) => window.router.showSettings.call(null, args)
export const useSettingModal = () => showSettings

View File

@ -5,17 +5,35 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { SettingModalContent } from "./SettingModalContent"
export type SettingModalOptions =
| string
| {
tab?: string
section?: string
}
const normalizeOptions = (options?: SettingModalOptions) => {
if (!options) return {}
if (typeof options === "string") {
return { tab: options }
}
return options
}
export const useSettingModal = () => {
const { present } = useModalStack()
return useCallback(
(initialTab?: string) => {
(options?: SettingModalOptions) => {
const { tab, section } = normalizeOptions(options)
return present({
title: "Setting",
id: "setting",
content: () =>
createElement(SettingModalContent, {
initialTab,
initialTab: tab,
initialSection: section,
}),
CustomModalComponent: PlainModal,
modalContainerClassName: "overflow-hidden",

View File

@ -3,27 +3,56 @@
import { cn } from "@follow/utils/utils"
import type { FC, PropsWithChildren, ReactNode } from "react"
import { cloneElement } from "react"
import { cloneElement, createContext, use, useEffect, useRef } from "react"
import * as React from "react"
import { titleCase } from "title-case"
import { SettingActionItem, SettingDescription, SettingSwitch } from "./control"
type SettingSectionHighlightContextValue = {
highlightedSectionId?: string
registerSection: (sectionId: string, element: HTMLElement | null) => void
}
export const SettingSectionHighlightContext =
createContext<SettingSectionHighlightContextValue | null>(null)
export const SettingSectionTitle: FC<{
title: string | ReactNode
className?: string
margin?: "compact" | "normal"
}> = ({ title, margin, className }) => (
<div
className={cn(
"text-text text-headline shrink-0 font-bold opacity-50 first:mt-0",
margin === "compact" ? "mb-2 mt-8" : "mb-4 mt-10",
className,
)}
>
{typeof title === "string" ? titleCase(title) : title}
</div>
)
sectionId?: string
}> = ({ title, margin, className, sectionId }) => {
const highlightCtx = use(SettingSectionHighlightContext)
const elementRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!sectionId || !highlightCtx) return
highlightCtx.registerSection(sectionId, elementRef.current)
return () => {
highlightCtx.registerSection(sectionId, null)
}
}, [highlightCtx, sectionId])
const isHighlighted =
!!sectionId && highlightCtx?.highlightedSectionId === sectionId && !!elementRef.current
return (
<div
ref={elementRef}
data-setting-section={sectionId}
data-highlighted={isHighlighted ? "true" : undefined}
className={cn(
"text-text text-headline shrink-0 font-bold opacity-50 transition-colors duration-300 first:mt-0",
margin === "compact" ? "mb-2 mt-8" : "mb-4 mt-10",
isHighlighted && "border-folo -ml-3 rounded-lg border px-3 py-1.5 opacity-100",
className,
)}
>
{typeof title === "string" ? titleCase(title) : title}
</div>
)
}
export const SettingItemGroup: FC<PropsWithChildren> = ({ children }) => {
const childrenArray = React.Children.toArray(children)

View File

@ -15,6 +15,11 @@ import { UsageAnalysisSection } from "./ai/usage"
const SettingBuilder = createSettingBuilder(useAISettingValue)
const defineSettingItem = createDefineSettingItem(useAISettingValue, setAISetting)
export const AI_SETTING_SECTION_IDS = {
shortcuts: "settings-ai-shortcuts",
tasks: "settings-ai-tasks",
} as const
export const SettingAI = () => {
const { t } = useTranslation("ai")
@ -47,6 +52,7 @@ export const SettingAI = () => {
{
type: "title",
value: t("shortcuts.title"),
id: AI_SETTING_SECTION_IDS.shortcuts,
},
AIShortcutsSection,
@ -59,6 +65,7 @@ export const SettingAI = () => {
{
type: "title",
value: t("tasks.section.title"),
id: AI_SETTING_SECTION_IDS.tasks,
},
TaskSchedulingSection,

View File

@ -18,13 +18,14 @@ import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { oneTimeToken } from "~/lib/auth"
import { queryClient } from "~/lib/query-client"
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
import type { SettingModalOptions } from "~/modules/settings/modal/useSettingModal"
import { useSettingModal } from "~/modules/settings/modal/useSettingModal"
import { handleSessionChanges } from "~/queries/auth"
import { clearDataIfLoginOtherAccount } from "~/store/utils/clear"
declare module "@follow/components/providers/stable-router-provider.js" {
interface CustomRoute {
showSettings: (path?: string) => void
showSettings: (options?: SettingModalOptions) => void
}
}