summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/listen/ContractPickerModal.tsx15
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx3
-rw-r--r--makima/frontend/src/components/listen/DiscussContractModal.tsx287
-rw-r--r--makima/frontend/src/lib/api.ts54
-rw-r--r--makima/frontend/src/routes/listen.tsx38
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>
);
}