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 +++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 makima/frontend/src/components/listen/DiscussContractModal.tsx (limited to 'makima/frontend/src/components') 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" + /> + +
+
+
+
+ ); +} -- cgit v1.2.3