From 8b17a175c3e7e27b789812eba4e3cd760beadb10 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 6 Jan 2026 04:08:11 +0000 Subject: Initial Control system --- .../frontend/src/components/files/BodyRenderer.tsx | 121 ++++++++- makima/frontend/src/components/files/CliInput.tsx | 58 +++- .../src/components/files/ElementContextMenu.tsx | 292 +++++++++++++++++++++ .../frontend/src/components/files/FileDetail.tsx | 59 +++++ makima/frontend/src/components/files/FileList.tsx | 207 ++++++++++++++- 5 files changed, 724 insertions(+), 13 deletions(-) create mode 100644 makima/frontend/src/components/files/ElementContextMenu.tsx (limited to 'makima/frontend/src/components/files') diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx index 867fc4c..cf99fde 100644 --- a/makima/frontend/src/components/files/BodyRenderer.tsx +++ b/makima/frontend/src/components/files/BodyRenderer.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; import type { BodyElement } from "../../lib/api"; import { ChartRenderer } from "../charts/ChartRenderer"; +import { ElementContextMenu } from "./ElementContextMenu"; interface BodyRendererProps { elements: BodyElement[]; @@ -10,11 +11,54 @@ interface BodyRendererProps { onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + onFocusElement?: (index: number) => void; + onDeleteElement?: (index: number) => void; + onDuplicateElement?: (index: number) => void; + onConvertElement?: (index: number, toType: string) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; } -export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) { +export function BodyRenderer({ + elements, + isEditing = false, + onUpdate, + onReorder, + onEditingChange, + hasPendingRemoteUpdate, + onOverwrite, + onFocusElement, + onDeleteElement, + onDuplicateElement, + onConvertElement, + onGenerateFromElement, + onCreateTaskFromElement, +}: BodyRendererProps) { const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + elementIndex: number; + selectedText?: string; + } | null>(null); + + const handleContextMenu = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + // Get any selected text + const selection = window.getSelection(); + const selectedText = selection?.toString().trim() || undefined; + setContextMenu({ + x: e.clientX, + y: e.clientY, + elementIndex: index, + selectedText, + }); + }; + + const closeContextMenu = () => { + setContextMenu(null); + }; if (elements.length === 0) { return ( @@ -73,6 +117,7 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onDragOver={handleDragOver(index)} onDragLeave={handleDragLeave} onDrop={handleDrop(index)} + onContextMenu={handleContextMenu(index)} > {/* Drag handle - only show in edit mode */} {isEditing && onReorder && ( @@ -109,6 +154,24 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, ))} + + {/* Context Menu */} + {contextMenu && ( + onFocusElement?.(index)} + onDelete={(index) => onDeleteElement?.(index)} + onDuplicate={(index) => onDuplicateElement?.(index)} + onConvert={(index, toType) => onConvertElement?.(index, toType)} + onGenerate={(index, action) => onGenerateFromElement?.(index, action)} + onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)} + /> + )} ); } @@ -156,6 +219,20 @@ function BodyElementRenderer({ onOverwrite={onOverwrite} /> ); + case "code": + return ( + + ); + case "list": + return ( + + ); case "chart": return ( ); } + +function CodeElement({ + language, + content, +}: { + language?: string; + content: string; +}) { + return ( +
+ {language && ( +
+ {language} +
+ )} +
+        
+          {content}
+        
+      
+
+ ); +} + +function ListElement({ + ordered, + items, +}: { + ordered: boolean; + items: string[]; +}) { + const ListTag = ordered ? "ol" : "ul"; + return ( + + {items.map((item, index) => ( +
  • + {item} +
  • + ))} +
    + ); +} diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx index ff2b0a4..47e7616 100644 --- a/makima/frontend/src/components/files/CliInput.tsx +++ b/makima/frontend/src/components/files/CliInput.tsx @@ -8,10 +8,15 @@ import { type UserAnswer, } from "../../lib/api"; import { SimpleMarkdown } from "../SimpleMarkdown"; +import type { FocusedElement } from "./FileDetail"; interface CliInputProps { fileId: string; onUpdate: (body: BodyElement[], summary: string | null) => void; + focusedElement?: FocusedElement | null; + onClearFocus?: () => void; + suggestedPrompt?: string | null; + onClearSuggestedPrompt?: () => void; } interface Message { @@ -28,7 +33,7 @@ const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [ { value: "groq", label: "Groq Kimi" }, ]; -export function CliInput({ fileId, onUpdate }: CliInputProps) { +export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) { const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); const [messages, setMessages] = useState([]); @@ -53,6 +58,21 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } }, [messages]); + // Auto-focus input when an element is focused + useEffect(() => { + if (focusedElement && inputRef.current) { + inputRef.current.focus(); + } + }, [focusedElement]); + + // Handle suggested prompt from generate actions + useEffect(() => { + if (suggestedPrompt) { + setInput(suggestedPrompt); + onClearSuggestedPrompt?.(); + } + }, [suggestedPrompt, onClearSuggestedPrompt]); + const handleSubmit = useCallback( async (e: React.FormEvent) => { e.preventDefault(); @@ -73,7 +93,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send request with conversation history for context - const response = await chatWithFile(fileId, userMessage, model, conversationHistory); + const response = await chatWithFile( + fileId, + userMessage, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -128,7 +154,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { inputRef.current?.focus(); } }, - [input, loading, fileId, model, onUpdate, conversationHistory] + [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement] ); // Handle option selection for a question @@ -206,7 +232,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { try { // Send answers as the next message - const response = await chatWithFile(fileId, answerText, model, conversationHistory); + const response = await chatWithFile( + fileId, + answerText, + model, + conversationHistory, + focusedElement?.index + ); // Add assistant response const assistantMsgId = (Date.now() + 1).toString(); @@ -258,7 +290,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { } finally { setLoading(false); } - }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]); + }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]); // Cancel answering questions const handleCancelQuestions = useCallback(() => { @@ -397,6 +429,22 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) { ))} + + {/* Focus Badge */} + {focusedElement && ( + + )} + > void; + onFocus: (index: number) => void; + onDelete: (index: number) => void; + onDuplicate: (index: number) => void; + onConvert: (index: number, toType: string) => void; + onGenerate: (index: number, action: string) => void; + onCreateTask: (index: number, selectedText?: string) => void; +} + +export function ElementContextMenu({ + x, + y, + element, + elementIndex, + selectedText, + onClose, + onFocus, + onDelete, + onDuplicate, + onConvert, + onGenerate, + onCreateTask, +}: ElementContextMenuProps) { + const menuRef = useRef(null); + const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + // Adjust position if menu would overflow viewport + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + if (rect.right > viewportWidth) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > viewportHeight) { + menuRef.current.style.top = `${y - rect.height}px`; + } + } + }, [x, y]); + + const getElementTypeLabel = () => { + switch (element.type) { + case "heading": + return `Heading ${element.level}`; + case "paragraph": + return "Paragraph"; + case "code": + return element.language ? `Code (${element.language})` : "Code"; + case "list": + return element.ordered ? "Ordered List" : "Bullet List"; + case "chart": + return `Chart (${element.chartType})`; + case "image": + return "Image"; + default: + return "Element"; + } + }; + + const menuItemClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2"; + const submenuTriggerClass = + "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between"; + const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + return ( +
    + {/* Header showing element type */} +
    + {getElementTypeLabel()} [{elementIndex}] +
    + + {/* Focus action */} + + + {/* Create task action */} + + +
    + + {/* Generate submenu */} +
    setActiveSubmenu("generate")} + onMouseLeave={() => setActiveSubmenu(null)} + > + + + {activeSubmenu === "generate" && ( +
    + + + +
    + )} +
    + + {/* Convert submenu */} +
    setActiveSubmenu("convert")} + onMouseLeave={() => setActiveSubmenu(null)} + > + + + {activeSubmenu === "convert" && ( +
    + {element.type !== "paragraph" && ( + + )} + {element.type !== "list" && ( + <> + + + + )} + {element.type !== "code" && ( + + )} + {element.type !== "heading" && ( + <> +
    + {[1, 2, 3, 4, 5, 6].map((level) => ( + + ))} + + )} +
    + )} +
    + +
    + + {/* Duplicate */} + + + {/* Delete */} + +
    + ); +} diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx index c7b716a..60458e9 100644 --- a/makima/frontend/src/components/files/FileDetail.tsx +++ b/makima/frontend/src/components/files/FileDetail.tsx @@ -3,6 +3,12 @@ import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } fr import { BodyRenderer } from "./BodyRenderer"; import { VersionHistoryDropdown } from "./VersionHistoryDropdown"; +export interface FocusedElement { + index: number; + type: string; + preview: string; +} + interface FileDetailProps { file: FileDetailType; loading: boolean; @@ -11,9 +17,17 @@ interface FileDetailProps { onDelete: (id: string) => void; onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void; onBodyReorder?: (fromIndex: number, toIndex: number) => void; + onBodyElementDelete?: (index: number) => void; + onBodyElementDuplicate?: (index: number) => void; onEditingChange?: (isEditing: boolean) => void; hasPendingRemoteUpdate?: boolean; onOverwrite?: () => void; + // Focus element props + focusedElement?: FocusedElement | null; + onFocusElement?: (element: FocusedElement | null) => void; + onGenerateFromElement?: (index: number, action: string) => void; + onConvertElement?: (index: number, toType: string) => void; + onCreateTaskFromElement?: (index: number, selectedText?: string) => void; // Version history props versions?: FileVersionSummary[]; versionsLoading?: boolean; @@ -33,9 +47,16 @@ export function FileDetail({ onDelete, onBodyElementUpdate, onBodyReorder, + onBodyElementDelete, + onBodyElementDuplicate, onEditingChange, hasPendingRemoteUpdate, onOverwrite, + focusedElement: _focusedElement, + onFocusElement, + onGenerateFromElement, + onConvertElement, + onCreateTaskFromElement, versions = [], versionsLoading = false, selectedVersion = null, @@ -50,6 +71,38 @@ export function FileDetail({ const [description, setDescription] = useState(file.description || ""); const [transcriptExpanded, setTranscriptExpanded] = useState(false); + // Helper to get element preview text + const getElementPreview = (index: number): string => { + const element = file.body[index]; + if (!element) return ""; + switch (element.type) { + case "heading": + case "paragraph": + return element.text.slice(0, 50) + (element.text.length > 50 ? "..." : ""); + case "code": + return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : ""); + case "list": + return element.items[0]?.slice(0, 40) + (element.items.length > 1 ? ` (+${element.items.length - 1} more)` : ""); + case "chart": + return element.title || `${element.chartType} chart`; + case "image": + return element.alt || element.caption || "Image"; + default: + return "Element"; + } + }; + + // Handler for focus action from context menu + const handleFocusElement = (index: number) => { + const element = file.body[index]; + if (!element || !onFocusElement) return; + onFocusElement({ + index, + type: element.type, + preview: getElementPreview(index), + }); + }; + // Update local state when file changes useEffect(() => { setName(file.name); @@ -192,6 +245,12 @@ export function FileDetail({ onEditingChange={onEditingChange} hasPendingRemoteUpdate={hasPendingRemoteUpdate} onOverwrite={onOverwrite} + onFocusElement={handleFocusElement} + onDeleteElement={onBodyElementDelete} + onDuplicateElement={onBodyElementDuplicate} + onConvertElement={onConvertElement} + onGenerateFromElement={onGenerateFromElement} + onCreateTaskFromElement={onCreateTaskFromElement} />
    diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx index a859aa1..c537846 100644 --- a/makima/frontend/src/components/files/FileList.tsx +++ b/makima/frontend/src/components/files/FileList.tsx @@ -1,4 +1,5 @@ -import type { FileSummary } from "../../lib/api"; +import { useRef } from "react"; +import type { FileSummary, BodyElement } from "../../lib/api"; interface FileListProps { files: FileSummary[]; @@ -6,6 +7,154 @@ interface FileListProps { onSelect: (id: string) => void; onDelete: (id: string) => void; onCreate: () => void; + onUploadMarkdown?: (name: string, body: BodyElement[]) => void; +} + +/** + * Parse markdown text into BodyElements. + * Converts image embeds to links instead of images. + */ +function parseMarkdown(markdown: string): BodyElement[] { + const elements: BodyElement[] = []; + const lines = markdown.split('\n'); + let currentParagraph: string[] = []; + let inCodeBlock = false; + let codeBlockLanguage: string | undefined; + let codeBlockContent: string[] = []; + let currentList: { ordered: boolean; items: string[] } | null = null; + + const flushParagraph = () => { + if (currentParagraph.length > 0) { + const text = currentParagraph.join('\n').trim(); + if (text) { + elements.push({ type: "paragraph", text }); + } + currentParagraph = []; + } + }; + + const flushCodeBlock = () => { + if (codeBlockContent.length > 0 || inCodeBlock) { + elements.push({ + type: "code", + language: codeBlockLanguage || undefined, + content: codeBlockContent.join('\n'), + }); + codeBlockContent = []; + codeBlockLanguage = undefined; + } + }; + + const flushList = () => { + if (currentList && currentList.items.length > 0) { + elements.push({ + type: "list", + ordered: currentList.ordered, + items: currentList.items, + }); + currentList = null; + } + }; + + // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url) + const convertImagesToLinks = (text: string): string => { + return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => { + const linkText = alt || 'image'; + return `[${linkText}](${url})`; + }); + }; + + for (const rawLine of lines) { + // Check for code block fence (``` or ~~~) + const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/); + if (codeFenceMatch) { + if (!inCodeBlock) { + // Starting a code block + flushParagraph(); + flushList(); + inCodeBlock = true; + codeBlockLanguage = codeFenceMatch[2] || undefined; + codeBlockContent = []; + } else { + // Ending a code block + flushCodeBlock(); + inCodeBlock = false; + } + continue; + } + + // If inside a code block, add line as-is + if (inCodeBlock) { + codeBlockContent.push(rawLine); + continue; + } + + // Convert images to links in all lines + const line = convertImagesToLinks(rawLine); + + // Check for headings + const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + flushList(); + const level = headingMatch[1].length; + const text = headingMatch[2].trim(); + elements.push({ type: "heading", level, text }); + continue; + } + + // Check for unordered list items (-, *, +) + const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/); + if (unorderedMatch) { + flushParagraph(); + const itemText = unorderedMatch[1].trim(); + if (currentList && currentList.ordered) { + // Switch from ordered to unordered + flushList(); + } + if (!currentList) { + currentList = { ordered: false, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Check for ordered list items (1. 2. etc) + const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/); + if (orderedMatch) { + flushParagraph(); + const itemText = orderedMatch[1].trim(); + if (currentList && !currentList.ordered) { + // Switch from unordered to ordered + flushList(); + } + if (!currentList) { + currentList = { ordered: true, items: [] }; + } + currentList.items.push(itemText); + continue; + } + + // Empty line - flush everything + if (line.trim() === '') { + flushParagraph(); + flushList(); + continue; + } + + // Regular text - flush list first, then add to paragraph + flushList(); + currentParagraph.push(line); + } + + // Flush any remaining content + if (inCodeBlock) { + flushCodeBlock(); + } + flushParagraph(); + flushList(); + + return elements; } function formatDuration(seconds: number | null): string { @@ -32,7 +181,32 @@ export function FileList({ onSelect, onDelete, onCreate, + onUploadMarkdown, }: FileListProps) { + const fileInputRef = useRef(null); + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !onUploadMarkdown) return; + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + if (content) { + const body = parseMarkdown(content); + // Use filename without extension as the name + const name = file.name.replace(/\.md$/i, '') || 'Imported Document'; + onUploadMarkdown(name, body); + } + }; + reader.readAsText(file); + + // Reset input so the same file can be uploaded again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + if (loading) { return (
    @@ -47,12 +221,31 @@ export function FileList({
    FILES//
    - +
    + {onUploadMarkdown && ( + <> + + + + )} + +
    -- cgit v1.2.3