FN-6338: enable chat steering for live CLI sessions
Allow the chat composer to recognize live CLI sessions as steerable agent sessions. - Pass live CLI session state from the task detail modal into the chat tab.\n- Keep paused and user-paused tasks blocked even when a CLI session exists.\n- Cover live, ended, and missing CLI session states in chat composer tests.\n\nFiles changed:\n packages/dashboard/app/components/TaskChatTab.tsx | 18 +++--\n .../dashboard/app/components/TaskDetailModal.tsx | 22 ++++--\n .../app/components/__tests__/TaskChatTab.test.tsx | 92 ++++++++++++++++++++--\n 3 files changed, 111 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-6338 Fusion-Task-Lineage: 7229c059-e094-4be3-bfba-b15d8c5069f9
This commit is contained in:
@@ -18,6 +18,7 @@ interface TaskChatTabProps {
|
||||
projectId?: string;
|
||||
active: boolean;
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
sessionLive?: boolean;
|
||||
}
|
||||
|
||||
type AgentLogRole = AgentRole | undefined;
|
||||
@@ -95,7 +96,10 @@ function groupEntriesByAgent(entries: AgentLogEntry[]): AgentLogGroup[] {
|
||||
}, []);
|
||||
}
|
||||
|
||||
function isActiveAgentSession(task: Task | TaskDetail): boolean {
|
||||
function isActiveAgentSession(task: Task | TaskDetail, opts: { sessionLive?: boolean } = {}): boolean {
|
||||
if (task.paused || task.userPaused) return false;
|
||||
if (opts.sessionLive) return true;
|
||||
|
||||
const hasAssignedAgent = Boolean(task.assignedAgentId || task.checkedOutBy);
|
||||
const statusBlocksProgressSteering = task.status ? STEERING_BLOCKED_STATUSES.has(task.status) : false;
|
||||
const statusAllowsProgressSteering = !statusBlocksProgressSteering;
|
||||
@@ -103,9 +107,7 @@ function isActiveAgentSession(task: Task | TaskDetail): boolean {
|
||||
const columnAllowsSteering = (task.column === "in-progress" && statusAllowsProgressSteering)
|
||||
|| (task.column === "in-review" && statusAllowsReviewSteering);
|
||||
return columnAllowsSteering
|
||||
&& hasAssignedAgent
|
||||
&& !task.paused
|
||||
&& !task.userPaused;
|
||||
&& hasAssignedAgent;
|
||||
}
|
||||
|
||||
function isToolLikeEntry(entry: AgentLogEntry): boolean {
|
||||
@@ -331,7 +333,7 @@ function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) {
|
||||
return <TaskChatTextEntry entry={segment.entry} />;
|
||||
}
|
||||
|
||||
export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabProps) {
|
||||
export function TaskChatTab({ task, projectId, active, addToast, sessionLive }: TaskChatTabProps) {
|
||||
const { entries, loading } = useAgentLogs(task.id, active, projectId);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -343,7 +345,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const groups = useMemo(() => groupEntriesByAgent(entries), [entries]);
|
||||
const activeSession = isActiveAgentSession(task);
|
||||
const activeSession = isActiveAgentSession(task, { sessionLive });
|
||||
const canSend = activeSession && draft.trim().length > 0 && !sending;
|
||||
|
||||
const resizeComposer = useCallback(() => {
|
||||
@@ -523,7 +525,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, non-paused agent session is required to send guidance.
|
||||
No active steerable agent session is available. An active assigned task agent or live, non-paused CLI session is required to send guidance.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="task-chat-composer-row">
|
||||
@@ -531,7 +533,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 non-paused agent session required"}
|
||||
placeholder={activeSession ? "Message the active agent session…" : "Active steerable agent session required"}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!activeSession || sending}
|
||||
|
||||
@@ -322,17 +322,19 @@ type CliTabVisibility =
|
||||
* - dead/needsAttention (PTY reaped) → replay "session ended"
|
||||
* - no recorded session → hidden
|
||||
*/
|
||||
export function isCliSessionLive(session: CliSessionSummaryRecord | null): boolean {
|
||||
return session?.agentState === "starting"
|
||||
|| session?.agentState === "ready"
|
||||
|| session?.agentState === "busy"
|
||||
|| session?.agentState === "waitingOnInput";
|
||||
}
|
||||
|
||||
export function deriveCliTabVisibility(
|
||||
session: CliSessionSummaryRecord | null,
|
||||
opts: { oneShot?: boolean; genericIdle?: boolean } = {},
|
||||
): CliTabVisibility {
|
||||
if (!session) return { kind: "hidden" };
|
||||
const live =
|
||||
session.agentState === "starting" ||
|
||||
session.agentState === "ready" ||
|
||||
session.agentState === "busy" ||
|
||||
session.agentState === "waitingOnInput";
|
||||
if (live) {
|
||||
if (isCliSessionLive(session)) {
|
||||
return {
|
||||
kind: "live",
|
||||
readOnly: Boolean(opts.oneShot),
|
||||
@@ -3123,7 +3125,13 @@ export function TaskDetailContent({
|
||||
</div>
|
||||
) : activeTab === "chat" ? (
|
||||
<div className="detail-section">
|
||||
<TaskChatTab task={task} projectId={projectId} active={activeTab === "chat"} addToast={addToast} />
|
||||
<TaskChatTab
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
active={activeTab === "chat"}
|
||||
addToast={addToast}
|
||||
sessionLive={isCliSessionLive(cliSession)}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "logs" ? (
|
||||
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
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";
|
||||
|
||||
@@ -39,6 +40,17 @@ function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function makeCliSession(agentState: CliSessionSummaryRecord["agentState"]): CliSessionSummaryRecord {
|
||||
return {
|
||||
id: "session-1",
|
||||
taskId: "FN-001",
|
||||
projectId: "project-1",
|
||||
adapterId: "claude",
|
||||
agentState,
|
||||
terminationReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntry(overrides: Partial<AgentLogEntry>): AgentLogEntry {
|
||||
return {
|
||||
timestamp: "2026-06-12T00:00:00.000Z",
|
||||
@@ -594,7 +606,7 @@ describe("TaskChatTab", () => {
|
||||
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();
|
||||
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
expect(input).not.toBeDisabled();
|
||||
await user.type(input, message);
|
||||
@@ -607,12 +619,67 @@ describe("TaskChatTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["starting", "ready", "busy", "waitingOnInput"] as const)(
|
||||
"enables steering for a live %s CLI session when static task fields are not steerable",
|
||||
async (agentState) => {
|
||||
const user = userEvent.setup();
|
||||
mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status: "queued" }));
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "in-review", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
projectId="project-1"
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={isCliSessionLive(makeCliSession(agentState))}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText("Message active agent session");
|
||||
expect(input).not.toBeDisabled();
|
||||
await user.type(input, `Please continue ${agentState}`);
|
||||
const sendButton = screen.getByRole("button", { name: "Send" });
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
await user.click(sendButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", `Please continue ${agentState}`, "project-1");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("enables steering for a live CLI session in a terminal column that static task fields reject", () => {
|
||||
render(
|
||||
<TaskChatTab
|
||||
task={makeTask({ column: "todo", status: undefined, assignedAgentId: undefined, checkedOutBy: undefined })}
|
||||
active
|
||||
addToast={vi.fn()}
|
||||
sessionLive={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it.each(["busy", "ready", "starting", "waitingOnInput"] as const)("treats %s CLI sessions as live", (agentState) => {
|
||||
expect(isCliSessionLive(makeCliSession(agentState))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["done", "dead", "needsAttention"] as const)("treats %s CLI sessions as not live", (agentState) => {
|
||||
expect(isCliSessionLive(makeCliSession(agentState))).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing CLI session as not live", () => {
|
||||
expect(isCliSessionLive(null)).toBe(false);
|
||||
});
|
||||
|
||||
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.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
||||
},
|
||||
);
|
||||
@@ -699,10 +766,23 @@ describe("TaskChatTab", () => {
|
||||
])("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, non-paused agent session is required/i)).toBeTruthy();
|
||||
expect(screen.getByText(/No active steerable agent session/)).toBeTruthy();
|
||||
expect(screen.getByText(/active assigned task agent or live, non-paused CLI session is required/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("Active steerable agent session required")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
||||
["user-paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
||||
["paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", paused: true })],
|
||||
["user-paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", userPaused: true })],
|
||||
])("disables the composer for %s", (_label, task) => {
|
||||
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={true} />);
|
||||
|
||||
expect(screen.getByText(/No active steerable agent session/)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("Active non-paused agent session required")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -711,7 +791,7 @@ describe("TaskChatTab", () => {
|
||||
(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.getByText(/No active steerable agent session/)).toBeTruthy();
|
||||
expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user