diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:48:41 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-03 23:48:41 +0000 |
| commit | 9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (patch) | |
| tree | 53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/frontend/src | |
| parent | dcbf8c834626870a43b633b099f409d69d4f9b87 (diff) | |
| download | soryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.tar.gz soryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.zip | |
Add 'Discuss Contract' feature to listen page (#57)
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/listen/ContractPickerModal.tsx | 15 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/ControlPanel.tsx | 3 | ||||
| -rw-r--r-- | makima/frontend/src/components/listen/DiscussContractModal.tsx | 287 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 54 | ||||
| -rw-r--r-- | makima/frontend/src/routes/listen.tsx | 38 |
5 files changed, 396 insertions, 1 deletions
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<HTMLDivElement>(null); @@ -90,6 +92,19 @@ export function ContractPickerModal({ </span> </button> + <button + onClick={() => { + onClose(); + onDiscussContract(); + }} + className="w-full text-left px-3 py-2 font-mono text-xs border bg-[#0d1b2d] border-[#3f6fb3] text-[#dbe7ff] hover:bg-[#153667] transition-colors" + > + <span className="uppercase tracking-wide">Discuss Contract</span> + <span className="block text-[10px] text-[#75aafc] mt-0.5"> + Chat with Makima to define a new contract + </span> + </button> + {contracts.map((contract) => ( <button key={contract.id} diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx index f482ec4..ab2bcee 100644 --- a/makima/frontend/src/components/listen/ControlPanel.tsx +++ b/makima/frontend/src/components/listen/ControlPanel.tsx @@ -22,6 +22,7 @@ interface ControlPanelProps { contracts: ContractOption[]; selectedContractId: string | null; onContractChange: (contractId: string | null) => void; + onDiscussContract: () => void; contractsLoading?: boolean; // Connection status for loading state connectionStatus?: ConnectionStatus; @@ -56,6 +57,7 @@ export function ControlPanel({ contracts, selectedContractId, onContractChange, + onDiscussContract, contractsLoading, connectionStatus, }: ControlPanelProps) { @@ -190,6 +192,7 @@ export function ControlPanel({ contracts={contracts} selectedContractId={selectedContractId} onSelect={onContractChange} + onDiscussContract={onDiscussContract} loading={contractsLoading} /> </div> diff --git a/makima/frontend/src/components/listen/DiscussContractModal.tsx b/makima/frontend/src/components/listen/DiscussContractModal.tsx new file mode 100644 index 0000000..984f505 --- /dev/null +++ b/makima/frontend/src/components/listen/DiscussContractModal.tsx @@ -0,0 +1,287 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useSpeakWebSocket } from "../../hooks/useSpeakWebSocket"; +import { + discussContract, + type CreatedContractInfo, + type ContractToolCallInfo, + type ChatMessage, +} from "../../lib/api"; + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + timestamp: Date; + toolCalls?: ContractToolCallInfo[]; +} + +interface DiscussContractModalProps { + isOpen: boolean; + onClose: () => void; + transcriptContext?: string; + onContractCreated: (contract: CreatedContractInfo) => void; +} + +interface ChatBubbleProps { + message: Message; + onSpeak: () => void; + isSpeaking: boolean; +} + +function ChatBubble({ message, onSpeak, isSpeaking }: ChatBubbleProps) { + const isUser = message.role === "user"; + + return ( + <div className={`flex ${isUser ? "justify-end" : "justify-start"}`}> + <div + className={`max-w-[80%] p-3 font-mono text-sm ${ + isUser + ? "bg-[#0f3c78] text-[#dbe7ff]" + : "bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff]" + }`} + > + <div className="whitespace-pre-wrap">{message.content}</div> + + {!isUser && ( + <div className="mt-2 flex items-center gap-2"> + <button + onClick={onSpeak} + className="text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff] uppercase" + > + {isSpeaking ? "[Stop]" : "[Speak]"} + </button> + </div> + )} + + {message.toolCalls && message.toolCalls.length > 0 && ( + <div className="mt-2 pt-2 border-t border-[rgba(117,170,252,0.25)]"> + {message.toolCalls.map((tc, i) => ( + <div key={i} className="text-[10px] text-[#75aafc]"> + {tc.result.success ? "+" : "-"} {tc.name}: {tc.result.message} + </div> + ))} + </div> + )} + </div> + </div> + ); +} + +export function DiscussContractModal({ + isOpen, + onClose, + transcriptContext, + onContractCreated, +}: DiscussContractModalProps) { + const [messages, setMessages] = useState<Message[]>([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState<string | null>(null); + const [createdContract, setCreatedContract] = useState<CreatedContractInfo | null>(null); + + const { speak, isSpeaking, cancel } = useSpeakWebSocket(); + const messagesEndRef = useRef<HTMLDivElement>(null); + const modalRef = useRef<HTMLDivElement>(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 ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"> + <div + ref={modalRef} + className="panel w-full max-w-2xl h-[600px] flex flex-col mx-4" + > + {/* Header */} + <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.25)]"> + <h2 className="font-mono text-sm text-[#dbe7ff] uppercase tracking-wide"> + Discuss Contract with Makima + </h2> + <button + onClick={onClose} + className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" + > + [X] + </button> + </div> + + {/* Messages */} + <div className="flex-1 overflow-y-auto p-4 space-y-4"> + {messages.map((message) => ( + <ChatBubble + key={message.id} + message={message} + onSpeak={() => handleSpeak(message.content)} + isSpeaking={isSpeaking} + /> + ))} + <div ref={messagesEndRef} /> + + {isLoading && ( + <div className="flex items-center gap-2 text-[#9bc3ff]"> + <span className="animate-pulse font-mono text-sm">Makima is thinking...</span> + </div> + )} + + {error && ( + <div className="font-mono text-xs text-red-400 px-2 py-2 border border-red-400/50 bg-red-400/10"> + {error} + </div> + )} + </div> + + {/* Contract Created Banner */} + {createdContract && ( + <div className="p-3 bg-green-400/10 border-t border-green-400/50"> + <div className="font-mono text-xs text-green-400"> + Contract "{createdContract.name}" created successfully! + </div> + </div> + )} + + {/* Input */} + <div className="p-4 border-t border-[rgba(117,170,252,0.25)]"> + <div className="flex gap-2"> + <input + type="text" + value={input} + onChange={(e) => 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" + /> + <button + onClick={handleSend} + disabled={isLoading || !input.trim() || !!createdContract} + className="px-4 py-2 bg-[#0f3c78] text-[#dbe7ff] font-mono text-xs uppercase hover:bg-[#153667] disabled:opacity-50 disabled:cursor-not-allowed transition-colors" + > + Send + </button> + </div> + </div> + </div> + </div> + ); +} 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 @@ -2137,6 +2137,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<DiscussContractResponse> { + 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() { </div> </div> )} + + {/* Discuss Contract Modal */} + <DiscussContractModal + isOpen={isDiscussModalOpen} + onClose={() => setIsDiscussModalOpen(false)} + transcriptContext={transcriptContext} + onContractCreated={handleContractCreated} + /> </div> ); } |
