From e7e426ba80458f17a616dc6ebbaa50edcdcd5cc1 Mon Sep 17 00:00:00 2001 From: Omkar Bansod Date: Thu, 28 Aug 2025 01:52:11 +0530 Subject: [PATCH] feat: implement interactive plan editor --- src/background/index.ts | 8 + src/lib/agent/BrowserAgent.ts | 62 ++- src/lib/pubsub/PubSub.ts | 19 +- src/lib/pubsub/types.ts | 38 +- src/lib/types/messaging.ts | 3 +- src/sidepanel/components/MessageItem.tsx | 52 ++- .../components/TaskManagerDropdown.tsx | 369 +++++++++++++----- src/sidepanel/stores/chatStore.ts | 18 +- 8 files changed, 473 insertions(+), 96 deletions(-) diff --git a/src/background/index.ts b/src/background/index.ts index 9c69fe222..03193311c 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -334,6 +334,14 @@ function handlePortMessage(message: PortMessage, port: chrome.runtime.Port): voi } break + case MessageType.PLAN_EDIT_RESPONSE: + // Forward plan edit response to the execution context + if (nxtScape) { + const pubsub = PubSub.getInstance() + pubsub.publishPlanEditResponse(payload as any) + } + break + case MessageType.RESET_CONVERSATION: handleResetConversationPort(payload as ResetConversationMessage['payload'], port, id) break diff --git a/src/lib/agent/BrowserAgent.ts b/src/lib/agent/BrowserAgent.ts index d9b38860d..2f0ef4cbe 100644 --- a/src/lib/agent/BrowserAgent.ts +++ b/src/lib/agent/BrowserAgent.ts @@ -391,8 +391,8 @@ export class BrowserAgent { while (outer_loop_index < BrowserAgent.MAX_STEPS_OUTER_LOOP) { this.checkIfAborted(); - // 1. PLAN: Create a new plan - const plan = await this._createMultiStepPlan(task); + // 1. PLAN: Create a new plan and show for editing + const plan = await this._createMultiStepPlanWithPreview(task); // 2. Convert plan to TODOs await this._updateTodosFromPlan(plan); @@ -668,6 +668,64 @@ export class BrowserAgent { throw new Error('Invalid plan format - no steps returned'); } + private async _createMultiStepPlanWithPreview(task: string): Promise { + const initialPlan = await this._createMultiStepPlan(task) + + const planId = `plan_${Date.now()}` + const editablePlan = { + planId, + steps: initialPlan.steps.map((step, index) => ({ + id: `step_${index}_${Date.now()}`, + action: step.action, + reasoning: step.reasoning || '', + order: index, + isEditable: true + })), + task, + isPreview: true + } + + this.pubsub.publishMessage(PubSub.createMessage( + JSON.stringify(editablePlan), + 'plan_editor' + )) + + const finalPlan = await this._waitForPlanConfirmation(planId) + + if (finalPlan === 'cancelled') { + throw new AbortError('Plan editing was cancelled by user') + } + + return finalPlan + } + + private async _waitForPlanConfirmation(planId: string): Promise { + return new Promise((resolve) => { + const subscription = this.pubsub.subscribe((event) => { + if (event.type === 'plan-edit-response' && event.payload.planId === planId) { + subscription.unsubscribe() + + if (event.payload.action === 'execute' && event.payload.steps) { + const editedPlan: Plan = { + steps: event.payload.steps.map((step) => ({ + action: step.action, + reasoning: step.reasoning || '' + })) + } + resolve(editedPlan) + } else { + resolve('cancelled') + } + } + }) + + setTimeout(() => { + subscription.unsubscribe() + resolve('cancelled') + }, 5 * 60 * 1000) + }); + } + private async _validateTaskCompletion(task: string): Promise<{ isComplete: boolean; reasoning: string; diff --git a/src/lib/pubsub/PubSub.ts b/src/lib/pubsub/PubSub.ts index 70731f98c..54af952ba 100644 --- a/src/lib/pubsub/PubSub.ts +++ b/src/lib/pubsub/PubSub.ts @@ -1,4 +1,4 @@ -import { Message, PubSubEvent, SubscriptionCallback, Subscription, ExecutionStatus, HumanInputRequest, HumanInputResponse } from './types' +import { Message, PubSubEvent, SubscriptionCallback, Subscription, ExecutionStatus, HumanInputRequest, HumanInputResponse, PlanEditRequest, PlanEditResponse } from './types' /** * Core pub-sub implementation for message passing @@ -61,6 +61,23 @@ export class PubSub { this._publish(event) } + // Publish plan edit request (called from agent) + publishPlanEditRequest(request: PlanEditRequest): void { + const event: PubSubEvent = { + type: 'plan-edit-request', + payload: request + } + this._publish(event) + } + + publishPlanEditResponse(response: PlanEditResponse): void { + const event: PubSubEvent = { + type: 'plan-edit-response', + payload: response + } + this._publish(event) + } + // Subscribe to events subscribe(callback: SubscriptionCallback): Subscription { this.subscribers.add(callback) diff --git a/src/lib/pubsub/types.ts b/src/lib/pubsub/types.ts index 7e8ff25af..9f6789099 100644 --- a/src/lib/pubsub/types.ts +++ b/src/lib/pubsub/types.ts @@ -4,7 +4,7 @@ import { z } from 'zod' export const MessageSchema = z.object({ msgId: z.string(), // Stable ID for message (e.g., "msg_think_1", "msg_tool_result_2") content: z.string(), // Full markdown content - role: z.enum(['thinking', 'user', 'assistant', 'error', 'narration']), // Message role (added narration) + role: z.enum(['thinking', 'user', 'assistant', 'error', 'narration', 'plan_editor']), // Message role (added plan_editor) ts: z.number(), // Timestamp in milliseconds }) @@ -34,6 +34,34 @@ export const HumanInputResponseSchema = z.object({ export type HumanInputResponse = z.infer +// Plan editing schemas +export const PlanStepSchema = z.object({ + id: z.string(), // Unique step identifier + action: z.string(), // Step description/action + reasoning: z.string().optional(), // Why this step is needed + order: z.number(), // Display order + isEditable: z.boolean().default(true) // Whether step can be edited +}) + +export type PlanStep = z.infer + +export const PlanEditRequestSchema = z.object({ + planId: z.string(), // Unique plan identifier + steps: z.array(PlanStepSchema), // Array of plan steps + task: z.string(), // Original task description + isPreview: z.boolean().default(true) // Whether this is a preview or final plan +}) + +export type PlanEditRequest = z.infer + +export const PlanEditResponseSchema = z.object({ + planId: z.string(), // Matching plan identifier + action: z.enum(['execute', 'cancel']), // User's choice + steps: z.array(PlanStepSchema).optional() // Modified steps (if action is 'execute') +}) + +export type PlanEditResponse = z.infer + // Pub-sub event types export const PubSubEventSchema = z.discriminatedUnion('type', [ z.object({ @@ -52,6 +80,14 @@ export const PubSubEventSchema = z.discriminatedUnion('type', [ type: z.literal('human-input-response'), payload: HumanInputResponseSchema }), + z.object({ + type: z.literal('plan-edit-request'), + payload: PlanEditRequestSchema + }), + z.object({ + type: z.literal('plan-edit-response'), + payload: PlanEditResponseSchema + }), ]) export type PubSubEvent = z.infer diff --git a/src/lib/types/messaging.ts b/src/lib/types/messaging.ts index 773eee96f..2c4515141 100644 --- a/src/lib/types/messaging.ts +++ b/src/lib/types/messaging.ts @@ -31,7 +31,8 @@ export enum MessageType { EXECUTE_QUERY_FROM_NEWTAB = 'EXECUTE_QUERY_FROM_NEWTAB', MCP_INSTALL_SERVER = 'MCP_INSTALL_SERVER', MCP_SERVER_STATUS = 'MCP_SERVER_STATUS', - HUMAN_INPUT_RESPONSE = 'HUMAN_INPUT_RESPONSE' + HUMAN_INPUT_RESPONSE = 'HUMAN_INPUT_RESPONSE', + PLAN_EDIT_RESPONSE = 'PLAN_EDIT_RESPONSE' } // Create a zod enum for MessageType diff --git a/src/sidepanel/components/MessageItem.tsx b/src/sidepanel/components/MessageItem.tsx index cb125eda7..fcac5554b 100644 --- a/src/sidepanel/components/MessageItem.tsx +++ b/src/sidepanel/components/MessageItem.tsx @@ -392,7 +392,8 @@ export const MessageItem = memo(function MessageItem({ message if (isTodoTable) { return } @@ -497,6 +498,55 @@ export const MessageItem = memo(function MessageItem({ message ) + case 'plan_editor': + try { + const planData = JSON.parse(message.content); + return ( + + `- [ ] ${step.action}` + ).join('\n')} + isEditable={true} + onTasksChange={(tasks: any[]) => { + const updatedSteps = tasks.map((task: any, index: number) => ({ + id: task.id, + action: task.content, + reasoning: '', + order: index, + isEditable: true + })) + }} + onExecute={(tasks: any[]) => { + const steps = tasks.map((task: any, index: number) => ({ + id: task.id, + action: task.content, + reasoning: '', + order: index, + isEditable: true + })) + + useChatStore.getState().publishPlanEditResponse({ + planId: planData.planId, + action: 'execute', + steps: steps + }) + }} + onCancel={() => { + useChatStore.getState().publishPlanEditResponse({ + planId: planData.planId, + action: 'cancel' + }) + }} + /> + ); + } catch (error) { + return ( +
+ Error rendering plan editor: {error instanceof Error ? error.message : 'Unknown error'} +
+ ); + } + default: // Fallback to markdown return ( diff --git a/src/sidepanel/components/TaskManagerDropdown.tsx b/src/sidepanel/components/TaskManagerDropdown.tsx index ff7720b87..6bd2d2797 100644 --- a/src/sidepanel/components/TaskManagerDropdown.tsx +++ b/src/sidepanel/components/TaskManagerDropdown.tsx @@ -1,85 +1,170 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react' import { cn } from '@/sidepanel/lib/utils' -import { ChevronDown, ChevronUp } from 'lucide-react' +import { ChevronDown, ChevronUp, Edit2, Check, X, Plus, Trash2, GripVertical } from 'lucide-react' import { z } from 'zod' +import { useChatStore } from '@/sidepanel/stores/chatStore' -// Define the task schema with Zod const TaskSchema = z.object({ - id: z.string(), // Task ID - status: z.string(), // Task status (✅, 🔄, etc.) - content: z.string() // Task description + id: z.string(), + status: z.string(), + content: z.string(), + order: z.number().optional(), + isEditable: z.boolean().default(true) }) -// Define the props schema with Zod -const TaskManagerDropdownPropsSchema = z.object({ - content: z.string(), // Raw markdown table content - className: z.string().optional() // Optional CSS classes -}) - -// Infer types from schemas type Task = z.infer -type TaskManagerDropdownProps = z.infer -// Small orange light component for completed tasks -const CompletionLight = ({ isCompleted }: { isCompleted: boolean }) => ( -
-
-
-) +interface TaskManagerDropdownProps { + content: string + className?: string + isEditable?: boolean + onTasksChange?: (tasks: Task[]) => void + onExecute?: (tasks: Task[]) => void + onCancel?: () => void +} -export function TaskManagerDropdown({ content, className }: TaskManagerDropdownProps) { - const [isExpanded, setIsExpanded] = useState(false) +export function TaskManagerDropdown({ content, className, isEditable = false, onTasksChange, onExecute, onCancel }: TaskManagerDropdownProps) { + const [isExpanded, setIsExpanded] = useState(isEditable) + const [editingTaskId, setEditingTaskId] = useState(null) + const [editText, setEditText] = useState('') + const [localTasks, setLocalTasks] = useState([]) + const [draggedTaskId, setDraggedTaskId] = useState(null) + const editInputRef = useRef(null) + + useEffect(() => { + if (editingTaskId && editInputRef.current) { + editInputRef.current.focus() + editInputRef.current.select() + } + }, [editingTaskId]) - // Parse tasks from markdown table content const tasks = useMemo(() => { const lines = content.split('\n') - const taskLines = lines.filter(line => { - const trimmedLine = line.trim() - if (!trimmedLine.startsWith('|')) return false - - const parts = trimmedLine.split('|').map(p => p.trim()) - // Skip header line (contains "Status" and "Task") - if (parts.includes('Status') && parts.includes('Task')) return false - // Skip separator line (contains only dashes and colons) - if (parts.every(part => part === '' || part.includes(':-'))) return false - - return true - }) - - return taskLines.map(line => { - const cells = line.split('|').filter(cell => cell.trim()) - if (cells.length >= 3) { + + const parsedTasks = lines + .map((line, index) => { + const trimmed = line.trim() + if (!trimmed.startsWith('- [ ]') && !trimmed.startsWith('- [x]')) return null + + const isCompleted = trimmed.startsWith('- [x]') + const taskContent = trimmed.replace(/^- \[[x ]\] /, '') + return { - id: cells[0].trim(), - status: cells[1].trim(), - content: cells[2].trim() + id: `task-${index}`, + status: isCompleted ? '✓' : '○', + content: taskContent, + order: index, + isEditable: true } - } - return null - }).filter(Boolean) as Task[] - }, [content]) + }) + .filter(Boolean) as Task[] + + if (isEditable && parsedTasks.length > 0) { + setLocalTasks(parsedTasks) + } + + return parsedTasks + }, [content, isEditable]) + + // Use local tasks if in edit mode, otherwise use parsed tasks + const displayTasks = isEditable ? localTasks : tasks - // Count completed tasks const completedCount = useMemo(() => { - return tasks.filter(task => task.status.includes('✅')).length - }, [tasks]) + return displayTasks.filter(task => task.status === '✓').length + }, [displayTasks]) - // Check if task is completed - const isTaskCompleted = (task: Task) => task.status.includes('✅') + const startEdit = useCallback((task: Task) => { + setEditingTaskId(task.id) + setEditText(task.content) + }, []) - // Show only first 6 tasks when expanded - const MAX_VISIBLE_TASKS = 6 - const visibleTasks = tasks.slice(0, MAX_VISIBLE_TASKS) - const hasMoreTasks = tasks.length > MAX_VISIBLE_TASKS + const saveEdit = useCallback(() => { + if (!editingTaskId) return - if (tasks.length === 0) { + const updatedTasks = localTasks.map(task => + task.id === editingTaskId + ? { ...task, content: editText.trim() } + : task + ) + setLocalTasks(updatedTasks) + setEditingTaskId(null) + setEditText('') + + // Notify parent of changes + onTasksChange?.(updatedTasks) + }, [editingTaskId, editText, localTasks, onTasksChange]) + + const cancelEdit = useCallback(() => { + setEditingTaskId(null) + setEditText('') + }, []) + + const addTask = useCallback(() => { + const newTask: Task = { + id: `task-${Date.now()}`, + status: '○', + content: 'New step', + order: localTasks.length, + isEditable: true + } + const updatedTasks = [...localTasks, newTask] + setLocalTasks(updatedTasks) + onTasksChange?.(updatedTasks) + setTimeout(() => startEdit(newTask), 50) + }, [localTasks, onTasksChange, startEdit]) + + const deleteTask = useCallback((taskId: string) => { + const updatedTasks = localTasks.filter(task => task.id !== taskId) + .map((task, index) => ({ ...task, order: index })) + setLocalTasks(updatedTasks) + onTasksChange?.(updatedTasks) + }, [localTasks, onTasksChange]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + saveEdit() + } else if (e.key === 'Escape') { + e.preventDefault() + cancelEdit() + } + }, [saveEdit, cancelEdit]) + + const handleDragStart = useCallback((e: React.DragEvent, taskId: string) => { + setDraggedTaskId(taskId) + e.dataTransfer.effectAllowed = 'move' + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + }, []) + + const handleDrop = useCallback((e: React.DragEvent, targetTaskId: string) => { + e.preventDefault() + if (!draggedTaskId || draggedTaskId === targetTaskId) return + + const draggedIndex = localTasks.findIndex(t => t.id === draggedTaskId) + const targetIndex = localTasks.findIndex(t => t.id === targetTaskId) + + if (draggedIndex === -1 || targetIndex === -1) return + + const newTasks = [...localTasks] + const [draggedTask] = newTasks.splice(draggedIndex, 1) + newTasks.splice(targetIndex, 0, draggedTask) + + const reorderedTasks = newTasks.map((task, index) => ({ ...task, order: index })) + setLocalTasks(reorderedTasks) + setDraggedTaskId(null) + onTasksChange?.(reorderedTasks) + }, [draggedTaskId, localTasks, onTasksChange]) + + const isTaskCompleted = (task: Task) => task.status === '✓' + const MAX_VISIBLE_TASKS = isEditable ? 20 : 6 + const visibleTasks = displayTasks.slice(0, MAX_VISIBLE_TASKS) + const hasMoreTasks = displayTasks.length > MAX_VISIBLE_TASKS + + if (displayTasks.length === 0 && !isEditable) { return (
@@ -93,43 +178,151 @@ export function TaskManagerDropdown({ content, className }: TaskManagerDropdownP return (
{/* Header */} -
-
- Task Manager + {!isEditable && ( +
+
+ Task Manager +
+
- - {/* Expand/Collapse button */} - -
+ )} {/* Expanded Task List */} {isExpanded && ( -
+
{visibleTasks.map((task, index) => ( -
- -
-
- {task.content} +
+
handleDragStart(e, task.id)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, task.id)} + > + {/* Step number */} + + Step {index + 1} + + + {/* Task content */} +
+ {editingTaskId === task.id ? ( + setEditText(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={saveEdit} + className="w-full px-1 py-0.5 text-xs bg-transparent border-b border-border focus:outline-none focus:border-brand" + placeholder="Enter step description..." + /> + ) : ( +
startEdit(task) : undefined} + title={task.content} + > + {task.content} +
+ )}
+ + {/* Delete button */} + {isEditable && editingTaskId !== task.id && ( + + )}
+ + {/* Add step line below each step on hover */} + {isEditable && ( +
+
+ +
+ )}
))} - + {hasMoreTasks && ( +
+ ... and {displayTasks.length - MAX_VISIBLE_TASKS} more steps +
+ )} + + {/* Add first step when no steps exist */} + {isEditable && visibleTasks.length === 0 && ( +
+
+ +
+ )} +
+ )} + + {/* Action buttons */} + {isEditable && ( +
+ +
)}
) -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/sidepanel/stores/chatStore.ts b/src/sidepanel/stores/chatStore.ts index b13bd6477..2b827d6f0 100644 --- a/src/sidepanel/stores/chatStore.ts +++ b/src/sidepanel/stores/chatStore.ts @@ -1,10 +1,13 @@ import { create } from 'zustand' import { z } from 'zod' +import { PubSub } from '@/lib/pubsub' +import { MessageType } from '@/lib/types/messaging' +import { PortMessaging } from '@/lib/runtime/PortMessaging' // Message schema - simplified for direct PubSub mapping export const MessageSchema = z.object({ msgId: z.string(), // Primary ID for both React keys and PubSub correlation - role: z.enum(['user', 'thinking', 'assistant', 'error', 'narration']), + role: z.enum(['user', 'thinking', 'assistant', 'error', 'narration', 'plan_editor']), content: z.string(), // Message content timestamp: z.date(), // When message was created metadata: z.object({ @@ -27,7 +30,7 @@ type ChatState = z.infer export interface PubSubMessage { msgId: string content: string - role: 'thinking' | 'user' | 'assistant' | 'error' | 'narration' + role: 'thinking' | 'user' | 'assistant' | 'error' | 'narration' | 'plan_editor' ts: number } @@ -43,6 +46,9 @@ interface ChatActions { // Error handling setError: (error: string | null) => void + // Plan editing + publishPlanEditResponse: (response: { planId: string; action: 'execute' | 'cancel'; steps?: any[] }) => void + // Reset everything reset: () => void } @@ -96,6 +102,14 @@ export const useChatStore = create((set) => ({ setError: (error) => set({ error }), + publishPlanEditResponse: (response) => { + const messaging = PortMessaging.getInstance() + const success = messaging.sendMessage(MessageType.PLAN_EDIT_RESPONSE, response) + if (!success) { + console.error('Failed to send plan edit response - port not connected') + } + }, + reset: () => set(initialState) }))