'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}
/>
)}
);
});