diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.scss b/src/web-ui/src/tools/editor/components/PlanViewer.scss index a481042c..f60ce132 100644 --- a/src/web-ui/src/tools/editor/components/PlanViewer.scss +++ b/src/web-ui/src/tools/editor/components/PlanViewer.scss @@ -63,17 +63,56 @@ display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; + padding: 8px 12px; background: var(--color-bg-secondary); border-bottom: 1px solid var(--border-base); flex-shrink: 0; + &--collapsible { + cursor: pointer; + + &:hover { + background: var(--color-bg-tertiary); + + .header-expand-indicator { + color: var(--color-text-primary); + background: rgba(255, 255, 255, 0.08); + } + } + } + .header-left { display: flex; align-items: center; gap: 8px; + min-width: 0; + + .header-expand-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--color-text-muted); + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + transition: all 0.15s ease; + + svg { + transition: transform 0.15s ease; + } + + &--expanded svg { + transform: rotate(180deg); + } + + &--disabled { + opacity: 0.5; + } + } .file-icon { + flex-shrink: 0; color: var(--color-text-muted); } @@ -82,6 +121,9 @@ font-weight: 500; color: var(--color-text-primary); font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .unsaved-indicator { @@ -98,6 +140,50 @@ display: flex; align-items: center; gap: 8px; + flex-shrink: 0; + + .todos-count { + font-size: 11px; + font-weight: 500; + color: var(--color-text-muted); + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--border-base); + background: var(--color-bg-primary); + letter-spacing: 0.2px; + } + + .edit-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + color: var(--color-text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-primary); + background: var(--color-bg-tertiary); + border-color: var(--border-medium); + } + + &--active { + color: var(--color-info); + background: rgba(59, 130, 246, 0.1); + border-color: var(--color-info); + + &:hover { + color: var(--color-info); + background: rgba(59, 130, 246, 0.15); + } + } + } } } @@ -106,7 +192,7 @@ display: flex; align-items: center; gap: 6px; - padding: 6px 16px; + padding: 4px 12px; font-size: 12px; font-weight: 500; color: #fff; @@ -159,63 +245,27 @@ border-bottom: 1px solid var(--border-base); flex-shrink: 0; - .todos-header { + .todo-inline-toolbar, + .trailing-todo-toolbar { display: flex; - align-items: center; - justify-content: space-between; - cursor: pointer; - padding: 2px 0; - border-radius: 4px; - transition: background 0.15s ease; - - &:hover { - background: rgba(255, 255, 255, 0.03); - } + justify-content: flex-end; + gap: 6px; + margin-bottom: 6px; - .todos-header-left { - display: flex; - align-items: center; - gap: 6px; - } - - .todos-toggle-btn { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - padding: 0; - border: none; - background: transparent; - color: var(--color-text-muted); - cursor: pointer; - border-radius: 4px; - transition: all 0.15s ease; - - &:hover { - color: var(--color-text-primary); - background: rgba(255, 255, 255, 0.08); - } - } - - .todos-count { - font-size: 12px; - font-weight: 500; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; + &.todo-toolbar--yaml { + margin-bottom: 0; } .edit-btn { - display: flex; + display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 22px; + height: 22px; padding: 0; color: var(--color-text-muted); background: transparent; - border: 1px solid var(--border-base); + border: 1px solid transparent; border-radius: 4px; cursor: pointer; transition: all 0.15s ease; @@ -223,30 +273,17 @@ &:hover { color: var(--color-text-primary); background: var(--color-bg-tertiary); - border-color: var(--border-medium); + border-color: var(--border-base); } - &--active { - color: var(--color-info); - background: rgba(59, 130, 246, 0.1); - border-color: var(--color-info); - - &:hover { - color: var(--color-info); - background: rgba(59, 130, 246, 0.15); - } + &--confirm { + color: var(--color-success); } } } - &--expanded .todos-header { - margin-bottom: 8px; - } - // YAML editor section .yaml-editor-section { - margin-bottom: 12px; - border: 1px solid var(--border-base); border-radius: 6px; overflow: hidden; @@ -258,6 +295,10 @@ > div { height: 100%; } + + .m-editor-textarea { + padding-top: 4px; + } } } @@ -271,6 +312,22 @@ } + &--trailing { + border: none; + border-top: 1px dashed var(--border-base); + background: transparent; + padding: 6px 16px 10px; + + .todos-list { + max-height: none; + overflow: visible; + } + + .todo-item { + padding: 3px 4px; + } + } + .todo-item { display: flex; align-items: flex-start; @@ -306,6 +363,45 @@ font-size: 13px; line-height: 1.5; color: var(--color-text-secondary); + flex: 1; + min-width: 0; + } + + .todo-content-input { + flex: 1; + min-width: 0; + font-size: 13px; + line-height: 1.5; + color: var(--color-text-primary); + background: var(--color-bg-primary); + border: 1px solid var(--border-base); + border-radius: 4px; + padding: 4px 8px; + outline: none; + + &:focus { + border-color: var(--color-info); + } + } + + .todo-delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--color-error); + background: rgba(var(--color-error-rgb), 0.1); + border-color: rgba(var(--color-error-rgb), 0.3); + } } // Status styles @@ -348,15 +444,33 @@ flex: 1; display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; + min-width: 0; .plan-markdown { - flex: 1; - overflow: hidden; + flex: none; + overflow-x: hidden; + overflow-y: visible; + width: 100%; + min-width: 0; - // Ensure MEditor fills entire area + // Let markdown and trailing todos share one parent scroller. > div { - height: 100%; + height: auto; + width: 100%; + min-width: 0; + } + + .m-editor, + .m-editor-content, + .m-editor-ir-panel, + .m-editor-ir { + height: auto; + width: 100%; + min-width: 0; + overflow-x: hidden; + overflow-y: visible; } } } diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.tsx b/src/web-ui/src/tools/editor/components/PlanViewer.tsx index 179f19da..9b2ab171 100644 --- a/src/web-ui/src/tools/editor/components/PlanViewer.tsx +++ b/src/web-ui/src/tools/editor/components/PlanViewer.tsx @@ -1,7 +1,7 @@ /** Optimized viewer/editor for `.plan.md` files (frontmatter + markdown body). */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Circle, ArrowRight, Check, XCircle, Loader2, CheckCircle, AlertCircle, FileText, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { Circle, ArrowRight, Check, XCircle, Loader2, CheckCircle, AlertCircle, FileText, Pencil, X, ChevronDown, Trash2, Plus } from 'lucide-react'; import yaml from 'yaml'; import { MEditor } from '../meditor'; import type { EditorInstance } from '../meditor'; @@ -69,6 +69,10 @@ const PlanViewer: React.FC = ({ const [originalYamlContent, setOriginalYamlContent] = useState(''); // Todos list expand/collapse state (collapsed by default) const [isTodosExpanded, setIsTodosExpanded] = useState(false); + const [isTrailingTodoEditing, setIsTrailingTodoEditing] = useState(false); + const [trailingTodoDrafts, setTrailingTodoDrafts] = useState>({}); + const [trailingDeletedTodoKeys, setTrailingDeletedTodoKeys] = useState([]); + const [trailingAddedTodos, setTrailingAddedTodos] = useState([]); const editorRef = useRef(null); const yamlEditorRef = useRef(null); @@ -91,6 +95,8 @@ const PlanViewer: React.FC = ({ return parts[parts.length - 1] || ''; }, [filePath, fileName]); + const hasTodos = !!(planData?.todos && planData.todos.length > 0); + useEffect(() => { isUnmountedRef.current = false; return () => { @@ -293,21 +299,250 @@ const PlanViewer: React.FC = ({ saveFileContent(); }, [saveFileContent]); - const toggleEditMode = useCallback(() => { - if (isEditingYaml) { + const startTrailingTodoEdit = useCallback(() => { + if (!planData?.todos?.length) return; + const drafts: Record = {}; + planData.todos.forEach((todo, index) => { + const key = todo.id || String(index); + drafts[key] = todo.content; + }); + setTrailingTodoDrafts(drafts); + setTrailingDeletedTodoKeys([]); + setTrailingAddedTodos([]); + setIsTrailingTodoEditing(true); + }, [planData]); + + const cancelTrailingTodoEdit = useCallback(() => { + setIsTrailingTodoEditing(false); + setTrailingTodoDrafts({}); + setTrailingDeletedTodoKeys([]); + setTrailingAddedTodos([]); + }, []); + + const handleAddTrailingTodo = useCallback(() => { + const id = `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newTodo: PlanTodo = { + id, + content: '', + status: 'pending', + }; + setTrailingAddedTodos(prev => [...prev, newTodo]); + setTrailingTodoDrafts(prev => ({ ...prev, [id]: '' })); + }, []); + + const saveTrailingTodoEdit = useCallback(async () => { + if (!planData?.todos?.length || !filePath) return; + + const nextTodos = planData.todos + .map((todo, index) => ({ todo, key: todo.id || String(index) })) + .filter(({ key }) => !trailingDeletedTodoKeys.includes(key)) + .map(({ todo, key }) => { + const nextContent = (trailingTodoDrafts[key] ?? todo.content).trim(); + return { + ...todo, + content: nextContent || todo.content, + }; + }) + .concat( + trailingAddedTodos + .map(todo => ({ + ...todo, + content: (trailingTodoDrafts[todo.id] ?? todo.content).trim(), + })) + .filter(todo => !!todo.content) + ); + + let nextYamlContent = yamlContent; + if (yamlContent) { try { - const parsed = yaml.parse(yamlContent); - setPlanData({ - name: parsed.name || '', - overview: parsed.overview || '', - todos: parsed.todos || [], - }); + const parsed = yaml.parse(yamlContent) || {}; + parsed.todos = nextTodos; + nextYamlContent = yaml.stringify(parsed).trimEnd(); } catch (e) { - log.warn('YAML parse failed', e); + log.warn('Failed to parse yaml while saving trailing todo edit', e); } } - setIsEditingYaml(!isEditingYaml); - }, [isEditingYaml, yamlContent]); + + try { + const fullContent = nextYamlContent + ? `---\n${nextYamlContent}\n---\n\n${planContent}` + : planContent; + await workspaceAPI.writeFileContent(workspacePath || '', filePath, fullContent); + setPlanData(prev => (prev ? { ...prev, todos: nextTodos } : prev)); + setYamlContent(nextYamlContent); + setOriginalYamlContent(nextYamlContent); + setOriginalContent(planContent); + setIsTrailingTodoEditing(false); + setTrailingTodoDrafts({}); + setTrailingDeletedTodoKeys([]); + setTrailingAddedTodos([]); + } catch (err) { + log.error('Failed to save trailing todo edit', err); + } + }, [planData, trailingAddedTodos, trailingDeletedTodoKeys, trailingTodoDrafts, yamlContent, planContent, workspacePath, filePath]); + + const handleDeleteTrailingTodo = useCallback((todoKey: string) => { + if (todoKey.startsWith('new-')) { + setTrailingAddedTodos(prev => prev.filter(todo => todo.id !== todoKey)); + setTrailingTodoDrafts(prev => { + const { [todoKey]: _removed, ...rest } = prev; + return rest; + }); + return; + } + setTrailingDeletedTodoKeys(prev => (prev.includes(todoKey) ? prev : [...prev, todoKey])); + }, []); + + const displayedTodos = useMemo(() => { + if (!planData?.todos) return []; + if (!isTrailingTodoEditing) return planData.todos; + return [ + ...planData.todos.filter((todo, index) => !trailingDeletedTodoKeys.includes(todo.id || String(index))), + ...trailingAddedTodos, + ]; + }, [isTrailingTodoEditing, planData, trailingAddedTodos, trailingDeletedTodoKeys]); + + const renderSharedTodoPanel = useCallback((placement: 'inline' | 'trailing') => { + const panelClassName = placement === 'trailing' + ? `plan-viewer-todos plan-viewer-todos--trailing ${isEditingYaml ? 'plan-viewer-todos--yaml-editing' : ''}` + : `plan-viewer-todos ${isTodosExpanded ? 'plan-viewer-todos--expanded' : ''} ${isEditingYaml ? 'plan-viewer-todos--yaml-editing' : ''}`; + const toolbarClassName = `${placement === 'trailing' ? 'trailing-todo-toolbar' : 'todo-inline-toolbar'} ${isEditingYaml ? 'todo-toolbar--yaml' : ''}`; + + return ( +
+
+ {isEditingYaml ? ( + + ) : isTrailingTodoEditing ? ( + <> + + + + + ) : ( + <> + + + + )} +
+ + {isEditingYaml ? ( +
+
+ +
+
+ ) : ( +
+ {displayedTodos.map((todo, index) => ( +
+ {getTodoIcon(todo.status)} + {isTrailingTodoEditing ? ( + <> + { + const key = todo.id || String(index); + setTrailingTodoDrafts(prev => ({ + ...prev, + [key]: e.target.value, + })); + }} + /> + + + ) : ( + {todo.content} + )} +
+ ))} +
+ )} +
+ ); + }, [ + cancelTrailingTodoEdit, + displayedTodos, + handleAddTrailingTodo, + handleDeleteTrailingTodo, + handleSave, + handleYamlChange, + isEditingYaml, + isTodosExpanded, + isTrailingTodoEditing, + saveTrailingTodoEdit, + startTrailingTodoEdit, + t, + trailingTodoDrafts, + yamlContent, + ]); // Build button click handler const handleBuild = useCallback(async () => { @@ -385,104 +620,56 @@ ${JSON.stringify(simpleTodos, null, 2)} return (
-
+
{ + if (hasTodos && !isEditingYaml) { + setIsTodosExpanded(!isTodosExpanded); + } + }} + >
+ {hasTodos && ( + + + + )} {displayFileName} {hasUnsavedChanges && {t('editor.planViewer.unsaved')}}
-
- {planData && planData.todos && planData.todos.length > 0 && ( - - )} -
-
+
e.stopPropagation()}> + {hasTodos && ( + <> + {t('editor.planViewer.remainingTodos', { count: remainingTodos })} - {planData && planData.todos && planData.todos.length > 0 && ( -
-
!isEditingYaml && setIsTodosExpanded(!isTodosExpanded)} - > -
- - {t('editor.planViewer.remainingTodos', { count: remainingTodos })} -
- -
- - {isEditingYaml && ( -
-
- -
-
- )} - - {!isEditingYaml && isTodosExpanded && ( -
- {planData.todos.map((todo, index) => ( -
- {getTodoIcon(todo.status)} - {todo.content} -
- ))} -
+ )}
- )} +
+ + {hasTodos && (isEditingYaml || isTodosExpanded) && renderSharedTodoPanel('inline')}
@@ -493,7 +680,7 @@ ${JSON.stringify(simpleTodos, null, 2)} onSave={handleSave} mode="ir" theme="dark" - height="100%" + height="auto" width="100%" placeholder={t('editor.planViewer.contentPlaceholder')} readonly={false} @@ -501,6 +688,7 @@ ${JSON.stringify(simpleTodos, null, 2)} basePath={basePath} />
+ {hasTodos && isTodosExpanded && !isEditingYaml && renderSharedTodoPanel('trailing')}
);