fix(dashboard): preserve fullDetail.log so task Activity tab renders
The live-token-usage refactor (199ee9cee) merged the SSE-updated `task`
prop on top of `fullDetail` to keep tokenUsage/status fresh. SSE strips
`log` to [] (stripTaskListHeavyFields) for list payloads, so the spread
clobbered fullDetail.log and the Activity timeline rendered empty.
Carve `log` out of the merge alongside `prompt`. Adds a regression test
that simulates the SSE-stripped task prop with a populated fullDetail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -296,11 +296,13 @@ export function TaskDetailModal({
|
|||||||
|
|
||||||
// Derive a working task that always has all available fields.
|
// Derive a working task that always has all available fields.
|
||||||
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
|
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
|
||||||
// Live fields (tokenUsage, log, workflowStepResults, status, column, …) are
|
// Live fields (tokenUsage, workflowStepResults, status, column, …) are taken
|
||||||
// taken from the parent `task` prop which receives SSE updates, so the stats
|
// from the parent `task` prop which receives SSE updates, so the stats tab
|
||||||
// tab keeps populating while a task runs after the modal was opened.
|
// keeps populating while a task runs after the modal was opened. `log` is
|
||||||
|
// stripped to [] in SSE payloads (stripTaskListHeavyFields), so we preserve
|
||||||
|
// fullDetail.log to keep the Activity timeline populated.
|
||||||
const workingTask: TaskDetail = fullDetail
|
const workingTask: TaskDetail = fullDetail
|
||||||
? ({ ...fullDetail, ...task, prompt: fullDetail.prompt } as TaskDetail)
|
? ({ ...fullDetail, ...task, prompt: fullDetail.prompt, log: fullDetail.log } as TaskDetail)
|
||||||
: ({ ...task, prompt: "" } as TaskDetail);
|
: ({ ...task, prompt: "" } as TaskDetail);
|
||||||
const canRetryTask =
|
const canRetryTask =
|
||||||
task.status === "failed" ||
|
task.status === "failed" ||
|
||||||
|
|||||||
@@ -5853,6 +5853,62 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(lastUsed).toBeTruthy();
|
expect(lastUsed).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves fullDetail.log when SSE-stripped task prop has empty log", async () => {
|
||||||
|
// Regression: SSE strips `log` to [] in task list payloads (see
|
||||||
|
// stripTaskListHeavyFields in packages/dashboard/src/sse.ts). The modal
|
||||||
|
// merges live `task` over `fullDetail` to keep tokenUsage/status fresh,
|
||||||
|
// which previously clobbered fullDetail.log and emptied the Activity tab.
|
||||||
|
const { fetchTaskDetail } = await import("../../api");
|
||||||
|
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||||
|
|
||||||
|
const strippedTask: Task = {
|
||||||
|
id: "FN-LOG-1",
|
||||||
|
description: "SSE stripped task",
|
||||||
|
column: "doing",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z",
|
||||||
|
} as Task;
|
||||||
|
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
...strippedTask,
|
||||||
|
prompt: "# Spec",
|
||||||
|
log: [
|
||||||
|
{ timestamp: "2026-04-24T09:00:00.000Z", action: "Created task" },
|
||||||
|
{ timestamp: "2026-04-24T09:01:00.000Z", action: "Started executor", outcome: "OK" },
|
||||||
|
],
|
||||||
|
} as TaskDetail);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={strippedTask}
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for fetchTaskDetail to resolve.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
||||||
|
}, { timeout: 3000 });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Logs"));
|
||||||
|
|
||||||
|
const activityList = container.querySelector(".detail-activity-list");
|
||||||
|
expect(activityList).toBeTruthy();
|
||||||
|
const logEntries = container.querySelectorAll(".detail-log-entry");
|
||||||
|
expect(logEntries).toHaveLength(2);
|
||||||
|
expect(logEntries[0].textContent).toContain("Started executor");
|
||||||
|
expect(logEntries[1].textContent).toContain("Created task");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows token stats empty state once detail is loaded without usage", async () => {
|
it("shows token stats empty state once detail is loaded without usage", async () => {
|
||||||
const { fetchTaskDetail } = await import("../../api");
|
const { fetchTaskDetail } = await import("../../api");
|
||||||
const mockFetch = vi.mocked(fetchTaskDetail);
|
const mockFetch = vi.mocked(fetchTaskDetail);
|
||||||
|
|||||||
Reference in New Issue
Block a user