diff --git a/frontend/src/components/shared/markdown-editor.tsx b/frontend/src/components/shared/markdown-editor.tsx index e5d4e00c..cb312fde 100644 --- a/frontend/src/components/shared/markdown-editor.tsx +++ b/frontend/src/components/shared/markdown-editor.tsx @@ -159,7 +159,12 @@ const MarkdownEditor = forwardRef( linkify: true, tightLists: true, transformCopiedText: true, - transformPastedText: true, + // Plain text pasted from the OS clipboard is left as-is. + // With `transformPastedText: true`, a leading "- " (or + // "1. ", "> ", etc.) would be parsed as markdown and + // turn the paste into a list/blockquote — almost never + // what the user wants for knowledge documents. + transformPastedText: false, }), ], immediatelyRender: false, @@ -243,11 +248,29 @@ const MarkdownEditor = forwardRef( if (autoFocus && editor) { editor.commands.focus('end'); } + // `autoFocus` is intentionally omitted from the deps — it's a + // mount-time prop, equivalent to the native `` + // attribute. We don't want a parent flipping `autoFocus` later + // to steal focus back into the editor. // eslint-disable-next-line react-hooks/exhaustive-deps }, [editor]); + // While tiptap is initializing (`useEditor` returns `null` on the + // first render with `immediatelyRender: false`), render a placeholder + // with the same outer classes so the bounding box is already correct + // and the parent layout doesn't jump when the editor mounts. if (!editor) { - return null; + return ( +
+ ); } return ( @@ -268,7 +291,7 @@ const MarkdownEditor = forwardRef( ) : null} void; + handleDiscard: () => void; + handleOpenChange: (open: boolean) => void; + handleSaveAndLeave: () => Promise | void; + isOpen: boolean; + isSavingFromDialog: boolean; + /** Override the default `` icon next to the save button. */ + saveIcon?: ReactNode; + saveText?: string; + title?: string; +} + +const UnsavedChangesDialog = ({ + canSave, + description = 'You have unsaved changes on this page. Would you like to save them before leaving?', + discardText = 'Discard', + handleCancel, + handleDiscard, + handleOpenChange, + handleSaveAndLeave, + isOpen, + isSavingFromDialog, + saveIcon = , + saveText = 'Save', + title = 'Unsaved changes', +}: UnsavedChangesDialogProps) => ( + + { + if (isSavingFromDialog) { + event.preventDefault(); + } + }} + onInteractOutside={(event) => { + if (isSavingFromDialog) { + event.preventDefault(); + } + }} + > + + {title} + {description} + + + + + + + + +); + +export { UnsavedChangesDialog }; diff --git a/frontend/src/features/knowledges/knowledge-form-controls.tsx b/frontend/src/features/knowledges/knowledge-form-controls.tsx new file mode 100644 index 00000000..27887db7 --- /dev/null +++ b/frontend/src/features/knowledges/knowledge-form-controls.tsx @@ -0,0 +1,250 @@ +import { type Control, useWatch } from 'react-hook-form'; + +import type { + KnowledgeAnswerType as KnowledgeAnswerTypeT, + KnowledgeGuideType as KnowledgeGuideTypeT, +} from '@/graphql/types'; + +import { MarkdownEditor } from '@/components/shared/markdown-editor'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { InputGroup, InputGroupTextareaAutosize } from '@/components/ui/input-group'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types'; + +import type { FormValues } from './knowledge-form'; + +// ` + + + + + + + {docTypeValues.map((value) => ( + + {value} + + ))} + + + ) : ( +
+ {field.value || '—'} +
+ )} + + + )} + /> + + {docType === KnowledgeDocType.Guide ? ( + ( + + Guide type + + + + )} + /> + ) : null} + + {docType === KnowledgeDocType.Answer ? ( + ( + + Answer type + + + + )} + /> + ) : null} + + {docType === KnowledgeDocType.Code ? ( + ( + + Code language + + + + + + )} + /> + ) : null} +
+ + ( + + Question + + + + + + + + )} + /> + + ( + + Description (optional) + + + + + + + + )} + /> + + ); +}; + +interface KnowledgeContentFieldProps { + control: Control; + /** When `true`, the editor stretches to fill its parent (desktop split view). */ + fillParent?: boolean; + isSaving: boolean; + showLabel?: boolean; +} + +export const KnowledgeContentField = ({ + control, + fillParent = false, + isSaving, + showLabel = false, +}: KnowledgeContentFieldProps) => ( + ( + + {showLabel ? Content : null} + + + + + + )} + /> +); diff --git a/frontend/src/features/knowledges/knowledge-form-layout.tsx b/frontend/src/features/knowledges/knowledge-form-layout.tsx new file mode 100644 index 00000000..9f9a1fe6 --- /dev/null +++ b/frontend/src/features/knowledges/knowledge-form-layout.tsx @@ -0,0 +1,123 @@ +import type { Control } from 'react-hook-form'; + +import { GripVertical } from 'lucide-react'; + +import type { KnowledgeDocumentFragmentFragment } from '@/graphql/types'; + +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; + +import type { FormValues } from './knowledge-form'; + +import { KnowledgeContentField, KnowledgeMetaFields } from './knowledge-form-controls'; + +interface KnowledgeIntroBlockProps { + isNew: boolean; + knowledge?: KnowledgeDocumentFragmentFragment | null; +} + +const KnowledgeIntroBlock = ({ isNew, knowledge }: KnowledgeIntroBlockProps) => ( +
+
+

+ {isNew ? 'Create a new knowledge document' : 'Edit knowledge document'} +

+

+ {isNew + ? 'Add an entry to the vector knowledge base' + : 'Edits to content or metadata will trigger re-embedding'} +

+
+ + {!isNew && knowledge ? ( +
+ + {knowledge.manual ? 'manual' : 'agent'} + + {knowledge.flowId ? flow #{knowledge.flowId} : null} + {knowledge.taskId ? task #{knowledge.taskId} : null} + {knowledge.subtaskId ? subtask #{knowledge.subtaskId} : null} + · + + chunk {knowledge.partSize} of {knowledge.totalSize} + +
+ ) : null} +
+); + +interface KnowledgeFormLayoutProps { + control: Control; + isNew: boolean; + isSaving: boolean; + knowledge?: KnowledgeDocumentFragmentFragment | null; +} + +export const KnowledgeFormLayoutDesktop = ({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) => ( +
+ + +
+ + + + + + +
+
+ + + + +
+ +
+
+
+
+); + +export const KnowledgeFormLayoutMobile = ({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) => ( +
+ + + + + + + +
+); diff --git a/frontend/src/features/knowledges/knowledge-form.tsx b/frontend/src/features/knowledges/knowledge-form.tsx new file mode 100644 index 00000000..6af9334e --- /dev/null +++ b/frontend/src/features/knowledges/knowledge-form.tsx @@ -0,0 +1,300 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Save } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { type FieldPath, type SubmitHandler, useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; +import { z } from 'zod'; + +import type { + CreateKnowledgeDocumentInput, + KnowledgeDocumentFragmentFragment, + UpdateKnowledgeDocumentInput, +} from '@/graphql/types'; + +import { UnsavedChangesDialog } from '@/components/shared/unsaved-changes-dialog'; +import { Button } from '@/components/ui/button'; +import { Form } from '@/components/ui/form'; +import { Spinner } from '@/components/ui/spinner'; +import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types'; +import { useBreakpoint } from '@/hooks/use-breakpoint'; +import { useUnsavedChangesGuard } from '@/hooks/use-unsaved-changes-guard'; +import { Log } from '@/lib/log'; + +import { KnowledgeFormLayoutDesktop, KnowledgeFormLayoutMobile } from './knowledge-form-layout'; +import { KnowledgeHeader } from './knowledge-header'; + +// --------------------------------------------------------------------------- +// Schema, types, pure helpers +// --------------------------------------------------------------------------- + +export const formSchema = z + .object({ + answerType: z.nativeEnum(KnowledgeAnswerType).optional(), + codeLang: z.string().trim().optional(), + content: z.string().trim().min(1, { message: 'Content is required' }), + description: z.string().trim().optional(), + docType: z.nativeEnum(KnowledgeDocType), + guideType: z.nativeEnum(KnowledgeGuideType).optional(), + question: z.string().trim().min(1, { message: 'Question is required' }), + }) + .superRefine((value, ctx) => { + const requiredByDocType: Partial; message: string }>> = + { + [KnowledgeDocType.Answer]: { field: 'answerType', message: 'Answer type is required' }, + [KnowledgeDocType.Code]: { field: 'codeLang', message: 'Code language is required' }, + [KnowledgeDocType.Guide]: { field: 'guideType', message: 'Guide type is required' }, + }; + + const rule = requiredByDocType[value.docType]; + + if (!rule) { + return; + } + + const fieldValue = value[rule.field]; + const isMissing = fieldValue === undefined || fieldValue === null || fieldValue === ''; + + if (isMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: rule.message, + path: [rule.field], + }); + } + }); + +export type FormValues = z.infer; + +export const newDocumentDefaults: FormValues = { + answerType: undefined, + codeLang: '', + content: '', + description: '', + docType: KnowledgeDocType.Answer, + guideType: undefined, + question: '', +}; + +export const documentToFormValues = (k: KnowledgeDocumentFragmentFragment): FormValues => ({ + answerType: k.answerType ?? undefined, + codeLang: k.codeLang ?? '', + content: k.content, + description: k.description ?? '', + docType: k.docType, + guideType: k.guideType ?? undefined, + question: k.question, +}); + +// `description` and `codeLang` are optional and we want empty strings to map +// to `undefined` so the backend treats them as "absent" instead of "set to ''". +const trimmedOrUndefined = (value: null | string | undefined): string | undefined => { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + + return trimmed.length === 0 ? undefined : trimmed; +}; + +// Shared field projection used by both create and update payloads. The only +// difference between the two GraphQL inputs is that `docType` is required on +// create and immutable on update — see `formValuesTo{Create,Update}Input` +// below. +const formValuesToBasePayload = (values: FormValues) => ({ + answerType: values.docType === KnowledgeDocType.Answer ? values.answerType : undefined, + codeLang: values.docType === KnowledgeDocType.Code ? trimmedOrUndefined(values.codeLang) : undefined, + content: values.content, + description: trimmedOrUndefined(values.description), + guideType: values.docType === KnowledgeDocType.Guide ? values.guideType : undefined, + question: values.question, +}); + +export const formValuesToCreateInput = (values: FormValues): CreateKnowledgeDocumentInput => ({ + ...formValuesToBasePayload(values), + docType: values.docType, +}); + +export const formValuesToUpdateInput = (values: FormValues): UpdateKnowledgeDocumentInput => + formValuesToBasePayload(values); + +// --------------------------------------------------------------------------- +// Form component +// --------------------------------------------------------------------------- + +export interface SubmitResult { + document?: KnowledgeDocumentFragmentFragment; + redirectTo?: string; +} + +interface KnowledgeFormProps { + initialValues: FormValues; + isNew: boolean; + knowledge?: KnowledgeDocumentFragmentFragment | null; + knowledgeName: null | string; + onSubmit: (values: FormValues) => Promise; +} + +export const KnowledgeForm = ({ initialValues, isNew, knowledge, knowledgeName, onSubmit }: KnowledgeFormProps) => { + const navigate = useNavigate(); + const { isDesktop } = useBreakpoint(); + const [isSaving, setIsSaving] = useState(false); + + const form = useForm({ + defaultValues: initialValues, + // `onTouched` validates a field on its first blur and on every change + // afterwards. With `onChange` we'd run the entire Zod schema on every + // keystroke (including every emit from the multi-kilobyte `content` + // markdown editor) — same UX after the first interaction, no waste + // on initial mount or untouched fields. + mode: 'onTouched', + resolver: zodResolver(formSchema), + }); + + const { control, formState, handleSubmit, reset } = form; + const { isDirty, isValid } = formState; + + const performSave = useCallback( + async (values: FormValues): Promise => { + try { + const result = await onSubmit(values); + + // Prefer the server's view of the document — backend may have + // trimmed/normalized fields, attached derived data, or filled + // optional fields. Falling back to the local `values` keeps + // the form stable when the mutation hook can't return the + // saved fragment for some reason. + const resetValues = result.document ? documentToFormValues(result.document) : values; + + // Reset BEFORE navigate so `isDirty` is false by the time the + // blocker re-evaluates. We also `skipNextBlock` defensively + // because reset's state propagation is async. + reset(resetValues, { keepDefaultValues: false }); + + if (result.redirectTo) { + skipNextBlockRef.current(); + navigate(result.redirectTo); + } + + return true; + } catch (error) { + Log.error('Failed to save knowledge document', error); + + return false; + } + }, + [navigate, onSubmit, reset], + ); + + // The ref below breaks an otherwise circular hook dependency: + // + // performSave → skipNextBlockRef.current() (ref filled by effect below) + // onSaveFromDialog → performSave + // useUnsavedChangesGuard({ onSave: onSaveFromDialog }) → exposes skipNextBlock + // useEffect → wires the exposed skipNextBlock back into the ref + // + // Replacing the ref with a plain dep would force `performSave` to depend + // on `guard.skipNextBlock`, which is produced by a hook (`guard`) whose + // own input (`onSave`) closes over `performSave` — a real cycle that + // can't be expressed in deps without `useRef`. + const skipNextBlockRef = useRef<() => void>(() => {}); + + const onSubmitWithGuard: SubmitHandler = useCallback( + async (values) => { + if (isSaving) { + return; + } + + setIsSaving(true); + + try { + await performSave(values); + } finally { + setIsSaving(false); + } + }, + [isSaving, performSave], + ); + + const onSaveFromDialog = useCallback(async (): Promise => { + if (isSaving || !isValid) { + return false; + } + + setIsSaving(true); + + try { + return await performSave(form.getValues()); + } finally { + setIsSaving(false); + } + }, [form, isSaving, isValid, performSave]); + + const guard = useUnsavedChangesGuard({ + isDirty, + isFormValid: isValid, + onSave: onSaveFromDialog, + }); + + useEffect(() => { + skipNextBlockRef.current = guard.skipNextBlock; + }, [guard.skipNextBlock]); + + const canSubmit = !isSaving && isValid && (isNew || isDirty); + + const saveButton = ( + + ); + + return ( + <> +
+ + + {isDesktop ? ( + + ) : ( + + )} + + + + + ); +}; diff --git a/frontend/src/features/knowledges/knowledge-header.tsx b/frontend/src/features/knowledges/knowledge-header.tsx new file mode 100644 index 00000000..c90b8651 --- /dev/null +++ b/frontend/src/features/knowledges/knowledge-header.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +import { LibraryBig } from 'lucide-react'; + +import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; +import { Separator } from '@/components/ui/separator'; +import { SidebarTrigger } from '@/components/ui/sidebar'; + +interface KnowledgeHeaderProps { + isNew: boolean; + knowledgeName: null | string; + saveButton?: ReactNode; +} + +export const KnowledgeHeader = ({ isNew, knowledgeName, saveButton }: KnowledgeHeaderProps) => ( +
+ + + + + + + + {isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')} + + + + + {saveButton ?
{saveButton}
: null} +
+); diff --git a/frontend/src/features/knowledges/knowledge-layout.tsx b/frontend/src/features/knowledges/knowledge-layout.tsx new file mode 100644 index 00000000..0782aab4 --- /dev/null +++ b/frontend/src/features/knowledges/knowledge-layout.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +import { KnowledgeHeader } from './knowledge-header'; + +interface KnowledgeLayoutProps { + children: ReactNode; + className?: string; + isNew: boolean; + knowledgeName: null | string; + saveButton?: ReactNode; +} + +/** + * Shared layout shell for the knowledge page in non-form branches + * (loading, not-found). `KnowledgeForm` owns its own `
` root and + * renders the header inline because the form must be the parent of every + * input. + */ +export const KnowledgeLayout = ({ children, className, isNew, knowledgeName, saveButton }: KnowledgeLayoutProps) => ( +
+ + {children} +
+); diff --git a/frontend/src/hooks/use-unsaved-changes-guard.ts b/frontend/src/hooks/use-unsaved-changes-guard.ts new file mode 100644 index 00000000..0e12394e --- /dev/null +++ b/frontend/src/hooks/use-unsaved-changes-guard.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { type BlockerFunction, useBlocker } from 'react-router-dom'; + +export interface UnsavedChangesGuard { + handleCancel: () => void; + handleDiscard: () => void; + handleOpenChange: (open: boolean) => void; + handleSaveAndLeave: () => Promise; + isOpen: boolean; + isSavingFromDialog: boolean; + /** + * Allows the next router navigation to bypass the blocker. Use this after + * a successful save when the consumer needs to navigate to a fresh URL + * (e.g. the new document page) without showing the dialog. + */ + skipNextBlock: () => void; +} + +export interface UseUnsavedChangesGuardArgs { + isDirty: boolean; + isFormValid: boolean; + onSave: () => Promise; +} + +/** + * Generic "are you sure you want to leave?" guard for any editing form + * inside React Router. Combines two mechanisms: + * + * 1. `useBlocker` — intercepts in-app router navigations and exposes a + * blocked state that the consumer can render as a dialog. + * 2. `beforeunload` — covers full page reloads and tab close attempts; + * the browser shows its native prompt while there are dirty changes. + * + * Designed to be UI-agnostic: pair with `` (or any + * custom dialog) by wiring the returned handlers. + */ +export const useUnsavedChangesGuard = ({ + isDirty, + isFormValid, + onSave, +}: UseUnsavedChangesGuardArgs): UnsavedChangesGuard => { + const allowNextRef = useRef(false); + const isDirtyRef = useRef(isDirty); + + useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + const blockerFn = useCallback(({ currentLocation, nextLocation }) => { + if (allowNextRef.current) { + allowNextRef.current = false; + + return false; + } + + if (currentLocation.pathname === nextLocation.pathname && currentLocation.search === nextLocation.search) { + return false; + } + + return isDirtyRef.current; + }, []); + + const blocker = useBlocker(blockerFn); + const [isSavingFromDialog, setIsSavingFromDialog] = useState(false); + + useEffect(() => { + if (!isDirty) { + return; + } + + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ''; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isDirty]); + + const isOpen = blocker.state === 'blocked'; + + const handleCancel = useCallback(() => { + if (blocker.state === 'blocked') { + blocker.reset(); + } + }, [blocker]); + + const handleDiscard = useCallback(() => { + if (blocker.state === 'blocked') { + blocker.proceed(); + } + }, [blocker]); + + const handleSaveAndLeave = useCallback(async () => { + if (isSavingFromDialog || !isFormValid) { + return; + } + + setIsSavingFromDialog(true); + + try { + const success = await onSave(); + + if (success && blocker.state === 'blocked') { + blocker.proceed(); + } + } finally { + setIsSavingFromDialog(false); + } + }, [blocker, isFormValid, isSavingFromDialog, onSave]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (isSavingFromDialog) { + return; + } + + if (!open && blocker.state === 'blocked') { + blocker.reset(); + } + }, + [blocker, isSavingFromDialog], + ); + + const skipNextBlock = useCallback(() => { + allowNextRef.current = true; + }, []); + + return { + handleCancel, + handleDiscard, + handleOpenChange, + handleSaveAndLeave, + isOpen, + isSavingFromDialog, + skipNextBlock, + }; +}; diff --git a/frontend/src/pages/knowledges/knowledge.tsx b/frontend/src/pages/knowledges/knowledge.tsx index 4af1c6e5..b5ceb36b 100644 --- a/frontend/src/pages/knowledges/knowledge.tsx +++ b/frontend/src/pages/knowledges/knowledge.tsx @@ -1,873 +1,22 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { GripVertical, LibraryBig, Save } from 'lucide-react'; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { type Control, type FieldPath, type SubmitHandler, useForm, useWatch } from 'react-hook-form'; -import { type BlockerFunction, useBlocker, useNavigate, useParams } from 'react-router-dom'; -import { z } from 'zod'; +import { useCallback, useMemo } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; -import type { - CreateKnowledgeDocumentInput, - KnowledgeAnswerType as KnowledgeAnswerTypeT, - KnowledgeDocumentFragmentFragment, - KnowledgeGuideType as KnowledgeGuideTypeT, - UpdateKnowledgeDocumentInput, -} from '@/graphql/types'; - -import { MarkdownEditor } from '@/components/shared/markdown-editor'; -import { Badge } from '@/components/ui/badge'; -import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { InputGroup, InputGroupTextareaAutosize } from '@/components/ui/input-group'; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Separator } from '@/components/ui/separator'; -import { SidebarTrigger } from '@/components/ui/sidebar'; import { Spinner } from '@/components/ui/spinner'; -import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType, useKnowledgeDocumentQuery } from '@/graphql/types'; -import { useBreakpoint } from '@/hooks/use-breakpoint'; -import { Log } from '@/lib/log'; +import { + documentToFormValues, + type FormValues, + formValuesToCreateInput, + formValuesToUpdateInput, + KnowledgeForm, + newDocumentDefaults, + type SubmitResult, +} from '@/features/knowledges/knowledge-form'; +import { KnowledgeLayout } from '@/features/knowledges/knowledge-layout'; +import { useKnowledgeDocumentQuery } from '@/graphql/types'; import { useKnowledges } from '@/providers/knowledges-provider'; -// --------------------------------------------------------------------------- -// Schema, types, pure helpers -// --------------------------------------------------------------------------- - -const docTypeValues = [KnowledgeDocType.Answer, KnowledgeDocType.Guide, KnowledgeDocType.Code] as const; -const guideTypeValues = Object.values(KnowledgeGuideType) as KnowledgeGuideTypeT[]; -const answerTypeValues = Object.values(KnowledgeAnswerType) as KnowledgeAnswerTypeT[]; - -const formSchema = z - .object({ - answerType: z.nativeEnum(KnowledgeAnswerType).optional(), - codeLang: z.string().trim().optional(), - content: z.string().trim().min(1, { message: 'Content is required' }), - description: z.string().trim().optional(), - docType: z.nativeEnum(KnowledgeDocType), - guideType: z.nativeEnum(KnowledgeGuideType).optional(), - question: z.string().trim().min(1, { message: 'Question is required' }), - }) - .superRefine((value, ctx) => { - const requiredByDocType: Partial; message: string }>> = { - [KnowledgeDocType.Answer]: { field: 'answerType', message: 'Answer type is required' }, - [KnowledgeDocType.Code]: { field: 'codeLang', message: 'Code language is required' }, - [KnowledgeDocType.Guide]: { field: 'guideType', message: 'Guide type is required' }, - }; - - const rule = requiredByDocType[value.docType]; - - if (!rule) { - return; - } - - const fieldValue = value[rule.field]; - const isMissing = fieldValue === undefined || fieldValue === null || fieldValue === ''; - - if (isMissing) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: rule.message, - path: [rule.field], - }); - } - }); - -type FormValues = z.infer; - -const newDocumentDefaults: FormValues = { - answerType: undefined, - codeLang: '', - content: '', - description: '', - docType: KnowledgeDocType.Answer, - guideType: undefined, - question: '', -}; - -const documentToFormValues = (k: KnowledgeDocumentFragmentFragment): FormValues => ({ - answerType: k.answerType ?? undefined, - codeLang: k.codeLang ?? '', - content: k.content, - description: k.description ?? '', - docType: k.docType, - guideType: k.guideType ?? undefined, - question: k.question, -}); - -// `description` and `codeLang` are optional and we want empty strings to map -// to `undefined` so the backend treats them as "absent" instead of "set to ''". -const trimmedOrUndefined = (value: null | string | undefined): string | undefined => { - if (!value) { - return undefined; - } - - const trimmed = value.trim(); - - return trimmed.length === 0 ? undefined : trimmed; -}; - -// Shared field projection used by both create and update payloads. The only -// difference between the two GraphQL inputs is that `docType` is required on -// create and immutable on update — see `formValuesTo{Create,Update}Input` -// below. -const formValuesToBasePayload = (values: FormValues) => ({ - answerType: values.docType === KnowledgeDocType.Answer ? values.answerType : undefined, - codeLang: values.docType === KnowledgeDocType.Code ? trimmedOrUndefined(values.codeLang) : undefined, - content: values.content, - description: trimmedOrUndefined(values.description), - guideType: values.docType === KnowledgeDocType.Guide ? values.guideType : undefined, - question: values.question, -}); - -const formValuesToCreateInput = (values: FormValues): CreateKnowledgeDocumentInput => ({ - ...formValuesToBasePayload(values), - docType: values.docType, -}); - -const formValuesToUpdateInput = (values: FormValues): UpdateKnowledgeDocumentInput => - formValuesToBasePayload(values); - -// --------------------------------------------------------------------------- -// Unsaved-changes guard hook -// --------------------------------------------------------------------------- - -interface UnsavedChangesGuard { - handleCancel: () => void; - handleDiscard: () => void; - handleOpenChange: (open: boolean) => void; - handleSaveAndLeave: () => Promise; - isOpen: boolean; - isSavingFromDialog: boolean; - /** - * Allows the next router navigation to bypass the blocker. Use this after - * a successful save when the form (or its parent) needs to navigate to - * a fresh URL (e.g. the new document page) without showing the dialog. - */ - skipNextBlock: () => void; -} - -interface UseUnsavedChangesGuardArgs { - isDirty: boolean; - isFormValid: boolean; - onSave: () => Promise; -} - -const useUnsavedChangesGuard = ({ - isDirty, - isFormValid, - onSave, -}: UseUnsavedChangesGuardArgs): UnsavedChangesGuard => { - const allowNextRef = useRef(false); - const isDirtyRef = useRef(isDirty); - - useEffect(() => { - isDirtyRef.current = isDirty; - }, [isDirty]); - - const blockerFn = useCallback(({ currentLocation, nextLocation }) => { - if (allowNextRef.current) { - allowNextRef.current = false; - - return false; - } - - if (currentLocation.pathname === nextLocation.pathname && currentLocation.search === nextLocation.search) { - return false; - } - - return isDirtyRef.current; - }, []); - - const blocker = useBlocker(blockerFn); - const [isSavingFromDialog, setIsSavingFromDialog] = useState(false); - - useEffect(() => { - if (!isDirty) { - return; - } - - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - event.preventDefault(); - event.returnValue = ''; - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, [isDirty]); - - const isOpen = blocker.state === 'blocked'; - - const handleCancel = useCallback(() => { - if (blocker.state === 'blocked') { - blocker.reset(); - } - }, [blocker]); - - const handleDiscard = useCallback(() => { - if (blocker.state === 'blocked') { - blocker.proceed(); - } - }, [blocker]); - - const handleSaveAndLeave = useCallback(async () => { - if (isSavingFromDialog || !isFormValid) { - return; - } - - setIsSavingFromDialog(true); - - try { - const success = await onSave(); - - if (success && blocker.state === 'blocked') { - blocker.proceed(); - } - } finally { - setIsSavingFromDialog(false); - } - }, [blocker, isFormValid, isSavingFromDialog, onSave]); - - const handleOpenChange = useCallback( - (open: boolean) => { - if (isSavingFromDialog) { - return; - } - - if (!open && blocker.state === 'blocked') { - blocker.reset(); - } - }, - [blocker, isSavingFromDialog], - ); - - const skipNextBlock = useCallback(() => { - allowNextRef.current = true; - }, []); - - return { - handleCancel, - handleDiscard, - handleOpenChange, - handleSaveAndLeave, - isOpen, - isSavingFromDialog, - skipNextBlock, - }; -}; - -// --------------------------------------------------------------------------- -// Sub-components -// --------------------------------------------------------------------------- - -interface KnowledgePageHeaderProps { - isNew: boolean; - knowledgeName: null | string; - saveButton?: ReactNode; -} - -const KnowledgePageHeader = ({ isNew, knowledgeName, saveButton }: KnowledgePageHeaderProps) => ( -
- - - - - - - - {isNew ? 'New knowledge' : (knowledgeName ?? 'Knowledge')} - - - - - {saveButton ?
{saveButton}
: null} -
-); - -interface KnowledgeIntroBlockProps { - isNew: boolean; - knowledge?: KnowledgeDocumentFragmentFragment | null; -} - -const KnowledgeIntroBlock = ({ isNew, knowledge }: KnowledgeIntroBlockProps) => ( -
-
-

- {isNew ? 'Create a new knowledge document' : 'Edit knowledge document'} -

-

- {isNew - ? 'Add an entry to the vector knowledge base' - : 'Edits to content or metadata will trigger re-embedding'} -

-
- - {!isNew && knowledge ? ( -
- - {knowledge.manual ? 'manual' : 'agent'} - - {knowledge.flowId ? flow #{knowledge.flowId} : null} - {knowledge.taskId ? task #{knowledge.taskId} : null} - {knowledge.subtaskId ? subtask #{knowledge.subtaskId} : null} - · - - chunk {knowledge.partSize} of {knowledge.totalSize} - -
- ) : null} -
-); - -interface KnowledgeMetaFieldsProps { - control: Control; - isNew: boolean; - isSaving: boolean; -} - -const KnowledgeMetaFields = ({ control, isNew, isSaving }: KnowledgeMetaFieldsProps) => { - // Targeted subscription: only this component re-renders when docType changes, - // not the whole form. The full-form `useWatch` from the original code - // re-rendered on every keystroke in the markdown editor. - const docType = useWatch({ control, name: 'docType' }); - - return ( -
-
- ( - - Document type - {isNew ? ( - - ) : ( -
- {field.value || '—'} -
- )} - -
- )} - /> - - {docType === KnowledgeDocType.Guide ? ( - ( - - Guide type - - - - )} - /> - ) : null} - - {docType === KnowledgeDocType.Answer ? ( - ( - - Answer type - - - - )} - /> - ) : null} - - {docType === KnowledgeDocType.Code ? ( - ( - - Code language - - - - - - )} - /> - ) : null} -
- - ( - - Question - - - - - - - - )} - /> - - ( - - Description (optional) - - - - - - - - )} - /> -
- ); -}; - -interface KnowledgeContentFieldProps { - control: Control; - /** When `true`, the editor stretches to fill its parent (desktop split view). */ - fillParent?: boolean; - isSaving: boolean; - showLabel?: boolean; -} - -const KnowledgeContentField = ({ control, fillParent = false, isSaving, showLabel = false }: KnowledgeContentFieldProps) => ( - ( - - {showLabel ? Content : null} - - - - - - )} - /> -); - -interface KnowledgeBodyProps { - control: Control; - isNew: boolean; - isSaving: boolean; - knowledge?: KnowledgeDocumentFragmentFragment | null; -} - -const KnowledgeBodyDesktop = ({ control, isNew, isSaving, knowledge }: KnowledgeBodyProps) => ( -
- - -
- - - - - - -
-
- - - - -
- -
-
-
-
-); - -const KnowledgeBodyMobile = ({ control, isNew, isSaving, knowledge }: KnowledgeBodyProps) => ( -
- - - - - - - -
-); - -interface KnowledgeLeaveDialogProps extends Pick { - canSave: boolean; -} - -const KnowledgeLeaveDialog = ({ - canSave, - handleCancel, - handleDiscard, - handleOpenChange, - handleSaveAndLeave, - isOpen, - isSavingFromDialog, -}: KnowledgeLeaveDialogProps) => ( - - { - if (isSavingFromDialog) { - event.preventDefault(); - } - }} - onInteractOutside={(event) => { - if (isSavingFromDialog) { - event.preventDefault(); - } - }} - > - - Unsaved changes - - You have unsaved changes on this page. Would you like to save them before leaving? - - - - - - - - - -); - -// --------------------------------------------------------------------------- -// Form view (uses the form, owns navigation guard) -// --------------------------------------------------------------------------- - -interface KnowledgeFormViewProps { - initialValues: FormValues; - isNew: boolean; - knowledge?: KnowledgeDocumentFragmentFragment | null; - knowledgeName: null | string; - onSubmit: (values: FormValues) => Promise; -} - -interface SubmitResult { - redirectTo?: string; -} - -const KnowledgeFormView = ({ initialValues, isNew, knowledge, knowledgeName, onSubmit }: KnowledgeFormViewProps) => { - const navigate = useNavigate(); - const { isDesktop } = useBreakpoint(); - const [isSaving, setIsSaving] = useState(false); - - const form = useForm({ - defaultValues: initialValues, - mode: 'onChange', - resolver: zodResolver(formSchema), - }); - - const { control, formState, handleSubmit, reset } = form; - const { isDirty, isValid } = formState; - - const performSave = useCallback( - async (values: FormValues): Promise => { - try { - const result = await onSubmit(values); - - // Reset BEFORE navigate so `isDirty` is false by the time the - // blocker re-evaluates. We also `skipNextBlock` defensively - // because reset's state propagation is async. - reset(values, { keepDefaultValues: false }); - - if (result.redirectTo) { - skipNextBlockRef.current(); - navigate(result.redirectTo); - } - - return true; - } catch (error) { - Log.error('Failed to save knowledge document', error); - - return false; - } - }, - [navigate, onSubmit, reset], - ); - - // The ref below breaks an otherwise circular hook dependency: - // - // performSave → skipNextBlockRef.current() (ref filled by effect below) - // onSaveFromDialog → performSave - // useUnsavedChangesGuard({ onSave: onSaveFromDialog }) → exposes skipNextBlock - // useEffect → wires the exposed skipNextBlock back into the ref - // - // Replacing the ref with a plain dep would force `performSave` to depend - // on `guard.skipNextBlock`, which is produced by a hook (`guard`) whose - // own input (`onSave`) closes over `performSave` — a real cycle that - // can't be expressed in deps without `useRef`. - const skipNextBlockRef = useRef<() => void>(() => {}); - - const onSubmitWithGuard: SubmitHandler = useCallback( - async (values) => { - if (isSaving) { - return; - } - - setIsSaving(true); - - try { - await performSave(values); - } finally { - setIsSaving(false); - } - }, - [isSaving, performSave], - ); - - const onSaveFromDialog = useCallback(async (): Promise => { - if (isSaving || !isValid) { - return false; - } - - setIsSaving(true); - - try { - return await performSave(form.getValues()); - } finally { - setIsSaving(false); - } - }, [form, isSaving, isValid, performSave]); - - const guard = useUnsavedChangesGuard({ - isDirty, - isFormValid: isValid, - onSave: onSaveFromDialog, - }); - - // Wire the ref so `performSave` can call into the guard's `skipNextBlock`. - useEffect(() => { - skipNextBlockRef.current = guard.skipNextBlock; - }, [guard.skipNextBlock]); - - const canSubmit = !isSaving && isValid && (isNew || isDirty); - - const saveButton = ( - - ); - - return ( - <> - - - - {isDesktop ? ( - - ) : ( - - )} - - - - - ); -}; - -// --------------------------------------------------------------------------- -// Page container -// --------------------------------------------------------------------------- - const Knowledge = () => { const navigate = useNavigate(); const { knowledgeId } = useParams<{ knowledgeId?: string }>(); @@ -895,6 +44,7 @@ const Knowledge = () => { const created = await createKnowledge(formValuesToCreateInput(values)); return { + document: created ?? undefined, redirectTo: created?.id ? `/knowledges/${created.id}` : '/knowledges', }; } @@ -903,35 +53,33 @@ const Knowledge = () => { return {}; } - await updateKnowledge(knowledgeId, formValuesToUpdateInput(values)); + const updated = await updateKnowledge(knowledgeId, formValuesToUpdateInput(values)); - return {}; + return { document: updated ?? undefined }; }, [createKnowledge, isNew, knowledgeId, updateKnowledge], ); if (!isNew && isLoadingKnowledge) { return ( - <> - -
+ +
- +
); } if (!isNew && !knowledge) { return ( - <> - -
+ +

Knowledge not found

@@ -942,12 +90,12 @@ const Knowledge = () => {
- +
); } return ( -