FN-7324: Focus task details on planner chat
Task details now prioritize the planner Chat experience while preserving Activity links and done-task summaries. - Default active task details to Chat and render it before Activity without breaking legacy Activity tab ids. - Add focused planner Chat expansion, pinned composer, mobile row hiding, and in-view expand/collapse controls. - Update responsive coverage, planner-chat tests, dashboard docs, and the release changeset. Files changed: .changeset/fn-7324-chat-focused-task-detail.md | 7 ++ docs/dashboard-guide.md | 11 +-- .../dashboard/app/components/TaskDetailModal.css | 47 ++++++++++- .../dashboard/app/components/TaskDetailModal.tsx | 36 +++++---- .../app/components/TaskPlannerChatTab.css | 36 ++++++++- .../app/components/TaskPlannerChatTab.tsx | 37 ++++++--- ...etailModal.responsive-and-dependencies.test.tsx | 14 +++- .../components/__tests__/TaskDetailModal.test.tsx | 93 +++++++++++++++++++--- .../__tests__/TaskPlannerChatTab.test.tsx | 18 +++++ 9 files changed, 255 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-7324 Fusion-Task-Lineage: 0699d224-7280-4959-8086-b0ea985937a2 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7324-chat-focused-task-detail.md
Normal file
7
.changeset/fn-7324-chat-focused-task-detail.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Make task details open with a focused planner Chat experience.
|
||||
category: feature
|
||||
dev: Reorders task-detail Chat before Activity, keeps legacy Activity tab ids, and pins the planner Chat composer.
|
||||
@@ -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.
|
||||
|
||||
- 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 **Activity** by default.
|
||||
- The **Activity → Live**, **Feed**, and **Raw Logs** segments share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The top-level **Chat** tab appears immediately after **Activity** and starts a task-scoped planner-model conversation. It uses the task's effective planning model, persists messages in a resumable chat session, and sends bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions without polluting visible chat history. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. Activity remains the primary operational transcript/feed/raw-log surface.
|
||||
- The top-level **Chat** tab appears first for active task details and is the default landing tab for non-`done` tasks. It starts a task-scoped planner-model conversation using the task's effective planning model, persists messages in a resumable chat session, and sends bounded server-built task context so the planner can answer current status, progress, recent activity, dependency, and task definition questions without polluting visible chat history. It shows starter prompts for common planning questions, can render structured planner questions, and converts only explicit operator steering intent through the scoped steering tool. The composer stays pinned while the transcript, loading, error, starter, history, and streaming states scroll internally; on mobile/narrow task detail, the default focused Chat layout hides nonessential title/metadata/tab/action rows until you collapse it from the in-view expand control.
|
||||
- The **Activity → Live**, **Feed**, and **Raw Logs** segments remain immediately after **Chat** and share an expand/collapse control that lets the active Activity segment fill the task-detail modal, then restores the normal header, tabs, and action footer when collapsed.
|
||||
- The **Summary** tab appears for `done` tasks and remains 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`.
|
||||
- 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.
|
||||
- Execution mode has a read-mode inline lightning-bolt toggle for Fast mode on/off without opening the full edit form.
|
||||
@@ -1119,9 +1119,10 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig
|
||||
### Activity → Raw Logs view
|
||||
|
||||
<!-- FNXC:TaskDetailActivity 2026-06-30-23:55: Activity Live is the explicit operational steering-comment entry surface, preserving the legacy internal `current` segment id. Feed and Raw Logs remain read-only Activity segments, the Activity-wide expand control is available on every segment, and the top-level Chat tab is intentionally separate planner-model conversation rather than steering. -->
|
||||
The **Activity** tab is the first task-detail tab and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live 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 Live 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 Live 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:TaskDetailPlannerChat 2026-06-30-23:58: Task Detail is Chat-first for active tasks: Chat renders before Activity and defaults open for non-done tasks, while Activity keeps the legacy `chat` id for explicit links and operational Live/Feed/Raw Logs history. -->
|
||||
The **Activity** tab is the second task-detail tab, after **Chat**, and presents a segmented control for **Live**, **Feed**, and **Raw Logs**. Live 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 Live 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 Live 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 adjacent top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate.
|
||||
The preceding top-level **Chat** tab opens the planner-model conversation for the same task instead of posting steering comments. Each send includes server-built, bounded context for the task id, status/column/progress/current step, dependencies, recent activity/comment excerpts, prompt/plan content, and available source/review state; unavailable sections are labeled so the planner states uncertainty rather than inventing execution evidence. When no planner-chat history has loaded yet, Chat shows a guided empty state with starter prompts for recent activity, current status/blockers, next best action, and plan/definition review; selecting one sends that prompt as an ordinary chat message through the task-context-aware planner-chat composer/stream path. The starter prompts disappear while history is loading or after conversation history exists, so Activity Live, Feed, Raw Logs, and the steering composer remain separate. Planner Chat defaults to focused mode, keeps its composer visible at the bottom while only the transcript scrolls, and on narrow/mobile task-detail layouts collapses nonessential rows above the chat until the user selects the Chat collapse control.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -130,7 +130,8 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l
|
||||
|
||||
/* Activity → Live and expanded Activity mirror the Raw Logs fill-height layout: the modal body does not scroll;
|
||||
each segment owns internal scrolling while composers and controls stay visible. */
|
||||
.detail-body--chat {
|
||||
.detail-body--chat,
|
||||
.detail-body--planner-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -865,6 +866,29 @@ The same expanded-mode chrome collapse now applies to all Activity segments: Liv
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
Planner Chat defaults to a focused state, but operator steering says large screens should keep the full task-detail context visible. Desktop therefore uses the same internal-scroll chat body without hiding rows; narrow/mobile layouts collapse nonessential title/meta/tabs/actions so the composer remains reachable while header identity and close/back controls stay available.
|
||||
*/
|
||||
.detail-section--planner-chat {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.task-detail-content--planner-chat-expanded .detail-body--planner-chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.task-detail-content--planner-chat-expanded .detail-section--planner-chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
.detail-spec-edit-trigger {
|
||||
display: flex;
|
||||
@@ -1141,6 +1165,27 @@ FN-6500 fixes a tablet regression from FN-5599: the task-detail overlay offset a
|
||||
.task-detail-content--chat-expanded .modal-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-detail-content--planner-chat-expanded .detail-heading-row,
|
||||
.task-detail-content--planner-chat-expanded .detail-description-toggle,
|
||||
.task-detail-content--planner-chat-expanded .detail-meta,
|
||||
.task-detail-content--planner-chat-expanded .detail-near-duplicate-banner,
|
||||
.task-detail-content--planner-chat-expanded .detail-tabs,
|
||||
.task-detail-content--planner-chat-expanded .branch-group-card,
|
||||
.task-detail-content--planner-chat-expanded .workspace-worktrees-summary,
|
||||
.task-detail-content--planner-chat-expanded .modal-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.task-detail-content--planner-chat-expanded .modal-header {
|
||||
flex: 0 0 auto;
|
||||
justify-content: space-between;
|
||||
padding-block: var(--space-sm);
|
||||
}
|
||||
|
||||
.task-detail-content--planner-chat-expanded .detail-body--planner-chat {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-actions-menu-item-danger {
|
||||
|
||||
@@ -197,10 +197,13 @@ type ActivitySegment = "current" | "feed" | "raw-logs";
|
||||
|
||||
/*
|
||||
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 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.
|
||||
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. Done tasks keep Summary as their omitted-initial-tab landing surface so completed work still opens on the completion report.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
Task detail now separates Activity from planner-model Chat. `chat` remains the legacy Activity id for old links and Activity → Live (internal `current`)/Feed/Raw Logs/steering, while `planner-chat` is the new top-level Chat tab for task-aware planning conversation and must render immediately after Activity.
|
||||
Task detail separates Activity from planner-model Chat. `chat` remains the legacy Activity id for old links and Activity → Live (internal `current`)/Feed/Raw Logs/steering, while `planner-chat` is the top-level Chat tab for task-aware planning conversation.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
Task details should prioritize task-aware Chat: render Chat before Activity, default omitted non-done task opens to `planner-chat`, and reserve expanded/focused layout behavior for the planner Chat surface rather than the legacy Activity `chat` id.
|
||||
|
||||
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 → Live (internal `current`). Legacy `initialTab="logs"` now routes to Activity → Feed, and Raw Logs remains an Activity segment.
|
||||
@@ -221,7 +224,7 @@ function resolveDefaultTab(initialTab: TabId | undefined, column: ColumnId): Tab
|
||||
if (initialTab) {
|
||||
return initialTab;
|
||||
}
|
||||
return column === "done" ? "summary" : "chat";
|
||||
return column === "done" ? "summary" : "planner-chat";
|
||||
}
|
||||
|
||||
function resolveDefaultActivitySegment(initialTab: TabId | undefined): ActivitySegment {
|
||||
@@ -536,6 +539,7 @@ export function TaskDetailContent({
|
||||
const [activeTab, setActiveTab] = useState<TabId>(() => resolveDefaultTab(initialTab, task.column));
|
||||
const [activitySegment, setActivitySegment] = useState<ActivitySegment>(() => resolveDefaultActivitySegment(initialTab));
|
||||
const [activityExpanded, setActivityExpanded] = useState(false);
|
||||
const [plannerChatExpanded, setPlannerChatExpanded] = useState(true);
|
||||
|
||||
// ── CLI agent session (U11) ────────────────────────────────────────────────
|
||||
const [cliSession, setCliSession] = useState<CliSessionSummaryRecord | null>(null);
|
||||
@@ -683,9 +687,10 @@ export function TaskDetailContent({
|
||||
}
|
||||
}, [activeTab, task.column]);
|
||||
|
||||
// Reset description expanded state when task changes
|
||||
// Reset description and planner-chat focus state when task changes
|
||||
useEffect(() => {
|
||||
setDescriptionExpanded(false);
|
||||
setPlannerChatExpanded(true);
|
||||
}, [task.column, task.id]);
|
||||
|
||||
const [highlightStallCode, setHighlightStallCode] = useState<string | null>(null);
|
||||
@@ -2618,6 +2623,7 @@ export function TaskDetailContent({
|
||||
const effectiveAutoMerge = resolveEffectiveAutoMerge({ autoMerge: task.autoMerge }, { autoMerge: autoMergeEnabled });
|
||||
const isManualPrFlow = mergeStrategy === "pull-request" && !effectiveAutoMerge;
|
||||
const isActivityExpanded = activityExpanded && activeTab === "chat" && !isEditing;
|
||||
const isPlannerChatExpanded = plannerChatExpanded && activeTab === "planner-chat" && !isEditing;
|
||||
/*
|
||||
FNXC:TaskDetailActivity 2026-06-30-23:55:
|
||||
Maximized Activity applies to Live, Feed, and Raw Logs, not only the legacy `current` chat segment. Reserve the detail surface for header context and Activity content, and do not mount branch-group chrome in this mode so expand/promote controls are not hidden-but-focusable.
|
||||
@@ -2740,7 +2746,7 @@ export function TaskDetailContent({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isActivityExpanded ? " task-detail-content--chat-expanded" : ""}`}
|
||||
className={`task-detail-content${embedded ? " task-detail-content--embedded" : ""}${isActivityExpanded ? " task-detail-content--chat-expanded" : ""}${isPlannerChatExpanded ? " task-detail-content--planner-chat-expanded" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
@@ -2820,7 +2826,7 @@ export function TaskDetailContent({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`detail-body${activeTab === "chat" && activitySegment === "raw-logs" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && (activitySegment === "current" || isActivityExpanded) && !isEditing ? " detail-body--chat" : ""}`}>
|
||||
<div className={`detail-body${activeTab === "chat" && activitySegment === "raw-logs" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && (activitySegment === "current" || isActivityExpanded) && !isEditing ? " detail-body--chat" : ""}${activeTab === "planner-chat" && !isEditing ? " detail-body--planner-chat" : ""}`}>
|
||||
{isEditing ? (
|
||||
<div className="modal-edit-form">
|
||||
<TaskForm
|
||||
@@ -3165,21 +3171,21 @@ export function TaskDetailContent({
|
||||
<>
|
||||
<div className="detail-tabs">
|
||||
{/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-22:30:
|
||||
The existing task activity/steering surface is labelled Activity and always renders first with the legacy `chat` tab id. The adjacent `planner-chat` tab is the separate planner-model Chat destination, so `initialTab="chat"` remains Activity while visible Chat opens task-aware planning conversation.
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
Chat is the first visible task-detail tab and maps to `planner-chat` so omitted non-done task opens prioritize task-aware planning conversation. Activity stays immediately after Chat with the legacy `chat` id for explicit `initialTab="chat"` callers and operational steering/feed/raw-log history.
|
||||
*/}
|
||||
<button
|
||||
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("chat")}
|
||||
>
|
||||
{t("taskDetail.tabs.activity", "Activity")}
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "planner-chat" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("planner-chat")}
|
||||
>
|
||||
{t("taskDetail.tabs.chat", "Chat")}
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("chat")}
|
||||
>
|
||||
{t("taskDetail.tabs.activity", "Activity")}
|
||||
</button>
|
||||
{task.column === "done" && (
|
||||
<button
|
||||
className={`detail-tab${activeTab === "summary" ? " detail-tab-active" : ""}`}
|
||||
@@ -3316,6 +3322,8 @@ export function TaskDetailContent({
|
||||
task={workingTask}
|
||||
projectId={projectId}
|
||||
active={activeTab === "planner-chat"}
|
||||
expanded={isPlannerChatExpanded}
|
||||
onExpandedChange={setPlannerChatExpanded}
|
||||
planningModel={resolveEffectivePlanning(workingTask, agentLogEntries, settings)}
|
||||
addToast={addToast}
|
||||
onTaskUpdated={onTaskUpdated}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
.task-planner-chat {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-height: min(60vh, 42rem);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-header {
|
||||
@@ -12,6 +13,21 @@
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.task-planner-chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-planner-chat-expand-toggle {
|
||||
flex: 0 0 auto;
|
||||
min-inline-size: var(--space-2xl);
|
||||
min-block-size: var(--space-2xl);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-header h4 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
@@ -42,7 +58,7 @@
|
||||
|
||||
.task-planner-chat-transcript {
|
||||
flex: 1 1 auto;
|
||||
min-height: 16rem;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -188,9 +204,13 @@
|
||||
/*
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-00:00:
|
||||
The Planner Chat composer should behave like a compact input/button row on desktop: match the textarea's default height to the Send button and let flex wrapping, not a forced column, decide when the controls no longer fit inline.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
Keep the composer outside the transcript scroller and make the panel fill the modal body. Loading, errors, starters, history, and streaming content scroll inside `.task-planner-chat-transcript`, while the input row remains pinned and reachable in normal and mobile-expanded detail layouts.
|
||||
*/
|
||||
.task-planner-chat-composer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
align-items: stretch;
|
||||
gap: var(--space-sm);
|
||||
@@ -212,7 +232,7 @@ The Planner Chat composer should behave like a compact input/button row on deskt
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.task-planner-chat {
|
||||
min-height: 70vh;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.task-planner-chat-header,
|
||||
@@ -221,6 +241,16 @@ The Planner Chat composer should behave like a compact input/button row on deskt
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-planner-chat-header-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.task-planner-chat-expand-toggle {
|
||||
align-self: flex-end;
|
||||
min-inline-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
min-block-size: calc(var(--space-2xl) + var(--space-sm));
|
||||
}
|
||||
|
||||
.task-planner-chat-starters {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getErrorMessage } from "@fusion/core";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { Loader2, Maximize2, Minimize2, Send } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { ToolCallInfo } from "../hooks/chatTypes";
|
||||
@@ -17,6 +17,8 @@ interface TaskPlannerChatTabProps {
|
||||
task: Task | TaskDetail;
|
||||
projectId?: string;
|
||||
active: boolean;
|
||||
expanded?: boolean;
|
||||
onExpandedChange?: (expanded: boolean) => void;
|
||||
planningModel: ResolvedModelSelection;
|
||||
addToast: (msg: string, type?: ToastType) => void;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
@@ -228,7 +230,7 @@ function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map
|
||||
return states;
|
||||
}
|
||||
|
||||
export function TaskPlannerChatTab({ task, projectId, active, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) {
|
||||
export function TaskPlannerChatTab({ task, projectId, active, expanded = false, onExpandedChange, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
@@ -481,6 +483,9 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
|
||||
Planner-generated clarification questions in the task-detail Chat transcript must reuse ChatQuestionResponse instead of bespoke chat text. Submitted answers stay in the planner-chat lane as ordinary follow-up user messages, render the prior question read-only, and duplicate refetched pending tool calls hide older live forms so users never see competing submit affordances.
|
||||
|
||||
FNXC:TaskDetailPlannerChat 2026-06-30-23:58:
|
||||
The planner Chat tab owns an in-view expand/collapse button so mobile users can reclaim vertical room while keeping close/back/task identity controls reachable. This state is independent from Activity Live expansion because Activity still represents operational steering/history, not planner-model conversation.
|
||||
*/
|
||||
return (
|
||||
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
|
||||
@@ -489,16 +494,30 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
|
||||
<h4>{t("taskDetail.plannerChat.heading", "Planner Chat")}</h4>
|
||||
<p>{t("taskDetail.plannerChat.description", "Ask planning questions about this task's current status, recent activity, blockers, next steps, or definition.")}</p>
|
||||
</div>
|
||||
{isUsableModel(planningModel) && (
|
||||
<span className="task-planner-chat-model" data-testid="task-planner-chat-model">
|
||||
{planningModel.provider}/{planningModel.modelId}
|
||||
</span>
|
||||
)}
|
||||
<div className="task-planner-chat-header-actions">
|
||||
{onExpandedChange && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm task-planner-chat-expand-toggle"
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
aria-label={expanded ? t("taskDetail.plannerChat.collapse", "Collapse planner chat") : t("taskDetail.plannerChat.expand", "Expand planner chat")}
|
||||
aria-pressed={expanded}
|
||||
aria-expanded={expanded}
|
||||
data-testid="task-planner-chat-expand-toggle"
|
||||
>
|
||||
{expanded ? <Minimize2 aria-hidden="true" /> : <Maximize2 aria-hidden="true" />}
|
||||
</button>
|
||||
)}
|
||||
{isUsableModel(planningModel) && (
|
||||
<span className="task-planner-chat-model" data-testid="task-planner-chat-model">
|
||||
{planningModel.provider}/{planningModel.modelId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
|
||||
|
||||
<div className="task-planner-chat-transcript" ref={transcriptRef} data-testid="task-planner-chat-transcript">
|
||||
{error && <div className="task-planner-chat-error" role="alert">{error}</div>}
|
||||
{loading ? (
|
||||
<div className="task-planner-chat-state" role="status" aria-live="polite">
|
||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||
|
||||
@@ -99,10 +99,13 @@ describe("TaskDetailModal", () => {
|
||||
const mobileSendBlock = getCssRuleBlock(mobileBlock, ".task-planner-chat-send");
|
||||
|
||||
expectBaseRule(css, ".task-planner-chat", "display: flex;");
|
||||
expectBaseRule(css, ".task-planner-chat", "min-height: 0;");
|
||||
expectBaseRule(css, ".task-planner-chat-transcript", "overflow: auto;");
|
||||
expectBaseRule(css, ".task-planner-chat-transcript", "min-height: 0;");
|
||||
expect(composerBlock).toContain("display: flex;");
|
||||
expect(composerBlock).toContain("flex-wrap: wrap;");
|
||||
expect(composerBlock).toContain("align-items: stretch;");
|
||||
expect(composerBlock).toContain("flex: 0 0 auto;");
|
||||
expect(composerBlock).not.toContain("flex-direction: column;");
|
||||
expect(inputBlock).toContain("height: calc(var(--space-lg) + (var(--space-sm) * 2) + (var(--btn-border-width) * 2));");
|
||||
expect(inputBlock).toContain("min-height: calc(var(--space-lg) + (var(--space-sm) * 2) + (var(--btn-border-width) * 2));");
|
||||
@@ -117,6 +120,15 @@ describe("TaskDetailModal", () => {
|
||||
expect(mobileBlock).toContain("grid-template-columns: 1fr;");
|
||||
expect(mobileBlock).toContain(".task-planner-chat-message .chat-question-response");
|
||||
expect(mobileBlock).toContain("margin-inline: 0;");
|
||||
|
||||
const detailCss = readDashboardStylesSource();
|
||||
const plannerMobileBlock = getCssAtRuleBlockContaining(detailCss, "@media (max-width: 768px)", ".task-detail-content--planner-chat-expanded .detail-tabs");
|
||||
expectBaseRule(detailCss, ".detail-body--planner-chat", "overflow-y: hidden;");
|
||||
expectBaseRule(detailCss, ".detail-section--planner-chat", "min-height: 0;");
|
||||
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .detail-heading-row");
|
||||
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .detail-tabs");
|
||||
expect(plannerMobileBlock).toContain(".task-detail-content--planner-chat-expanded .modal-actions");
|
||||
expect(plannerMobileBlock).toContain("display: none;");
|
||||
});
|
||||
|
||||
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {
|
||||
@@ -254,8 +266,8 @@ describe("TaskDetailModal", () => {
|
||||
expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2);
|
||||
const tabs = container.querySelectorAll(".detail-tab");
|
||||
expect(Array.from(tabs).map((tab) => tab.textContent?.trim())).toEqual([
|
||||
"Activity",
|
||||
"Chat",
|
||||
"Activity",
|
||||
"Plan",
|
||||
"Changes",
|
||||
"Review",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
FNXC:TaskDetailTabs 2026-06-17-08:20:
|
||||
FN-7306 labels the stable internal `chat` tab as Activity and keeps it as the default TaskDetailModal tab. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Activity landing state.
|
||||
FN-7324 keeps the stable internal `chat` tab as Activity for explicit legacy links, but the omitted non-done default is now planner Chat. Tests that assert Definition-only sections must opt into `initialTab="definition"` so they verify the intended surface instead of the Chat landing state.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
@@ -96,25 +96,25 @@ describe("TaskDetailModal planner Chat tab", () => {
|
||||
.map((button) => button.textContent?.trim() ?? "");
|
||||
}
|
||||
|
||||
it("renders Activity then Chat as the first task-detail conversation tabs for active tasks", async () => {
|
||||
it("renders Chat then Activity as the first task-detail conversation tabs and defaults active tasks to Chat", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTask("in-progress");
|
||||
|
||||
expect(tabLabels().slice(0, 2)).toEqual(["Activity", "Chat"]);
|
||||
expect(tabLabels().slice(0, 2)).toEqual(["Chat", "Activity"]);
|
||||
expect(screen.getAllByRole("button", { name: "Chat" })).toHaveLength(1);
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Chat" }));
|
||||
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.getByRole("button", { name: "Activity" })).not.toHaveClass("detail-tab-active");
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Activity" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.queryByTestId("task-planner-chat-panel")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves Summary as the default for done tasks while keeping Activity then Chat order", () => {
|
||||
it("preserves Summary as the default for done tasks while keeping Chat then Activity order", () => {
|
||||
renderTask("done");
|
||||
|
||||
expect(tabLabels().slice(0, 3)).toEqual(["Activity", "Chat", "Summary"]);
|
||||
expect(tabLabels().slice(0, 3)).toEqual(["Chat", "Activity", "Summary"]);
|
||||
expect(screen.getByRole("button", { name: "Summary" })).toHaveClass("detail-tab-active");
|
||||
});
|
||||
|
||||
@@ -131,6 +131,76 @@ describe("TaskDetailModal planner Chat tab", () => {
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toHaveClass("detail-tab-active");
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("defaults planner Chat to focused mode and lets the in-view control collapse it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTask("todo");
|
||||
const detail = container.querySelector(".task-detail-content");
|
||||
|
||||
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
|
||||
expect(toggle).toHaveAccessibleName("Collapse planner chat");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
await user.click(toggle);
|
||||
|
||||
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
expect(screen.getByRole("button", { name: "Chat" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Activity" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("task-planner-chat-expand-toggle")).toHaveAccessibleName("Expand planner chat");
|
||||
});
|
||||
|
||||
it("resets planner Chat focused mode when switching tasks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container, rerender } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-7324-A", column: "todo" as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
const detail = container.querySelector(".task-detail-content");
|
||||
|
||||
await user.click(screen.getByTestId("task-planner-chat-expand-toggle"));
|
||||
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
|
||||
rerender(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ id: "FN-7324-B", column: "todo" as any })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
});
|
||||
|
||||
it("keeps Activity expansion independent from planner Chat expansion", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderTask("todo", "chat");
|
||||
const detail = container.querySelector(".task-detail-content");
|
||||
|
||||
await user.click(screen.getByTestId("task-chat-expand-toggle"));
|
||||
expect(detail).toHaveClass("task-detail-content--chat-expanded");
|
||||
|
||||
const chatTab = container.querySelectorAll<HTMLButtonElement>(".detail-tabs .detail-tab")[0];
|
||||
expect(chatTab?.textContent?.trim()).toBe("Chat");
|
||||
fireEvent.click(chatTab!);
|
||||
expect(detail).toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
expect(detail).not.toHaveClass("task-detail-content--chat-expanded");
|
||||
|
||||
await user.click(screen.getByTestId("task-planner-chat-expand-toggle"));
|
||||
expect(detail).not.toHaveClass("task-detail-content--planner-chat-expanded");
|
||||
expect(detail).not.toHaveClass("task-detail-content--chat-expanded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskDetailModal summarize title action", () => {
|
||||
@@ -499,6 +569,7 @@ describe("TaskDetailModal Chat task merge", () => {
|
||||
task={makeTask({ id: "FN-7310", column: "todo" as any, steeringComments: undefined, log: [] })}
|
||||
projectId="project-7309"
|
||||
embedded
|
||||
initialTab="chat"
|
||||
onRequestClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
|
||||
Maximize2: (props: any) => React.createElement("svg", { "data-testid": "maximize2-icon", ...props }),
|
||||
Minimize2: (props: any) => React.createElement("svg", { "data-testid": "minimize2-icon", ...props }),
|
||||
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
|
||||
}));
|
||||
|
||||
@@ -121,6 +123,22 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(screen.getAllByTestId(/task-planner-chat-starter-/)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("renders accessible expand controls without moving the composer out of the panel", async () => {
|
||||
const onExpandedChange = vi.fn();
|
||||
renderPlannerChat({ expanded: true, onExpandedChange });
|
||||
|
||||
expect(await screen.findByTestId("task-planner-chat-empty")).toBeInTheDocument();
|
||||
const toggle = screen.getByTestId("task-planner-chat-expand-toggle");
|
||||
expect(toggle).toHaveAccessibleName("Collapse planner chat");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByLabelText("Message planner chat"));
|
||||
expect(screen.getByTestId("task-planner-chat-panel")).toContainElement(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
await userEvent.click(toggle);
|
||||
|
||||
expect(onExpandedChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("omits model override when the effective planning model is undefined", async () => {
|
||||
renderPlannerChat({ planningModel: {} });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user