FN-6372: start refinements from done task chat
Done-task chat messages now create refinement tasks while preserving steering behavior elsewhere. - Route completed-task Chat composer submissions through refineTask and show the created task ID. - Keep non-done task sends on the existing steering-comment path with queued/live session copy. - Cover refinement success, failure rollback, send lifecycle, and routing surfaces in TaskChatTab tests. - Document the completed-task refinement behavior in the dashboard guide. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.tsx | 45 +++--- .../app/components/__tests__/TaskChatTab.test.tsx | 152 ++++++++++++++++++++- 3 files changed, 177 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-6372 Fusion-Task-Lineage: 201f3a5e-e951-4f70-8b92-8d270e3181de
This commit is contained in:
@@ -735,7 +735,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
|
||||
### Logs → Agent Log view
|
||||
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, the composer sends guidance to the running agent through the same steering path used by comments; this includes regular engine agents working in a task worktree as well as live CLI sessions. When no active session is available, the composer is disabled with an explanatory hint. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” and the send affordance is an inline, icon-only button to the right of the input at every breakpoint.
|
||||
The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. Thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. For non-`done` tasks, the composer sends guidance through the same steering path used by comments, including active assigned `in-progress`/`in-review` sessions and messages queued when no session is currently live. On a `done` task, sending a Chat message starts a refinement task using the typed text as feedback and shows a success toast with the new task ID; the current task detail modal remains on the completed task. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally; its textarea placeholder reads “Steer the currently executing agent” for steering mode and switches to refinement copy for completed tasks, with the same inline, icon-only send affordance to the right of the input at every breakpoint.
|
||||
|
||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChevronDown, Loader2, Maximize2, Minimize2, Send } from "lucide-react";
|
||||
import { addSteeringComment } from "../api";
|
||||
import { addSteeringComment, refineTask } from "../api";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -429,9 +429,15 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
const transcriptItems = useMemo(() => buildTranscriptItems(entries, userMessages), [entries, userMessages]);
|
||||
const transcriptItemCount = entries.length + userMessages.length;
|
||||
const activeSession = isActiveAgentSession(task, { sessionLive });
|
||||
const sessionHint = activeSession
|
||||
? "Message the active agent session. Guidance is delivered to the running session in real time."
|
||||
: null;
|
||||
const isDoneTask = task.column === "done";
|
||||
const sessionHint = isDoneTask
|
||||
? "Send a message to start a refinement task for this completed task."
|
||||
: activeSession
|
||||
? "Message the active agent session. Guidance is delivered to the running session in real time."
|
||||
: null;
|
||||
const composerPlaceholder = isDoneTask
|
||||
? "Start a refinement task for this completed task"
|
||||
: "Steer the currently executing agent";
|
||||
const canSend = draft.trim().length > 0 && !sending;
|
||||
|
||||
const resizeComposer = useCallback(() => {
|
||||
@@ -574,18 +580,23 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
setOptimisticMessages((current) => [...current, optimisticMessage]);
|
||||
setSending(true);
|
||||
try {
|
||||
const updatedTask = await addSteeringComment(task.id, text, projectId);
|
||||
const persistedComment = updatedTask.steeringComments
|
||||
?.filter((comment) => comment.author === "user" && comment.text === text)
|
||||
.at(-1);
|
||||
if (persistedComment) {
|
||||
setOptimisticMessages((current) => current.map((message) => (
|
||||
message.id === optimisticMessage.id
|
||||
? { id: persistedComment.id, text: persistedComment.text, createdAt: persistedComment.createdAt, optimistic: true }
|
||||
: message
|
||||
)));
|
||||
if (isDoneTask) {
|
||||
const newTask = await refineTask(task.id, text, projectId);
|
||||
addToast(`Refinement task created: ${newTask.id}`, "success");
|
||||
} else {
|
||||
const updatedTask = await addSteeringComment(task.id, text, projectId);
|
||||
const persistedComment = updatedTask.steeringComments
|
||||
?.filter((comment) => comment.author === "user" && comment.text === text)
|
||||
.at(-1);
|
||||
if (persistedComment) {
|
||||
setOptimisticMessages((current) => current.map((message) => (
|
||||
message.id === optimisticMessage.id
|
||||
? { id: persistedComment.id, text: persistedComment.text, createdAt: persistedComment.createdAt, optimistic: true }
|
||||
: message
|
||||
)));
|
||||
}
|
||||
onTaskUpdated?.(updatedTask);
|
||||
}
|
||||
onTaskUpdated?.(updatedTask);
|
||||
setDraft("");
|
||||
} catch (error) {
|
||||
setOptimisticMessages((current) => current.filter((message) => message.id !== optimisticMessage.id));
|
||||
@@ -593,7 +604,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [addToast, draft, onTaskUpdated, projectId, sending, task.id]);
|
||||
}, [addToast, draft, isDoneTask, onTaskUpdated, projectId, sending, task.id]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
||||
@@ -688,7 +699,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
||||
ref={textareaRef}
|
||||
className="input task-chat-input"
|
||||
value={draft}
|
||||
placeholder="Steer the currently executing agent"
|
||||
placeholder={composerPlaceholder}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={sending}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { TaskChatTab } from "../TaskChatTab";
|
||||
import { isCliSessionLive, type CliSessionSummaryRecord } from "../TaskDetailModal";
|
||||
import { useAgentLogs } from "../../hooks/useAgentLogs";
|
||||
import { addSteeringComment } from "../../api";
|
||||
import { addSteeringComment, refineTask } from "../../api";
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
useAgentLogs: vi.fn(),
|
||||
@@ -15,10 +15,12 @@ vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
addSteeringComment: vi.fn(),
|
||||
refineTask: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
|
||||
const mockedAddSteeringComment = vi.mocked(addSteeringComment);
|
||||
const mockedRefineTask = vi.mocked(refineTask);
|
||||
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
||||
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
||||
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
||||
@@ -128,6 +130,11 @@ function expectActiveSessionCopy() {
|
||||
expect(screen.getByText(/delivered to the running session in real time/i)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function expectDoneRefinementCopy() {
|
||||
expect(screen.getByText(/start a refinement task for this completed task/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, descriptor);
|
||||
@@ -827,8 +834,10 @@ describe("TaskChatTab", () => {
|
||||
|
||||
it("posts composer text through addSteeringComment and clears on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAddSteeringComment.mockResolvedValue(makeTask());
|
||||
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||
const onTaskUpdated = vi.fn();
|
||||
const updatedTask = makeTask();
|
||||
mockedAddSteeringComment.mockResolvedValue(updatedTask);
|
||||
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
expect(input).not.toBeDisabled();
|
||||
@@ -840,9 +849,78 @@ describe("TaskChatTab", () => {
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1");
|
||||
});
|
||||
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("routes done-task composer sends to refineTask without replacing the current task", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
const refinementTask = makeTask({ id: "FN-222", column: "todo" });
|
||||
mockedRefineTask.mockResolvedValue(refinementTask);
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "done", status: undefined })}
|
||||
projectId="project-1"
|
||||
active
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
/>,
|
||||
);
|
||||
|
||||
expectDoneRefinementCopy();
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
await user.type(input, "Please add a follow-up report");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please add a follow-up report", "project-1");
|
||||
});
|
||||
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||
expect(within(screen.getByTestId("task-chat-transcript")).getByText("You")).toBeVisible();
|
||||
expect(within(screen.getByTestId("task-chat-transcript")).getByText("Please add a follow-up report")).toBeVisible();
|
||||
expect(input).toHaveValue("");
|
||||
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success");
|
||||
expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask);
|
||||
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, null, "failed", "done"])("routes done-task sends to refineTask regardless of %s status", async (status) => {
|
||||
const user = userEvent.setup();
|
||||
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-333", column: "todo" }));
|
||||
render(<TaskChatTab task={makeTask({ column: "done", status })} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Message active agent session"), `Refine from ${String(status)}`);
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", `Refine from ${String(status)}`, "project-1");
|
||||
});
|
||||
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["in-progress", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" })],
|
||||
["in-review", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" })],
|
||||
["todo", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["triage", makeTask({ column: "triage", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["archived", makeTask({ column: "archived", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
])("keeps %s sends routed to addSteeringComment", async (_label, task) => {
|
||||
const user = userEvent.setup();
|
||||
mockedAddSteeringComment.mockResolvedValue(task);
|
||||
render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} sessionLive={false} />);
|
||||
|
||||
await user.type(screen.getByLabelText("Message active agent session"), "Keep steering");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Keep steering", "project-1");
|
||||
});
|
||||
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders a sent user message in the chat transcript", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
@@ -1192,7 +1270,9 @@ describe("TaskChatTab", () => {
|
||||
])("keeps the composer sendable for %s column", (_label, task, showsActiveCopy) => {
|
||||
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={false} />);
|
||||
|
||||
if (showsActiveCopy) {
|
||||
if (task.column === "done") {
|
||||
expectDoneRefinementCopy();
|
||||
} else if (showsActiveCopy) {
|
||||
expectActiveSessionCopy();
|
||||
} else {
|
||||
expectNoInactiveSessionHint();
|
||||
@@ -1265,6 +1345,38 @@ describe("TaskChatTab", () => {
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("uses the same send lifecycle while creating a done-task refinement", async () => {
|
||||
const user = userEvent.setup();
|
||||
const send = deferred<Task>();
|
||||
mockedRefineTask.mockReturnValue(send.promise);
|
||||
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} sessionLive={false} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(sendButton).toBeDisabled();
|
||||
|
||||
await user.type(input, " ");
|
||||
expect(sendButton).toBeDisabled();
|
||||
await user.clear(input);
|
||||
await user.type(input, "Create follow-up");
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await user.click(sendButton);
|
||||
|
||||
const sendingButton = screen.getByRole("button", { name: "Sending" });
|
||||
expect(sendingButton).toBeDisabled();
|
||||
expect(sendingButton).toHaveTextContent("");
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
await act(async () => {
|
||||
send.resolve(makeTask({ id: "FN-444", column: "todo" }));
|
||||
await send.promise;
|
||||
});
|
||||
|
||||
expect(input).not.toBeDisabled();
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("rolls back optimistic messages and surfaces send failures through addToast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
@@ -1293,6 +1405,38 @@ describe("TaskChatTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back done-task optimistic messages when refinement creation fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
const onTaskUpdated = vi.fn();
|
||||
const send = deferred<Task>();
|
||||
mockedRefineTask.mockReturnValue(send.promise);
|
||||
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={addToast} onTaskUpdated={onTaskUpdated} />);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
await user.type(input, "make a follow-up");
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
const transcript = screen.getByTestId("task-chat-transcript");
|
||||
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
||||
expect(within(transcript).getByText("make a follow-up")).toBeVisible();
|
||||
|
||||
await act(async () => {
|
||||
send.reject(new Error("refine failed"));
|
||||
try {
|
||||
await send.promise;
|
||||
} catch {
|
||||
// Expected rejection drives the component rollback path.
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
||||
expect(addToast).toHaveBeenCalledWith("Unable to send message: refine failed", "error");
|
||||
});
|
||||
expect(input).toHaveValue("make a follow-up");
|
||||
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the same composer affordance shell on desktop and mobile breakpoints", () => {
|
||||
mockMatchMedia(false);
|
||||
const desktop = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
Reference in New Issue
Block a user