diff options
Diffstat (limited to 'makima/frontend/src/components/mesh/TaskOutput.tsx')
| -rw-r--r-- | makima/frontend/src/components/mesh/TaskOutput.tsx | 281 |
1 files changed, 281 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx new file mode 100644 index 0000000..10de225 --- /dev/null +++ b/makima/frontend/src/components/mesh/TaskOutput.tsx @@ -0,0 +1,281 @@ +import { useRef, useEffect, useState, useCallback } from "react"; +import { SimpleMarkdown } from "../SimpleMarkdown"; +import type { TaskOutputEvent } from "../../hooks/useTaskSubscription"; +import { sendTaskMessage } from "../../lib/api"; + +interface TaskOutputProps { + /** Array of parsed output events from the backend */ + entries: TaskOutputEvent[]; + isStreaming: boolean; + /** Name of subtask whose output is being viewed (null = parent task) */ + viewingSubtaskName?: string | null; + /** Callback to return to parent task output */ + onClearSubtaskView?: () => void; + onClear?: () => void; + /** Task ID for sending input (if provided, shows input bar when streaming) */ + taskId?: string | null; + /** Callback when user sends input (to show it immediately in output) */ + onUserInput?: (message: string) => void; +} + +export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) { + const containerRef = useRef<HTMLDivElement>(null); + const [autoScroll, setAutoScroll] = useState(true); + const [inputValue, setInputValue] = useState(""); + const [sendingInput, setSendingInput] = useState(false); + const [inputError, setInputError] = useState<string | null>(null); + const inputRef = useRef<HTMLInputElement>(null); + + // Handle scroll to check if user has scrolled up + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = containerRef.current; + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; + setAutoScroll(isAtBottom); + }, []); + + // Auto-scroll when entries change + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [entries, autoScroll]); + + // Handle sending input to the task + const handleSendInput = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!taskId || !inputValue.trim() || sendingInput) return; + + const message = inputValue.trim(); + setSendingInput(true); + setInputError(null); + + // Show user input immediately in the output window + onUserInput?.(message); + + try { + await sendTaskMessage(taskId, message); + setInputValue(""); + inputRef.current?.focus(); + } catch (err) { + setInputError(err instanceof Error ? err.message : "Failed to send input"); + } finally { + setSendingInput(false); + } + }, [taskId, inputValue, sendingInput, onUserInput]); + + // Show input bar when task is running and has a valid taskId + const showInputBar = isStreaming && taskId; + + return ( + <div className="flex flex-col h-full"> + {/* Header */} + <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0"> + <div className="flex items-center gap-2"> + {viewingSubtaskName ? ( + <> + <button + onClick={onClearSubtaskView} + className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + < + </button> + <span className="font-mono text-xs text-green-400 tracking-wide uppercase"> + Subtask: {viewingSubtaskName} + </span> + </> + ) : ( + <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Output + </span> + )} + {isStreaming && ( + <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase"> + <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" /> + Live + </span> + )} + </div> + <div className="flex items-center gap-2"> + {!autoScroll && ( + <button + onClick={() => { + setAutoScroll(true); + if (containerRef.current) { + containerRef.current.scrollTop = + containerRef.current.scrollHeight; + } + }} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Resume Scroll + </button> + )} + {onClear && entries.length > 0 && ( + <button + onClick={onClear} + className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase" + > + Clear + </button> + )} + </div> + </div> + + {/* Output area */} + <div + ref={containerRef} + onScroll={handleScroll} + className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0" + > + {entries.length === 0 ? ( + <div className="text-[#555] italic"> + {isStreaming ? "Waiting for output..." : "No output yet"} + </div> + ) : ( + <div className="space-y-3"> + {entries.map((entry, idx) => ( + <OutputEntryRenderer key={idx} entry={entry} /> + ))} + {isStreaming && ( + <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" /> + )} + </div> + )} + </div> + + {/* Input bar for sending messages to running tasks */} + {showInputBar && ( + <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]"> + {inputError && ( + <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono"> + {inputError} + </div> + )} + <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2"> + <span className="text-green-400 font-mono text-sm">></span> + <input + ref={inputRef} + type="text" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + placeholder={sendingInput ? "Sending..." : "Send input to Claude..."} + disabled={sendingInput} + className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]" + /> + <button + type="submit" + disabled={sendingInput || !inputValue.trim()} + className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase" + > + {sendingInput ? "..." : "Send"} + </button> + </form> + </div> + )} + </div> + ); +} + +function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) { + const [expanded, setExpanded] = useState(false); + + switch (entry.messageType) { + case "user_input": + return ( + <div className="pl-2 border-l-2 border-cyan-400/50"> + <div className="flex items-center gap-2"> + <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span> + </div> + <div className="text-cyan-300 mt-1">{entry.content}</div> + </div> + ); + + case "system": + return ( + <div className="text-[#555] text-[10px] uppercase tracking-wide"> + {entry.content} + </div> + ); + + case "assistant": + return ( + <div className="pl-2 border-l-2 border-[#3f6fb3]"> + <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" /> + </div> + ); + + case "tool_use": + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="text-yellow-500">*</span> + <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span> + {entry.toolInput && Object.keys(entry.toolInput).length > 0 && ( + <button + onClick={() => setExpanded(!expanded)} + className="text-[#555] hover:text-[#9bc3ff] text-[10px]" + > + {expanded ? "[-]" : "[+]"} + </button> + )} + </div> + {expanded && entry.toolInput && ( + <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto"> + {JSON.stringify(entry.toolInput, null, 2)} + </pre> + )} + </div> + ); + + case "tool_result": + if (!entry.content) return null; + return ( + <div className="ml-4 text-[10px]"> + <span className={entry.isError ? "text-red-400" : "text-green-500"}> + {entry.isError ? "x" : "+"} + </span>{" "} + <span className="text-[#555]"> + {entry.content.split("\n")[0]} + {entry.content.includes("\n") && "..."} + </span> + </div> + ); + + case "result": + return ( + <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2"> + <div className="text-green-500 font-semibold mb-1">Result:</div> + <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" /> + {(entry.costUsd !== undefined || entry.durationMs !== undefined) && ( + <div className="text-[10px] text-[#555] mt-2"> + {entry.durationMs !== undefined && ( + <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span> + )} + {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "} + {entry.costUsd !== undefined && ( + <span>Cost: ${entry.costUsd.toFixed(4)}</span> + )} + </div> + )} + </div> + ); + + case "error": + return ( + <div className="text-red-400 pl-2 border-l-2 border-red-400/50"> + {entry.content} + </div> + ); + + case "raw": + return ( + <div className="text-[#555] text-[10px]"> + {entry.content} + </div> + ); + + default: + return null; + } +} |
