summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/SupervisorQuestionNotification.tsx106
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx151
-rw-r--r--makima/frontend/src/contexts/SupervisorQuestionsContext.tsx20
-rw-r--r--makima/frontend/src/routes/contracts.tsx64
-rw-r--r--makima/frontend/src/routes/mesh.tsx15
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>
)}