Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 205 additions & 22 deletions taskboard-electron/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ import {
mergeChannelsStatus,
} from "./channelsSettings.ts";
import { createRequestGenerationGuard, parseApiResponse } from "./apiReliability.ts";
import {
attemptTargetedTaskRefresh,
getTaskResponseUiState,
markTaskResponseSubmitted,
mergeTargetedTaskDetail,
prepareTaskResponse,
reconcileTasksWithSubmittedAnswers,
selectTickAfterRefresh,
startHeartbeatTickPolling,
taskNeedsResponse,
type TaskResponseRefreshResult,
} from "./operatorUi.ts";
import { buildExecutionSteps } from "./traceSteps.ts";

const API = "http://127.0.0.1:9712/api";
Expand Down Expand Up @@ -830,6 +842,13 @@ async function fetchTasks() {
return res.json();
}

async function fetchTask(id) {
const res = await fetch(`${API}/tasks/${id}`);
const payload = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
return payload;
}

async function fetchHeartbeats() {
const res = await fetch(`${API}/heartbeats`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Expand Down Expand Up @@ -2212,9 +2231,12 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
const [tickOutput, setTickOutput] = useState("");
const [tickRunning, setTickRunning] = useState(false);
const outputRef = useRef<any>(null);
const previousHeartbeatIdRef = useRef(heartbeat.id);

useEffect(() => {
setSelectedTickId(ticks[0]?.id || null);
const heartbeatChanged = previousHeartbeatIdRef.current !== heartbeat.id;
previousHeartbeatIdRef.current = heartbeat.id;
setSelectedTickId((current) => selectTickAfterRefresh(current, ticks, heartbeatChanged));
}, [heartbeat.id, ticks]);

useEffect(() => {
Expand All @@ -2224,12 +2246,16 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
return;
}
let cancelled = false;
let timeout;
const load = async () => {
try {
const data = await fetchHeartbeatTickOutput(heartbeat.id, selectedTickId);
if (cancelled) return;
setTickOutput(data.output || "");
setTickRunning(!!data.is_running);
if (data.is_running) {
timeout = setTimeout(load, 1000);
}
} catch {
if (!cancelled) {
setTickOutput("");
Expand All @@ -2238,10 +2264,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
}
};
load();
const interval = setInterval(load, 1000);
return () => {
cancelled = true;
clearInterval(interval);
if (timeout) clearTimeout(timeout);
};
}, [heartbeat.id, selectedTickId]);

Expand Down Expand Up @@ -3015,14 +3040,18 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) {
);
}

function DetailPanel({ task, onClose, onResume }: any) {
function DetailPanel({ task, onClose, onRespond, onResume }: any) {
// `task` is always truthy here — the only caller renders this inside
// `{detail && <DetailPanel task={... || detail} />}`. Hooks must stay
// unconditional, so do not early-return before them.
const [liveOutput, setLiveOutput] = useState("");
const [resumeText, setResumeText] = useState("");
const [resumeError, setResumeError] = useState("");
const [resumeSent, setResumeSent] = useState(false);
const [answerText, setAnswerText] = useState("");
const [responseLoading, setResponseLoading] = useState(false);
const [responseError, setResponseError] = useState("");
const [responseResult, setResponseResult] = useState<TaskResponseRefreshResult | null>(null);
const [messages, setMessages] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]);
const [showMessages, setShowMessages] = useState(false);
Expand Down Expand Up @@ -3100,6 +3129,34 @@ function DetailPanel({ task, onClose, onResume }: any) {
}
}, [messages]);

useEffect(() => {
setAnswerText("");
setResponseError("");
setResponseLoading(false);
setResponseResult(null);
}, [task.id, task.question, task.answer]);

const handleResponse = async () => {
const prepared = prepareTaskResponse(answerText);
if (prepared.error) {
setResponseError(prepared.error);
return;
}

setResponseError("");
setResponseLoading(true);
try {
const result = await onRespond(task.id, prepared.answer);
setAnswerText("");
setResponseResult({ refreshed: result?.refreshed !== false });
} catch (error) {
setResponseError(error instanceof Error ? error.message : "Failed to submit response.");
} finally {
setResponseLoading(false);
}
};
const responseUiState = getTaskResponseUiState(task.question, task.answer, responseResult);

const handleResume = async () => {
if (!resumeText.trim()) return;
setResumeError("");
Expand Down Expand Up @@ -3227,6 +3284,89 @@ function DetailPanel({ task, onClose, onResume }: any) {
)}
</Section>

{responseUiState !== "hidden" && (
<Section title="Agent Question">
<div
style={{
background: theme.orangeBg,
border: `1px solid ${theme.orange}`,
borderRadius: 8,
padding: 14,
fontSize: 12,
color: theme.text,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
lineHeight: 1.6,
marginBottom: 12,
}}
>
{task.question}
</div>
{responseUiState === "form" ? (
<>
<textarea
value={answerText}
onChange={(event) => {
setAnswerText(event.target.value);
if (responseError) setResponseError("");
}}
placeholder="Enter your answer..."
disabled={responseLoading}
rows={4}
style={{
width: "100%",
boxSizing: "border-box",
background: theme.field,
color: theme.text,
border: `1px solid ${responseError ? theme.red : theme.border}`,
borderRadius: 8,
padding: 12,
fontSize: 12,
fontFamily: "'JetBrains Mono', monospace",
lineHeight: 1.6,
resize: "vertical",
outline: "none",
}}
/>
{responseError && (
<div style={{ color: theme.red, fontSize: 11, marginTop: 8 }}>{responseError}</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 10 }}>
<button
onClick={handleResponse}
disabled={responseLoading}
style={{
...primaryButton(),
cursor: responseLoading ? "wait" : "pointer",
opacity: responseLoading ? 0.65 : 1,
}}
>
{responseLoading ? "Submitting..." : "Submit Answer"}
</button>
</div>
</>
) : (
<div
style={{
background: responseUiState === "submitted-stale" ? theme.orangeBg : theme.greenBg,
border: `1px solid ${
responseUiState === "submitted-stale" ? theme.orange : theme.green
}`,
borderRadius: 8,
padding: 12,
color: theme.text,
fontSize: 12,
lineHeight: 1.6,
}}
>
{responseUiState === "submitted-stale"
? "Answer submitted, but the task refresh failed. This view may be stale; automatic refresh will retry."
: "Answer submitted. Refreshing task status..."}
</div>
)}
</Section>
)}

