"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 "@/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 { 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 (
}
/>
);
}