Files
kandev/apps/web/components/task/task-plan-panel.tsx
T

310 lines
11 KiB
TypeScript

'use client';
import { memo, useState, useCallback, useEffect, useRef } from 'react';
import dynamic from 'next/dynamic';
import { IconLoader2 } from '@tabler/icons-react';
import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import { TaskPlanToolbar } from './task-plan-toolbar';
import { TaskPlanDialogs } from './task-plan-dialogs';
import { PlanSelectionPopover } from './plan-selection-popover';
import type { PlanComment } from './plan-comments';
import type { TextSelection, CommentHighlight } from './markdown-editor';
// Dynamic import to avoid SSR issues with Milkdown
const MarkdownEditor = dynamic(
() => import('./markdown-editor').then((mod) => mod.MarkdownEditor),
{ ssr: false, loading: () => <div className="flex h-full items-center justify-center text-muted-foreground text-sm">Loading editor...</div> }
);
type TaskPlanPanelProps = {
taskId: string | null;
showApproveButton?: boolean;
onApprove?: () => void;
visible?: boolean;
};
export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, showApproveButton, onApprove, visible = true }: TaskPlanPanelProps) {
const { plan, isLoading, isSaving, savePlan, deletePlan } = 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 ?? '');
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [showClearDialog, setShowClearDialog] = useState(false);
const [isReanalyzing, setIsReanalyzing] = useState(false);
// 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<string | undefined>(undefined);
// Text selection state for the selection popover
const [textSelection, setTextSelection] = useState<TextSelection | null>(null);
// Plan comments state
const [comments, setComments] = useState<PlanComment[]>([]);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [isSubmittingComments, setIsSubmittingComments] = useState(false);
// Ref to the editor wrapper for positioning gutter markers
const editorWrapperRef = useRef<HTMLDivElement>(null);
// 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) {
if (newContent !== undefined) {
setDraftContent(newContent);
} else {
setDraftContent('');
}
// 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 () => {
await savePlan(draftContent, plan?.title);
}, [savePlan, draftContent, plan?.title]);
const handleDiscard = useCallback(() => {
setDraftContent(plan?.content || '');
setEditorKey(k => k + 1); // Force editor remount to reset content
setShowDiscardDialog(false);
}, [plan?.content]);
const handleCopy = useCallback(async () => {
if (!draftContent) return;
try {
await navigator.clipboard.writeText(draftContent);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
}, [draftContent]);
const handleReanalyze = useCallback(async () => {
if (!taskId || !activeSessionId || isAgentBusy || isReanalyzing) return;
const client = getWebSocketClient();
if (!client) return;
setIsReanalyzing(true);
try {
await client.request(
'message.add',
{
task_id: taskId,
session_id: activeSessionId,
content: 'Please review and update the plan based on the current state of the task. Consider what has been completed and what still needs to be done.',
},
10000
);
} catch (err) {
console.error('Failed to send re-analyze request:', err);
} finally {
setIsReanalyzing(false);
}
}, [taskId, activeSessionId, isAgentBusy, isReanalyzing]);
const handleClear = useCallback(async () => {
setShowClearDialog(false);
const success = await deletePlan();
if (success) {
setDraftContent('');
setEditorKey(k => k + 1);
}
}, [deletePlan]);
// 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);
// Clear the browser selection
window.getSelection()?.removeAllRanges();
}, []);
// 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 clearing all comments
const handleClearComments = useCallback(() => {
setComments([]);
}, []);
// Handle submitting all comments to the agent
const handleSubmitComments = useCallback(async () => {
if (!taskId || !activeSessionId || comments.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
setIsSubmittingComments(true);
try {
// Format comments for display (short version)
const commentsList = comments.map((c, i) =>
`${i + 1}. "${c.selectedText.slice(0, 50)}${c.selectedText.length > 50 ? '...' : ''}" → ${c.comment}`
).join('\n');
// Format full details inside system tags (not rendered in UI)
const fullDetails = comments.map((c, i) =>
`Comment ${i + 1}:\n- User comment: ${c.comment}\n- Selected text: "${c.selectedText}"`
).join('\n\n');
const message = `I have feedback on the plan:\n${commentsList}\n\n<kandev-system>\nFull comment details for plan modifications:\n\n${fullDetails}\n\nCurrent plan content:\n${draftContent}\n</kandev-system>`;
await client.request(
'message.add',
{
task_id: taskId,
session_id: activeSessionId,
content: message,
},
10000
);
// Clear comments after successful submission
setComments([]);
} catch (err) {
console.error('Failed to submit comments:', err);
} finally {
setIsSubmittingComments(false);
}
}, [taskId, activeSessionId, comments, draftContent]);
// Ctrl+S to save
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]);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<IconLoader2 className="h-5 w-5 animate-spin mr-2" />
<span className="text-sm">Loading plan...</span>
</div>
);
}
if (!taskId) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<span className="text-sm">No task selected</span>
</div>
);
}
// Convert PlanComment to CommentHighlight for the editor
const commentHighlights: CommentHighlight[] = comments.map((c) => ({
id: c.id,
selectedText: c.selectedText,
comment: c.comment,
}));
return (
<div className="flex flex-col h-full">
{/* Content - Markdown Editor with inline comment highlights */}
<div className="flex-1 min-h-0 relative" ref={editorWrapperRef}>
<MarkdownEditor
key={`${taskId}-${editorKey}`}
value={draftContent}
onChange={setDraftContent}
placeholder="Start typing your plan..."
onSelectionChange={activeSessionId ? setTextSelection : undefined}
comments={commentHighlights}
onCommentClick={handleCommentHighlightClick}
/>
</div>
{/* Bottom toolbar */}
<TaskPlanToolbar
plan={plan}
hasUnsavedChanges={hasUnsavedChanges}
hasDraftContent={draftContent.length > 0}
isSaving={isSaving}
isAgentBusy={isAgentBusy}
isReanalyzing={isReanalyzing}
hasActiveSession={!!activeSessionId}
showApproveButton={showApproveButton}
commentCount={comments.length}
isSubmittingComments={isSubmittingComments}
onSubmitComments={handleSubmitComments}
onClearComments={handleClearComments}
onSave={handleSave}
onDiscard={() => setShowDiscardDialog(true)}
onCopy={handleCopy}
onReanalyze={handleReanalyze}
onClear={() => setShowClearDialog(true)}
onApprove={onApprove}
/>
{/* Confirmation dialogs */}
<TaskPlanDialogs
showDiscardDialog={showDiscardDialog}
showClearDialog={showClearDialog}
onDiscardDialogChange={setShowDiscardDialog}
onClearDialogChange={setShowClearDialog}
onDiscard={handleDiscard}
onClear={handleClear}
/>
{/* Selection popover for adding comments */}
{textSelection && activeSessionId && (
<PlanSelectionPopover
selectedText={textSelection.text}
position={textSelection.position}
onAdd={handleAddComment}
onClose={handleSelectionClose}
editingComment={editingCommentId ? comments.find(c => c.id === editingCommentId)?.comment : undefined}
/>
)}
</div>
);
});