"use client"; import { memo, useState, useCallback, useEffect, useRef, useMemo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { PlanPanelHeader } from "./task-plan-panel-header"; import dynamic from "@/lib/routing/client-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 { usePlanDraft } from "@/hooks/domains/session/use-plan-draft"; import { TaskPlanSaveErrorBanner } from "./task-plan-save-error-banner"; 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 { Editor } from "@tiptap/core"; import { PanelSearchBar } from "@/components/search/panel-search-bar"; import { usePlanFindShortcut } from "./use-plan-find-shortcut"; import { usePlanSelection } from "./use-plan-selection"; import { Trans, useTranslation } from "react-i18next"; import { t } from "@/lib/i18n"; // 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: () => (
{t("task:loadingEditor")}
), }, ); type TaskPlanPanelProps = { taskId: string | null; visible?: boolean; /** Internal mobile layout offset for the docked formatting strip. */ mobileBottomOffset?: string; }; type TaskOwnedEditor = { taskId: string | null; editor: Editor; } | null; export function getAvailableTaskEditor( ownedEditor: TaskOwnedEditor, selectedTaskId: string | null, ): Editor | null { if (!ownedEditor || ownedEditor.taskId !== selectedTaskId || ownedEditor.editor.isDestroyed) { return null; } return ownedEditor.editor; } function useTaskPlanPanelState(taskId: string | null, visible: boolean) { const { plan, isLoading, isSaving, saveError, 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 [ownedEditor, setOwnedEditor] = useState(null); const editorInstance = getAvailableTaskEditor(ownedEditor, taskId); const { draftContent, setDraftContent, editorKey, isEditorFocused, handleEmptyStateClick, hasUnsavedChanges, attemptSave, } = usePlanDraft({ plan, isSaving, savePlan, editorWrapperRef, taskId, saveError }); const commentState = usePlanComments(activeSessionId); const selectionState = usePlanSelection(activeSessionId, commentState); const handleEditorReady = useCallback( (editor: Editor) => setOwnedEditor({ taskId, editor }), [taskId], ); 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, saveError, savePlan, activeSessionId, draftContent, setDraftContent, editorKey, isEditorFocused, handleEmptyStateClick, hasUnsavedChanges, attemptSave, commentState, selectionState, handleEditorReady, handleCommentDeleted, commentHighlights, isAgentBusy, isAgentCreatingPlan, editorWrapperRef, editorInstance, revisions, isLoadingRevisions, loadRevisions, loadRevisionContent, revertTo, previewRevisionId, setPreviewRevision, comparePair, toggleCompareSelection, clearComparePair, }; } export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = true, mobileBottomOffset, }: TaskPlanPanelProps) { const { t } = useTranslation(); const state = useTaskPlanPanelState(taskId, visible); // Ctrl+S to save immediately useSaveShortcut({ hasUnsavedChanges: state.hasUnsavedChanges, isSaving: state.isSaving, attemptSave: state.attemptSave, draftContent: state.draftContent, title: state.plan?.title, }); if (state.isLoading) { return (
{t("task:loadingPlan")}
); } if (!taskId) { return (
{t("task:noTaskSelected")}
); } return ; }); function PlanPanelContent({ taskId, state, mobileBottomOffset, }: { taskId: string; state: ReturnType; mobileBottomOffset?: string; }) { const { t } = useTranslation(); const { editorWrapperRef, 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 ( {state.saveError && } {planSearch.isOpen && ( )} ); } function removeCommentMark(editor: Editor | null, commentId: string) { if (!editor || editor.isDestroyed) 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, editor, onClose, }: { textSelection: TextSelection | null; activeSessionId: string | null | undefined; taskId: string | null; commentState: ReturnType; editor: Editor | null; 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); if (id && editor && !editor.isDestroyed && from != null && to != null) { editor .chain() .setTextSelection({ from, to }) .setMark("commentMark", { commentId: id }) .run(); } return id; }, [commentState, textSelection, editor], ); 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(editor, id); commentState.handleDeleteComment(id); } : undefined; return ( ); } type SaveShortcutOptions = { hasUnsavedChanges: boolean; isSaving: boolean; attemptSave: (content: string, title?: string) => Promise; draftContent: string; title: string | undefined; }; /** Ctrl+S save shortcut */ function useSaveShortcut({ hasUnsavedChanges, isSaving, attemptSave, draftContent, title, }: SaveShortcutOptions) { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); if (hasUnsavedChanges && !isSaving) { // An explicit save is the user's escape hatch from a suppressed // autosave: it proceeds unconditionally, even resubmitting // unchanged content. attemptSave(draftContent, title); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [hasUnsavedChanges, isSaving, attemptSave, 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; }) { const { t } = useTranslation(); if (isLoading || draftContent.trim() !== "" || isEditorFocused || isAgentCreatingPlan) return null; return (

{t("task:planYourImplementation")}

{t("task:aSharedDocumentForYouAnd")}

{t("task:theAgentCanWriteAndUpdate")}

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

{t("task:clickAnywhereToStartWriting")}
); }