Skip to content

Commit 82b97d9

Browse files
authored
Complete task response and heartbeat detail workflows
1 parent 1cb2768 commit 82b97d9

3 files changed

Lines changed: 511 additions & 22 deletions

File tree

taskboard-electron/src/renderer/App.tsx

Lines changed: 205 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ import {
3636
mergeChannelsStatus,
3737
} from "./channelsSettings.ts";
3838
import { createRequestGenerationGuard, parseApiResponse } from "./apiReliability.ts";
39+
import {
40+
attemptTargetedTaskRefresh,
41+
getTaskResponseUiState,
42+
markTaskResponseSubmitted,
43+
mergeTargetedTaskDetail,
44+
prepareTaskResponse,
45+
reconcileTasksWithSubmittedAnswers,
46+
selectTickAfterRefresh,
47+
startHeartbeatTickPolling,
48+
taskNeedsResponse,
49+
type TaskResponseRefreshResult,
50+
} from "./operatorUi.ts";
3951
import { buildExecutionSteps } from "./traceSteps.ts";
4052

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

845+
async function fetchTask(id) {
846+
const res = await fetch(`${API}/tasks/${id}`);
847+
const payload = await res.json().catch(() => ({}));
848+
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
849+
return payload;
850+
}
851+
833852
async function fetchHeartbeats() {
834853
const res = await fetch(`${API}/heartbeats`);
835854
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -2212,9 +2231,12 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22122231
const [tickOutput, setTickOutput] = useState("");
22132232
const [tickRunning, setTickRunning] = useState(false);
22142233
const outputRef = useRef<any>(null);
2234+
const previousHeartbeatIdRef = useRef(heartbeat.id);
22152235

22162236
useEffect(() => {
2217-
setSelectedTickId(ticks[0]?.id || null);
2237+
const heartbeatChanged = previousHeartbeatIdRef.current !== heartbeat.id;
2238+
previousHeartbeatIdRef.current = heartbeat.id;
2239+
setSelectedTickId((current) => selectTickAfterRefresh(current, ticks, heartbeatChanged));
22182240
}, [heartbeat.id, ticks]);
22192241

22202242
useEffect(() => {
@@ -2224,12 +2246,16 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22242246
return;
22252247
}
22262248
let cancelled = false;
2249+
let timeout;
22272250
const load = async () => {
22282251
try {
22292252
const data = await fetchHeartbeatTickOutput(heartbeat.id, selectedTickId);
22302253
if (cancelled) return;
22312254
setTickOutput(data.output || "");
22322255
setTickRunning(!!data.is_running);
2256+
if (data.is_running) {
2257+
timeout = setTimeout(load, 1000);
2258+
}
22332259
} catch {
22342260
if (!cancelled) {
22352261
setTickOutput("");
@@ -2238,10 +2264,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
22382264
}
22392265
};
22402266
load();
2241-
const interval = setInterval(load, 1000);
22422267
return () => {
22432268
cancelled = true;
2244-
clearInterval(interval);
2269+
if (timeout) clearTimeout(timeout);
22452270
};
22462271
}, [heartbeat.id, selectedTickId]);
22472272

@@ -3015,14 +3040,18 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) {
30153040
);
30163041
}
30173042

3018-
function DetailPanel({ task, onClose, onResume }: any) {
3043+
function DetailPanel({ task, onClose, onRespond, onResume }: any) {
30193044
// `task` is always truthy here — the only caller renders this inside
30203045
// `{detail && <DetailPanel task={... || detail} />}`. Hooks must stay
30213046
// unconditional, so do not early-return before them.
30223047
const [liveOutput, setLiveOutput] = useState("");
30233048
const [resumeText, setResumeText] = useState("");
30243049
const [resumeError, setResumeError] = useState("");
30253050
const [resumeSent, setResumeSent] = useState(false);
3051+
const [answerText, setAnswerText] = useState("");
3052+
const [responseLoading, setResponseLoading] = useState(false);
3053+
const [responseError, setResponseError] = useState("");
3054+
const [responseResult, setResponseResult] = useState<TaskResponseRefreshResult | null>(null);
30263055
const [messages, setMessages] = useState<any[]>([]);
30273056
const [events, setEvents] = useState<any[]>([]);
30283057
const [showMessages, setShowMessages] = useState(false);
@@ -3100,6 +3129,34 @@ function DetailPanel({ task, onClose, onResume }: any) {
31003129
}
31013130
}, [messages]);
31023131

