feat: implement interactive plan editor

This commit is contained in:
Omkar Bansod
2025-08-28 01:52:11 +05:30
parent 50b3e159df
commit e7e426ba80
8 changed files with 473 additions and 96 deletions

View File

@@ -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

View File

@@ -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<Plan> {
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<Plan | 'cancelled'> {
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;

View File

@@ -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)

View File

@@ -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<typeof HumanInputResponseSchema>
// 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<typeof PlanStepSchema>
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<typeof PlanEditRequestSchema>
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<typeof PlanEditResponseSchema>
// 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<typeof PubSubEventSchema>

View File

@@ -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

View File

@@ -392,7 +392,8 @@ export const MessageItem = memo<MessageItemProps>(function MessageItem({ message
if (isTodoTable) {
return <TaskManagerDropdown
key={`task-manager-${message.msgId}`}
content={message.content}
content={message.content}
isEditable={false}
/>
}
@@ -497,6 +498,55 @@ export const MessageItem = memo<MessageItemProps>(function MessageItem({ message
</div>
)
case 'plan_editor':
try {
const planData = JSON.parse(message.content);
return (
<TaskManagerDropdown
content={planData.steps.map((step: any) =>
`- [ ] ${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 (
<div className="text-red-500 text-sm">
Error rendering plan editor: {error instanceof Error ? error.message : 'Unknown error'}
</div>
);
}
default:
// Fallback to markdown
return (

View File

@@ -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<typeof TaskSchema>
type TaskManagerDropdownProps = z.infer<typeof TaskManagerDropdownPropsSchema>
// Small orange light component for completed tasks
const CompletionLight = ({ isCompleted }: { isCompleted: boolean }) => (
<div className="flex-shrink-0">
<div
className={cn(
"w-2 h-2 rounded-full transition-colors duration-200",
isCompleted
? "bg-orange-500 shadow-sm" // Brand color equivalent
: "bg-gray-300 dark:bg-gray-600"
)}
/>
</div>
)
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<string | null>(null)
const [editText, setEditText] = useState('')
const [localTasks, setLocalTasks] = useState<Task[]>([])
const [draggedTaskId, setDraggedTaskId] = useState<string | null>(null)
const editInputRef = useRef<HTMLInputElement>(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 (
<div className={cn("my-1", className)}>
<div className="flex items-center gap-2">
@@ -93,43 +178,151 @@ export function TaskManagerDropdown({ content, className }: TaskManagerDropdownP
return (
<div className={cn("my-1", className)}>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">Task Manager</span>
{!isEditable && (
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-foreground">Task Manager</span>
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-4"
aria-label={isExpanded ? 'Collapse task list' : 'Expand task list'}
>
<span>{completedCount}/{displayTasks.length} completed</span>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
{/* Expand/Collapse button */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-4"
aria-label={isExpanded ? 'Collapse task list' : 'Expand task list'}
>
<span>{completedCount}/{tasks.length} completed</span>
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
)}
{/* Expanded Task List */}
{isExpanded && (
<div className="space-y-1 max-h-48 overflow-y-auto pt-2">
<div className="space-y-0 max-h-64 overflow-y-auto pb-4">
{visibleTasks.map((task, index) => (
<div
key={index}
className="flex items-center gap-2 py-1 text-xs"
>
<CompletionLight isCompleted={isTaskCompleted(task)} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate text-foreground">
{task.content}
<div key={task.id} className="group/step">
<div
className={cn(
"flex items-center gap-2 py-1.5 px-1 text-xs",
isEditable && "hover:bg-muted/20",
draggedTaskId === task.id && "opacity-50"
)}
draggable={isEditable && editingTaskId !== task.id}
onDragStart={(e) => handleDragStart(e, task.id)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, task.id)}
>
{/* Step number */}
<span className="text-muted-foreground font-medium min-w-[50px]">
Step {index + 1}
</span>
{/* Task content */}
<div className="flex-1 min-w-0">
{editingTaskId === task.id ? (
<input
ref={editInputRef}
value={editText}
onChange={(e) => 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..."
/>
) : (
<div
className={cn(
"truncate text-foreground",
isEditable && "cursor-pointer"
)}
onClick={isEditable ? () => startEdit(task) : undefined}
title={task.content}
>
{task.content}
</div>
)}
</div>
{/* Delete button */}
{isEditable && editingTaskId !== task.id && (
<button
onClick={() => deleteTask(task.id)}
className="opacity-0 group-hover/step:opacity-100 p-0.5 hover:bg-red-50 text-red-400 hover:text-red-600 rounded transition-all"
title="Delete step"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
{/* Add step line below each step on hover */}
{isEditable && (
<div className="group/add-line relative opacity-0 group-hover/step:opacity-100 transition-opacity">
<div className="h-px bg-border mx-4" />
<button
onClick={() => {
const newTask = {
id: `task-${Date.now()}`,
status: '○',
content: 'New step',
order: index + 1,
isEditable: true
}
const updatedTasks = [...localTasks]
updatedTasks.splice(index + 1, 0, newTask)
updatedTasks.forEach((t, i) => t.order = i)
setLocalTasks(updatedTasks)
onTasksChange?.(updatedTasks)
setTimeout(() => startEdit(newTask), 50)
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-full p-1 hover:bg-muted transition-all text-muted-foreground hover:text-foreground"
title="Add step below"
>
<Plus className="w-2.5 h-2.5" />
</button>
</div>
)}
</div>
))}
{hasMoreTasks && (
<div className="text-xs text-muted-foreground text-center py-2">
... and {displayTasks.length - MAX_VISIBLE_TASKS} more steps
</div>
)}
{/* Add first step when no steps exist */}
{isEditable && visibleTasks.length === 0 && (
<div className="group/empty relative py-2">
<div className="h-px bg-border mx-4" />
<button
onClick={addTask}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background border border-border rounded-full p-1 hover:bg-muted transition-all text-muted-foreground hover:text-foreground"
title="Add first step"
>
<Plus className="w-2.5 h-2.5" />
</button>
</div>
)}
</div>
)}
{/* Action buttons */}
{isEditable && (
<div className="flex gap-2 pt-2">
<button
onClick={() => onExecute?.(localTasks)}
className="px-3 py-1 bg-brand text-white text-xs rounded hover:bg-brand/90 transition-colors"
>
Run Agent
</button>
<button
onClick={onCancel}
className="px-3 py-1 bg-muted text-muted-foreground text-xs rounded hover:bg-muted/80 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
)
}
}

View File

@@ -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<typeof ChatStateSchema>
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<ChatState & ChatActions>((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)
}))