Skip to content

Commit df34b56

Browse files
Use targeted refresh after task responses
Co-authored-by: DanielWalnut <hetaoBackend@users.noreply.github.com>
1 parent 67a11ee commit df34b56

3 files changed

Lines changed: 151 additions & 2 deletions

File tree

taskboard-electron/src/renderer/App.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,15 @@ import {
3636
mergeChannelsStatus,
3737
} from "./channelsSettings.ts";
3838
import {
39+
attemptTargetedTaskRefresh,
3940
getTaskResponseUiState,
41+
markTaskResponseSubmitted,
42+
mergeTargetedTaskDetail,
4043
prepareTaskResponse,
44+
reconcileTasksWithSubmittedAnswers,
4145
selectTickAfterRefresh,
4246
startHeartbeatTickPolling,
47+
taskNeedsResponse,
4348
type TaskResponseRefreshResult,
4449
} from "./operatorUi.ts";
4550
import { buildExecutionSteps } from "./traceSteps.ts";
@@ -836,6 +841,13 @@ async function fetchTasks() {
836841
return res.json();
837842
}
838843

844+
async function fetchTask(id) {
845+
const res = await fetch(`${API}/tasks/${id}`);
846+
const payload = await res.json().catch(() => ({}));
847+
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
848+
return payload;
849+
}
850+
839851
async function fetchHeartbeats() {
840852
const res = await fetch(`${API}/heartbeats`);
841853
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -5953,6 +5965,7 @@ export default function App() {
59535965
const [forkingTask, setForkingTask] = useState<any>(null);
59545966
const [editingHeartbeat, setEditingHeartbeat] = useState<any>(null);
59555967
const heartbeatDetailId = heartbeatDetail?.id;
5968+
const submittedTaskAnswersRef = useRef<Record<string, string>>({});
59565969

59575970
// ─── Color mode ───
59585971
const [colorMode, setColorMode] = useState(() => localStorage.getItem("colorMode") || "system");
@@ -6008,7 +6021,14 @@ export default function App() {
60086021
fetchSkillPatterns(),
60096022
fetchSkills(),
60106023
]);
6011-
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);
60126032
setHeartbeats(heartbeatData);
60136033
setSkillData(skillRes);
60146034
setSkills(skillsRes.skills || []);
@@ -6195,7 +6215,35 @@ export default function App() {
61956215
setApiError(`Respond failed: ${e.message}`);
61966216
throw e;
61976217
}
6198-
const refreshed = await poll();
6218+
6219+
submittedTaskAnswersRef.current[String(id)] = answer;
6220+
setTasks((current) => current.map((task) => markTaskResponseSubmitted(task, id, answer)));
6221+
setDetail((current) =>
6222+
current?.id === id ? markTaskResponseSubmitted(current, id, answer) : current,
6223+
);
6224+
6225+
const refreshed = await attemptTargetedTaskRefresh({
6226+
load: () => fetchTask(id),
6227+
onTask: (refreshedTask) => {
6228+
const responseStillPending = taskNeedsResponse(
6229+
refreshedTask.question,
6230+
refreshedTask.answer,
6231+
);
6232+
if (responseStillPending) {
6233+
submittedTaskAnswersRef.current[String(id)] = answer;
6234+
} else {
6235+
delete submittedTaskAnswersRef.current[String(id)];
6236+
}
6237+
const safeTask = responseStillPending
6238+
? markTaskResponseSubmitted(refreshedTask, id, answer)
6239+
: refreshedTask;
6240+
setTasks((current) => current.map((task) => (task.id === id ? safeTask : task)));
6241+
setDetail((current) => mergeTargetedTaskDetail(current, id, safeTask));
6242+
},
6243+
onError: () => {
6244+
setApiError("Answer submitted, but task details have not refreshed yet.");
6245+
},
6246+
});
61996247
return { refreshed };
62006248
};
62016249

taskboard-electron/src/renderer/operatorUi.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { expect, test } from "bun:test";
22

