diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/directives/TaskSlideOutPanel.tsx | 145 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/GitActionsPanel.tsx | 40 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskDetail.tsx | 41 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx | 7 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 24 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 23 |
6 files changed, 251 insertions, 29 deletions
diff --git a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx index 29fce23..176728c 100644 --- a/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx +++ b/makima/frontend/src/components/directives/TaskSlideOutPanel.tsx @@ -3,7 +3,8 @@ import { useTaskSubscription } from "../../hooks/useTaskSubscription"; import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; import { TaskOutput } from "../mesh/TaskOutput"; import { WorktreeFilesPanel } from "../mesh/WorktreeFilesPanel"; -import { getTaskOutput } from "../../lib/api"; +import { OverlayDiffViewer } from "../mesh/OverlayDiffViewer"; +import { getTaskOutput, getTaskDiff } from "../../lib/api"; interface TaskSlideOutPanelProps { taskId: string; @@ -21,21 +22,37 @@ export function TaskSlideOutPanel({ const [entries, setEntries] = useState<TaskOutputEvent[]>([]); const [isStreaming, setIsStreaming] = useState(false); const [loadingHistory, setLoadingHistory] = useState(false); + const [showDiff, setShowDiff] = useState(false); + const [diffContent, setDiffContent] = useState<string>(""); + const [diffLoading, setDiffLoading] = useState(false); // Escape key handler useEffect(() => { const handleEsc = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + // If diff is showing, close diff first; otherwise close panel + if (e.key === "Escape") { + if (selectedFileDiff !== null || diffLoading) { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + } else { + onClose(); + } + } }; if (isOpen) document.addEventListener("keydown", handleEsc); return () => document.removeEventListener("keydown", handleEsc); - }, [isOpen, onClose]); + }, [isOpen, onClose, selectedFileDiff, diffLoading]); // Load historical output when panel opens with a taskId useEffect(() => { if (!isOpen || !taskId) { setEntries([]); setIsStreaming(false); + // Reset diff state when panel closes + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); return; } @@ -98,6 +115,23 @@ export function TaskSlideOutPanel({ [] ); + // Handle file click to show diff + const handleFileClick = useCallback(async (_filePath: string) => { + if (!taskId) return; + setDiffLoading(true); + try { + const result = await getTaskDiff(taskId); + if (result.success && result.diff) { + setDiffContent(result.diff); + setShowDiff(true); + } + } catch (e) { + console.error("Failed to get diff:", e); + } finally { + setDiffLoading(false); + } + }, [taskId]); + // Subscribe to live output useTaskSubscription({ taskId: isOpen ? taskId : null, @@ -106,6 +140,8 @@ export function TaskSlideOutPanel({ onUpdate: handleUpdate, }); + const showingDiff = selectedFileDiff !== null || diffLoading; + return ( <> {/* Backdrop overlay */} @@ -125,13 +161,27 @@ export function TaskSlideOutPanel({ {/* Header */} <div className="flex items-center justify-between px-4 py-3 border-b border-dashed border-[rgba(117,170,252,0.25)] shrink-0"> <div className="flex items-center gap-2 min-w-0 flex-1"> + {showingDiff && ( + <button + type="button" + onClick={() => { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + className="text-[#75aafc] hover:text-white font-mono text-xs transition-colors shrink-0 mr-1" + title="Back to worktree view" + > + ← + </button> + )} <span className="text-[10px] font-mono text-[#75aafc] uppercase tracking-wide shrink-0"> - Task + {showingDiff ? "Diff" : "Task"} </span> <span className="text-[12px] font-mono text-white truncate"> - {taskName || taskId} + {showingDiff ? (selectedFilePath || "Loading...") : (taskName || taskId)} </span> - {isStreaming && ( + {!showingDiff && isStreaming && ( <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/30 shrink-0"> <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> <span className="text-green-400 font-mono text-[9px] uppercase"> @@ -141,39 +191,88 @@ export function TaskSlideOutPanel({ )} </div> <button + onClick={async () => { + if (!taskId) return; + setDiffLoading(true); + try { + const result = await getTaskDiff(taskId); + if (result.success && result.diff) { + setDiffContent(result.diff); + setShowDiff(true); + } + } catch (e) { + console.error("Failed to get diff:", e); + } finally { + setDiffLoading(false); + } + }} + className="text-[10px] font-mono text-[#75aafc] hover:text-[#9bc3ff] transition-colors px-1.5 py-0.5 border border-[rgba(117,170,252,0.2)] hover:border-[rgba(117,170,252,0.4)] shrink-0" + > + {diffLoading ? "Loading..." : "View Diff"} + </button> + <button type="button" onClick={onClose} className="text-[#7788aa] hover:text-white font-mono text-sm transition-colors ml-2 shrink-0 w-6 h-6 flex items-center justify-center" > - ✕ + ✕ </button> </div> {/* Content */} <div className="flex-1 flex flex-col min-h-0 overflow-hidden"> - {/* Task Output section (~60% height) */} - <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> - {loadingHistory ? ( - <div className="flex-1 flex items-center justify-center"> - <span className="font-mono text-xs text-[#555] animate-pulse"> - Loading output... - </span> - </div> - ) : ( - <TaskOutput - entries={entries} - isStreaming={isStreaming} - taskId={taskId} + {showingDiff ? ( + /* Diff view replaces the worktree panel content */ + <div className="flex-1 min-h-0 overflow-y-auto"> + <OverlayDiffViewer + diff={selectedFileDiff || ""} + loading={diffLoading} + onClose={() => { + setSelectedFileDiff(null); + setSelectedFilePath(null); + setDiffLoading(false); + }} + title={selectedFilePath ? `Diff: ${selectedFilePath}` : "File Diff"} /> - )} - </div> + </div> + ) : ( + <> + {/* Task Output section (~60% height) */} + <div className="flex-[3] min-h-0 flex flex-col border-b border-[rgba(117,170,252,0.15)]"> + {loadingHistory ? ( + <div className="flex-1 flex items-center justify-center"> + <span className="font-mono text-xs text-[#555] animate-pulse"> + Loading output... + </span> + </div> + ) : ( + <TaskOutput + entries={entries} + isStreaming={isStreaming} + taskId={taskId} + /> + )} + </div> {/* Worktree Changes section (~40% height) */} <div className="flex-[2] min-h-0 overflow-y-auto"> - {taskId && <WorktreeFilesPanel taskId={taskId} />} + {taskId && <WorktreeFilesPanel taskId={taskId} onFileClick={handleFileClick} />} </div> </div> </div> + + {/* Diff modal */} + {showDiff && ( + <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-4xl w-full max-h-[80vh]"> + <OverlayDiffViewer + diff={diffContent} + onClose={() => setShowDiff(false)} + title={`Changes in ${taskName || taskId}`} + /> + </div> + </div> + )} </> ); } diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx index be2e06d..ff1dc7d 100644 --- a/makima/frontend/src/components/mesh/GitActionsPanel.tsx +++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api"; +import { exportTaskPatch, pushTaskBranch, createTaskPR, commitWorktree, type ExportPatchResponse } from "../../lib/api"; interface GitActionsPanelProps { taskId: string; @@ -20,6 +20,8 @@ export function GitActionsPanel({ const [isExporting, setIsExporting] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isCreatingPR, setIsCreatingPR] = useState(false); + const [isCommitting, setIsCommitting] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); const [toast, setToast] = useState<ToastMessage | null>(null); const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(null); @@ -82,6 +84,23 @@ export function GitActionsPanel({ } }, [taskId, isLocalOnly]); + const handleCommit = useCallback(async () => { + setIsCommitting(true); + try { + const result = await commitWorktree(taskId, commitMessage || undefined); + if (result.success) { + showToast("success", `Committed: ${result.commitSha?.substring(0, 8) || "done"}`); + setCommitMessage(""); + } else { + showToast("error", result.error || "Failed to commit"); + } + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to commit"); + } finally { + setIsCommitting(false); + } + }, [taskId, commitMessage]); + return ( <div className="space-y-3"> {/* Section Header */} @@ -112,6 +131,25 @@ export function GitActionsPanel({ </div> )} + {/* Commit section */} + <div className="flex gap-2"> + <input + type="text" + value={commitMessage} + onChange={(e) => setCommitMessage(e.target.value)} + placeholder="Commit message (optional)" + className="flex-1 px-2 py-1.5 font-mono text-xs bg-transparent border border-[rgba(117,170,252,0.2)] text-[#dbe7ff] placeholder:text-[#555] focus:border-[#75aafc] focus:outline-none" + onKeyDown={(e) => { if (e.key === "Enter") handleCommit(); }} + /> + <button + onClick={handleCommit} + disabled={isCommitting} + className="px-3 py-1.5 font-mono text-xs text-emerald-400 border border-emerald-400/30 hover:border-emerald-400/50 hover:bg-emerald-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" + > + {isCommitting ? "Committing..." : "Commit"} + </button> + </div> + {/* Action buttons */} <div className="flex flex-wrap gap-2"> <button diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index fdcc58b..00719e2 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo, useEffect } from "react"; import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api"; -import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api"; +import { retryCompletionAction, getDaemonDirectories, cloneWorktree, getWorktreeDiff } from "../../lib/api"; import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree"; import { OverlayDiffViewer } from "./OverlayDiffViewer"; import { PRPreview } from "./PRPreview"; @@ -136,6 +136,9 @@ export function TaskDetail({ ); const [showDiff, setShowDiff] = useState(false); const [showPRPreview, setShowPRPreview] = useState(false); + const [worktreeFileDiff, setWorktreeFileDiff] = useState<string | null>(null); + const [worktreeFileDiffPath, setWorktreeFileDiffPath] = useState<string | null>(null); + const [worktreeFileDiffLoading, setWorktreeFileDiffLoading] = useState(false); const [useTreeView, setUseTreeView] = useState(false); // Track which subtask is expanded for inline editing const [expandedSubtaskId, setExpandedSubtaskId] = useState<string | null>(null); @@ -153,6 +156,22 @@ export function TaskDetail({ // Track branch modal state const [showBranchModal, setShowBranchModal] = useState(false); + // Handle clicking a file in the worktree panel to show its diff + const handleWorktreeFileClick = useCallback(async (filePath: string) => { + setWorktreeFileDiffLoading(true); + setWorktreeFileDiffPath(filePath); + setWorktreeFileDiff(null); + try { + const result = await getWorktreeDiff(task.id, filePath); + setWorktreeFileDiff(result.diff); + } catch (e) { + console.error("Failed to fetch worktree diff:", e); + setWorktreeFileDiff(""); + } finally { + setWorktreeFileDiffLoading(false); + } + }, [task.id]); + // Check if task is running const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting"; // Check if task is in a terminal state (can be continued/reopened) @@ -897,7 +916,7 @@ export function TaskDetail({ {/* Worktree Files Panel - show changed files in the worktree */} {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && ( - <WorktreeFilesPanel taskId={task.id} /> + <WorktreeFilesPanel taskId={task.id} onFileClick={handleWorktreeFileClick} /> )} {/* Patches List Panel - show exported patches for this task */} @@ -920,6 +939,24 @@ export function TaskDetail({ </div> )} + {/* Worktree File Diff Modal */} + {(worktreeFileDiff !== null || worktreeFileDiffLoading) && ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> + <div className="max-w-4xl w-full max-h-[80vh]"> + <OverlayDiffViewer + diff={worktreeFileDiff || ""} + loading={worktreeFileDiffLoading} + onClose={() => { + setWorktreeFileDiff(null); + setWorktreeFileDiffPath(null); + setWorktreeFileDiffLoading(false); + }} + title={worktreeFileDiffPath ? `Diff: ${worktreeFileDiffPath}` : "File Diff"} + /> + </div> + </div> + )} + {/* PR Preview Modal */} {showPRPreview && ( <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx index b529588..bb3361d 100644 --- a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -4,6 +4,7 @@ import { getWorktreeInfo } from "../../lib/api"; interface WorktreeFilesPanelProps { taskId: string; + onFileClick?: (filePath: string) => void; } /** Get status badge styling based on file status */ @@ -35,7 +36,7 @@ function getStatusStyle(status: string): { color: string; bgColor: string; label } } -export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { +export function WorktreeFilesPanel({ taskId, onFileClick }: WorktreeFilesPanelProps) { const [worktreeInfo, setWorktreeInfo] = useState<WorktreeInfo | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); @@ -116,6 +117,7 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { const { stats, files } = worktreeInfo; const displayFiles = expanded ? files : files.slice(0, 10); + const isClickable = !!onFileClick; return ( <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]"> @@ -152,7 +154,8 @@ export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { return ( <div key={file.path} - className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]" + className={`flex items-center gap-2 px-3 py-1.5 ${onFileClick ? 'cursor-pointer hover:bg-[rgba(117,170,252,0.08)]' : 'hover:bg-[rgba(117,170,252,0.03)]'}`} + onClick={() => onFileClick?.(file.path)} > {/* Status badge */} <span diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 7968583..aecdac7 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3068,6 +3068,30 @@ export async function getWorktreeInfo(taskId: string): Promise<WorktreeInfo> { return res.json(); } +/** Get the diff for a task's worktree changes */ +export async function getTaskDiff(taskId: string): Promise<{ taskId: string; success: boolean; diff: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/diff`); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to get task diff: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Commit changes in a task's worktree */ +export async function commitWorktree(taskId: string, message?: string): Promise<{ taskId: string; success: boolean; commitSha: string | null; error: string | null }> { + const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/worktree-commit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to commit worktree: ${errorText || res.statusText}`); + } + return res.json(); +} + // ============================================================================= // Patch Types and Functions // ============================================================================= diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index 67129f9..f210227 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -9,7 +9,7 @@ import { ContractCompleteQuestion } from "../components/mesh/ContractCompleteQue import { useTasks } from "../hooks/useTasks"; import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription"; import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRelations, DaemonDirectory, TaskSummary, RepositoryHistoryEntry } from "../lib/api"; -import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions } from "../lib/api"; +import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi, resumeSupervisor, branchTask, getRepositorySuggestions, getTaskDiff } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; @@ -136,6 +136,8 @@ export default function MeshPage() { const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null); // For supervisor tasks: all tasks in the contract (excluding the supervisor itself) const [contractTasks, setContractTasks] = useState<TaskSummary[]>([]); + // Overlay diff content for viewing worktree changes + const [overlayDiff, setOverlayDiff] = useState<string | undefined>(undefined); // View mode for the split panel layout const [viewMode, setViewMode] = useState<ViewMode>("split"); // Width of the task panel as a percentage (0-100) @@ -297,6 +299,7 @@ export default function MeshPage() { useEffect(() => { setViewingSubtaskId(null); setViewingSubtaskName(null); + setOverlayDiff(undefined); }, [id]); // Toggle viewing a subtask's output (for running subtasks) @@ -315,6 +318,22 @@ export default function MeshPage() { [viewingSubtaskId] ); + // Request diff for the current task + const handleRequestDiff = useCallback(async () => { + if (!id) return; + try { + const result = await getTaskDiff(id); + if (result.success && result.diff) { + setOverlayDiff(result.diff); + } else { + setOverlayDiff(result.error || "Failed to get diff"); + } + } catch (e) { + console.error("Failed to get diff:", e); + setOverlayDiff(e instanceof Error ? e.message : "Failed to get diff"); + } + }, [id]); + // Load task detail when URL has an id useEffect(() => { if (id) { @@ -810,6 +829,8 @@ export default function MeshPage() { onViewContract={(contractId) => navigate(`/contracts/${contractId}`)} onBranch={handleBranch} contractTasks={taskDetail.isSupervisor ? contractTasks : undefined} + overlayDiff={overlayDiff} + onRequestDiff={handleRequestDiff} /> </div> )} |
