FN-6415: send task chat on Enter
Enable the task-detail Chat composer to submit messages with standard Enter behavior. - Submit non-empty task chat drafts on plain Enter while preserving Shift+Enter newline entry and IME composition safety. - Keep Cmd/Ctrl+Enter as a supported send path through the same handler. - Document the composer keyboard shortcuts and add dashboard tests for steering, refinement, newline, shortcut, and composition behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/tiny-tasks-chat-enter.md | 5 + docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.tsx | 13 ++- .../app/components/__tests__/TaskChatTab.test.tsx | 120 +++++++++++++++++++++ 4 files changed, 136 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6415 Fusion-Task-Lineage: 7d200bf6-5130-4fb8-b59c-66bccffe4265
This commit is contained in:
5
.changeset/tiny-tasks-chat-enter.md
Normal file
5
.changeset/tiny-tasks-chat-enter.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Send task-detail Chat composer messages on plain Enter while preserving Shift+Enter newlines and Cmd/Ctrl+Enter sending.
|
||||||
@@ -741,7 +741,7 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
|||||||
|
|
||||||
### Logs → Agent Log view
|
### 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 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 **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. In the composer, plain **Enter** sends, **Shift+Enter** inserts a newline, and **Cmd/Ctrl+Enter** remains a supported send shortcut.
|
||||||
|
|
||||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||||
|
|
||||||
|
|||||||
@@ -606,10 +606,17 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
|
|||||||
}
|
}
|
||||||
}, [addToast, draft, isDoneTask, onTaskUpdated, projectId, sending, task.id]);
|
}, [addToast, draft, isDoneTask, onTaskUpdated, projectId, sending, task.id]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:TaskDetailChat 2026-06-13-19:05:
|
||||||
|
* Task-detail chat follows chat composer keyboard expectations: Enter sends, Shift+Enter keeps textarea newline entry, Cmd/Ctrl+Enter remains supported for existing users, and IME composition Enter is ignored so CJK candidate selection is not submitted mid-composition.
|
||||||
|
*/
|
||||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
if (event.key !== "Enter") return;
|
||||||
void handleSubmit();
|
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||||
}
|
if (event.shiftKey) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
}, [handleSubmit]);
|
}, [handleSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -902,6 +902,126 @@ describe("TaskChatTab", () => {
|
|||||||
expect(onTaskUpdated).not.toHaveBeenCalled();
|
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends an in-progress task steering message on plain Enter", async () => {
|
||||||
|
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");
|
||||||
|
fireEvent.change(input, { target: { value: "Plain Enter guidance" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Plain Enter guidance", "project-1");
|
||||||
|
});
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||||
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a done-task refinement on plain Enter", async () => {
|
||||||
|
const refinementTask = makeTask({ id: "FN-224", column: "todo" });
|
||||||
|
mockedRefineTask.mockResolvedValue(refinementTask);
|
||||||
|
render(<TaskChatTab task={makeTask({ column: "done" })} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
fireEvent.change(input, { target: { value: "Plain Enter refinement" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Plain Enter refinement", "project-1");
|
||||||
|
});
|
||||||
|
expect(mockedRefineTask).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Shift+Enter as textarea newline input without sending", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
await user.click(input);
|
||||||
|
await user.keyboard("Line one");
|
||||||
|
await user.keyboard("{Shift>}{Enter}{/Shift}Line two");
|
||||||
|
|
||||||
|
expect(input).toHaveValue("Line one\nLine two");
|
||||||
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["", " \n "])("does not send a %s draft on Enter", async (draft) => {
|
||||||
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
fireEvent.change(input, { target: { value: draft } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit another Enter while a send is already in flight", async () => {
|
||||||
|
const send = deferred<Task>();
|
||||||
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
||||||
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
fireEvent.change(input, { target: { value: "Only send once" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Sending" })).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
send.resolve(makeTask());
|
||||||
|
await send.promise;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["isComposing", { isComposing: true }],
|
||||||
|
["keyCode 229", { keyCode: 229 }],
|
||||||
|
])("does not send Enter during IME composition signaled by %s", async (_label, eventPatch) => {
|
||||||
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
fireEvent.change(input, { target: { value: "Composing text" } });
|
||||||
|
const event = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true });
|
||||||
|
for (const [key, value] of Object.entries(eventPatch)) {
|
||||||
|
Object.defineProperty(event, key, { value });
|
||||||
|
}
|
||||||
|
fireEvent(input, event);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
||||||
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Cmd+Enter", { metaKey: true }],
|
||||||
|
["Ctrl+Enter", { ctrlKey: true }],
|
||||||
|
])("keeps %s sending for backward compatibility", async (_label, modifier) => {
|
||||||
|
mockedAddSteeringComment.mockResolvedValue(makeTask());
|
||||||
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
fireEvent.change(input, { target: { value: "Shortcut guidance" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter", ...modifier });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Shortcut guidance", "project-1");
|
||||||
|
});
|
||||||
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([undefined, null, "failed", "done"])("routes done-task sends to refineTask regardless of %s status", async (status) => {
|
it.each([undefined, null, "failed", "done"])("routes done-task sends to refineTask regardless of %s status", async (status) => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-333", column: "todo" }));
|
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-333", column: "todo" }));
|
||||||
|
|||||||
Reference in New Issue
Block a user