summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/TaskOutput.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/frontend/src/components/mesh/TaskOutput.tsx
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
Diffstat (limited to 'makima/frontend/src/components/mesh/TaskOutput.tsx')
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx281
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"
+ >
+ &lt;
+ </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">&gt;</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;
+ }
+}