3132+
useEffect(() => {
3133+
setAnswerText("");
3134+
setResponseError("");
3135+
setResponseLoading(false);
3136+
setResponseResult(null);
3137+
}, [task.id, task.question, task.answer]);
3138+
3139+
const handleResponse = async () => {
3140+
const prepared = prepareTaskResponse(answerText);
3141+
if (prepared.error) {
3142+
setResponseError(prepared.error);
3143+
return;
3144+
}
3145+
3146+
setResponseError("");
3147+
setResponseLoading(true);
3148+
try {
3149+
const result = await onRespond(task.id, prepared.answer);
3150+
setAnswerText("");
3151+
setResponseResult({ refreshed: result?.refreshed !== false });
3152+
} catch (error) {
3153+
setResponseError(error instanceof Error ? error.message : "Failed to submit response.");
3154+
} finally {
3155+
setResponseLoading(false);
3156+
}
3157+
};
3158+
const responseUiState = getTaskResponseUiState(task.question, task.answer, responseResult);
3159+
31033160
const handleResume = async () => {
31043161
if (!resumeText.trim()) return;
31053162
setResumeError("");
@@ -3227,6 +3284,89 @@ function DetailPanel({ task, onClose, onResume }: any) {
32273284
)}
32283285
</Section>
32293286

3287+
{responseUiState !== "hidden" && (
3288+
<Section title="Agent Question">
3289+
<div
3290+
style={{
3291+
background: theme.orangeBg,
3292+
border: `1px solid ${theme.orange}`,
3293+
borderRadius: 8,
3294+
padding: 14,
3295+
fontSize: 12,
3296+
color: theme.text,
3297+
whiteSpace: "pre-wrap",
3298+
wordBreak: "break-word",
3299+
lineHeight: 1.6,
3300+
marginBottom: 12,
3301+
}}
3302+
>
3303+
{task.question}
3304+
</div>
3305+
{responseUiState === "form" ? (
3306+
<>
3307+
<textarea
3308+
value={answerText}
3309+
onChange={(event) => {
3310+
setAnswerText(event.target.value);
3311+
if (responseError) setResponseError("");
3312+
}}
3313+
placeholder="Enter your answer..."
3314+
disabled={responseLoading}
3315+
rows={4}
3316+
style={{
3317+
width: "100%",
3318+
boxSizing: "border-box",
3319+
background: theme.field,
3320+
color: theme.text,
3321+
border: `1px solid ${responseError ? theme.red : theme.border}`,
3322+
borderRadius: 8,
3323+
padding: 12,
3324+
fontSize: 12,
3325+
fontFamily: "'JetBrains Mono', monospace",
3326+
lineHeight: 1.6,
3327+
resize: "vertical",
3328+
outline: "none",
3329+
}}
3330+
/>
3331+
{responseError && (
3332+
<div style={{ color: theme.red, fontSize: 11, marginTop: 8 }}>{responseError}</div>
3333+
)}
3334+
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 10 }}>
3335+
<button
3336+
onClick={handleResponse}
3337+
disabled={responseLoading}
3338+
style={{
3339+
...primaryButton(),
3340+
cursor: responseLoading ? "wait" : "pointer",
3341+
opacity: responseLoading ? 0.65 : 1,
3342+
}}
3343+
>
3344+
{responseLoading ? "Submitting..." : "Submit Answer"}
3345+
</button>
3346+
</div>
3347+
</>
3348+
) : (
3349+
<div
3350+
style={{
3351+
background: responseUiState === "submitted-stale" ? theme.orangeBg : theme.greenBg,
3352+
border: `1px solid ${
3353+
responseUiState === "submitted-stale" ? theme.orange : theme.green
3354+
}`,
3355+
borderRadius: 8,
3356+
padding: 12,
3357+
color: theme.text,
3358+
fontSize: 12,
3359+
lineHeight: 1.6,
3360+
}}
3361+
>
3362+
{responseUiState === "submitted-stale"
3363+
? "Answer submitted, but the task refresh failed. This view may be stale; automatic refresh will retry."
3364+
: "Answer submitted. Refreshing task status..."}
3365+
</div>
3366+
)}
3367+
</Section>
3368+
)}
3369+
32303370
<Section title="Configuration">
32313371
<InfoRow label="Working Dir" value={task.working_dir} />
32323372
<InfoRow label="Agent" value={task.agent || DEFAULT_AGENT} />
@@ -5822,6 +5962,8 @@ export default function App() {
58225962
const [forkingTask, setForkingTask] = useState<any>(null);
58235963
const [editingHeartbeat, setEditingHeartbeat] = useState<any>(null);
58245964
const pollGuardRef = useRef(createRequestGenerationGuard());
5965+
const heartbeatDetailId = heartbeatDetail?.id;
5966+
const submittedTaskAnswersRef = useRef<Record<string, string>>({});
58255967

58265968
// ─── Color mode ───
58275969
const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system");
@@ -5879,16 +6021,25 @@ export default function App() {
58796021
fetchSkills(),
58806022
]);
58816023
if (!pollGuardRef.current.isCurrent(generation)) return;
5882-
setTasks(taskData);
6024+
const reconciled = reconcileTasksWithSubmittedAnswers(
6025+
taskData,
6026+
submittedTaskAnswersRef.current,
6027+
);
6028+
submittedTaskAnswersRef.current = Object.fromEntries(
6029+
reconciled.pendingSubmissionIds.map((id) => [id, submittedTaskAnswersRef.current[id]]),
6030+
);
6031+
setTasks(reconciled.tasks);
58836032
setHeartbeats(heartbeatData);
58846033
setSkillData(skillRes);
58856034
setSkills(skillsRes.skills || []);
58866035
setConnected(true);
58876036
setApiError(null);
6037+
return true;
58886038
} catch (err) {
58896039
if (!pollGuardRef.current.isCurrent(generation)) return;
58906040
setConnected(false);
58916041
setApiError(`Failed to fetch tasks: ${err.message}`);
6042+
return false;
58926043
}
58936044
}, []);
58946045

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

