From d2c7ff5f95be0af3c5eb2ad609e98e39901749c9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 20 Jun 2026 02:56:51 -0700 Subject: [PATCH] FN-6751: show idle task chat warning Make task-detail chat disclose when sent guidance will not receive an immediate agent reply. - Add an idle session hint for non-done tasks without an active steerable agent while keeping the composer sendable. - Style the idle hint with warning color and expose a stable test id for regression coverage. - Expand TaskChatTab tests across inline, expanded, empty, populated, paused, and non-live session states. - Document the idle saved-guidance behavior in the dashboard guide. Files changed: docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/TaskChatTab.css | 5 ++ packages/dashboard/app/components/TaskChatTab.tsx | 13 ++- .../app/components/__tests__/TaskChatTab.test.tsx | 95 +++++++++++++++++++--- 4 files changed, 99 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-6751 Fusion-Task-Lineage: 21f0764d-23db-49ca-b737-3b6a8be7a918 --- docs/dashboard-guide.md | 2 +- .../dashboard/app/components/TaskChatTab.css | 5 + .../dashboard/app/components/TaskChatTab.tsx | 13 ++- .../components/__tests__/TaskChatTab.test.tsx | 95 ++++++++++++++++--- 4 files changed, 99 insertions(+), 16 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index caf77ccd7e..9b847b8b27 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -832,7 +832,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. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. 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 older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. 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 **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. Agent group headers and user message headers show a small muted relative timestamp (for example, “just now”, “1m ago”, or “2h ago”) based on the transcript timestamp, while agent group metadata still includes the entry count. 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 older task-agent history exists, scrolling to the top or selecting **Load previous messages** prepends earlier transcript entries without moving the message you were reading. 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. When no active or steerable agent session will reply immediately, the composer shows an idle warning hint that no agent is currently working on the task and that the sent message is saved for the next run; the input and Send button remain usable. 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: diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 69df5a020d..7ca5a27319 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -355,6 +355,11 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon font-size: var(--space-md); } +/* FNXC:TaskDetailChat 2026-06-19-22:54: Idle task-detail chat needs a warning-token hint so saved guidance is visibly non-live without disabling the composer. */ +.task-chat-session-hint--idle { + color: var(--color-warning); +} + .task-chat-composer-row { display: flex; align-items: flex-end; diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 274d2b2f5d..98c4265e7b 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -457,11 +457,16 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on const firstEntryKey = entries[0] ? getEntryKey(entries[0], 0) : null; const activeSession = isActiveAgentSession(task, { sessionLive }); const isDoneTask = task.column === "done"; + const isIdleSession = !isDoneTask && !activeSession; + /** + * FNXC:TaskDetailChat 2026-06-19-22:54: + * The task-detail chat must never silently accept a question when no agent session will consume it. Keep idle chats sendable, but surface that the message is saved as guidance for the next task run instead of implying a live reply. + */ const sessionHint = isDoneTask ? "Send a message to start a refinement task for this completed task." : activeSession ? "Message the active agent session. Guidance is delivered to the running session in real time." - : null; + : "No agent is working on this task right now. Your message is saved as guidance and will reach an agent the next time this task runs."; const composerPlaceholder = isDoneTask ? "Start a refinement task for this completed task" : "Steer the currently executing agent"; @@ -804,7 +809,11 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on
{sessionHint ? ( -
+
{sessionHint}
) : null} diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 09337978ab..0d043c7a8d 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -167,20 +167,25 @@ function expectTranscriptTextOrder(...texts: string[]) { } } -function expectNoInactiveSessionHint() { - expect(screen.queryByText(/picked up by the next session/i)).not.toBeInTheDocument(); - expect(document.querySelector(".task-chat-session-hint")).not.toBeInTheDocument(); +function expectIdleSessionHint() { + const idleHint = screen.getByTestId("task-chat-idle-hint"); + expect(idleHint).toBeVisible(); + expect(idleHint).toHaveTextContent(/no agent is working on this task right now/i); + expect(idleHint).toHaveTextContent(/saved as guidance/i); + expect(idleHint).toHaveTextContent(/next time this task runs/i); expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument(); } function expectActiveSessionCopy() { expect(screen.getByText(/active agent session/i)).toBeInTheDocument(); expect(screen.getByText(/delivered to the running session in real time/i)).toBeInTheDocument(); + expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument(); } function expectDoneRefinementCopy() { expect(screen.getByText(/start a refinement task for this completed task/i)).toBeInTheDocument(); expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument(); + expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument(); } function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) { @@ -525,6 +530,40 @@ describe("TaskChatTab", () => { expect(within(userHeader as HTMLElement).getByTestId("task-chat-user-time")).toHaveTextContent("3m ago"); }); + it.each([ + ["inline", false], + ["expanded", true], + ])("renders the idle no-reply hint in the %s task chat surface", (_label, expanded) => { + render( + , + ); + + expectIdleSessionHint(); + }); + + it.each([ + ["empty", [], makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })], + ["populated", [makeEntry({ agent: "executor", text: "Earlier agent output" })], makeTask({ + column: "todo", + assignedAgentId: undefined, + checkedOutBy: undefined, + status: undefined, + steeringComments: [makeSteeringComment({ id: "idle-populated-user", text: "Earlier saved guidance" })], + })], + ] as const)("renders the composer-anchored idle hint with an %s transcript", (_label, entries, task) => { + mockLogs([...entries]); + render(); + + expectIdleSessionHint(); + }); + it("renders a single text entry as one text bubble", () => { mockLogs([ makeEntry({ agent: "executor", text: "single response" }), @@ -1659,7 +1698,7 @@ describe("TaskChatTab", () => { ); expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); - expectNoInactiveSessionHint(); + expectIdleSessionHint(); const input = screen.getByLabelText("Message active agent session"); expect(input).not.toBeDisabled(); const sendButton = screen.getByRole("button", { name: "Send" }); @@ -1672,9 +1711,37 @@ describe("TaskChatTab", () => { await waitFor(() => { expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Queue this for later", "project-1"); }); + expectIdleSessionHint(); expect(within(screen.getByTestId("task-chat-transcript")).getByText("Queue this for later")).toBeVisible(); }); + it.each([ + ["todo task with no agent", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })], + ["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })], + ])("makes the no-reply send path non-silent for a %s", async (_label, task) => { + const user = userEvent.setup(); + const send = deferred(); + mockedAddSteeringComment.mockReturnValue(send.promise); + const message = `Will anyone answer ${_label}?`; + render(); + + expectIdleSessionHint(); + await user.type(screen.getByLabelText("Message active agent session"), message); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", message, "project-1"); + expectIdleSessionHint(); + expect(within(screen.getByTestId("task-chat-transcript")).getByText(message)).toBeVisible(); + + await act(async () => { + send.resolve(makeTask({ ...task, steeringComments: [makeSteeringComment({ id: `persisted-${task.column}`, text: message })] })); + await send.promise; + }); + + expectIdleSessionHint(); + expect(within(screen.getByTestId("task-chat-transcript")).getByText(message)).toBeVisible(); + }); + 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) => { @@ -1691,6 +1758,7 @@ describe("TaskChatTab", () => { ); expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expectActiveSessionCopy(); const input = screen.getByLabelText("Message active agent session"); expect(input).not.toBeDisabled(); await user.type(input, `Please continue ${agentState}`); @@ -1715,10 +1783,11 @@ describe("TaskChatTab", () => { ); expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument(); + expectActiveSessionCopy(); expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled(); }); - it.each(["done", "dead", "needsAttention", null] as const)("shows queued copy but stays sendable when the CLI session is not live: %s", (agentState) => { + it.each(["done", "dead", "needsAttention", null] as const)("shows idle guidance but stays sendable when the CLI session is not live: %s", (agentState) => { const sessionLive = agentState === null ? isCliSessionLive(null) : isCliSessionLive(makeCliSession(agentState)); render( { />, ); - expectNoInactiveSessionHint(); + expectIdleSessionHint(); expectComposerSendableAfterDraft(); }); @@ -1864,7 +1933,7 @@ describe("TaskChatTab", () => { } else if (showsActiveCopy) { expectActiveSessionCopy(); } else { - expectNoInactiveSessionHint(); + expectIdleSessionHint(); } expectComposerSendableAfterDraft(); }); @@ -1876,10 +1945,10 @@ describe("TaskChatTab", () => { ["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 })], - ])("keeps the composer sendable with queued copy for %s", (_label, task) => { + ])("keeps the composer sendable with idle guidance for %s", (_label, task) => { render(); - expectNoInactiveSessionHint(); + expectIdleSessionHint(); expectComposerSendableAfterDraft(); }); @@ -1888,19 +1957,19 @@ describe("TaskChatTab", () => { ["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 })], - ])("keeps the composer sendable with queued copy for %s", (_label, task) => { + ])("keeps the composer sendable with idle guidance for %s", (_label, task) => { render(); - expectNoInactiveSessionHint(); + expectIdleSessionHint(); expectComposerSendableAfterDraft(); }); it.each(["paused", "awaiting-user-input", "awaiting-cli-approval", "awaiting-user-review", "failed", "needs-replan"])( - "keeps in-progress steering sendable with queued copy for %s status", + "keeps in-progress steering sendable with idle guidance for %s status", (status) => { render(); - expectNoInactiveSessionHint(); + expectIdleSessionHint(); expectComposerSendableAfterDraft(); }, );