33
import {
4+
attemptTargetedTaskRefresh,
45
getTaskResponseUiState,
6+
markTaskResponseSubmitted,
7+
mergeTargetedTaskDetail,
58
prepareTaskResponse,
9+
reconcileTasksWithSubmittedAnswers,
610
selectTickAfterRefresh,
711
startHeartbeatTickPolling,
812
taskNeedsResponse,
@@ -37,6 +41,44 @@ test("task response UI hides once the refreshed task is answered", () => {
3741
expect(getTaskResponseUiState(null, null, { refreshed: false })).toBe("hidden");
3842
});
3943

44+
test("successful submit remains answered after refresh failure and panel reopen", async () => {
45+
const original = { id: 7, question: "Proceed?", answer: null };
46+
const submitted = markTaskResponseSubmitted(original, 7, "Yes");
47+
const appState = { connected: true, warning: "" };
48+
49+
const refreshed = await attemptTargetedTaskRefresh({
50+
load: async () => {
51+
throw new Error("offline");
52+
},
53+
onTask: () => {},
54+
onError: () => {
55+
appState.warning = "Answer submitted, but task details have not refreshed yet.";
56+
},
57+
});
58+
59+
expect(refreshed).toBe(false);
60+
expect(appState.warning).toContain("Answer submitted");
61+
expect(appState.connected).toBe(true);
62+
expect(getTaskResponseUiState(submitted.question, submitted.answer, null)).toBe("hidden");
63+
});
64+
65+
test("global task reconciliation cannot restore a submitted response form", () => {
66+
const staleTasks = [{ id: 7, question: "Proceed?", answer: null }];
67+
const reconciled = reconcileTasksWithSubmittedAnswers(staleTasks, { "7": "Yes" });
68+
69+
expect(reconciled.tasks[0]).toEqual({ id: 7, question: null, answer: "Yes" });
70+
expect(reconciled.pendingSubmissionIds).toEqual(["7"]);
71+
});
72+
73+
test("stale targeted refresh cannot overwrite a different open detail", () => {
74+
const openDetail = { id: 8, question: "Other question", answer: null };
75+
const staleResponse = { id: 7, question: null, answer: "Yes" };
76+
const updatedResponse = { ...staleResponse, answer: "Updated" };
77+
78+
expect(mergeTargetedTaskDetail(openDetail, 7, staleResponse)).toBe(openDetail);
79+
expect(mergeTargetedTaskDetail(staleResponse, 7, updatedResponse)).toBe(updatedResponse);
80+
});
81+
4082
test("selectTickAfterRefresh preserves a selected tick still in the refreshed list", () => {
4183
const ticks = [{ id: 12 }, { id: 11 }, { id: 10 }];
4284

taskboard-electron/src/renderer/operatorUi.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ type HeartbeatTickPollingOptions<T> = {
1414
cancelScheduled?: (timer: unknown) => void;
1515
};
1616

17+
type TaskWithResponse = {
18+
id: TickId;
19+
question?: unknown;
20+
answer?: unknown;
21+
};
22+
23+
type TargetedTaskRefreshOptions<T> = {
24+
load: () => Promise<T>;
25+
onTask: (task: T) => void;
26+
onError: (error: unknown) => void;
27+
};
28+
1729
export type TaskResponseRefreshResult = {
1830
refreshed: boolean;
1931
};
@@ -34,6 +46,53 @@ export function getTaskResponseUiState(
3446
return result.refreshed ? "submitted" : "submitted-stale";
3547
}
3648

49+
export function markTaskResponseSubmitted<T extends TaskWithResponse>(
50+
task: T,
51+
taskId: TickId,
52+
answer: string,
53+
): T {
54+
if (task.id !== taskId) return task;
55+
return { ...task, question: null, answer };
56+
}
57+
58+
export function reconcileTasksWithSubmittedAnswers<T extends TaskWithResponse>(
59+
tasks: T[],
60+
submittedAnswers: Readonly<Record<string, string>>,
61+
): { tasks: T[]; pendingSubmissionIds: string[] } {
62+
const pendingSubmissionIds: string[] = [];
63+
const reconciled = tasks.map((task) => {
64+
const key = String(task.id);
65+
if (!Object.hasOwn(submittedAnswers, key) || !taskNeedsResponse(task.question, task.answer)) {
66+
return task;
67+
}
68+
pendingSubmissionIds.push(key);
69+
return markTaskResponseSubmitted(task, task.id, submittedAnswers[key]);
70+
});
71+
return { tasks: reconciled, pendingSubmissionIds };
72+
}
73+
74+
export function mergeTargetedTaskDetail<T extends TaskWithResponse>(
75+
currentDetail: T | null,
76+
requestedTaskId: TickId,
77+
refreshedTask: T,
78+
): T | null {
79+
return currentDetail?.id === requestedTaskId ? refreshedTask : currentDetail;
80+
}
81+
82+
export async function attemptTargetedTaskRefresh<T>({
83+
load,
84+
onTask,
85+
onError,
86+
}: TargetedTaskRefreshOptions<T>): Promise<boolean> {
87+
try {
88+
onTask(await load());
89+
return true;
90+
} catch (error) {
91+
onError(error);
92+
return false;
93+
}
94+
}
95+
3796
export function prepareTaskResponse(
3897
value: string,
3998
): { answer: string; error: null } | { answer: null; error: string } {

0 commit comments

Comments
 (0)