6057+
useEffect(() => {
6058+
if (!heartbeatDetailId) return;
6059+
6060+
return startHeartbeatTickPolling({
6061+
heartbeatId: heartbeatDetailId,
6062+
load: fetchHeartbeatTicks,
6063+
onTicks: setHeartbeatTicks,
6064+
onError: (error) =>
6065+
setApiError(
6066+
`Failed to fetch heartbeat ticks: ${
6067+
error instanceof Error ? error.message : String(error)
6068+
}`,
6069+
),
6070+
});
6071+
}, [heartbeatDetailId]);
6072+
59066073
useEffect(() => {
59076074
if (!backendReady) return;
59086075
fetchSettings().then((s) => {
@@ -5955,14 +6122,6 @@ export default function App() {
59556122
return;
59566123
}
59576124
poll();
5958-
if (heartbeatDetail?.id === id && action !== "delete") {
5959-
const [updatedHeartbeat, ticks] = await Promise.all([
5960-
fetch(`${API}/heartbeats/${id}`).then((r) => r.json()),
5961-
fetchHeartbeatTicks(id),
5962-
]);
5963-
setHeartbeatDetail(updatedHeartbeat);
5964-
setHeartbeatTicks(ticks);
5965-
}
59666125
} catch (e) {
59676126
setApiError(`Heartbeat ${action} failed: ${e.message}`);
59686127
}
@@ -6057,10 +6216,40 @@ export default function App() {
60576216
const handleRespond = async (id, answer) => {
60586217
try {
60596218
await respondToTask(id, answer);
6060-
poll();
60616219
} catch (e) {
60626220
setApiError(`Respond failed: ${e.message}`);
6221+
throw e;
60636222
}
6223+
6224+
submittedTaskAnswersRef.current[String(id)] = answer;
6225+
setTasks((current) => current.map((task) => markTaskResponseSubmitted(task, id, answer)));
6226+
setDetail((current) =>
6227+
current?.id === id ? markTaskResponseSubmitted(current, id, answer) : current,
6228+
);
6229+
6230+
const refreshed = await attemptTargetedTaskRefresh({
6231+
load: () => fetchTask(id),
6232+
onTask: (refreshedTask) => {
6233+
const responseStillPending = taskNeedsResponse(
6234+
refreshedTask.question,
6235+
refreshedTask.answer,
6236+
);
6237+
if (responseStillPending) {
6238+
submittedTaskAnswersRef.current[String(id)] = answer;
6239+
} else {
6240+
delete submittedTaskAnswersRef.current[String(id)];
6241+
}
6242+
const safeTask = responseStillPending
6243+
? markTaskResponseSubmitted(refreshedTask, id, answer)
6244+
: refreshedTask;
6245+
setTasks((current) => current.map((task) => (task.id === id ? safeTask : task)));
6246+
setDetail((current) => mergeTargetedTaskDetail(current, id, safeTask));
6247+
},
6248+
onError: () => {
6249+
setApiError("Answer submitted, but task details have not refreshed yet.");
6250+
},
6251+
});
6252+
return { refreshed };
60646253
};
60656254

60666255
const handleResume = () => {
@@ -6087,15 +6276,9 @@ export default function App() {
60876276
}
60886277
};
60896278

6090-
const openHeartbeatDetail = async (heartbeat) => {
6279+
const openHeartbeatDetail = (heartbeat) => {
6280+
if (heartbeatDetail?.id !== heartbeat.id) setHeartbeatTicks([]);
60916281
setHeartbeatDetail(heartbeat);
6092-
try {
6093-
const ticks = await fetchHeartbeatTicks(heartbeat.id);
6094-
setHeartbeatTicks(ticks);
6095-
} catch (e) {
6096-
setApiError(`Failed to fetch heartbeat ticks: ${e.message}`);
6097-
setHeartbeatTicks([]);
6098-
}
60996282
};
61006283

61016284
const filter =

0 commit comments

Comments
 (0)