diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index a96725619..a17d7362b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -35,6 +35,7 @@ import { membersAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { Thread } from "@/components/assistant-ui/thread"; import { MobileEditorPanel } from "@/components/editor-panel/editor-panel"; +import { MobileHitlEditPanel } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { MobileReportPanel } from "@/components/report-panel/report-panel"; import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; @@ -1683,6 +1684,7 @@ export default function NewChatPage() { + ); diff --git a/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts b/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts new file mode 100644 index 000000000..eec3879ab --- /dev/null +++ b/surfsense_web/atoms/chat/hitl-edit-panel.atom.ts @@ -0,0 +1,59 @@ +import { atom } from "jotai"; +import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; + +interface HitlEditPanelState { + isOpen: boolean; + title: string; + content: string; + toolName: string; + onSave: ((title: string, content: string) => void) | null; +} + +const initialState: HitlEditPanelState = { + isOpen: false, + title: "", + content: "", + toolName: "", + onSave: null, +}; + +export const hitlEditPanelAtom = atom(initialState); + +const preHitlCollapsedAtom = atom(null); + +export const openHitlEditPanelAtom = atom( + null, + ( + get, + set, + payload: { + title: string; + content: string; + toolName: string; + onSave: (title: string, content: string) => void; + } + ) => { + if (!get(hitlEditPanelAtom).isOpen) { + set(preHitlCollapsedAtom, get(rightPanelCollapsedAtom)); + } + set(hitlEditPanelAtom, { + isOpen: true, + title: payload.title, + content: payload.content, + toolName: payload.toolName, + onSave: payload.onSave, + }); + set(rightPanelTabAtom, "hitl-edit"); + set(rightPanelCollapsedAtom, false); + } +); + +export const closeHitlEditPanelAtom = atom(null, (get, set) => { + set(hitlEditPanelAtom, initialState); + set(rightPanelTabAtom, "sources"); + const prev = get(preHitlCollapsedAtom); + if (prev !== null) { + set(rightPanelCollapsedAtom, prev); + set(preHitlCollapsedAtom, null); + } +}); diff --git a/surfsense_web/atoms/layout/right-panel.atom.ts b/surfsense_web/atoms/layout/right-panel.atom.ts index fa1b80613..e06500113 100644 --- a/surfsense_web/atoms/layout/right-panel.atom.ts +++ b/surfsense_web/atoms/layout/right-panel.atom.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; -export type RightPanelTab = "sources" | "report" | "editor"; +export type RightPanelTab = "sources" | "report" | "editor" | "hitl-edit"; export const rightPanelTabAtom = atom("sources"); diff --git a/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx b/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx new file mode 100644 index 000000000..c397fcf3e --- /dev/null +++ b/surfsense_web/components/hitl-edit-panel/hitl-edit-panel.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useAtomValue, useSetAtom } from "jotai"; +import { XIcon } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { + closeHitlEditPanelAtom, + hitlEditPanelAtom, +} from "@/atoms/chat/hitl-edit-panel.atom"; +import { PlateEditor } from "@/components/editor/plate-editor"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; +import { useMediaQuery } from "@/hooks/use-media-query"; + +export function HitlEditPanelContent({ + title: initialTitle, + content: initialContent, + onSave, + onClose, +}: { + title: string; + content: string; + toolName: string; + onSave: (title: string, content: string) => void; + onClose?: () => void; +}) { + const [editedTitle, setEditedTitle] = useState(initialTitle); + const markdownRef = useRef(initialContent); + const [isSaving, setIsSaving] = useState(false); + + const handleMarkdownChange = useCallback((md: string) => { + markdownRef.current = md; + }, []); + + const handleSave = useCallback(() => { + if (!editedTitle.trim()) return; + setIsSaving(true); + onSave(editedTitle, markdownRef.current); + onClose?.(); + }, [editedTitle, onSave, onClose]); + + return ( + <> +
+ setEditedTitle(e.target.value)} + placeholder="Untitled" + className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground" + aria-label="Page title" + /> + {onClose && ( + + )} +
+ +
+ +
+ + ); +} + +function DesktopHitlEditPanel() { + const panelState = useAtomValue(hitlEditPanelAtom); + const closePanel = useSetAtom(closeHitlEditPanelAtom); + + if (!panelState.isOpen || !panelState.onSave) return null; + + return ( +
+ +
+ ); +} + +function MobileHitlEditDrawer() { + const panelState = useAtomValue(hitlEditPanelAtom); + const closePanel = useSetAtom(closeHitlEditPanelAtom); + + if (!panelState.onSave) return null; + + return ( + { + if (!open) closePanel(); + }} + shouldScaleBackground={false} + > + + + + Edit {panelState.toolName} + +
+ +
+
+
+ ); +} + +export function HitlEditPanel() { + const panelState = useAtomValue(hitlEditPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (!panelState.isOpen) return null; + + if (isDesktop) { + return ; + } + + return ; +} + +export function MobileHitlEditPanel() { + const panelState = useAtomValue(hitlEditPanelAtom); + const isDesktop = useMediaQuery("(min-width: 1024px)"); + + if (isDesktop || !panelState.isOpen) return null; + + return ; +} diff --git a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx index e94982bc5..2489d2a64 100644 --- a/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx +++ b/surfsense_web/components/layout/ui/right-panel/RightPanel.tsx @@ -3,11 +3,13 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { PanelRight, PanelRightClose } from "lucide-react"; import { startTransition, useEffect } from "react"; +import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom"; import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-panel.atom"; import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom"; import { EditorPanelContent } from "@/components/editor-panel/editor-panel"; +import { HitlEditPanelContent } from "@/components/hitl-edit-panel/hitl-edit-panel"; import { ReportPanelContent } from "@/components/report-panel/report-panel"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -44,9 +46,11 @@ export function RightPanelExpandButton() { const documentsOpen = useAtomValue(documentsSidebarOpenAtom); const reportState = useAtomValue(reportPanelAtom); const editorState = useAtomValue(editorPanelAtom); + const hitlEditState = useAtomValue(hitlEditPanelAtom); const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && !!editorState.documentId; - const hasContent = documentsOpen || reportOpen || editorOpen; + const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; + const hasContent = documentsOpen || reportOpen || editorOpen || hitlEditOpen; if (!collapsed || !hasContent) return null; @@ -70,7 +74,7 @@ export function RightPanelExpandButton() { ); } -const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640 } as const; +const PANEL_WIDTHS = { sources: 420, report: 640, editor: 640, "hitl-edit": 640 } as const; export function RightPanel({ documentsPanel }: RightPanelProps) { const [activeTab] = useAtom(rightPanelTabAtom); @@ -78,33 +82,39 @@ export function RightPanel({ documentsPanel }: RightPanelProps) { const closeReport = useSetAtom(closeReportPanelAtom); const editorState = useAtomValue(editorPanelAtom); const closeEditor = useSetAtom(closeEditorPanelAtom); + const hitlEditState = useAtomValue(hitlEditPanelAtom); + const closeHitlEdit = useSetAtom(closeHitlEditPanelAtom); const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom); const documentsOpen = documentsPanel?.open ?? false; const reportOpen = reportState.isOpen && !!reportState.reportId; const editorOpen = editorState.isOpen && !!editorState.documentId; + const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave; useEffect(() => { - if (!reportOpen && !editorOpen) return; + if (!reportOpen && !editorOpen && !hitlEditOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { - if (editorOpen) closeEditor(); + if (hitlEditOpen) closeHitlEdit(); + else if (editorOpen) closeEditor(); else if (reportOpen) closeReport(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [reportOpen, editorOpen, closeReport, closeEditor]); + }, [reportOpen, editorOpen, hitlEditOpen, closeReport, closeEditor, closeHitlEdit]); - const isVisible = (documentsOpen || reportOpen || editorOpen) && !collapsed; + const isVisible = (documentsOpen || reportOpen || editorOpen || hitlEditOpen) && !collapsed; let effectiveTab = activeTab; - if (effectiveTab === "editor" && !editorOpen) { + if (effectiveTab === "hitl-edit" && !hitlEditOpen) { + effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources"; + } else if (effectiveTab === "editor" && !editorOpen) { effectiveTab = reportOpen ? "report" : "sources"; } else if (effectiveTab === "report" && !reportOpen) { effectiveTab = editorOpen ? "editor" : "sources"; } else if (effectiveTab === "sources" && !documentsOpen) { - effectiveTab = editorOpen ? "editor" : reportOpen ? "report" : "sources"; + effectiveTab = hitlEditOpen ? "hitl-edit" : editorOpen ? "editor" : reportOpen ? "report" : "sources"; } const targetWidth = PANEL_WIDTHS[effectiveTab]; @@ -148,6 +158,17 @@ export function RightPanel({ documentsPanel }: RightPanelProps) { /> )} + {effectiveTab === "hitl-edit" && hitlEditOpen && hitlEditState.onSave && ( +
+ +
+ )} ); diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index 46e092649..f057ff143 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,10 +1,9 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { CornerDownLeftIcon, Loader2Icon, Pen } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -12,7 +11,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Textarea } from "@/components/ui/textarea"; +import { PlateEditor } from "@/components/editor/plate-editor"; +import { useSetAtom } from "jotai"; +import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; interface InterruptResult { __interrupt__: true; @@ -99,8 +100,8 @@ function ApprovalCard({ const [decided, setDecided] = useState<"approve" | "reject" | "edit" | null>( interruptData.__decided__ ?? null ); - const [isEditing, setIsEditing] = useState(false); - const [editedArgs, setEditedArgs] = useState>(args); + const [isPanelOpen, setIsPanelOpen] = useState(false); + const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const accounts = interruptData.context?.accounts ?? []; const parentPages = interruptData.context?.parent_pages ?? {}; @@ -122,293 +123,244 @@ function ApprovalCard({ }, [selectedAccountId, parentPages]); const isTitleValid = useMemo(() => { - const currentTitle = isEditing ? editedArgs.title : args.title; - return currentTitle && typeof currentTitle === "string" && currentTitle.trim().length > 0; - }, [isEditing, editedArgs.title, args.title]); + return args.title && typeof args.title === "string" && (args.title as string).trim().length > 0; + }, [args.title]); const reviewConfig = interruptData.review_configs[0]; const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"]; const canEdit = allowedDecisions.includes("edit"); + const handleApprove = useCallback(() => { + if (decided || isPanelOpen || !selectedAccountId || !isTitleValid) return; + if (!allowedDecisions.includes("approve")) return; + setDecided("approve"); + onDecision({ + type: "approve", + edited_action: { + name: interruptData.action_requests[0].name, + args: { + ...args, + connector_id: selectedAccountId ? Number(selectedAccountId) : null, + parent_page_id: + selectedParentPageId === "__none__" ? null : selectedParentPageId, + }, + }, + }); + }, [decided, isPanelOpen, selectedAccountId, isTitleValid, allowedDecisions, onDecision, interruptData, args, selectedParentPageId]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) { + handleApprove(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [handleApprove]); + return (
-
-
- -
-
-

- Create Notion Page + {/* Header */} +

+
+

+ {decided === "reject" + ? "Notion Page Rejected" + : decided === "approve" || decided === "edit" + ? "Notion Page Approved" + : "Create Notion Page"}

-

- {isEditing ? "You can edit the arguments below" : "Requires your approval to proceed"} +

+ {decided === "reject" + ? "Page creation was cancelled" + : decided === "edit" + ? "Page creation is in progress with your changes" + : decided === "approve" + ? "Page creation is in progress" + : "Requires your approval to proceed"}

-
- - {/* Context section - account and parent page selection */} - {!decided && interruptData.context && ( -
- {interruptData.context.error ? ( -

{interruptData.context.error}

- ) : ( - <> - {accounts.length > 0 && ( -
-
- Notion Account * -
- -
- )} - - {selectedAccountId && ( -
-
- Parent Page (optional) -
- - {availableParentPages.length === 0 && selectedAccountId && ( -

- No pages available. Page will be created at workspace root. -

- )} -
- )} - - )} -
- )} - - {/* Display mode - show args as read-only */} - {!isEditing && ( -
- {args.title != null && ( -
-

Title

-

{String(args.title)}

-
- )} - {args.content != null && ( -
-

Content

-

- {String(args.content)} -

-
- )} -
- )} - - {/* Edit mode - show editable form fields */} - {isEditing && !decided && ( -
-
- - setEditedArgs({ ...editedArgs, title: e.target.value })} - placeholder="Enter page title" - className={!isTitleValid ? "border-destructive" : ""} - /> - {!isTitleValid && ( -

Title is required and cannot be empty

- )} -
-
- -