diff options
Diffstat (limited to 'makima/frontend')
| -rw-r--r-- | makima/frontend/src/components/SupervisorQuestionNotification.tsx | 106 | ||||
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 151 | ||||
| -rw-r--r-- | makima/frontend/src/contexts/SupervisorQuestionsContext.tsx | 20 | ||||
| -rw-r--r-- | makima/frontend/src/routes/contracts.tsx | 64 | ||||
| -rw-r--r-- | makima/frontend/src/routes/mesh.tsx | 15 |
5 files changed, 259 insertions, 97 deletions
diff --git a/makima/frontend/src/components/SupervisorQuestionNotification.tsx b/makima/frontend/src/components/SupervisorQuestionNotification.tsx index 6a71de2..1457d86 100644 --- a/makima/frontend/src/components/SupervisorQuestionNotification.tsx +++ b/makima/frontend/src/components/SupervisorQuestionNotification.tsx @@ -1,50 +1,22 @@ -import { useState } from "react"; import { useNavigate } from "react-router"; import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; -import type { PendingQuestion } from "../lib/api"; export function SupervisorQuestionNotification() { const navigate = useNavigate(); - const { pendingQuestions, submitAnswer } = useSupervisorQuestions(); - const [expandedQuestion, setExpandedQuestion] = useState<string | null>(null); - const [response, setResponse] = useState(""); - const [submitting, setSubmitting] = useState(false); + const { notificationQuestions, dismissNotification } = useSupervisorQuestions(); - if (pendingQuestions.length === 0) { + if (notificationQuestions.length === 0) { return null; } - const handleGoToTask = (taskId: string) => { + const handleGoToTask = (questionId: string, taskId: string) => { + dismissNotification(questionId); navigate(`/mesh/${taskId}`); }; - const handleExpand = (questionId: string) => { - setExpandedQuestion(expandedQuestion === questionId ? null : questionId); - setResponse(""); - }; - - const handleSubmit = async (question: PendingQuestion) => { - if (!response.trim()) return; - - setSubmitting(true); - const success = await submitAnswer(question.questionId, response.trim()); - setSubmitting(false); - - if (success) { - setExpandedQuestion(null); - setResponse(""); - } - }; - - const handleChoiceSelect = async (question: PendingQuestion, choice: string) => { - setSubmitting(true); - await submitAnswer(question.questionId, choice); - setSubmitting(false); - }; - return ( <div className="fixed bottom-4 right-4 z-50 max-w-md space-y-2"> - {pendingQuestions.map((question) => ( + {notificationQuestions.map((question) => ( <div key={question.questionId} className="bg-[#0d1b2d] border border-amber-500/50 rounded-lg shadow-lg overflow-hidden" @@ -54,24 +26,15 @@ export function SupervisorQuestionNotification() { <div className="flex items-center gap-2"> <span className="text-amber-400 text-lg">?</span> <span className="font-mono text-sm text-amber-300 uppercase"> - Supervisor Question + Task needs input </span> </div> - <div className="flex items-center gap-2"> - <button - onClick={() => handleGoToTask(question.taskId)} - className="px-2 py-1 font-mono text-xs text-amber-400 hover:text-amber-300 transition-colors" - title="Go to task" - > - View Task - </button> - <button - onClick={() => handleExpand(question.questionId)} - className="px-2 py-1 font-mono text-xs text-amber-400 border border-amber-500/30 hover:border-amber-400/50 transition-colors uppercase" - > - {expandedQuestion === question.questionId ? "Collapse" : "Answer"} - </button> - </div> + <button + onClick={() => handleGoToTask(question.questionId, question.taskId)} + className="px-3 py-1 font-mono text-xs text-amber-400 border border-amber-500/30 hover:border-amber-400/50 hover:bg-amber-900/20 transition-colors uppercase" + > + View Task + </button> </div> {/* Question preview */} @@ -81,53 +44,10 @@ export function SupervisorQuestionNotification() { {question.context} </div> )} - <p className="text-sm text-[#dbe7ff] font-mono"> + <p className="text-sm text-[#dbe7ff] font-mono line-clamp-2"> {question.question} </p> </div> - - {/* Expanded answer section */} - {expandedQuestion === question.questionId && ( - <div className="px-4 pb-4 border-t border-amber-500/20 pt-3"> - {question.choices.length > 0 ? ( - // Choice buttons - <div className="space-y-2"> - <p className="text-xs text-[#8b949e] font-mono uppercase mb-2"> - Select an option: - </p> - {question.choices.map((choice, idx) => ( - <button - key={idx} - onClick={() => handleChoiceSelect(question, choice)} - disabled={submitting} - className="w-full px-3 py-2 text-left font-mono text-sm text-[#dbe7ff] bg-[#0a1628] border border-[#3f6fb3] hover:border-amber-400/50 hover:bg-amber-900/20 disabled:opacity-50 transition-colors" - > - {choice} - </button> - ))} - </div> - ) : ( - // Free-form text input - <div className="space-y-2"> - <textarea - value={response} - onChange={(e) => setResponse(e.target.value)} - placeholder="Type your response..." - rows={3} - className="w-full px-3 py-2 bg-[#0a1628] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-amber-400 resize-none" - disabled={submitting} - /> - <button - onClick={() => handleSubmit(question)} - disabled={submitting || !response.trim()} - className="w-full px-4 py-2 font-mono text-xs text-[#0a1628] bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed transition-colors uppercase" - > - {submitting ? "Submitting..." : "Submit Response"} - </button> - </div> - )} - </div> - )} </div> ))} </div> diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx index cb0eba3..d53429d 100644 --- a/makima/frontend/src/components/mesh/TaskOutput.tsx +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -16,9 +16,23 @@ interface TaskOutputProps { taskId?: string | null; /** Callback when user sends input (to show it immediately in output) */ onUserInput?: (message: string) => void; + /** Set of pending question IDs (for supervisor questions) */ + pendingQuestionIds?: Set<string>; + /** Callback to answer a supervisor question */ + onAnswerQuestion?: (questionId: string, response: string) => Promise<void>; } -export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) { +export function TaskOutput({ + entries, + isStreaming, + viewingSubtaskName, + onClearSubtaskView, + onClear, + taskId, + onUserInput, + pendingQuestionIds, + onAnswerQuestion, +}: TaskOutputProps) { const containerRef = useRef<HTMLDivElement>(null); const [autoScroll, setAutoScroll] = useState(true); const [inputValue, setInputValue] = useState(""); @@ -135,7 +149,12 @@ export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSu ) : ( <div className="space-y-3"> {entries.map((entry, idx) => ( - <OutputEntryRenderer key={idx} entry={entry} /> + <OutputEntryRenderer + key={idx} + entry={entry} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={onAnswerQuestion} + /> ))} {isStreaming && ( <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" /> @@ -177,7 +196,13 @@ export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSu ); } -function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { +interface OutputEntryRendererProps { + entry: TaskOutputEvent; + pendingQuestionIds?: Set<string>; + onAnswerQuestion?: (questionId: string, response: string) => Promise<void>; +} + +function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: OutputEntryRendererProps) { const [expanded, setExpanded] = useState(false); switch (entry.messageType) { @@ -278,11 +303,131 @@ function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { case "auth_required": return <AuthRequiredEntry entry={entry} />; + case "supervisor_question": + return ( + <SupervisorQuestionEntry + entry={entry} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={onAnswerQuestion} + /> + ); + default: return null; } } +function SupervisorQuestionEntry({ + entry, + pendingQuestionIds, + onAnswerQuestion, +}: { + entry: TaskOutputEvent; + pendingQuestionIds?: Set<string>; + onAnswerQuestion?: (questionId: string, response: string) => Promise<void>; +}) { + const questionId = entry.toolInput?.question_id as string; + const choices = (entry.toolInput?.choices as string[]) || []; + const context = entry.toolInput?.context as string | null; + + const [customInput, setCustomInput] = useState(""); + const [showOther, setShowOther] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const isPending = pendingQuestionIds?.has(questionId) ?? false; + + const handleChoiceSelect = async (choice: string) => { + if (!onAnswerQuestion || submitting) return; + setSubmitting(true); + try { + await onAnswerQuestion(questionId, choice); + } finally { + setSubmitting(false); + } + }; + + const handleOtherSubmit = async () => { + if (!onAnswerQuestion || !customInput.trim() || submitting) return; + setSubmitting(true); + try { + await onAnswerQuestion(questionId, customInput.trim()); + setCustomInput(""); + } finally { + setSubmitting(false); + } + }; + + return ( + <div className="bg-amber-900/20 border border-amber-500/50 rounded p-3 my-2"> + <div className="flex items-center gap-2 text-amber-400 font-semibold mb-2"> + <span>?</span> + <span>Question</span> + {!isPending && ( + <span className="text-green-400 text-xs font-normal">(Answered)</span> + )} + </div> + + {context && ( + <p className="text-amber-200/60 text-xs mb-2 uppercase">{context}</p> + )} + + <p className="text-amber-100 mb-3">{entry.content}</p> + + {isPending && ( + <div className="space-y-2"> + {choices.length > 0 && ( + <div className="flex flex-wrap gap-2"> + {choices.map((choice, idx) => ( + <button + key={idx} + onClick={() => handleChoiceSelect(choice)} + disabled={submitting} + className="px-3 py-1.5 text-sm font-mono bg-amber-500/20 border border-amber-500/50 hover:bg-amber-500/30 disabled:opacity-50 text-amber-100 transition-colors" + > + {choice} + </button> + ))} + </div> + )} + + {/* Other option */} + {!showOther ? ( + <button + onClick={() => setShowOther(true)} + className="text-xs text-amber-400 hover:text-amber-300 transition-colors" + > + + Other (custom response) + </button> + ) : ( + <div className="flex gap-2"> + <input + type="text" + value={customInput} + onChange={(e) => setCustomInput(e.target.value)} + placeholder="Type custom response..." + disabled={submitting} + className="flex-1 px-2 py-1 bg-[#0a1525] border border-amber-500/30 text-amber-100 text-sm rounded focus:outline-none focus:border-amber-400" + onKeyDown={(e) => { + if (e.key === "Enter" && customInput.trim()) { + handleOtherSubmit(); + } + }} + /> + <button + onClick={handleOtherSubmit} + disabled={submitting || !customInput.trim()} + className="px-3 py-1 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400" + > + {submitting ? "..." : "Submit"} + </button> + </div> + )} + </div> + )} + </div> + ); +} + function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) { const [authCode, setAuthCode] = useState(""); const [submitting, setSubmitting] = useState(false); diff --git a/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx b/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx index aa1bb12..712c755 100644 --- a/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx +++ b/makima/frontend/src/contexts/SupervisorQuestionsContext.tsx @@ -4,10 +4,14 @@ import { useAuth } from "./AuthContext"; interface SupervisorQuestionsContextValue { pendingQuestions: PendingQuestion[]; + /** Questions that are pending but not dismissed from notifications */ + notificationQuestions: PendingQuestion[]; loading: boolean; error: string | null; refreshQuestions: () => Promise<void>; submitAnswer: (questionId: string, response: string) => Promise<boolean>; + /** Dismiss a question from the notification (but keep it pending in task output) */ + dismissNotification: (questionId: string) => void; } const SupervisorQuestionsContext = createContext<SupervisorQuestionsContextValue | null>(null); @@ -15,9 +19,17 @@ const SupervisorQuestionsContext = createContext<SupervisorQuestionsContextValue export function SupervisorQuestionsProvider({ children }: { children: ReactNode }) { const { isAuthenticated } = useAuth(); const [pendingQuestions, setPendingQuestions] = useState<PendingQuestion[]>([]); + const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set()); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + // Questions that should show in notifications (not dismissed) + const notificationQuestions = pendingQuestions.filter(q => !dismissedIds.has(q.questionId)); + + const dismissNotification = useCallback((questionId: string) => { + setDismissedIds(prev => new Set(prev).add(questionId)); + }, []); + const refreshQuestions = useCallback(async () => { if (!isAuthenticated) return; @@ -44,6 +56,12 @@ export function SupervisorQuestionsProvider({ children }: { children: ReactNode if (result.success) { // Remove the question from local state setPendingQuestions(prev => prev.filter(q => q.questionId !== questionId)); + // Also clean up dismissed state + setDismissedIds(prev => { + const next = new Set(prev); + next.delete(questionId); + return next; + }); } return result.success; } catch (err) { @@ -74,10 +92,12 @@ export function SupervisorQuestionsProvider({ children }: { children: ReactNode <SupervisorQuestionsContext.Provider value={{ pendingQuestions, + notificationQuestions, loading, error, refreshQuestions, submitAnswer, + dismissNotification, }} > {children} diff --git a/makima/frontend/src/routes/contracts.tsx b/makima/frontend/src/routes/contracts.tsx index f09ec5b..5e9bf60 100644 --- a/makima/frontend/src/routes/contracts.tsx +++ b/makima/frontend/src/routes/contracts.tsx @@ -6,7 +6,7 @@ import { ContractDetail } from "../components/contracts/ContractDetail"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useContracts } from "../hooks/useContracts"; import { useAuth } from "../contexts/AuthContext"; -import { createTask, getDaemonDirectories } from "../lib/api"; +import { createTask, getDaemonDirectories, getRepositorySuggestions } from "../lib/api"; import type { ContractWithRelations, ContractPhase, @@ -15,6 +15,7 @@ import type { CreateContractRequest, RepositorySourceType, DaemonDirectory, + RepositoryHistoryEntry, } from "../lib/api"; import { getValidPhases, getDefaultPhase } from "../lib/api"; @@ -81,6 +82,38 @@ function ContractsPageContent() { const [repoPath, setRepoPath] = useState(""); const [createError, setCreateError] = useState<string | null>(null); const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]); + const [repoSuggestions, setRepoSuggestions] = useState<RepositoryHistoryEntry[]>([]); + const [showRepoSuggestions, setShowRepoSuggestions] = useState(false); + + // Fetch repository suggestions when modal opens and repo type changes + useEffect(() => { + if (isCreating && (repoType === "remote" || repoType === "local")) { + getRepositorySuggestions(repoType, undefined, 10) + .then((res) => { + setRepoSuggestions(res.entries); + setShowRepoSuggestions(res.entries.length > 0); + }) + .catch(() => { + setRepoSuggestions([]); + setShowRepoSuggestions(false); + }); + } else { + setRepoSuggestions([]); + setShowRepoSuggestions(false); + } + }, [isCreating, repoType]); + + // Apply a repository suggestion + const applyRepoSuggestion = useCallback((suggestion: RepositoryHistoryEntry) => { + setRepoName(suggestion.name); + if (suggestion.repositoryUrl) { + setRepoUrl(suggestion.repositoryUrl); + } + if (suggestion.localPath) { + setRepoPath(suggestion.localPath); + } + setShowRepoSuggestions(false); + }, []); // Fetch daemon directories when "local" repo type is selected useEffect(() => { @@ -540,6 +573,35 @@ function ContractsPageContent() { </button> </div> + {/* Repository suggestions */} + {showRepoSuggestions && repoSuggestions.length > 0 && ( + <div className="mb-3"> + <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + Recent Repositories + </label> + <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-32 overflow-y-auto"> + {repoSuggestions.map((suggestion) => ( + <button + key={suggestion.id} + type="button" + onClick={() => applyRepoSuggestion(suggestion)} + className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" + > + <div className="flex items-center justify-between"> + <span className="text-[#9bc3ff] truncate">{suggestion.name}</span> + <span className="text-[10px] text-[#556677] ml-2"> + {suggestion.useCount}× + </span> + </div> + <div className="text-[10px] text-[#556677] truncate"> + {suggestion.repositoryUrl || suggestion.localPath} + </div> + </button> + ))} + </div> + </div> + )} + {/* Repository name */} <div className="mb-3"> <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx index ed5a6d0..050381a 100644 --- a/makima/frontend/src/routes/mesh.tsx +++ b/makima/frontend/src/routes/mesh.tsx @@ -11,6 +11,7 @@ import type { TaskWithSubtasks, MeshChatContext, ContractSummary, ContractWithRe import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput, listContracts, getContract, getDaemonDirectories, continueTask as continueTaskApi } from "../lib/api"; import { DirectoryInput } from "../components/mesh/DirectoryInput"; import { useAuth } from "../contexts/AuthContext"; +import { useSupervisorQuestions } from "../contexts/SupervisorQuestionsContext"; // View modes for the task detail page type ViewMode = "split" | "task" | "output"; @@ -80,6 +81,18 @@ export default function MeshPage() { const navigate = useNavigate(); const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks(); + const { pendingQuestions, submitAnswer } = useSupervisorQuestions(); + + // Memoize pending question IDs for efficient lookup + const pendingQuestionIds = useMemo( + () => new Set(pendingQuestions.map(q => q.questionId)), + [pendingQuestions] + ); + + // Handler for answering supervisor questions + const handleAnswerQuestion = useCallback(async (questionId: string, response: string) => { + await submitAnswer(questionId, response); + }, [submitAnswer]); // Redirect to login if not authenticated useEffect(() => { @@ -720,6 +733,8 @@ export default function MeshPage() { }} taskId={activeOutputTaskId} onUserInput={handleUserInput} + pendingQuestionIds={pendingQuestionIds} + onAnswerQuestion={handleAnswerQuestion} /> </div> )} |
