diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index cef21ec2a1..c3fefb6e85 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -96,6 +96,14 @@ function getTimestampMs(value: string): number { return Number.isFinite(parsed) ? parsed : 0; } +function getLatestTranscriptTimestampMs(entries: readonly AgentLogEntry[], userMessages: readonly UserChatMessage[]): number { + return Math.max( + 0, + ...entries.map((entry) => getTimestampMs(entry.timestamp)), + ...userMessages.map((message) => getTimestampMs(message.createdAt)), + ); +} + function getUserMessageDedupKey(message: Pick): string { return message.id ? `id:${message.id}` : `fallback:${message.text}:${message.createdAt}`; } @@ -117,13 +125,13 @@ function mergeUserMessages(persistedComments: readonly SteeringComment[] | undef messages.push(message); }; + for (const message of optimisticMessages) { + addMessage(message); + } for (const comment of persistedComments ?? []) { if (comment.author !== "user") continue; addMessage({ id: comment.id, text: comment.text, createdAt: comment.createdAt }); } - for (const message of optimisticMessages) { - addMessage(message); - } return messages; } @@ -625,10 +633,16 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on const text = draft.trim(); if (!text || sending) return; + const latestTimestampMs = getLatestTranscriptTimestampMs(entries, userMessages); + const optimisticCreatedAtMs = Math.max(Date.now(), latestTimestampMs + 1); + /* + FNXC:TaskDetailChat 2026-06-17-08:12: + Freshly-sent user steering must appear immediately at the transcript tail below current agent output and keep that display order after persistence reconciliation, so the agent's follow-up thinking or response renders after the user's bubble even when client and server clocks are skewed. + */ const optimisticMessage: UserChatMessage = { - id: `optimistic-${task.id}-${Date.now()}-${Math.random().toString(36).slice(2)}`, + id: `optimistic-${task.id}-${optimisticCreatedAtMs}-${Math.random().toString(36).slice(2)}`, text, - createdAt: new Date().toISOString(), + createdAt: new Date(optimisticCreatedAtMs).toISOString(), optimistic: true, }; setOptimisticMessages((current) => [...current, optimisticMessage]); @@ -645,7 +659,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on if (persistedComment) { setOptimisticMessages((current) => current.map((message) => ( message.id === optimisticMessage.id - ? { id: persistedComment.id, text: persistedComment.text, createdAt: persistedComment.createdAt, optimistic: true } + ? { id: persistedComment.id, text: persistedComment.text, createdAt: message.createdAt, optimistic: true } : message ))); } @@ -658,7 +672,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on } finally { setSending(false); } - }, [addToast, draft, isDoneTask, onTaskUpdated, projectId, sending, task.id]); + }, [addToast, draft, entries, isDoneTask, onTaskUpdated, projectId, sending, task.id, userMessages]); /** * FNXC:TaskDetailChat 2026-06-13-19:05: diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 8d93b625bc..88a8667275 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -26,6 +26,7 @@ const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLEleme const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; +const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia"); function makeTask(overrides: Partial = {}): Task { return { @@ -124,6 +125,17 @@ function expectComposerSendableAfterDraft(message = "Please continue") { expect(sendButton).not.toBeDisabled(); } +function expectTranscriptTextOrder(...texts: string[]) { + const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? ""; + let previousIndex = -1; + for (const text of texts) { + const index = transcriptText.indexOf(text); + expect(index, `Expected transcript to contain ${text}`).toBeGreaterThanOrEqual(0); + expect(index, `Expected ${text} to appear after the previous transcript text`).toBeGreaterThan(previousIndex); + previousIndex = index; + } +} + function expectNoInactiveSessionHint() { expect(screen.queryByText(/picked up by the next session/i)).not.toBeInTheDocument(); expect(document.querySelector(".task-chat-session-hint")).not.toBeInTheDocument(); @@ -263,6 +275,7 @@ describe("TaskChatTab", () => { }); afterEach(() => { + vi.useRealTimers(); restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor); restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor); restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor); @@ -276,6 +289,11 @@ describe("TaskChatTab", () => { writable: true, value: originalCancelAnimationFrame, }); + if (originalMatchMediaDescriptor) { + Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor); + } else { + delete (window as Partial).matchMedia; + } }); it("subscribes to live agent logs only when active", () => { @@ -1255,6 +1273,105 @@ describe("TaskChatTab", () => { expect(input).toHaveValue(""); }); + it("renders a sent user message after pre-existing agent output under client-behind-server clock skew", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z")); + mockLogs([ + makeEntry({ agent: "executor", text: "agent output with server timestamp", timestamp: "2026-06-12T00:00:05.000Z" }), + ]); + const send = deferred(); + mockedAddSteeringComment.mockReturnValue(send.promise); + render(); + + fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Please stay below the agent output" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expectTranscriptTextOrder("agent output with server timestamp", "Please stay below the agent output"); + }); + + it.each([ + ["in-review client-ahead mobile", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" }), "2026-06-12T00:00:10.000Z", true], + ["in-progress clock-sync desktop", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" }), "2026-06-12T00:00:05.000Z", false], + ])("keeps sent user messages at the transcript tail for %s", (_label, task, now, mobile) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(now)); + mockMatchMedia(mobile); + mockLogs([ + makeEntry({ agent: "executor", text: "pre-existing agent output", timestamp: "2026-06-12T00:00:05.000Z" }), + ]); + mockedAddSteeringComment.mockReturnValue(deferred().promise); + render(); + + fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Tail guidance" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expectTranscriptTextOrder("pre-existing agent output", "Tail guidance"); + }); + + it("renders agent follow-up below the newly-sent user message", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z")); + const task = makeTask({ steeringComments: [makeSteeringComment({ id: "old-user", text: "historical guidance", createdAt: "2026-06-12T00:00:02.000Z" })] }); + mockLogs([ + makeEntry({ agent: "executor", text: "pre-existing output", timestamp: "2026-06-12T00:00:05.000Z" }), + ]); + mockedAddSteeringComment.mockReturnValue(deferred().promise); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "New steering" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expectTranscriptTextOrder("historical guidance", "pre-existing output", "New steering"); + + mockLogs([ + makeEntry({ agent: "executor", text: "pre-existing output", timestamp: "2026-06-12T00:00:05.000Z" }), + makeEntry({ agent: "executor", text: "agent follow-up after steering", timestamp: "2026-06-12T00:00:06.000Z" }), + ]); + rerender(); + + expectTranscriptTextOrder("pre-existing output", "New steering", "agent follow-up after steering"); + }); + + it("keeps a reconciled persisted steering comment at the clamped tail without duplication", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z")); + mockLogs([ + makeEntry({ agent: "executor", text: "agent output before send", timestamp: "2026-06-12T00:00:05.000Z" }), + ]); + const send = deferred(); + mockedAddSteeringComment.mockReturnValue(send.promise); + const persistedComment = makeSteeringComment({ id: "steer-reconciled", text: "Reconciled guidance", createdAt: "2026-06-12T00:00:01.000Z" }); + const { rerender } = render(); + + fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Reconciled guidance" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + expectTranscriptTextOrder("agent output before send", "Reconciled guidance"); + + await act(async () => { + send.resolve(makeTask({ steeringComments: [persistedComment] })); + await send.promise; + }); + rerender(); + + expectTranscriptTextOrder("agent output before send", "Reconciled guidance"); + expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Reconciled guidance")).toHaveLength(1); + }); + + it("inserts done-task refinement messages immediately at the transcript tail", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z")); + mockLogs([ + makeEntry({ agent: "reviewer", text: "final agent summary", timestamp: "2026-06-12T00:00:05.000Z" }), + ]); + mockedRefineTask.mockReturnValue(deferred().promise); + render(); + + fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Please refine this task" } }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expectTranscriptTextOrder("final agent summary", "Please refine this task"); + expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please refine this task", "project-1"); + }); + it("renders persisted user steering comments but not agent-authored steering comments", () => { render(