<Section title="Configuration">
<InfoRow label="Working Dir" value={task.working_dir} />
<InfoRow label="Agent" value={task.agent || DEFAULT_AGENT} />
Expand Down Expand Up @@ -5822,6 +5962,8 @@ export default function App() {
const [forkingTask, setForkingTask] = useState<any>(null);
const [editingHeartbeat, setEditingHeartbeat] = useState<any>(null);
const pollGuardRef = useRef(createRequestGenerationGuard());
const heartbeatDetailId = heartbeatDetail?.id;
const submittedTaskAnswersRef = useRef<Record<string, string>>({});

// ─── Color mode ───
const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system");
Expand Down Expand Up @@ -5879,16 +6021,25 @@ export default function App() {
fetchSkills(),
]);
if (!pollGuardRef.current.isCurrent(generation)) return;
setTasks(taskData);
const reconciled = reconcileTasksWithSubmittedAnswers(
taskData,
submittedTaskAnswersRef.current,
);
submittedTaskAnswersRef.current = Object.fromEntries(
reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]),
);
setTasks(reconciled.tasks);
setHeartbeats(heartbeatData);
setSkillData(skillRes);
setSkills(skillsRes.skills || []);
setConnected(true);
setApiError(null);
return true;
} catch (err) {
if (!pollGuardRef.current.isCurrent(generation)) return;
setConnected(false);
setApiError(`Failed to fetch tasks: ${err.message}`);
return false;
}
}, []);

Expand All @@ -5903,6 +6054,22 @@ export default function App() {
};
}, [poll, backendReady]);

useEffect(() => {
if (!heartbeatDetailId) return;

return startHeartbeatTickPolling({
heartbeatId: heartbeatDetailId,
load: fetchHeartbeatTicks,
onTicks: setHeartbeatTicks,
onError: (error) =>
setApiError(
`Failed to fetch heartbeat ticks: ${
error instanceof Error ? error.message : String(error)
}`,
),
});
}, [heartbeatDetailId]);

useEffect(() => {
if (!backendReady) return;
fetchSettings().then((s) => {
Expand Down Expand Up @@ -5955,14 +6122,6 @@ export default function App() {
return;
}
poll();
if (heartbeatDetail?.id === id && action !== "delete") {
const [updatedHeartbeat, ticks] = await Promise.all([
fetch(`${API}/heartbeats/${id}`).then((r) => r.json()),
fetchHeartbeatTicks(id),
]);
setHeartbeatDetail(updatedHeartbeat);
setHeartbeatTicks(ticks);
}
} catch (e) {
setApiError(`Heartbeat ${action} failed: ${e.message}`);
}
Expand Down Expand Up @@ -6057,10 +6216,40 @@ export default function App() {
const handleRespond = async (id, answer) => {
try {
await respondToTask(id, answer);
poll();
} catch (e) {
setApiError(`Respond failed: ${e.message}`);
throw e;
}

submittedTaskAnswersRef.current[String(id)] = answer;
setTasks((current) => current.map((task) => markTaskResponseSubmitted(task, id, answer)));
setDetail((current) =>
current?.id === id ? markTaskResponseSubmitted(current, id, answer) : current,
);

const refreshed = await attemptTargetedTaskRefresh({
load: () => fetchTask(id),
onTask: (refreshedTask) => {
const responseStillPending = taskNeedsResponse(
refreshedTask.question,
refreshedTask.answer,
);
if (responseStillPending) {
submittedTaskAnswersRef.current[String(id)] = answer;
} else {
delete submittedTaskAnswersRef.current[String(id)];
}
const safeTask = responseStillPending
? markTaskResponseSubmitted(refreshedTask, id, answer)
: refreshedTask;
setTasks((current) => current.map((task) => (task.id === id ? safeTask : task)));
setDetail((current) => mergeTargetedTaskDetail(current, id, safeTask));
},
onError: () => {
setApiError("Answer submitted, but task details have not refreshed yet.");
},
});
return { refreshed };
};

const handleResume = () => {
Expand All @@ -6087,15 +6276,9 @@ export default function App() {
}
};

const openHeartbeatDetail = async (heartbeat) => {
const openHeartbeatDetail = (heartbeat) => {
if (heartbeatDetail?.id !== heartbeat.id) setHeartbeatTicks([]);
setHeartbeatDetail(heartbeat);
try {
const ticks = await fetchHeartbeatTicks(heartbeat.id);
setHeartbeatTicks(ticks);
} catch (e) {
setApiError(`Failed to fetch heartbeat ticks: ${e.message}`);
setHeartbeatTicks([]);
}
};

const filter =
Expand Down
Loading