feat(desktop-settings): focus ai sections when opened from chat
This commit is contained in:
parent
b064d3385e
commit
e9de114ebe
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue