FN-6578: keep sent chat messages at transcript tail

Ensure task-detail chat places newly sent user steering immediately after current transcript output and before later agent replies.

- Clamp optimistic user message timestamps after the latest transcript entry to handle client/server clock skew.
- Preserve the clamped display timestamp when reconciling persisted steering comments to avoid jumps or duplicates.
- Cover in-progress, in-review, done/refinement, desktop, mobile, and persisted reconciliation chat ordering cases.

Files changed:
 packages/dashboard/app/components/TaskChatTab.tsx  |  28 +++--
 .../app/components/__tests__/TaskChatTab.test.tsx  | 117 +++++++++++++++++++++
 2 files changed, 138 insertions(+), 7 deletions(-)

Fusion-Task-Id: FN-6578

Fusion-Task-Lineage: d9ba03c7-e151-41b6-9b00-87d5732f2fab
This commit is contained in:
gsxdsm
2026-06-17 08:35:04 -07:00
parent 593ebac85c
commit 9cfa6d8176
2 changed files with 138 additions and 7 deletions

View File

@@ -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<SteeringComment, "id" | "text" | "createdAt">): 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:

View File

@@ -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> = {}): 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<Window>).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<Task>();
mockedAddSteeringComment.mockReturnValue(send.promise);
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1" })} projectId="project-1" active addToast={vi.fn()} />);
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<Task>().promise);
render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
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<Task>().promise);
const { rerender } = render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
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(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
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<Task>();
mockedAddSteeringComment.mockReturnValue(send.promise);
const persistedComment = makeSteeringComment({ id: "steer-reconciled", text: "Reconciled guidance", createdAt: "2026-06-12T00:00:01.000Z" });
const { rerender } = render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask({ steeringComments: [persistedComment] })} projectId="project-1" active addToast={vi.fn()} />);
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<Task>().promise);
render(<TaskChatTab task={makeTask({ column: "done", status: "done", assignedAgentId: undefined })} projectId="project-1" active addToast={vi.fn()} />);
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(
<TaskChatTab