"use client"; import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react"; import { PanelRoot, PanelBody, PanelHeaderBarSplit } from "./panel-primitives"; import { TaskPlanRevisions } from "./task-plan-revisions"; import dynamic from "next/dynamic"; import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useTaskPlan } from "@/hooks/domains/session/use-task-plan"; import { useAppStore } from "@/components/state-provider"; import { PlanSelectionPopover } from "./plan-selection-popover"; import { usePlanComments } from "@/hooks/domains/comments/use-plan-comments"; import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import type { PlanComment } from "@/lib/state/slices/comments"; import type { TextSelection, CommentForEditor, } from "@/components/editors/tiptap/tiptap-plan-editor"; import type { TaskPlanRevision } from "@/lib/types/http"; import type { Editor } from "@tiptap/core"; import { PanelSearchBar } from "@/components/search/panel-search-bar"; import { usePlanFindShortcut } from "./use-plan-find-shortcut"; // Dynamic import to avoid SSR issues with TipTap const PlanEditor = dynamic( () => import("@/components/editors/tiptap/tiptap-plan-editor").then((mod) => mod.TipTapPlanEditor), { ssr: false, loading: () => (
Loading editor...
), }, ); /** Debounce delay for auto-saving plan content (ms) */ const AUTO_SAVE_DELAY = 1500; type TaskPlanPanelProps = { taskId: string | null; visible?: boolean; }; function useTaskPlanPanelState(taskId: string | null, visible: boolean) { const { plan, isLoading, isSaving, savePlan, revisions, isLoadingRevisions, loadRevisions, loadRevisionContent, revertTo, previewRevisionId, setPreviewRevision, comparePair, toggleCompareSelection, clearComparePair, } = useTaskPlan(taskId, { visible }); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSession = useAppStore((state) => activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, ); const sessionState = activeSession?.state; const isAgentBusy = sessionState === "STARTING" || sessionState === "RUNNING"; const editorWrapperRef = useRef(null); const editorInstanceRef = useRef(null); const [editorInstance, setEditorInstance] = useState(null); const { draftContent, setDraftContent, editorKey, isEditorFocused, handleEmptyStateClick, hasUnsavedChanges, } = usePlanDraft(plan, isSaving, savePlan, editorWrapperRef); const commentState = usePlanComments(activeSessionId); const selectionState = usePlanSelection(activeSessionId, commentState); const handleEditorReady = useCallback((editor: Editor) => { editorInstanceRef.current = editor; setEditorInstance(editor); }, []); const handleCommentDeleted = useCallback( (ids: string[]) => { for (const id of ids) { commentState.handleDeleteComment(id); } }, [commentState], ); const commentHighlights: CommentForEditor[] = useMemo( () => commentState.comments.map((c) => ({ id: c.id, selectedText: c.selectedText, from: c.from, to: c.to, })), [commentState.comments], ); const isAgentCreatingPlan = isAgentBusy && !plan && draftContent.trim() === ""; return { plan, isLoading, isSaving, savePlan, activeSessionId, draftContent, setDraftContent, editorKey, isEditorFocused, handleEmptyStateClick, hasUnsavedChanges, commentState, selectionState, handleEditorReady, handleCommentDeleted, commentHighlights, isAgentBusy, isAgentCreatingPlan, editorWrapperRef, editorInstanceRef, editorInstance, revisions, isLoadingRevisions, loadRevisions, loadRevisionContent, revertTo, previewRevisionId, setPreviewRevision, comparePair, toggleCompareSelection, clearComparePair, }; } export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = true, }: TaskPlanPanelProps) { const state = useTaskPlanPanelState(taskId, visible); // Ctrl+S to save immediately useSaveShortcut( state.hasUnsavedChanges, state.isSaving, state.savePlan, state.draftContent, state.plan?.title, ); if (state.isLoading) { return (
Loading plan...
); } if (!taskId) { return (
No task selected
); } return ; }); function PlanPanelContent({ taskId, state, }: { taskId: string; state: ReturnType; }) { const { editorWrapperRef, editorInstanceRef, editorInstance, selectionState } = state; const { textSelection, setTextSelection } = selectionState; // Ctrl+F in-document find (registers a keydown listener on the editor wrapper) const planSearch = usePlanFindShortcut(editorWrapperRef, editorInstance); return ( {planSearch.isOpen && ( )} ); } function removeCommentMark(editor: Editor | null, commentId: string) { if (!editor) return; const markType = editor.state.schema.marks.commentMark; if (!markType) return; const { tr } = editor.state; tr.removeMark(0, editor.state.doc.content.size, markType.create({ commentId })); editor.view.dispatch(tr); } /** Conditional selection popover for adding/editing comments */ function PlanSelectionPopoverWrapper({ textSelection, activeSessionId, taskId, commentState, editorRef, onClose, }: { textSelection: TextSelection | null; activeSessionId: string | null | undefined; taskId: string | null; commentState: ReturnType; editorRef: React.RefObject; onClose: () => void; }) { const { runComment } = useRunComment({ sessionId: activeSessionId ?? null, taskId, }); const addCommentAndApplyMark = useCallback( (comment: string, selectedText: string) => { const from = textSelection?.from; const to = textSelection?.to; const id = commentState.handleAddComment(comment, selectedText, from, to); const editor = editorRef.current; if (id && editor && from != null && to != null) { editor .chain() .setTextSelection({ from, to }) .setMark("commentMark", { commentId: id }) .run(); } return id; }, [commentState, textSelection, editorRef], ); const handleAdd = useCallback( (comment: string, selectedText: string) => { addCommentAndApplyMark(comment, selectedText); }, [addCommentAndApplyMark], ); const handleAddAndRun = useCallback( (comment: string, selectedText: string) => { const id = addCommentAndApplyMark(comment, selectedText); if (!id || !activeSessionId) return; const newComment: PlanComment = { id, sessionId: activeSessionId, source: "plan", text: comment, selectedText, from: textSelection?.from, to: textSelection?.to, createdAt: new Date().toISOString(), status: "pending", }; runComment(newComment).catch((err) => console.error("Failed to run plan comment:", err)); }, [addCommentAndApplyMark, activeSessionId, runComment, textSelection], ); if (!textSelection || !activeSessionId) return null; const editingComment = commentState.editingCommentId ? commentState.comments.find((c) => c.id === commentState.editingCommentId)?.text : undefined; const onDelete = commentState.editingCommentId ? () => { const id = commentState.editingCommentId!; removeCommentMark(editorRef.current, id); commentState.handleDeleteComment(id); } : undefined; return ( ); } /** Draft content, editor key, focus tracking, and auto-save */ function usePlanDraft( plan: { content?: string; title?: string } | null | undefined, isSaving: boolean, savePlan: (content: string, title?: string) => Promise, editorWrapperRef: React.RefObject, ) { const [draftContent, setDraftContent] = useState(plan?.content ?? ""); const draftContentRef = useRef(draftContent); const [editorKey, setEditorKey] = useState(0); const lastPlanContentRef = useRef(undefined); const isExternalUpdateRef = useRef(false); const [isEditorFocused, setIsEditorFocused] = useState(false); const autoSaveTimerRef = useRef | null>(null); const handleEmptyStateClick = useCallback(() => { const el = editorWrapperRef.current?.querySelector(".ProseMirror"); if (el) (el as HTMLElement).focus(); }, [editorWrapperRef]); // Track focus state useEffect(() => { const checkFocus = () => { const wrapper = editorWrapperRef.current; if (!wrapper) return; setIsEditorFocused(wrapper.contains(document.activeElement)); }; document.addEventListener("focusin", checkFocus); document.addEventListener("focusout", checkFocus); checkFocus(); return () => { document.removeEventListener("focusin", checkFocus); document.removeEventListener("focusout", checkFocus); }; }, [editorWrapperRef]); useEffect(() => { draftContentRef.current = draftContent; }, [draftContent]); // Sync from external plan updates useEffect(() => { const prevContent = lastPlanContentRef.current; const newContent = plan?.content; lastPlanContentRef.current = newContent; if (newContent !== prevContent) { const resolved = newContent ?? ""; if (resolved === draftContentRef.current) return; isExternalUpdateRef.current = true; // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing external plan data to local editor state setDraftContent(resolved); setEditorKey((k) => k + 1); } }, [plan?.content]); // Auto-save with debounce useEffect(() => { if (isExternalUpdateRef.current) { isExternalUpdateRef.current = false; return; } const hasChanges = plan ? draftContent !== plan.content : draftContent.length > 0; if (!hasChanges || isSaving) return; if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = setTimeout(() => { autoSaveTimerRef.current = null; savePlan(draftContent, plan?.title); }, AUTO_SAVE_DELAY); return () => { if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } }; }, [draftContent, plan, isSaving, savePlan]); const hasUnsavedChanges = plan ? draftContent !== plan.content : draftContent.length > 0; return { draftContent, setDraftContent, editorKey, isEditorFocused, handleEmptyStateClick, hasUnsavedChanges, }; } /** Text selection state for comment popover */ function usePlanSelection( activeSessionId: string | null | undefined, commentState: ReturnType, ) { const [textSelection, setTextSelection] = useState(null); const handleCommentHighlightClick = useCallback( (id: string, position: { x: number; y: number }) => { const comment = commentState.comments.find((c) => c.id === id); if (comment) { commentState.setEditingCommentId(id); setTextSelection({ text: comment.selectedText, from: comment.from, to: comment.to, position, }); } }, [commentState], ); const handleSelectionClose = useCallback(() => { setTextSelection(null); commentState.setEditingCommentId(null); window.getSelection()?.removeAllRanges(); }, [commentState]); return { textSelection, setTextSelection, handleCommentHighlightClick, handleSelectionClose }; } /** Ctrl+S save shortcut */ function useSaveShortcut( hasUnsavedChanges: boolean, isSaving: boolean, savePlan: (content: string, title?: string) => Promise, draftContent: string, title?: string, ) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); if (hasUnsavedChanges && !isSaving) savePlan(draftContent, title); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [hasUnsavedChanges, isSaving, savePlan, draftContent, title]); } /** Rich empty state - shows when no content and editor not focused */ function PlanEmptyState({ isLoading, draftContent, isEditorFocused, isAgentCreatingPlan, onClick, }: { isLoading: boolean; draftContent: string; isEditorFocused: boolean; isAgentCreatingPlan: boolean; onClick: () => void; }) { if (isLoading || draftContent.trim() !== "" || isEditorFocused || isAgentCreatingPlan) return null; return (

Plan your implementation

A shared document for you and the agent to collaborate on the approach

The agent can write and update the plan as it works

Select text and press{" "} ⌘⇧C {" "} to comment

Click anywhere to start writing
); } type PlanPanelHeaderProps = { taskId: string; revisions: TaskPlanRevision[]; isLoadingRevisions: boolean; isSaving: boolean; onOpenRevisions: () => void; onRevert: (id: string) => Promise; loadRevisionContent: (revisionId: string) => Promise; previewRevisionId: string | null; setPreviewRevision: (revisionId: string | null) => void; comparePair: [string | null, string | null]; toggleCompareSelection: (revisionId: string) => void; clearComparePair: () => void; }; // Header bar lives only to host the rewind button. The plan's title (default // "Plan") is already shown by the dockview/mobile tab above the panel, so we // don't repeat it here. function PlanPanelHeader({ taskId, revisions, isLoadingRevisions, isSaving, onOpenRevisions, onRevert, loadRevisionContent, previewRevisionId, setPreviewRevision, comparePair, toggleCompareSelection, clearComparePair, }: PlanPanelHeaderProps) { return ( } /> ); }