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:
gsxdsm
2026-04-27 11:30:43 -07:00
parent db504d2ab6
commit 597daeaf33
2 changed files with 62 additions and 4 deletions

View File

@@ -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" ||

View File

@@ -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);