FN-6320: enable active task chat steering
Allow task chat steering whenever an in-progress task has an active assigned or checked-out agent session. - Treat in-progress sessions as steerable unless they are explicitly paused, waiting, failed, or needing replan. - Keep in-review steering constrained to review and merge statuses while honoring paused states. - Cover queued, cleared, checked-out, blocked, and non-board task states in TaskChatTab tests. - Update dashboard docs to describe active, assigned, non-paused chat sessions. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.tsx | 16 +++-- .../app/components/__tests__/TaskChatTab.test.tsx | 81 ++++++++++++++++++++-- 3 files changed, 89 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-6320 Fusion-Task-Lineage: bdc7df52-5d1f-4882-82a7-9fe32496e1d1
This commit is contained in:
@@ -728,7 +728,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. The transcript follows new live output when you are already near the bottom, but it preserves your scroll position when you review older messages. For active assigned 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; when no active session is available, the composer is disabled with an explanatory hint.
|
||||
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. The transcript follows new live output when you are already near the bottom, but it preserves your scroll position when you review older messages. 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; when no active session is available, the composer is disabled with an explanatory hint.
|
||||
|
||||
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:
|
||||
|
||||
|
||||
@@ -28,7 +28,14 @@ interface AgentLogGroup {
|
||||
entries: AgentLogEntry[];
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]);
|
||||
const STEERING_BLOCKED_STATUSES = new Set([
|
||||
"paused",
|
||||
"awaiting-user-input",
|
||||
"awaiting-cli-approval",
|
||||
"awaiting-user-review",
|
||||
"failed",
|
||||
"needs-replan",
|
||||
]);
|
||||
const REVIEW_STEERABLE_STATUSES = new Set(["reviewing", "merging", "merging-fix", "fixing"]);
|
||||
const BOTTOM_FOLLOW_THRESHOLD = 48;
|
||||
|
||||
@@ -81,7 +88,8 @@ function groupEntriesByAgent(entries: AgentLogEntry[]): AgentLogGroup[] {
|
||||
|
||||
function isActiveAgentSession(task: Task | TaskDetail): boolean {
|
||||
const hasAssignedAgent = Boolean(task.assignedAgentId || task.checkedOutBy);
|
||||
const statusAllowsProgressSteering = !task.status || ACTIVE_STATUSES.has(task.status);
|
||||
const statusBlocksProgressSteering = task.status ? STEERING_BLOCKED_STATUSES.has(task.status) : false;
|
||||
const statusAllowsProgressSteering = !statusBlocksProgressSteering;
|
||||
const statusAllowsReviewSteering = !task.status || REVIEW_STEERABLE_STATUSES.has(task.status);
|
||||
const columnAllowsSteering = (task.column === "in-progress" && statusAllowsProgressSteering)
|
||||
|| (task.column === "in-review" && statusAllowsReviewSteering);
|
||||
@@ -253,7 +261,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
<form className="task-chat-composer card" onSubmit={handleSubmit}>
|
||||
{!activeSession ? (
|
||||
<div className="task-chat-session-hint" role="status">
|
||||
No active assigned agent session is available. An active, assigned agent session is required to send guidance.
|
||||
No active assigned agent session is available. An active, assigned, non-paused agent session is required to send guidance.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="task-chat-composer-row">
|
||||
@@ -261,7 +269,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
ref={textareaRef}
|
||||
className="input task-chat-input"
|
||||
value={draft}
|
||||
placeholder={activeSession ? "Message the active agent session…" : "Active assigned agent session required"}
|
||||
placeholder={activeSession ? "Message the active agent session…" : "Active non-paused agent session required"}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!activeSession || sending}
|
||||
|
||||
@@ -29,7 +29,7 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
assignedAgentId: "agent-1",
|
||||
status: "executing",
|
||||
status: undefined,
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
@@ -153,6 +153,61 @@ describe("TaskChatTab", () => {
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["queued", "Please continue after dispatch"],
|
||||
[undefined, "Please continue with a cleared status"],
|
||||
])("enables in-progress steering for realistic %s status and posts guidance", async (status, message) => {
|
||||
const user = userEvent.setup();
|
||||
mockedAddSteeringComment.mockResolvedValue(makeTask({ status }));
|
||||
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} projectId="project-1" active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText(/No active assigned agent session/)).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
expect(input).not.toBeDisabled();
|
||||
await user.type(input, message);
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await user.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", message, "project-1");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null, "queued", "planning", "merging", "merging-fix"])(
|
||||
"enables in-progress steering for assigned agents with %s status",
|
||||
(status) => {
|
||||
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.queryByText(/No active assigned agent session/)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("enables in-progress steering with checkedOutBy when no assignedAgentId exists", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedAddSteeringComment.mockResolvedValue(makeTask({ status: "queued" }));
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: "agent-1" })}
|
||||
projectId="project-1"
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
expect(input).not.toBeDisabled();
|
||||
await user.type(input, "Please keep going");
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await user.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please keep going", "project-1");
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["reviewing", "merging", "merging-fix", "fixing"])(
|
||||
"enables in-review steering while %s with an assigned agent",
|
||||
async (status) => {
|
||||
@@ -198,21 +253,37 @@ describe("TaskChatTab", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["todo task", makeTask({ column: "todo", assignedAgentId: undefined, status: undefined })],
|
||||
["todo task", makeTask({ column: "todo", assignedAgentId: "agent-1", status: undefined })],
|
||||
["triage task", makeTask({ column: "triage", assignedAgentId: "agent-1", status: undefined })],
|
||||
["done task", makeTask({ column: "done", assignedAgentId: "agent-1", status: undefined })],
|
||||
["archived task", makeTask({ column: "archived", assignedAgentId: "agent-1", status: undefined })],
|
||||
["in-progress task without an assigned or checked-out agent", makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["paused in-progress task", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
||||
["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
||||
["in-review task without an assigned or checked-out agent", makeTask({ column: "in-review", status: "reviewing", assignedAgentId: undefined, checkedOutBy: undefined })],
|
||||
["paused in-review task", makeTask({ column: "in-review", status: "reviewing", paused: true })],
|
||||
["user-paused in-review task", makeTask({ column: "in-review", status: "reviewing", userPaused: true })],
|
||||
["done task", makeTask({ column: "done", status: undefined })],
|
||||
])("disables the composer and shows a hint for %s", (_label, task) => {
|
||||
render(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/No active assigned agent session/)).toBeTruthy();
|
||||
expect(screen.getByText(/active, assigned agent session is required/i)).toBeTruthy();
|
||||
expect(screen.getByText(/active, assigned, non-paused agent session is required/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("Active assigned agent session required")).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText("Active non-paused agent session required")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each(["paused", "awaiting-user-input", "awaiting-cli-approval", "awaiting-user-review", "failed", "needs-replan"])(
|
||||
"disables in-progress steering for non-steerable %s status",
|
||||
(status) => {
|
||||
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/No active assigned agent session/)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces send failures through addToast", async () => {
|
||||
const user = userEvent.setup();
|
||||
const addToast = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user