diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/routes/files.tsx | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/frontend/src/routes/files.tsx')
| -rw-r--r-- | makima/frontend/src/routes/files.tsx | 350 |
1 files changed, 347 insertions, 3 deletions
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx index 0d870f7..0645b85 100644 --- a/makima/frontend/src/routes/files.tsx +++ b/makima/frontend/src/routes/files.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useParams, useNavigate } from "react-router"; import { Masthead } from "../components/Masthead"; import { FileList } from "../components/files/FileList"; -import { FileDetail } from "../components/files/FileDetail"; +import { FileDetail, type FocusedElement } from "../components/files/FileDetail"; import { CliInput } from "../components/files/CliInput"; import { ConflictNotification } from "../components/files/ConflictNotification"; import { UpdateNotification } from "../components/files/UpdateNotification"; @@ -12,7 +12,8 @@ import { useFileSubscription, type FileUpdateEvent, } from "../hooks/useFileSubscription"; -import type { FileDetail as FileDetailType, BodyElement } from "../lib/api"; +import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api"; +import { createTask } from "../lib/api"; export default function FilesPage() { const { id } = useParams<{ id: string }>(); @@ -23,6 +24,9 @@ export default function FilesPage() { const [creating, setCreating] = useState(false); const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null); const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null); + const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null); + const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null); + const [createdTask, setCreatedTask] = useState<Task | null>(null); const pendingUpdateRef = useRef(false); // Track the last version we sent to detect our own updates const lastSentVersionRef = useRef<number | null>(null); @@ -85,6 +89,7 @@ export default function FilesPage() { currentVersionRef.current = null; setRemoteUpdate(null); setRemoteFileData(null); + setFocusedElement(null); fetchFile(id).then((detail) => { if (detail) { currentVersionRef.current = detail.version; @@ -285,6 +290,276 @@ export default function FilesPage() { [fileDetail, id, editFile, updateHasLocalChanges] ); + // Element action handlers for context menu + const handleBodyElementDelete = useCallback( + async (index: number) => { + if (fileDetail && id) { + const newBody = fileDetail.body.filter((_, i) => i !== index); + + // Update local state immediately + setFileDetail({ + ...fileDetail, + body: newBody, + }); + updateHasLocalChanges(true); + + // Clear focus if deleting focused element + if (focusedElement?.index === index) { + setFocusedElement(null); + } else if (focusedElement && focusedElement.index > index) { + // Adjust focus index if deleting an element before it + setFocusedElement({ + ...focusedElement, + index: focusedElement.index - 1, + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + const handleBodyElementDuplicate = useCallback( + async (index: number) => { + if (fileDetail && id) { + const elementToDuplicate = fileDetail.body[index]; + if (!elementToDuplicate) return; + + const newBody = [...fileDetail.body]; + // Insert duplicate after the original + newBody.splice(index + 1, 0, { ...elementToDuplicate }); + + // Update local state immediately + setFileDetail({ + ...fileDetail, + body: newBody, + }); + updateHasLocalChanges(true); + + // Adjust focus index if duplicating before focused element + if (focusedElement && focusedElement.index > index) { + setFocusedElement({ + ...focusedElement, + index: focusedElement.index + 1, + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + const handleFocusElement = useCallback((element: FocusedElement | null) => { + setFocusedElement(element); + }, []); + + const handleClearFocus = useCallback(() => { + setFocusedElement(null); + }, []); + + // Convert element to a different type + const handleConvertElement = useCallback( + async (index: number, toType: string) => { + if (!fileDetail || !id) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Extract text content from current element + let textContent = ""; + switch (element.type) { + case "heading": + case "paragraph": + textContent = element.text; + break; + case "code": + textContent = element.content; + break; + case "list": + textContent = element.items.join("\n"); + break; + default: + return; // Can't convert charts/images + } + + // Create new element based on target type + let newElement: BodyElement; + if (toType === "paragraph") { + newElement = { type: "paragraph", text: textContent }; + } else if (toType === "list_unordered") { + const items = textContent.split("\n").filter(line => line.trim()); + newElement = { type: "list", ordered: false, items }; + } else if (toType === "list_ordered") { + const items = textContent.split("\n").filter(line => line.trim()); + newElement = { type: "list", ordered: true, items }; + } else if (toType === "code") { + newElement = { type: "code", content: textContent }; + } else if (toType.startsWith("heading_")) { + const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6; + newElement = { type: "heading", level, text: textContent }; + } else { + return; // Unknown type + } + + const newBody = [...fileDetail.body]; + newBody[index] = newElement; + + // Update local state + setFileDetail({ ...fileDetail, body: newBody }); + updateHasLocalChanges(true); + + // Update focus if this element was focused + if (focusedElement?.index === index) { + setFocusedElement({ + index, + type: newElement.type, + preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""), + }); + } + + // Save to backend + pendingUpdateRef.current = true; + lastSentVersionRef.current = fileDetail.version; + try { + const result = await editFile(id, { body: newBody, version: fileDetail.version }); + if (result) { + lastSavedVersionRef.current = result.version; + currentVersionRef.current = result.version; + setFileDetail(result); + updateHasLocalChanges(false); + } + } finally { + pendingUpdateRef.current = false; + lastSentVersionRef.current = null; + } + }, + [fileDetail, id, editFile, updateHasLocalChanges, focusedElement] + ); + + // Generate from element - focus on it and pre-fill a prompt + const handleGenerateFromElement = useCallback( + (index: number, action: string) => { + if (!fileDetail) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Get preview text + let preview = ""; + switch (element.type) { + case "heading": + case "paragraph": + preview = element.text.slice(0, 50); + break; + case "code": + preview = element.content.slice(0, 50); + break; + case "list": + preview = element.items[0]?.slice(0, 40) || ""; + break; + default: + preview = "Element"; + } + + // Focus on the element + setFocusedElement({ + index, + type: element.type, + preview: preview + (preview.length >= 50 ? "..." : ""), + }); + + // Set suggested prompt based on action + let prompt = ""; + switch (action) { + case "elaborate": + prompt = "Elaborate and expand on this content"; + break; + case "summarize": + prompt = "Summarize this content"; + break; + case "extract_actions": + prompt = "Extract action items from this content"; + break; + } + setSuggestedPrompt(prompt); + }, + [fileDetail] + ); + + // Create a mesh task from an element + const handleCreateTaskFromElement = useCallback( + async (index: number, selectedText?: string) => { + if (!fileDetail) return; + + const element = fileDetail.body[index]; + if (!element) return; + + // Get the content to use as task plan + let content = selectedText || ""; + if (!content) { + switch (element.type) { + case "heading": + case "paragraph": + content = element.text; + break; + case "code": + content = element.content; + break; + case "list": + content = element.items.join("\n"); + break; + default: + content = "Task from file element"; + } + } + + // Create a task name from the content + const name = content.slice(0, 60) + (content.length > 60 ? "..." : ""); + + try { + const task = await createTask({ + name, + plan: content, + description: `Created from ${fileDetail.name}`, + }); + setCreatedTask(task); + } catch (err) { + console.error("Failed to create task:", err); + } + }, + [fileDetail] + ); + const handleCreate = useCallback(async () => { if (creating) return; setCreating(true); @@ -301,6 +576,28 @@ export default function FilesPage() { } }, [creating, saveFile, navigate]); + const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => { + if (creating) return; + setCreating(true); + try { + const newFile = await saveFile({ + name, + transcript: [], + }); + if (newFile) { + // Update with the parsed body + const updated = await editFile(newFile.id, { body, version: newFile.version }); + if (updated) { + navigate(`/files/${updated.id}`); + } else { + navigate(`/files/${newFile.id}`); + } + } + } finally { + setCreating(false); + } + }, [creating, saveFile, editFile, navigate]); + // Conflict resolution handlers const handleConflictReload = useCallback(async () => { if (id) { @@ -381,9 +678,16 @@ export default function FilesPage() { onDelete={handleDelete} onBodyElementUpdate={handleBodyElementUpdate} onBodyReorder={handleBodyReorder} + onBodyElementDelete={handleBodyElementDelete} + onBodyElementDuplicate={handleBodyElementDuplicate} + onConvertElement={handleConvertElement} + onGenerateFromElement={handleGenerateFromElement} + onCreateTaskFromElement={handleCreateTaskFromElement} onEditingChange={updateIsActivelyEditing} hasPendingRemoteUpdate={!!remoteUpdate} onOverwrite={handleRemoteUpdateDismiss} + focusedElement={focusedElement} + onFocusElement={handleFocusElement} versions={versions} versionsLoading={versionsLoading} selectedVersion={selectedVersion} @@ -395,7 +699,14 @@ export default function FilesPage() { /> </div> <div className="shrink-0"> - <CliInput fileId={id} onUpdate={handleBodyUpdate} /> + <CliInput + fileId={id} + onUpdate={handleBodyUpdate} + focusedElement={focusedElement} + onClearFocus={handleClearFocus} + suggestedPrompt={suggestedPrompt} + onClearSuggestedPrompt={() => setSuggestedPrompt(null)} + /> </div> </div> ) : id && detailLoading ? ( @@ -409,6 +720,7 @@ export default function FilesPage() { onSelect={handleSelectFile} onDelete={handleDelete} onCreate={handleCreate} + onUploadMarkdown={handleUploadMarkdown} /> )} </main> @@ -432,6 +744,38 @@ export default function FilesPage() { onDismiss={handleRemoteUpdateDismiss} /> )} + + {/* Task created notification */} + {createdTask && ( + <div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm"> + <div className="flex items-start gap-3"> + <span className="text-[#75aafc] text-lg">@</span> + <div className="flex-1"> + <p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p> + <p className="font-mono text-sm text-white truncate mb-3"> + {createdTask.name} + </p> + <div className="flex gap-2"> + <button + onClick={() => { + navigate(`/mesh/${createdTask.id}`); + setCreatedTask(null); + }} + className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors" + > + Go to task + </button> + <button + onClick={() => setCreatedTask(null)} + className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors" + > + Dismiss + </button> + </div> + </div> + </div> + </div> + )} </div> ); } |
