FN-7309: add Activity steering composer labels
Make Activity Current clearly expose steering and refinement entry points. - Add explicit Steering comment and Refinement request labels with accessible hints to the Activity Current composer. - Keep Feed and Raw Logs read-only while preserving done-task Summary defaults and legacy Activity Current links. - Cover desktop, mobile, embedded detail, completed-task, and segment-switching behavior in dashboard tests. - Document the Activity steering affordance and add a patch changeset for the published CLI package. Files changed: .changeset/fn-7309-activity-steering.md | 7 ++ docs/dashboard-guide.md | 9 +-- packages/dashboard/app/components/TaskChatTab.css | 29 ++++++++ packages/dashboard/app/components/TaskChatTab.tsx | 22 +++++-- .../dashboard/app/components/TaskDetailModal.tsx | 12 +++- .../app/components/__tests__/TaskChatTab.test.tsx | 19 +++++- .../__tests__/TaskDetailModal.summary-tab.test.tsx | 22 +++---- .../components/__tests__/TaskDetailModal.test.tsx | 77 ++++++++++++++++++++++ 8 files changed, 173 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-7309 Fusion-Task-Lineage: 0674bf35-b0a3-4fa7-81c3-4312024c9c53 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7309-activity-steering.md
Normal file
7
.changeset/fn-7309-activity-steering.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add a steering entry affordance to task Activity.
|
||||||
|
category: feature
|
||||||
|
dev: Labels the Activity Current composer as steering/refinement and covers Feed/Raw Logs placement.
|
||||||
@@ -1040,9 +1040,9 @@ For related global/project configuration behavior, see [Settings reference](./se
|
|||||||
Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal.
|
Inspect task definition, logs, review feedback, comments, artifacts, workflow outcomes, model overrides, and task routing from a single modal.
|
||||||
|
|
||||||
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
|
- Editable tasks with descriptions show **Summarize as title** beside the read-mode title; it asks AI to generate a concise title from the description and saves it without opening the edit form.
|
||||||
- The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. Non-`done` tasks still open on **Chat** by default.
|
- The **Summary** tab appears first for `done` tasks and is their default landing tab. It shows the recorded completion summary, changed-file/merge stats when available, completed steps, workflow results, retry counts, and a token usage & cost section broken down by model from the already-loaded task detail; unpriced models show cost as unavailable rather than `$0`. Non-`done` tasks still open on **Activity** by default.
|
||||||
- The **Chat** tab includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
- The **Activity → Current** segment includes an expand/collapse control that lets the transcript and composer fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||||
- Task-detail Chat messages are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass.
|
- Task-detail Activity steering comments are persisted as user comments/steering guidance and surfaced to every relevant agent lane: live executor sessions receive steering injection, while planner, reviewer (spec/plan/code), and merger agents (standard and clean-room AI merge/review) receive the latest user comments in their next prompt/pass.
|
||||||
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
- The priority chip in task metadata is an inline picker: you can change priority directly without entering full edit mode.
|
||||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||||
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
- These two metadata controls share matched sizing/alignment in read mode (including mobile wrapping) so they behave like a single polished control group.
|
||||||
@@ -1117,7 +1117,8 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
|||||||
|
|
||||||
### Activity → Raw Logs view
|
### Activity → Raw Logs view
|
||||||
|
|
||||||
The **Activity** tab is the first task-detail tab and presents a segmented control for **Current**, **Feed**, and **Raw Logs**. Current contains the 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 and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, 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 planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Current message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. The composer no longer renders guidance text above the entry box; active, idle, and completed task actions are communicated through the textarea placeholder while the input and Send button remain usable. On a `done` task, sending an Activity Current 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 Activity Current segment 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.
|
<!-- FNXC:TaskDetailActivity 2026-06-30-21:55: Activity Current is the explicit operational steering-comment entry surface. Feed and Raw Logs remain read-only Activity segments, and the future planner-model Chat tab is intentionally separate. -->
|
||||||
|
The **Activity** tab is the first task-detail tab and presents a segmented control for **Current**, **Feed**, and **Raw Logs**. Current contains the 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 and mirrors regular Chat's dense treatment; the summary stays single-line/ellipsis-friendly on desktop and mobile, 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 **Steering comment** composer sends guidance through the same steering path used by comments, including active planning/triage, `in-progress`, and `in-review` sessions, plus live CLI-agent sessions reported by the session bridge; an `in-review` Activity Current message or Comments-tab task comment re-engages an executor unless an open PR blocks moving the task back, and other messages are still saved as queued guidance when no session is currently live. Feed and Raw Logs do not show a steering composer. On a `done` task, the composer switches to **Refinement request** copy; sending starts a refinement task using the typed text as feedback and shows a success toast with the new task ID, while the current task detail modal remains on the completed task. The task-detail Activity Current segment 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 **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed:
|
The **Raw Logs** segment is designed for debugging long-running and tool-heavy sessions, while legacy links that requested the former top-level Logs tab land on Activity → Feed:
|
||||||
|
|
||||||
|
|||||||
@@ -481,6 +481,25 @@ FN-7241 adds timestamps inside individual task-detail transcript blocks. Keep bl
|
|||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-chat-composer-affordance {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-chat-composer-label {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-chat-composer-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--space-md);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.task-chat-composer-row {
|
.task-chat-composer-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -618,6 +637,16 @@ FN-6660 corrects the repeated mobile sizing misses from FN-6507, FN-6604, and FN
|
|||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-chat-composer-affordance {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: calc(var(--space-xs) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-chat-composer-hint {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.task-chat-composer-row {
|
.task-chat-composer-row {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core";
|
import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from "@fusion/core";
|
||||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react";
|
import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react";
|
||||||
@@ -582,6 +582,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
const previousActiveRef = useRef(false);
|
const previousActiveRef = useRef(false);
|
||||||
const anchorFrameRef = useRef<number | null>(null);
|
const anchorFrameRef = useRef<number | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const composerHintId = useId();
|
||||||
|
|
||||||
const userMessages = useMemo(
|
const userMessages = useMemo(
|
||||||
() => mergeUserMessages(task.steeringComments, optimisticMessages),
|
() => mergeUserMessages(task.steeringComments, optimisticMessages),
|
||||||
@@ -591,10 +592,16 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
const transcriptItemCount = entries.length + userMessages.length;
|
const transcriptItemCount = entries.length + userMessages.length;
|
||||||
const firstEntryKey = entries[0] ? getEntryKey(entries[0], 0) : null;
|
const firstEntryKey = entries[0] ? getEntryKey(entries[0], 0) : null;
|
||||||
const isDoneTask = task.column === "done";
|
const isDoneTask = task.column === "done";
|
||||||
/**
|
/*
|
||||||
* FNXC:TaskDetailChat 2026-06-24-00:00:
|
* FNXC:TaskDetailActivity 2026-06-30-21:51:
|
||||||
* The task-detail chat composer must not render guidance above the entry box in active, idle, or done states. Keep the action-specific copy in the textarea placeholder so the composer remains compact while send/refinement behavior stays unchanged.
|
* Activity → Current is the operational steering surface for task execution. Show an explicit steering-comment affordance here while keeping the future top-level planner-model Chat tab out of scope; Feed and Raw Logs remain read-only Activity segments without this composer.
|
||||||
*/
|
*/
|
||||||
|
const composerLabel = isDoneTask
|
||||||
|
? t("taskChat.refinementComposerLabel", "Refinement request")
|
||||||
|
: t("taskChat.steeringComposerLabel", "Steering comment");
|
||||||
|
const composerHint = isDoneTask
|
||||||
|
? t("taskChat.refinementComposerHint", "Create a follow-up refinement task from this completed task.")
|
||||||
|
: t("taskChat.steeringComposerHint", "Send operational guidance to the active task through steering comments.");
|
||||||
const composerPlaceholder = isDoneTask
|
const composerPlaceholder = isDoneTask
|
||||||
? t("taskChat.donePlaceholder", "Start a refinement task for this completed task")
|
? t("taskChat.donePlaceholder", "Start a refinement task for this completed task")
|
||||||
: t("taskChat.activePlaceholder", "Steer the currently executing agent");
|
: t("taskChat.activePlaceholder", "Steer the currently executing agent");
|
||||||
@@ -931,7 +938,11 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="task-chat-composer card" onSubmit={handleSubmit}>
|
<form className="task-chat-composer card" onSubmit={handleSubmit} aria-label={composerLabel}>
|
||||||
|
<div className="task-chat-composer-affordance">
|
||||||
|
<span className="task-chat-composer-label">{composerLabel}</span>
|
||||||
|
<span className="task-chat-composer-hint" id={composerHintId}>{composerHint}</span>
|
||||||
|
</div>
|
||||||
<div className="task-chat-composer-row">
|
<div className="task-chat-composer-row">
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@@ -942,6 +953,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
aria-label={t("taskChat.messageActiveAgentSession", "Message active agent session")}
|
aria-label={t("taskChat.messageActiveAgentSession", "Message active agent session")}
|
||||||
|
aria-describedby={composerHintId}
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -196,12 +196,15 @@ type ActivitySegment = "current" | "feed" | "raw-logs";
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
FNXC:TaskDetailActivityTab 2026-06-30-00:00:
|
||||||
The existing task activity/steering surface keeps the stable internal `chat` tab id for deep-link/plugin compatibility, but its top-level user-facing label is Activity. Activity is the implicit default for every task column, including done tasks; Summary remains explicitly available for completed work until a later subtask adds the separate planner-model Chat surface.
|
The existing task activity/steering surface keeps the stable internal `chat` tab id for deep-link/plugin compatibility, but its top-level user-facing label is Activity. Activity is the implicit default for active task columns; done tasks keep Summary as their omitted-initial-tab landing surface so completed work still opens on the completion report while Activity remains first in tab order.
|
||||||
|
|
||||||
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
||||||
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open Activity → Current. Legacy `initialTab="logs"` now routes to Activity → Feed, and Raw Logs remains an Activity segment, because the legacy top-level Logs tab must not return while the later planner-model Chat tab remains out of scope.
|
Only an omitted initial tab is the implicit default. Preserve explicit `initialTab="chat"` requests from plugins and task-detail entrypoints so existing links continue to open Activity → Current. Legacy `initialTab="logs"` now routes to Activity → Feed, and Raw Logs remains an Activity segment, because the legacy top-level Logs tab must not return while the later planner-model Chat tab remains out of scope.
|
||||||
|
|
||||||
|
FNXC:TaskDetailActivity 2026-06-30-21:55:
|
||||||
|
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as the future planner-model Chat conversation; that belongs to a later top-level tab.
|
||||||
*/
|
*/
|
||||||
function resolveDefaultTab(initialTab: TabId | undefined, _column: ColumnId): TabId {
|
function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId): TabId {
|
||||||
if (initialTab === "retries") {
|
if (initialTab === "retries") {
|
||||||
return "definition";
|
return "definition";
|
||||||
}
|
}
|
||||||
@@ -211,7 +214,7 @@ function resolveDefaultTab(initialTab: TabId | undefined, _column: ColumnId): Ta
|
|||||||
if (initialTab) {
|
if (initialTab) {
|
||||||
return initialTab;
|
return initialTab;
|
||||||
}
|
}
|
||||||
return "chat";
|
return column === "done" ? "summary" : "chat";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDefaultActivitySegment(initialTab: TabId | undefined): ActivitySegment {
|
function resolveDefaultActivitySegment(initialTab: TabId | undefined): ActivitySegment {
|
||||||
@@ -3289,6 +3292,9 @@ export function TaskDetailContent({
|
|||||||
{/*
|
{/*
|
||||||
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
FNXC:TaskDetailActivity 2026-06-30-15:50:
|
||||||
Activity owns the existing steering/current view, Feed, and Raw Logs inside one segmented control. The later planner-model Chat tab is intentionally out of scope, so the stable top-level tab id remains `chat`, legacy `logs` callers land on Feed, and Raw Logs is the only segment that enables raw agent-log fetching.
|
Activity owns the existing steering/current view, Feed, and Raw Logs inside one segmented control. The later planner-model Chat tab is intentionally out of scope, so the stable top-level tab id remains `chat`, legacy `logs` callers land on Feed, and Raw Logs is the only segment that enables raw agent-log fetching.
|
||||||
|
|
||||||
|
FNXC:TaskDetailActivity 2026-06-30-21:55:
|
||||||
|
The first Activity segment keeps the stable Current label for legacy segment tests and links, but its embedded composer labels the operational steering-comment affordance explicitly. Do not reuse this segment as the future planner-model Chat conversation; that belongs to a later top-level tab.
|
||||||
*/}
|
*/}
|
||||||
<div className="activity-segmented-control" role="tablist" aria-label={t("taskDetail.activity.segmentsLabel", "Activity views")}>
|
<div className="activity-segmented-control" role="tablist" aria-label={t("taskDetail.activity.segmentsLabel", "Activity views")}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2415,11 +2415,14 @@ describe("TaskChatTab", () => {
|
|||||||
expect(onTaskUpdated).not.toHaveBeenCalled();
|
expect(onTaskUpdated).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the same composer affordance shell on desktop and mobile breakpoints", () => {
|
it("renders the same explicit steering composer affordance on desktop and mobile breakpoints", () => {
|
||||||
mockMatchMedia(false);
|
mockMatchMedia(false);
|
||||||
const desktop = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
const desktop = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("form", { name: "Steering comment" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Send operational guidance to the active task through steering comments.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Message active agent session")).toHaveAccessibleDescription("Send operational guidance to the active task through steering comments.");
|
||||||
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
||||||
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
||||||
desktop.unmount();
|
desktop.unmount();
|
||||||
@@ -2428,10 +2431,20 @@ describe("TaskChatTab", () => {
|
|||||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||||
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("form", { name: "Steering comment" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Send operational guidance to the active task through steering comments.")).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
||||||
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps completed tasks on the refinement affordance instead of steering copy", () => {
|
||||||
|
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("form", { name: "Refinement request" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Create a follow-up refinement task from this completed task.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("FN-6347 pins the composer while the transcript flex-fills without fixed viewport caps", () => {
|
it("FN-6347 pins the composer while the transcript flex-fills without fixed viewport caps", () => {
|
||||||
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
||||||
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
|
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
|
||||||
@@ -2754,17 +2767,21 @@ describe("TaskChatTab", () => {
|
|||||||
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
||||||
const sendRule = getCssRuleBlock(css, ".task-chat-send");
|
const sendRule = getCssRuleBlock(css, ".task-chat-send");
|
||||||
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
||||||
|
const mobileAffordanceRule = getCssRuleBlock(mobileCss, ".task-chat-composer-affordance");
|
||||||
const mobileComposerRule = getCssRuleBlock(mobileCss, ".task-chat-composer-row");
|
const mobileComposerRule = getCssRuleBlock(mobileCss, ".task-chat-composer-row");
|
||||||
const mobileSendRule = getCssRuleBlock(mobileCss, ".task-chat-send");
|
const mobileSendRule = getCssRuleBlock(mobileCss, ".task-chat-send");
|
||||||
|
|
||||||
expect(css).toContain("@media (max-width: 768px)");
|
expect(css).toContain("@media (max-width: 768px)");
|
||||||
expect(css).toContain(".task-chat-transcript");
|
expect(css).toContain(".task-chat-transcript");
|
||||||
expect(css).toContain(".task-chat-jump-to-bottom");
|
expect(css).toContain(".task-chat-jump-to-bottom");
|
||||||
|
expect(css).toContain(".task-chat-composer-affordance");
|
||||||
expect(css).toContain(".task-chat-composer-row");
|
expect(css).toContain(".task-chat-composer-row");
|
||||||
expect(sendRule).toContain("--btn-icon-size: var(--space-2xl)");
|
expect(sendRule).toContain("--btn-icon-size: var(--space-2xl)");
|
||||||
expect(sendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))");
|
expect(sendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))");
|
||||||
expect(sendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-sm))");
|
expect(sendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-sm))");
|
||||||
expect(sendRule).not.toContain("gap");
|
expect(sendRule).not.toContain("gap");
|
||||||
|
expect(mobileAffordanceRule).toContain("flex-direction: column");
|
||||||
|
expect(mobileAffordanceRule).toContain("gap: calc(var(--space-xs) / 2)");
|
||||||
expect(mobileComposerRule).toContain("align-items: flex-end");
|
expect(mobileComposerRule).toContain("align-items: flex-end");
|
||||||
expect(mobileComposerRule).not.toContain("flex-direction: column");
|
expect(mobileComposerRule).not.toContain("flex-direction: column");
|
||||||
expect(mobileComposerRule).not.toContain("align-items: stretch");
|
expect(mobileComposerRule).not.toContain("align-items: stretch");
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ function doneTask(overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("TaskDetailModal Summary tab", () => {
|
describe("TaskDetailModal Summary tab", () => {
|
||||||
it("lands done tasks on Activity first and keeps Summary accessible", () => {
|
it("lands done tasks on Summary by default while keeping Activity first and accessible", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
task={doneTask()}
|
task={doneTask()}
|
||||||
@@ -90,15 +90,9 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Activity");
|
expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Activity");
|
||||||
const activityButton = screen.getByRole("button", { name: "Activity" });
|
|
||||||
expectButtonActive(activityButton);
|
|
||||||
expect(screen.queryByRole("button", { name: "Chat" })).toBeNull();
|
|
||||||
expect(screen.queryByText("Completion summary")).toBeNull();
|
|
||||||
expect(container.querySelector(".detail-section--chat [data-testid='task-chat-tab']")).toBeTruthy();
|
|
||||||
|
|
||||||
const summaryButton = screen.getByRole("button", { name: "Summary" });
|
const summaryButton = screen.getByRole("button", { name: "Summary" });
|
||||||
fireEvent.click(summaryButton);
|
|
||||||
expectButtonActive(summaryButton);
|
expectButtonActive(summaryButton);
|
||||||
|
expect(screen.queryByRole("button", { name: "Chat" })).toBeNull();
|
||||||
expect(screen.getByText("Completion summary")).toBeTruthy();
|
expect(screen.getByText("Completion summary")).toBeTruthy();
|
||||||
expect(screen.getByText("summary")).toBeTruthy();
|
expect(screen.getByText("summary")).toBeTruthy();
|
||||||
expect(screen.getByText("What changed")).toBeTruthy();
|
expect(screen.getByText("What changed")).toBeTruthy();
|
||||||
@@ -107,6 +101,12 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
expect(screen.getByText("Preflight")).toBeTruthy();
|
expect(screen.getByText("Preflight")).toBeTruthy();
|
||||||
expect(screen.getByText("Code Review")).toBeTruthy();
|
expect(screen.getByText("Code Review")).toBeTruthy();
|
||||||
expect(screen.getByText("Agents retried this task 1 time.")).toBeTruthy();
|
expect(screen.getByText("Agents retried this task 1 time.")).toBeTruthy();
|
||||||
|
|
||||||
|
const activityButton = screen.getByRole("button", { name: "Activity" });
|
||||||
|
fireEvent.click(activityButton);
|
||||||
|
expectButtonActive(activityButton);
|
||||||
|
expect(screen.queryByText("Completion summary")).toBeNull();
|
||||||
|
expect(container.querySelector(".detail-section--chat [data-testid='task-chat-tab']")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors explicit initialTab=\"chat\" for done tasks", () => {
|
it("honors explicit initialTab=\"chat\" for done tasks", () => {
|
||||||
@@ -461,7 +461,7 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
expect(summaryButton.classList.contains("detail-tab")).toBe(true);
|
expect(summaryButton.classList.contains("detail-tab")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves the done-task Activity default in embedded TaskDetailContent", () => {
|
it("resolves the done-task Summary default in embedded TaskDetailContent", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<TaskDetailContent
|
<TaskDetailContent
|
||||||
task={doneTask()}
|
task={doneTask()}
|
||||||
@@ -474,8 +474,8 @@ describe("TaskDetailModal Summary tab", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expectButtonActive(screen.getByRole("button", { name: "Activity" }));
|
expectButtonActive(screen.getByRole("button", { name: "Summary" }));
|
||||||
expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Activity");
|
expect(container.querySelector(".detail-tabs")?.firstElementChild?.textContent).toBe("Activity");
|
||||||
expect(screen.queryByText("Completion summary")).toBeNull();
|
expect(screen.getByText("Completion summary")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -375,6 +375,83 @@ describe("TaskDetailModal Activity feed loading", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskDetailModal Chat task merge", () => {
|
describe("TaskDetailModal Chat task merge", () => {
|
||||||
|
it("exposes the steering composer only in Activity Current and posts through task updates", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const { addSteeringComment } = await import("../../api");
|
||||||
|
const onTaskUpdated = vi.fn();
|
||||||
|
const updatedTask = makeTask({
|
||||||
|
id: "FN-7309",
|
||||||
|
column: "in-progress" as any,
|
||||||
|
steeringComments: [{ id: "steer-7309", text: "Please keep the current approach", author: "user", createdAt: "2026-06-30T21:00:00.000Z" }],
|
||||||
|
});
|
||||||
|
vi.mocked(addSteeringComment).mockReset();
|
||||||
|
vi.mocked(addSteeringComment).mockResolvedValueOnce(updatedTask);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TaskDetailModal
|
||||||
|
task={makeTask({ id: "FN-7309", column: "in-progress" as any, log: [{ timestamp: "2026-06-30T20:00:00.000Z", action: "Started work" }] })}
|
||||||
|
initialTab="chat"
|
||||||
|
projectId="project-7309"
|
||||||
|
onClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
onTaskUpdated={onTaskUpdated}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("tab", { name: "Current" })).toHaveAttribute("aria-selected", "true");
|
||||||
|
expect(screen.getAllByRole("form", { name: "Steering comment" })).toHaveLength(1);
|
||||||
|
expect(screen.getByText("Send operational guidance to the active task through steering comments.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Feed" }));
|
||||||
|
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Started work")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Raw Logs" }));
|
||||||
|
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("agent-log-viewer")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Current" }));
|
||||||
|
const input = screen.getByLabelText("Message active agent session");
|
||||||
|
await user.type(input, "Please keep the current approach");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addSteeringComment).toHaveBeenCalledWith("FN-7309", "Please keep the current approach", "project-7309");
|
||||||
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the steering composer in embedded task detail without duplicating Feed or Raw Logs composers", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<TaskDetailContent
|
||||||
|
task={makeTask({ id: "FN-7310", column: "todo" as any, steeringComments: undefined, log: [] })}
|
||||||
|
projectId="project-7309"
|
||||||
|
embedded
|
||||||
|
onRequestClose={noop}
|
||||||
|
onMoveTask={noopMove}
|
||||||
|
onDeleteTask={noopDelete}
|
||||||
|
onMergeTask={noopMerge}
|
||||||
|
onOpenDetail={noopOpenDetail}
|
||||||
|
addToast={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByRole("form", { name: "Steering comment" })).toHaveLength(1);
|
||||||
|
expect(screen.getByText("No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Feed" }));
|
||||||
|
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("(no activity)")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("tab", { name: "Raw Logs" }));
|
||||||
|
expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards full-detail agent fields to Chat when a sparse parent task has undefined live fields", async () => {
|
it("forwards full-detail agent fields to Chat when a sparse parent task has undefined live fields", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { fetchTaskDetail, addSteeringComment } = await import("../../api");
|
const { fetchTaskDetail, addSteeringComment } = await import("../../api");
|
||||||
|
|||||||
Reference in New Issue
Block a user