summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/files.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/routes/files.tsx
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-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.tsx350
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>
);
}