Skip to content

Commit

Permalink
feat(ui): support adding new sections to pages (#3898)
Browse files Browse the repository at this point in the history
* feat(ui): support adding new sections to pages

* update
  • Loading branch information
liangfung authored Feb 25, 2025
1 parent fed47c1 commit e9eca4a
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 15 deletions.
90 changes: 90 additions & 0 deletions ee/tabby-ui/app/pages/components/new-section-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import Textarea from 'react-textarea-autosize'
import * as z from 'zod'

import { useEnterSubmit } from '@/lib/hooks/use-enter-submit'
import { makeFormErrorHandler } from '@/lib/tabby/gql'
import { ExtendedCombinedError } from '@/lib/types'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'
import { IconPlus } from '@/components/ui/icons'

export function NewSectionForm({
onSubmit,
disabled: propDisabled,
className
}: {
onSubmit: (title: string) => Promise<ExtendedCombinedError | void>
disabled?: boolean
className?: string
}) {
const formSchema = z.object({
title: z.string().trim()
})
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema)
})

const { formRef, onKeyDown } = useEnterSubmit()

const title = form.watch('title')
const { isSubmitting } = form.formState

const disabled = propDisabled || !title || isSubmitting

const handleSubmit = async (values: z.infer<typeof formSchema>) => {
if (disabled) return
const title = values.title
onSubmit(title).then(error => {
if (error) {
makeFormErrorHandler(form)(error)
}
})
form.reset({
title: ''
})
}

return (
<Form {...form}>
<form
className={cn(className)}
onSubmit={form.handleSubmit(handleSubmit)}
ref={formRef}
>
<div className="relative">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Textarea
autoFocus
draggable={false}
minRows={2}
maxRows={6}
className="w-full rounded-lg border-2 bg-background p-4 pr-12 text-xl outline-ring"
placeholder="What is the section about?"
onKeyDown={onKeyDown}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<Button
size="icon"
className="absolute right-4 top-1/2 z-10 h-7 w-7 -translate-y-1/2 rounded-full"
type="submit"
disabled={disabled}
>
<IconPlus />
</Button>
</div>
</form>
</Form>
)
}
150 changes: 135 additions & 15 deletions ee/tabby-ui/app/pages/components/page-main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useQuery } from 'urql'
import { ERROR_CODE_NOT_FOUND, SLUG_TITLE_MAX_LENGTH } from '@/lib/constants'
import { graphql } from '@/lib/gql/generates'
import {
CreatePageSectionRunSubscription,
CreateThreadToPageRunSubscription,
MoveSectionDirection
} from '@/lib/gql/generates/graphql'
Expand All @@ -29,7 +30,7 @@ import {
listSecuredUsers
} from '@/lib/tabby/query'
import { ExtendedCombinedError } from '@/lib/types'
import { cn } from '@/lib/utils'
import { cn, nanoid } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { IconClock, IconFileSearch } from '@/components/ui/icons'
import { ScrollArea } from '@/components/ui/scroll-area'
Expand All @@ -43,6 +44,7 @@ import { UserAvatar } from '@/components/user-avatar'
import { PageItem, SectionItem } from '../types'
import { Header } from './header'
import { Navbar } from './nav-bar'
import { NewSectionForm } from './new-section-form'
import { PageContext } from './page-context'
import { SectionContent } from './section-content'
import { SectionTitle } from './section-title'
Expand Down Expand Up @@ -89,6 +91,25 @@ const createThreadToPageRunSubscription = graphql(/* GraphQL */ `
}
`)

const createPageSectionRunSubscription = graphql(/* GraphQL */ `
subscription createPageSectionRun($input: CreatePageSectionRunInput!) {
createPageSectionRun(input: $input) {
__typename
... on PageSection {
id
title
position
}
... on PageSectionContentDelta {
delta
}
... on PageSectionContentCompleted {
id
}
}
}
`)

