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:
gsxdsm
2026-06-12 23:32:16 -07:00
parent a9b11399d9
commit 9377d20420
3 changed files with 111 additions and 21 deletions

View File

@@ -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}

View File

@@ -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" : ""}`}>

View File

@@ -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();
},