diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index c430611082..e1f5884478 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -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 ; } -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(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
{!activeSession ? (
- 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.
) : null}
@@ -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} diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 3a0d4ab156..c277d10935 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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({
) : activeTab === "chat" ? (
- +
) : activeTab === "logs" ? (
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 6c7fa69019..660e5f4c05 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -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 { } 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 { return { timestamp: "2026-06-12T00:00:00.000Z", @@ -594,7 +606,7 @@ describe("TaskChatTab", () => { mockedAddSteeringComment.mockResolvedValue(makeTask({ status })); render(); - 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( + , + ); + + 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( + , + ); + + 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(); - 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(); - 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(); + + 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(); - 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(); },