const deletePageSectionMutation = graphql(/* GraphQL */ `
mutation DeletePageSection($sectionId: ID!) {
deletePageSection(sectionId: $sectionId)
Expand Down Expand Up @@ -122,6 +143,7 @@ export function Page() {
const [currentSectionId, setCurrentSectionId] = useState<string | undefined>(
undefined
)
const [pageCompleted, setPageCompleted] = useState(true)
const contentContainerRef = useRef<HTMLDivElement>(null)
const [isShowDemoBanner] = useShowDemoBanner()
const initializing = useRef(false)
Expand All @@ -140,6 +162,14 @@ export function Page() {
}, [activePathname])

const unsubscribeFn = useRef<(() => void) | undefined>()
const stop = useLatest(() => {
unsubscribeFn.current?.()
unsubscribeFn.current = undefined
setIsLoading(false)
setPendingSectionIds(new Set())
setCurrentSectionId(undefined)
setPageCompleted(true)
})

const processPageStream = (
data: CreateThreadToPageRunSubscription['createThreadToPageRun']
Expand All @@ -157,7 +187,6 @@ export function Page() {
}
setPage(nextPage)
setPageId(data.id)
// todo remove this
updatePageURL(nextPage)
break
case 'PageContentDelta': {
Expand Down Expand Up @@ -221,6 +250,95 @@ export function Page() {
}
}

const processNewSectionStream = (
data: CreatePageSectionRunSubscription['createPageSectionRun']
) => {
switch (data.__typename) {
case 'PageSection': {
const { id, title, position } = data
setCurrentSectionId(id)
setPendingSectionIds(new Set([id]))
setSections(prev => {
if (!prev) return prev
const _sections = prev.slice(0, -1)
return [
..._sections,
{ id, title, position, content: '', pageId: pageId as string }
]
})
break
}
case 'PageSectionContentDelta': {
const { delta } = data
setSections(prev => {
if (!prev) return prev
const len = prev.length
return prev.map((x, index) => {
if (index === len - 1) {
return {
...x,
content: x.content + delta
}
}
return x
})
})
break
}
case 'PageSectionContentCompleted': {
stop.current()
break
}
}
}

const appendNewSection = async (title: string) => {
if (!pageId) return

const tempSectionId = nanoid()
setIsLoading(true)
setError(undefined)
setSections(prev => {
const lastPosition = prev?.[prev.length - 1]?.position || 0
const newSection: SectionItem = {
id: tempSectionId,
title,
pageId,
content: '',
position: lastPosition + 1
}

if (!prev) return [newSection]
return [...prev, newSection]
})
setPendingSectionIds(new Set([tempSectionId]))
setCurrentSectionId(tempSectionId)

const { unsubscribe } = client
.subscription(createPageSectionRunSubscription, {
input: {
pageId,
title
}
})
.subscribe(res => {
if (res?.error) {
setIsLoading(false)
setError(res.error)
unsubscribe()
return
}

const value = res?.data?.createPageSectionRun
if (!value) {
return
}
processNewSectionStream(value)
})

unsubscribeFn.current = unsubscribe
}

const convertThreadToPage = (threadId: string) => {
const { unsubscribe } = client
.subscription(createThreadToPageRunSubscription, {
Expand All @@ -245,17 +363,6 @@ export function Page() {
return unsubscribe
}

const stop = useLatest(() => {
unsubscribeFn.current?.()
unsubscribeFn.current = undefined
setIsLoading(false)
setPendingSectionIds(new Set())
setCurrentSectionId(undefined)
// if (page) {
// updatePageURL(page)
// }
})

const deletePageSection = useMutation(deletePageSectionMutation)
const movePageSectionPosition = useMutation(movePageSectionPositionMutation)

Expand Down Expand Up @@ -389,7 +496,7 @@ export function Page() {
if (pendingThreadId) {
setIsLoading(true)
setError(undefined)

setPageCompleted(false)
// trigger convert
unsubscribeFn.current = convertThreadToPage(pendingThreadId)
updatePendingThreadId(undefined)
Expand Down Expand Up @@ -607,7 +714,11 @@ export function Page() {
<motion.div
layout={!isLoading && mode === 'edit'}
key={`section_${section.id}`}
exit={{ opacity: 0 }}
exit={
isLoading
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, transition: { duration: 0.5 } }
}
>
<SectionTitle
className="section-title pt-8"
Expand All @@ -623,6 +734,15 @@ export function Page() {
)
})}
</AnimatePresence>

{/* append section */}
{isPageOwner && mode === 'edit' && pageCompleted && (
<NewSectionForm
onSubmit={appendNewSection}
disabled={!pageId || isLoading}
className="mt-10"
/>
)}
</LoadingWrapper>
</div>
<div className="relative col-span-1">
Expand Down

0 comments on commit e9eca4a

Please sign in to comment.