From 9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 23:48:41 +0000 Subject: Add 'Discuss Contract' feature to listen page (#57) --- .../src/components/listen/ContractPickerModal.tsx | 15 ++ .../src/components/listen/ControlPanel.tsx | 3 + .../src/components/listen/DiscussContractModal.tsx | 287 +++++++++++++++++++++ makima/frontend/src/lib/api.ts | 54 ++++ makima/frontend/src/routes/listen.tsx | 38 ++- 5 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 makima/frontend/src/components/listen/DiscussContractModal.tsx (limited to 'makima/frontend') diff --git a/makima/frontend/src/components/listen/ContractPickerModal.tsx b/makima/frontend/src/components/listen/ContractPickerModal.tsx index 961ccba..f3c72d0 100644 --- a/makima/frontend/src/components/listen/ContractPickerModal.tsx +++ b/makima/frontend/src/components/listen/ContractPickerModal.tsx @@ -7,6 +7,7 @@ interface ContractPickerModalProps { contracts: ContractOption[]; selectedContractId: string | null; onSelect: (contractId: string | null) => void; + onDiscussContract: () => void; loading?: boolean; } @@ -16,6 +17,7 @@ export function ContractPickerModal({ contracts, selectedContractId, onSelect, + onDiscussContract, loading, }: ContractPickerModalProps) { const modalRef = useRef(null); @@ -90,6 +92,19 @@ export function ContractPickerModal({ + + {contracts.map((contract) => ( + + )} + + {message.toolCalls && message.toolCalls.length > 0 && ( +
+ {message.toolCalls.map((tc, i) => ( +
+ {tc.result.success ? "+" : "-"} {tc.name}: {tc.result.message} +
+ ))} +
+ )} + + + ); +} + +export function DiscussContractModal({ + isOpen, + onClose, + transcriptContext, + onContractCreated, +}: DiscussContractModalProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [createdContract, setCreatedContract] = useState(null); + + const { speak, isSpeaking, cancel } = useSpeakWebSocket(); + const messagesEndRef = useRef(null); + const modalRef = useRef(null); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Initial greeting when modal opens + useEffect(() => { + if (isOpen && messages.length === 0) { + const greeting = transcriptContext + ? "I've reviewed your session transcript. What would you like to build based on this discussion?" + : "Hello! I'm Makima. Tell me about what you'd like to build, and I'll help you create a contract for it."; + + setMessages([{ + id: crypto.randomUUID(), + role: "assistant", + content: greeting, + timestamp: new Date(), + }]); + } + }, [isOpen, transcriptContext, messages.length]); + + // Handle escape key and click outside + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + onClose(); + } + } + + function handleClickOutside(e: MouseEvent) { + if (modalRef.current && !modalRef.current.contains(e.target as Node)) { + onClose(); + } + } + + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onClose]); + + // Reset state when modal closes + useEffect(() => { + if (!isOpen) { + setMessages([]); + setInput(""); + setIsLoading(false); + setError(null); + setCreatedContract(null); + } + }, [isOpen]); + + const handleSend = useCallback(async () => { + if (!input.trim() || isLoading) return; + + const userMessage: Message = { + id: crypto.randomUUID(), + role: "user", + content: input, + timestamp: new Date(), + }; + + setMessages(prev => [...prev, userMessage]); + setInput(""); + setIsLoading(true); + setError(null); + + try { + // Build history from existing messages (excluding the greeting) + const history: ChatMessage[] = messages.map(m => ({ + role: m.role, + content: m.content, + })); + + const response = await discussContract( + input, + undefined, // model + history, + transcriptContext + ); + + const assistantMessage: Message = { + id: crypto.randomUUID(), + role: "assistant", + content: response.response, + timestamp: new Date(), + toolCalls: response.toolCalls, + }; + + setMessages(prev => [...prev, assistantMessage]); + + if (response.createdContract) { + setCreatedContract(response.createdContract); + onContractCreated(response.createdContract); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to get response"); + } finally { + setIsLoading(false); + } + }, [input, isLoading, messages, transcriptContext, onContractCreated]); + + const handleSpeak = useCallback((text: string) => { + if (isSpeaking) { + cancel(); + } else { + speak(text); + } + }, [isSpeaking, cancel, speak]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, [handleSend]); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ Discuss Contract with Makima +

+ +
+ + {/* Messages */} +
+ {messages.map((message) => ( + handleSpeak(message.content)} + isSpeaking={isSpeaking} + /> + ))} +
+ + {isLoading && ( +
+ Makima is thinking... +
+ )} + + {error && ( +
+ {error} +
+ )} +
+ + {/* Contract Created Banner */} + {createdContract && ( +
+
+ Contract "{createdContract.name}" created successfully! +
+
+ )} + + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Describe your project..." + disabled={isLoading || !!createdContract} + className="flex-1 px-3 py-2 bg-[#0d1b2d] border border-[#0f3c78] text-[#dbe7ff] font-mono text-sm focus:border-[#3f6fb3] outline-none disabled:opacity-50" + /> + +
+
+
+
+ ); +} diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 56491fd..bdaedf9 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2136,6 +2136,60 @@ export async function clearContractChatHistory( } } +// ============================================================================= +// Contract Discussion Types and API +// ============================================================================= + +export interface DiscussContractRequest { + message: string; + model?: LlmModel; + history?: ChatMessage[]; + transcriptContext?: string; +} + +export interface CreatedContractInfo { + id: string; + name: string; + description: string | null; + contractType: string; + initialPhase: string; +} + +export interface DiscussContractResponse { + response: string; + toolCalls: ContractToolCallInfo[]; + createdContract?: CreatedContractInfo; + pendingQuestions?: UserQuestion[]; +} + +/** + * Discuss a potential contract with Makima. + * This is an ephemeral conversation that can result in contract creation. + */ +export async function discussContract( + message: string, + model?: LlmModel, + history?: ChatMessage[], + transcriptContext?: string +): Promise { + const body: DiscussContractRequest = { message }; + if (model) body.model = model; + if (history && history.length > 0) body.history = history; + if (transcriptContext) body.transcriptContext = transcriptContext; + + const res = await authFetch(`${API_BASE}/api/v1/contracts/discuss`, { + method: "POST", + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Discussion failed: ${errorText || res.statusText}`); + } + + return res.json(); +} + // ============================================================================= // Template Types and API // ============================================================================= diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx index 8af538e..a53cbd9 100644 --- a/makima/frontend/src/routes/listen.tsx +++ b/makima/frontend/src/routes/listen.tsx @@ -4,9 +4,10 @@ import { SpeakerPanel } from "../components/listen/SpeakerPanel"; import { TranscriptPanel } from "../components/listen/TranscriptPanel"; import { ControlPanel, type ContractOption } from "../components/listen/ControlPanel"; import { TranscriptAnalysisPanel } from "../components/listen/TranscriptAnalysisPanel"; +import { DiscussContractModal } from "../components/listen/DiscussContractModal"; import { useMicrophone } from "../hooks/useMicrophone"; import { useWebSocket } from "../hooks/useWebSocket"; -import { listContracts } from "../lib/api"; +import { listContracts, type CreatedContractInfo } from "../lib/api"; import { useAuth } from "../contexts/AuthContext"; export default function ListenPage() { @@ -27,6 +28,9 @@ export default function ListenPage() { contractId: string; } | null>(null); + // Discuss contract modal state + const [isDiscussModalOpen, setIsDiscussModalOpen] = useState(false); + // Fetch contracts on mount useEffect(() => { if (!isAuthenticated) { @@ -175,6 +179,29 @@ export default function ListenPage() { setSavedTranscript(null); }, []); + // Get current transcript context for discussion + const transcriptContext = useMemo(() => { + if (ws.transcripts.length === 0) return undefined; + return ws.transcripts + .map(t => `[${t.speaker}]: ${t.text}`) + .join("\n"); + }, [ws.transcripts]); + + const handleOpenDiscussModal = useCallback(() => { + setIsDiscussModalOpen(true); + }, []); + + const handleContractCreated = useCallback((contract: CreatedContractInfo) => { + // Add to contracts list and select it + setContracts(prev => [ + { id: contract.id, name: contract.name }, + ...prev, + ]); + setSelectedContractId(contract.id); + // Close the modal after a short delay to show success + setTimeout(() => setIsDiscussModalOpen(false), 2000); + }, []); + const error = ws.error || mic.error; return ( @@ -206,6 +233,7 @@ export default function ListenPage() { contracts={contracts} selectedContractId={selectedContractId} onContractChange={setSelectedContractId} + onDiscussContract={handleOpenDiscussModal} contractsLoading={contractsLoading} connectionStatus={ws.status} /> @@ -236,6 +264,14 @@ export default function ListenPage() {
)} + + {/* Discuss Contract Modal */} + setIsDiscussModalOpen(false)} + transcriptContext={transcriptContext} + onContractCreated={handleContractCreated} + /> ); } -- cgit v1.2.3