import { useRef, useEffect, useState, useCallback } from "react";
import { SimpleMarkdown } from "../SimpleMarkdown";
import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
import { sendTaskMessage } from "../../lib/api";
import {
PhaseConfirmationInline,
type PhaseConfirmationData,
} from "../contracts/PhaseConfirmationModal";
import type { ContractPhase } 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;
/** Set of pending question IDs (for supervisor questions) */
pendingQuestionIds?: Set<string>;
/** Callback to answer a supervisor question */
onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}
export function TaskOutput({
entries,
isStreaming,
viewingSubtaskName,
onClearSubtaskView,
onClear,
taskId,
onUserInput,
pendingQuestionIds,
onAnswerQuestion,
}: 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}
pendingQuestionIds={pendingQuestionIds}
onAnswerQuestion={onAnswerQuestion}
/>
))}
{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>
);
}
interface OutputEntryRendererProps {
entry: TaskOutputEvent;
pendingQuestionIds?: Set<string>;
onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}
function OutputEntryRenderer({ entry, pendingQuestionIds, onAnswerQuestion }: OutputEntryRendererProps) {
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>
);
case "auth_required":
return <AuthRequiredEntry entry={entry} />;
case "supervisor_question":
return (
<SupervisorQuestionEntry
entry={entry}
pendingQuestionIds={pendingQuestionIds}
onAnswerQuestion={onAnswerQuestion}
/>
);
case "phase_confirmation":
return (
<PhaseConfirmationEntry
entry={entry}
pendingQuestionIds={pendingQuestionIds}
onAnswerQuestion={onAnswerQuestion}
/>
);
default:
return null;
}
}
function SupervisorQuestionEntry({
entry,
pendingQuestionIds,
onAnswerQuestion,
}: {
entry: TaskOutputEvent;
pendingQuestionIds?: Set<string>;
onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}) {
const questionId = entry.toolInput?.question_id as string;
const choices = (entry.toolInput?.choices as string[]) || [];
const context = entry.toolInput?.context as string | null;
const [customInput, setCustomInput] = useState("");
const [showOther, setShowOther] = useState(false);
const [submitting, setSubmitting] = useState(false);
const isPending = pendingQuestionIds?.has(questionId) ?? false;
const handleChoiceSelect = async (choice: string) => {
if (!onAnswerQuestion || submitting) return;
setSubmitting(true);
try {
await onAnswerQuestion(questionId, choice);
} finally {
setSubmitting(false);
}
};
const handleOtherSubmit = async () => {
if (!onAnswerQuestion || !customInput.trim() || submitting) return;
setSubmitting(true);
try {
await onAnswerQuestion(questionId, customInput.trim());
setCustomInput("");
} finally {
setSubmitting(false);
}
};
return (
<div className="bg-amber-900/20 border border-amber-500/50 rounded p-3 my-2">
<div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
<span>?</span>
<span>Question</span>
{!isPending && (
<span className="text-green-400 text-xs font-normal">(Answered)</span>
)}
</div>
{context && (
<p className="text-amber-200/60 text-xs mb-2 uppercase">{context}</p>
)}
<p className="text-amber-100 mb-3">{entry.content}</p>
{isPending && (
<div className="space-y-2">
{choices.length > 0 && (
<div className="flex flex-wrap gap-2">
{choices.map((choice, idx) => (
<button
key={idx}
onClick={() => handleChoiceSelect(choice)}
disabled={submitting}
className="px-3 py-1.5 text-sm font-mono bg-amber-500/20 border border-amber-500/50 hover:bg-amber-500/30 disabled:opacity-50 text-amber-100 transition-colors"
>
{choice}
</button>
))}
</div>
)}
{/* Other option */}
{!showOther ? (
<button
onClick={() => setShowOther(true)}
className="text-xs text-amber-400 hover:text-amber-300 transition-colors"
>
+ Other (custom response)
</button>
) : (
<div className="flex gap-2">
<input
type="text"
value={customInput}
onChange={(e) => setCustomInput(e.target.value)}
placeholder="Type custom response..."
disabled={submitting}
className="flex-1 px-2 py-1 bg-[#0a1525] border border-amber-500/30 text-amber-100 text-sm rounded focus:outline-none focus:border-amber-400"
onKeyDown={(e) => {
if (e.key === "Enter" && customInput.trim()) {
handleOtherSubmit();
}
}}
/>
<button
onClick={handleOtherSubmit}
disabled={submitting || !customInput.trim()}
className="px-3 py-1 bg-amber-500 text-black text-sm font-medium rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors hover:bg-amber-400"
>
{submitting ? "..." : "Submit"}
</button>
</div>
)}
</div>
)}
</div>
);
}
function AuthRequiredEntry({ entry }: { entry: TaskOutputEvent }) {
const [authCode, setAuthCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const loginUrl = entry.toolInput?.loginUrl as string | undefined;
const hostname = entry.toolInput?.hostname as string | undefined;
// Get taskId from entry or fallback to toolInput (for robustness)
const taskId = entry.taskId || (entry.toolInput?.taskId as string | undefined);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!authCode.trim() || !taskId) return;
setSubmitting(true);
setError(null);
try {
// Send the auth code to the task via the message endpoint
await sendTaskMessage(taskId, `AUTH_CODE:${authCode.trim()}`);
setSubmitted(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit code");
} finally {
setSubmitting(false);
}
};
if (submitted) {
return (
<div className="bg-green-900/30 border border-green-500/50 rounded p-3 my-2">
<div className="flex items-center gap-2 text-green-400 font-semibold">
<span>✓</span>
<span>Authentication code submitted</span>
</div>
<p className="text-green-200/80 text-sm mt-1">
Waiting for authentication to complete...
</p>
</div>
);
}
return (
<div className="bg-amber-900/30 border border-amber-500/50 rounded p-3 my-2">
<div className="flex items-center gap-2 text-amber-400 font-semibold mb-2">
<span>🔐</span>
<span>Authentication Required{hostname ? ` (${hostname})` : ""}</span>
</div>
<p className="text-amber-200/80 text-sm mb-3">
The daemon's OAuth token has expired. Click the button to login, then paste the code below:
</p>
<div className="flex flex-col gap-3">
{loginUrl ? (
<a
href={loginUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block bg-amber-500 hover:bg-amber-400 text-black font-medium px-4 py-2 rounded transition-colors text-center"
>
1. Login to Claude
</a>
) : (
<p className="text-red-400 text-sm">Login URL not available</p>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
placeholder="2. Paste authentication code here"
className="flex-1 bg-[#0a1525] border border-amber-500/30 rounded px-3 py-2 text-amber-100 placeholder-amber-500/50 focus:outline-none focus:border-amber-400"
disabled={submitting}
/>
<button
type="submit"
disabled={submitting || !authCode.trim()}
className="bg-amber-500 hover:bg-amber-400 disabled:bg-amber-700 disabled:cursor-not-allowed text-black font-medium px-4 py-2 rounded transition-colors"
>
{submitting ? "..." : "Submit"}
</button>
</form>
{error && (
<p className="text-red-400 text-sm">{error}</p>
)}
</div>
</div>
);
}
/** Entry for phase transition confirmations */
function PhaseConfirmationEntry({
entry,
pendingQuestionIds,
onAnswerQuestion,
}: {
entry: TaskOutputEvent;
pendingQuestionIds?: Set<string>;
onAnswerQuestion?: (questionId: string, response: string) => Promise<void>;
}) {
const questionId = entry.toolInput?.question_id as string;
const currentPhase = entry.toolInput?.current_phase as ContractPhase;
const nextPhase = entry.toolInput?.next_phase as ContractPhase;
const contractId = entry.toolInput?.contract_id as string;
const contractName = entry.toolInput?.contract_name as string | undefined;
const summary = entry.toolInput?.summary as string | undefined;
const deliverables = entry.toolInput?.deliverables as
| Array<{ name: string; completed: boolean }>
| undefined;
const isPending = pendingQuestionIds?.has(questionId) ?? false;
const data: PhaseConfirmationData = {
questionId,
contractId,
contractName,
currentPhase,
nextPhase,
summary,
deliverables,
};
const handleApprove = async (qId: string) => {
if (!onAnswerQuestion) return;
await onAnswerQuestion(qId, "APPROVE");
};
const handleRequestChanges = async (qId: string, feedback: string) => {
if (!onAnswerQuestion) return;
await onAnswerQuestion(qId, `CHANGES_REQUESTED: ${feedback}`);
};
return (
<PhaseConfirmationInline
data={data}
isPending={isPending}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
/>
);
}