mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 12:55:09 +00:00
feat: implement interactive plan editor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user