'use client'; import { memo, useState, useCallback, useEffect, useRef } from 'react'; import { PanelRoot, PanelBody } from './panel-primitives'; import dynamic from 'next/dynamic'; import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick, IconMessagePlus } from '@tabler/icons-react'; import { GridSpinner } from '@/components/grid-spinner'; import { cn } from '@/lib/utils'; import { Button } from '@kandev/ui/button'; import { useTaskPlan } from '@/hooks/domains/session/use-task-plan'; import { useAppStore } from '@/components/state-provider'; import { PlanSelectionPopover } from './plan-selection-popover'; import type { PlanComment } from './plan-comments'; import type { TextSelection, CommentHighlight } from '@/components/editors/tiptap/tiptap-plan-editor'; import type { DocumentComment } from '@/lib/state/slices/ui/types'; const EMPTY_COMMENTS: DocumentComment[] = []; // 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; }; export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = true }: TaskPlanPanelProps) { const { plan, isLoading, isSaving, savePlan } = useTaskPlan(taskId, { visible }); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSession = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null ); const isAgentBusy = activeSession?.state === 'STARTING' || activeSession?.state === 'RUNNING'; const [draftContent, setDraftContent] = useState(plan?.content ?? ''); // Ref mirror of draftContent so the sync effect can read it without re-running on every keystroke const draftContentRef = useRef(draftContent); // Key to force editor remount on external content changes const [editorKey, setEditorKey] = useState(0); // Track the last known plan content to detect external updates // Initialize to undefined so the first useEffect run always syncs if plan is already loaded const lastPlanContentRef = useRef(undefined); // Track whether content change is from external update (skip auto-save) const isExternalUpdateRef = useRef(false); // Text selection state for the selection popover const [textSelection, setTextSelection] = useState(null); // Floating button position (shown after selection ends) const [floatingButtonPos, setFloatingButtonPos] = useState<{ x: number; y: number } | null>(null); const [currentSelectionText, setCurrentSelectionText] = useState(null); // Plan comments state const setDocumentComments = useAppStore((state) => state.setDocumentComments); const storeComments = useAppStore((state) => activeSessionId ? state.documentPanel.commentsBySessionId[activeSessionId] ?? EMPTY_COMMENTS : EMPTY_COMMENTS ); const [comments, setComments] = useState([]); const [editingCommentId, setEditingCommentId] = useState(null); // Ref to the editor wrapper for positioning gutter markers const editorWrapperRef = useRef(null); // Track editor focus state to show/hide placeholder const [isEditorFocused, setIsEditorFocused] = useState(false); // Auto-save debounce timer const autoSaveTimerRef = useRef | null>(null); // Handler to focus the editor when clicking the empty state const handleEmptyStateClick = useCallback(() => { const editorElement = editorWrapperRef.current?.querySelector('.ProseMirror'); if (editorElement) { (editorElement as HTMLElement).focus(); } }, []); // Track focus state using document-level listener 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); }; }, []); // Keep draftContentRef in sync (must be in effect, not render, per React Compiler) useEffect(() => { draftContentRef.current = draftContent; }, [draftContent]); // Sync draft with plan content and force editor remount when plan changes externally useEffect(() => { const prevContent = lastPlanContentRef.current; const newContent = plan?.content; lastPlanContentRef.current = newContent; // Only update if the plan content actually changed (external update) if (newContent !== prevContent) { // Skip remount if the editor already has this content (our own save response) const resolvedContent = newContent ?? ''; if (resolvedContent === 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(resolvedContent); // Force editor remount to reflect new content (Milkdown doesn't respond to prop changes) setEditorKey(k => k + 1); } }, [plan?.content]); const hasUnsavedChanges = plan ? draftContent !== plan.content : draftContent.length > 0; const handleSave = useCallback(async () => { // Cancel any pending auto-save if (autoSaveTimerRef.current) { clearTimeout(autoSaveTimerRef.current); autoSaveTimerRef.current = null; } await savePlan(draftContent, plan?.title); }, [savePlan, draftContent, plan?.title]); // Auto-save with debounce when draft content changes useEffect(() => { // Skip auto-save when content change came from external update (agent/poll) if (isExternalUpdateRef.current) { isExternalUpdateRef.current = false; return; } // Only auto-save when there are actual changes to save const hasChanges = plan ? draftContent !== plan.content : draftContent.length > 0; if (!hasChanges || isSaving) return; // Clear previous timer 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]); // Ctrl+S to save immediately useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (hasUnsavedChanges && !isSaving) { handleSave(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [hasUnsavedChanges, isSaving, handleSave]); // Handle adding a comment (from selection popover) const handleAddComment = useCallback((comment: string, selectedText: string) => { if (editingCommentId) { // Update existing comment setComments(prev => prev.map(c => c.id === editingCommentId ? { ...c, comment, selectedText } : c )); setEditingCommentId(null); } else { // Add new comment const newComment: PlanComment = { id: crypto.randomUUID(), selectedText, comment, }; setComments(prev => [...prev, newComment]); } }, [editingCommentId]); const handleSelectionClose = useCallback(() => { setTextSelection(null); setEditingCommentId(null); setFloatingButtonPos(null); setCurrentSelectionText(null); // Clear the browser selection window.getSelection()?.removeAllRanges(); }, []); // Handle selection end (mouseup) - show floating button const handleSelectionEnd = useCallback((selection: TextSelection | null) => { if (!activeSessionId) return; if (selection) { setFloatingButtonPos(selection.position); setCurrentSelectionText(selection.text); } else { setFloatingButtonPos(null); setCurrentSelectionText(null); } }, [activeSessionId]); // Handle floating button click - open the popover const handleFloatingButtonClick = useCallback(() => { if (!floatingButtonPos || !currentSelectionText) return; setTextSelection({ text: currentSelectionText, position: floatingButtonPos, }); setFloatingButtonPos(null); setCurrentSelectionText(null); }, [floatingButtonPos, currentSelectionText]); // Handle clicking on a highlighted comment in the editor (opens edit popover) const handleCommentHighlightClick = useCallback( (id: string, position: { x: number; y: number }) => { const comment = comments.find((c) => c.id === id); if (comment) { setEditingCommentId(id); setTextSelection({ text: comment.selectedText, position, }); } }, [comments] ); // Handle deleting a single comment const handleDeleteComment = useCallback((commentId: string) => { setComments(prev => prev.filter(c => c.id !== commentId)); }, []); // Sync plan comments to document panel store for chat integration useEffect(() => { if (activeSessionId) { setDocumentComments(activeSessionId, comments.map(c => ({ id: c.id, selectedText: c.selectedText, comment: c.comment, }))); } }, [activeSessionId, comments, setDocumentComments]); // Detect when comments are cleared externally (e.g., after message send from chat). // Only react when the store transitions from non-empty → empty (a true external clear), // not when the store simply hasn't synced yet from a local addition. const prevStoreCommentsLenRef = useRef(storeComments.length); useEffect(() => { const prevLen = prevStoreCommentsLenRef.current; prevStoreCommentsLenRef.current = storeComments.length; if (prevLen > 0 && storeComments.length === 0 && comments.length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing store clear to local state setComments([]); } }, [storeComments.length, comments.length]); if (isLoading) { return (
Loading plan...
); } if (!taskId) { return (
No task selected
); } // Show loading state while agent is creating the initial plan const isAgentCreatingPlan = isAgentBusy && !plan && draftContent.trim() === ''; // Convert PlanComment to CommentHighlight for the editor const commentHighlights: CommentHighlight[] = comments.map((c) => ({ id: c.id, selectedText: c.selectedText, comment: c.comment, })); return ( {/* Content - Markdown Editor with inline comment highlights */} {/* Floating "Comment" button when text is selected */} {floatingButtonPos && !textSelection && activeSessionId && ( )} {/* Agent creating plan overlay */} {isAgentCreatingPlan && (
Agent is creating a plan...
)} {/* Rich empty state - shows when no content and editor not focused */} {!isLoading && draftContent.trim() === '' && !isEditorFocused && !isAgentCreatingPlan && (
{/* Main icon */}
{/* Title */}

Plan your implementation

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

{/* Feature list */}

The agent can write and update the plan as it works

Select text and press ⌘I to request changes

{/* Call to action */}
Click anywhere to start writing
)}
{/* Selection popover for adding comments */} {textSelection && activeSessionId && ( c.id === editingCommentId)?.comment : undefined} onDelete={editingCommentId ? () => handleDeleteComment(editingCommentId) : undefined} /> )}
); });