"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 { resolvePlanCommentRunAvailability, 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"; import { usePlanCommentMigration } from "@/hooks/domains/comments/use-plan-comment-migration"; import { PlanCommentMigrationNotice } from "./plan-comment-migration-notice"; // 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(taskId); const planCommentMigration = usePlanCommentMigration(taskId); const selectionState = usePlanSelection(taskId, plan?.id, commentState); const handleEditorReady = useCallback( (editor: Editor) => setOwnedEditor({ taskId, editor }), [taskId], ); const handleCommentDeleted = useCallback( (ids: string[]) => { for (const id of ids) { void 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, planCommentMigration, 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 && !state.plan) { 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); } function planCommentRunDisabledReason( reason: ReturnType["reason"], ) { if (reason === "no-primary-session") return t("task:noPrimarySessionForPlanComment"); if (reason === "primary-session-unavailable") { return t("task:primarySessionUnavailableForPlanComment"); } return null; } export function usePlanSelectionCommentActions({ textSelection, commentState, editor, runComment, }: { textSelection: TextSelection | null; commentState: ReturnType; editor: Editor | null; runComment: ReturnType["runComment"]; }) { const [runError, setRunError] = useState(null); const savedRunCommentRef = useRef<{ owner: string; selection: string; body: string; comment: PlanComment; } | null>(null); const ownerIdentity = `${commentState.snapshot?.task_id ?? ""}:${commentState.snapshot?.plan_id ?? ""}`; const selectionIdentity = textSelection ? `${textSelection.from ?? ""}:${textSelection.to ?? ""}:${textSelection.text}` : ""; useEffect(() => { savedRunCommentRef.current = null; setRunError(null); }, [ownerIdentity]); const addCommentAndApplyMark = useCallback( async (comment: string, selectedText: string) => { const from = textSelection?.from; const to = textSelection?.to; const saved = await commentState.handleAddComment(comment, selectedText, from, to); if (saved && editor && !editor.isDestroyed && from != null && to != null) { editor .chain() .setTextSelection({ from, to }) .setMark("commentMark", { commentId: saved.id }) .run(); } return saved; }, [commentState, textSelection, editor], ); const handleAdd = useCallback( async (comment: string, selectedText: string) => { setRunError(null); return Boolean(await addCommentAndApplyMark(comment, selectedText)); }, [addCommentAndApplyMark], ); const handleAddAndRun = useCallback( async (comment: string, selectedText: string) => { setRunError(null); const previous = savedRunCommentRef.current; const current = previous ? commentState.comments.find((candidate) => candidate.id === previous.comment.id) : null; let saved = previous?.owner === ownerIdentity && previous.selection === selectionIdentity && previous.body === comment.trim() && (current == null || (current.version === previous.comment.version && current.text === previous.comment.text)) ? (current ?? previous.comment) : null; if (!saved) { saved = await addCommentAndApplyMark(comment, selectedText); if (!saved) return false; savedRunCommentRef.current = { owner: ownerIdentity, selection: selectionIdentity, body: comment.trim(), comment: saved, }; } try { await runComment(saved); savedRunCommentRef.current = null; return true; } catch (error) { setRunError(error instanceof Error ? error.message : t("task:failedToRunPlanComment")); return false; } }, [addCommentAndApplyMark, commentState.comments, ownerIdentity, runComment, selectionIdentity], ); return { handleAdd, handleAddAndRun, runError }; } /** Conditional selection popover for adding/editing comments */ function PlanSelectionPopoverWrapper({ textSelection, taskId, commentState, editor, onClose, }: { textSelection: TextSelection | null; taskId: string | null; commentState: ReturnType; editor: Editor | null; onClose: () => void; }) { const { runComment } = useRunComment({ sessionId: null, taskId, }); const runUnavailableReason = useAppStore( (state) => resolvePlanCommentRunAvailability(state, taskId).reason, ); const runDisabledReason = planCommentRunDisabledReason(runUnavailableReason); const { handleAdd, handleAddAndRun, runError } = usePlanSelectionCommentActions({ textSelection, commentState, editor, runComment, }); if (!textSelection) return null; const editingComment = commentState.editingCommentId ? commentState.comments.find((c) => c.id === commentState.editingCommentId)?.text : undefined; const onDelete = commentState.editingCommentId ? async () => { const id = commentState.editingCommentId!; const deleted = await commentState.handleDeleteComment(id); if (deleted) removeCommentMark(editor, id); return deleted; } : 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")}
); }