FN-8194: align task detail inline controls

Align task-detail actions with Quick Add while preserving existing integrations.

- Add inline attachment and eligible GitHub tracking controls
- Replace the Oversight menu dots with an Eye icon and reorder metadata actions
- Cover action order, integration behavior, icon rendering, and mobile wrapping

Files changed:
 .changeset/fn-8194-task-detail-inline-controls.md  |  7 ++
 docs/dashboard-guide.md                            |  6 +-
 .../dashboard/app/components/TaskDetailModal.tsx   | 99 +++++++++++++++-------
 ...lModal.inline-editing-and-integrations.test.tsx | 80 ++++++++++++++++-
 .../TaskDetailModal.mock-coverage.test.ts          |  1 +
 .../TaskDetailModal.oversight-controls.test.tsx    | 19 +++++
 ...etailModal.responsive-and-dependencies.test.tsx |  5 +-
 .../__tests__/TaskDetailModal.test-helpers.ts      | 10 +--
 8 files changed, 187 insertions(+), 40 deletions(-)

Fusion-Task-Id: FN-8194

Fusion-Task-Lineage: 8de0e36f-b428-401c-98aa-35964c556a39

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 00:55:57 -07:00
parent 3fdc2c1fb7
commit 87ffb24fca
8 changed files with 187 additions and 40 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Task detail action row now matches Quick Add — Eye icon for oversight, plus attach and GitHub-tracking buttons.
category: feature
dev: TaskDetailModal inline controls reordered to attach → GitHub → oversight(Eye) → priority → Fast; reuses existing upload and GitHub-tracking handlers. New test ids: detail-inline-attach, detail-inline-github-toggle.

View File

@@ -232,8 +232,10 @@ Features:
- Task cards show a compact **planner-overseer eye badge** (`data-testid="planner-overseer-state-badge"`) when the engine reports a non-idle transient `plannerOverseerState`. The eye badge is an active-overseer state marker, not a human-read/view indicator: `watching` means passive monitoring, `steering`/`recovering` mean active guidance or recovery is underway, and `awaiting-confirmation` means a human decision is required before the overseer can continue. Hover exposes the composed tooltip with the overseer's reason, watched stage/signal, and pending-confirmation note when present.
<!-- FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 adds interactive task-detail planner-overseer controls (quick level change, manual nudge, stop oversight, explain current action) alongside the FN-7516 read-only card badges above. These controls live ONLY in TaskDetailModal, not TaskCard.
FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 collapses the desktop inline cluster (documented below through FN-7545/FN-7546) into the single universal overflow-menu dropdown that FN-7545 originally built for mobile only — the dropdown is now the ONE canonical surface on every viewport, desktop included. -->
- The task detail modal's inline meta-controls cluster (next to Priority/Execution mode) exposes planner-overseer controls behind a single compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `MoreVertical` icon, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 collapses the desktop inline cluster (documented below through FN-7545/FN-7546) into the single universal overflow-menu dropdown that FN-7545 originally built for mobile only — the dropdown is now the ONE canonical surface on every viewport, desktop included.
FNXC:QuickAddActionRow 2026-07-16-16:00: FN-8194 aligns task-detail metadata actions with Quick Add: attach, GitHub tracking, Oversight (Eye), Priority, then Fast. -->
- The task detail modal's inline meta-controls cluster follows Quick Add order: compact **Attach file** (`data-testid="detail-inline-attach"`), eligible-task **GitHub tracking** toggle (`data-testid="detail-inline-github-toggle"`), **Oversight**, Priority, and Execution mode. Attach opens the existing task attachment picker; GitHub toggles the existing tracking setting and is omitted for GitLab-tracked or non-editable tasks. The Oversight control exposes planner-overseer actions behind a compact **"Oversight" overflow-menu button** (`data-testid="detail-oversight-menu-trigger"`, `Eye` icon, `aria-haspopup="menu"`) on every viewport, desktop and mobile alike. Clicking the trigger opens a `role="menu"` popover (mirroring the existing move-action dropdown pattern) containing: a **quick oversight-level select** (`data-testid="detail-oversight-level-select"`) that writes the per-task `plannerOversightLevel` override (Off/Observe/Steer/Autonomous recovery) or clears it back to the inherited workflow/project default via an "Inherit" option; a **manual nudge** button (`data-testid="detail-overseer-nudge"`) that asks the overseer to inject one guidance-only steering comment into the currently watched stage right now (never a merge/PR/destructive action), disabled when the overseer is off/inactive or the task is user-paused/done/archived/`autoMerge:false` in-review; a **stop oversight** button (`data-testid="detail-overseer-stop"`) that disables active oversight for the task (confirmation-gated), hidden once oversight is already off; and an **explain current action** button (`data-testid="detail-overseer-explain"`) that toggles a small read-only panel (`data-testid="detail-overseer-explain-panel"`) showing the overseer's watched stage, reason, last action, and attempt count/limit, with a non-empty-shell inactive state when the overseer is not currently watching. All three action controls call the `POST /tasks/:id/overseer/nudge`, `POST /tasks/:id/overseer/stop`, and `GET /tasks/:id/overseer/explain` routes. The trigger itself is withheld entirely when none of the menu's controls would render (no empty-shell overflow button for the unresolved-default case).
<!-- FNXC:PlannerOversight 2026-07-04-20:30: FN-7546 clarifies the cluster above — operators reported the buttons were unlabeled and looked inert, with only a hover title explaining why. Adds a visible group label and an always-visible disabled-reason line, and makes Explain always openable since it never mutates anything. -->
- The controls inside the Oversight menu carry a visible, non-interactive **`"Overseer controls"` group label** (`data-testid="detail-oversight-controls-label"`) so Nudge/Stop/Explain read as an identifiable cluster rather than unlabeled entries; the label is gated by the same `(hasTaskOversightOverride || workflowOversightResolved) && !oversightIsOff` condition as the buttons, so it never renders when oversight is Off/unresolved (opening the menu in that state shows only the level select). When **Nudge** is disabled, an always-visible helper line (`data-testid="detail-overseer-nudge-disabled-reason"`) states the reason in-DOM (mirroring the existing hover `title`) instead of relying on a mouse-hover tooltip alone. **Explain** is read-only and non-mutating, so it is never disabled purely because the overseer is inactive — clicking it always opens/closes the panel, which shows the overseer's live state when watching or an informative "not currently watching this task" message otherwise. Nudge's mutating enablement rule (`canNudgeOverseer`, including the human-control suppression cases) and Stop's confirmation dialog are unchanged.
<!-- FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 originally introduced this overflow menu for mobile only, collapsing the level-select/nudge/stop/explain controls behind the trigger below the 768px breakpoint while desktop kept an inline cluster.

View File

@@ -2,7 +2,7 @@ import "./TaskDetailModal.css";
import React, { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, MoreVertical, Eye, EyeOff } from "lucide-react";
import { Pencil, Bot, X, ChevronDown, ChevronRight, GitBranch, ArrowLeft, Zap, Loader2, AlertTriangle, Sparkles, Maximize2, Minimize2, Send, Square, Info, Paperclip, Eye, EyeOff } from "lucide-react";
import { useModalResizePersist } from "../hooks/useModalResizePersist";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -4181,40 +4181,39 @@ export function TaskDetailContent({
</div>
)}
<div className="detail-meta">
{/*
FNXC:QuickAddActionRow 2026-07-16-16:00:
FN-8194: task-detail metadata mirrors Quick Add's action order:
attach, GitHub tracking, Oversight, Priority, then Fast. The compact
controls delegate to the existing single file-input upload and
GitHub-tracking handlers so this row never forks persistence paths.
*/}
<div className="detail-meta-inline-controls" data-testid="detail-meta-inline-controls">
<label
className={`card-priority-badge card-priority-badge--${inlinePriority} detail-priority-chip ${isSavingInlinePriority ? "detail-priority-chip--saving" : ""}`}
>
<span>{t("taskDetail.priority.label", "Priority:")}</span>
<select
className="detail-priority-select"
value={inlinePriority}
onChange={(event) => {
void handleInlinePriorityChange(event.target.value);
}}
disabled={isSavingInlinePriority}
aria-label={t("taskDetail.priority.ariaLabel", "Task priority")}
>
{TASK_PRIORITIES.map((priorityOption) => (
<option key={priorityOption} value={priorityOption}>
{priorityOption}
</option>
))}
</select>
</label>
<button
type="button"
className={`btn btn-sm detail-execution-mode-toggle ${inlineExecutionMode === "fast" ? "detail-execution-mode-toggle--fast" : ""} ${isSavingInlineExecutionMode ? "detail-execution-mode-toggle--saving" : ""}`}
onClick={() => {
void handleInlineExecutionModeToggle();
}}
disabled={isSavingInlineExecutionMode}
aria-label={t("taskDetail.executionMode.ariaLabel", "Execution mode: {{mode}}", { mode: inlineExecutionMode })}
aria-pressed={inlineExecutionMode === "fast"}
className="btn btn-icon btn-sm"
data-testid="detail-inline-attach"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
aria-label={t("taskDetail.attachments.attachInline", "Attach file")}
title={t("taskDetail.attachments.attachInline", "Attach file")}
>
<Zap aria-hidden="true" />
<span>{inlineExecutionMode === "fast" ? t("taskDetail.executionMode.fast", "Fast") : t("taskDetail.executionMode.standard", "Standard")}</span>
<Paperclip aria-hidden="true" />
</button>
{canEditGithubTracking && !gitlabTrackedItem && (
<button
type="button"
className={`btn btn-icon btn-sm ${githubTrackingEnabled ? "btn-primary" : ""}`}
data-testid="detail-inline-github-toggle"
onClick={() => void handleToggleGithubTracking()}
disabled={isSavingGithubTracking}
aria-pressed={githubTrackingEnabled}
aria-label={t("taskDetail.githubTracking.toggleInline", "Toggle GitHub tracking")}
title={t("taskDetail.githubTracking.toggleInline", "Toggle GitHub tracking")}
>
<ProviderIcon provider="github" size="sm" />
</button>
)}
{/*
FNXC:PlannerOversight 2026-07-04-17:00:
FN-7517 quick oversight-level-change control. Shows the current
@@ -4240,6 +4239,11 @@ export function TaskDetailContent({
stop / explain) render ONLY behind the "Oversight" overflow-menu
trigger on every surface (desktop and mobile); the former desktop
inline cluster was removed for a consistent, simpler control bar.
FNXC:PlannerOversight 2026-07-16-16:00:
FN-8194: use Eye for the Oversight overflow trigger so task detail
matches Quick Add's planner-advisor affordance without changing
the labeled menu's accessibility or behavior.
*/}
{(hasTaskOversightOverride || workflowOversightResolved) && (
<div className="detail-oversight-menu-dropdown" ref={oversightMenuRef}>
@@ -4254,7 +4258,7 @@ export function TaskDetailContent({
aria-expanded={showOversightMenu}
aria-label={t("taskDetail.oversight.menuAriaLabel", "Oversight actions")}
>
<MoreVertical aria-hidden="true" />
<Eye aria-hidden="true" />
<span>{t("taskDetail.oversight.menuLabel", "Oversight")}</span>
</button>
{showOversightMenu && (
@@ -4404,6 +4408,39 @@ export function TaskDetailContent({
)}
</div>
)}
<label
className={`card-priority-badge card-priority-badge--${inlinePriority} detail-priority-chip ${isSavingInlinePriority ? "detail-priority-chip--saving" : ""}`}
>
<span>{t("taskDetail.priority.label", "Priority:")}</span>
<select
className="detail-priority-select"
value={inlinePriority}
onChange={(event) => {
void handleInlinePriorityChange(event.target.value);
}}
disabled={isSavingInlinePriority}
aria-label={t("taskDetail.priority.ariaLabel", "Task priority")}
>
{TASK_PRIORITIES.map((priorityOption) => (
<option key={priorityOption} value={priorityOption}>
{priorityOption}
</option>
))}
</select>
</label>
<button
type="button"
className={`btn btn-sm detail-execution-mode-toggle ${inlineExecutionMode === "fast" ? "detail-execution-mode-toggle--fast" : ""} ${isSavingInlineExecutionMode ? "detail-execution-mode-toggle--saving" : ""}`}
onClick={() => {
void handleInlineExecutionModeToggle();
}}
disabled={isSavingInlineExecutionMode}
aria-label={t("taskDetail.executionMode.ariaLabel", "Execution mode: {{mode}}", { mode: inlineExecutionMode })}
aria-pressed={inlineExecutionMode === "fast"}
>
<Zap aria-hidden="true" />
<span>{inlineExecutionMode === "fast" ? t("taskDetail.executionMode.fast", "Fast") : t("taskDetail.executionMode.standard", "Standard")}</span>
</button>
</div>
{overseerExplainOpen && (
<div className="detail-overseer-explain-panel" data-testid="detail-overseer-explain-panel" role="region" aria-live="polite">

View File

@@ -1207,7 +1207,12 @@ describe("TaskDetailModal", () => {
const prioritySelect = screen.getByRole("combobox", { name: "Task priority" });
const executionModeToggle = screen.getByRole("button", { name: "Execution mode: standard" });
expect(prioritySelect.parentElement).toBe(controls.firstElementChild);
/*
FNXC:QuickAddActionRow 2026-07-16-16:00:
FN-8194: attach, GitHub, and Oversight precede the Quick Add-matched
Priority/Fast controls; both controls remain direct interactive children.
*/
expect(controls).toContainElement(prioritySelect.parentElement);
expect(executionModeToggle.parentElement).toBe(controls);
fireEvent.change(prioritySelect, {
@@ -3338,3 +3343,76 @@ describe("TaskDetailModal", () => {
});
});
});
describe("TaskDetailModal inline action row parity (FN-8194)", () => {
const renderDetail = (task: Task) => render(
<TaskDetailModal
initialTab="definition"
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
it("uses the existing file input and preserves Quick Add action order", async () => {
renderDetail(makeTask({ id: "FN-8194", column: "todo", plannerOversightLevel: "observe" }));
const controls = screen.getByTestId("detail-meta-inline-controls");
const attach = screen.getByTestId("detail-inline-attach");
const github = screen.getByTestId("detail-inline-github-toggle");
const oversight = await screen.findByTestId("detail-oversight-menu-trigger");
const priority = screen.getByRole("combobox", { name: "Task priority" }).parentElement!;
const fast = screen.getByRole("button", { name: "Execution mode: standard" });
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"]')!;
const fileInputClick = vi.spyOn(fileInput, "click");
expect([...controls.children]).toEqual([attach, github, oversight.parentElement, priority, fast]);
fireEvent.click(attach);
expect(fileInputClick).toHaveBeenCalledOnce();
});
it("toggles GitHub tracking through the existing update path and reflects enabled state", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce(makeTask({ id: "FN-8194", column: "todo", githubTracking: { enabled: true } }) as Task);
renderDetail(makeTask({ id: "FN-8194", column: "todo", githubTracking: { enabled: false } }));
const toggle = screen.getByTestId("detail-inline-github-toggle");
expect(toggle).toHaveAttribute("aria-pressed", "false");
expect(toggle).not.toHaveClass("btn-primary");
fireEvent.click(toggle);
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-8194", { githubTracking: { enabled: true } }, undefined);
});
expect(toggle).toHaveAttribute("aria-pressed", "true");
expect(toggle).toHaveClass("btn-primary");
});
it("hides the GitHub toggle for non-editable and GitLab-tracked tasks", () => {
const { unmount } = renderDetail(makeTask({ id: "FN-8194", column: "done", githubTracking: { enabled: true } }));
expect(screen.queryByTestId("detail-inline-github-toggle")).not.toBeInTheDocument();
unmount();
renderDetail(makeTask({
id: "FN-8195",
column: "todo",
gitlabTracking: {
item: {
kind: "project_issue",
projectPath: "acme/app",
iid: 1,
title: "GitLab issue",
url: "https://gitlab.com/acme/app/-/issues/1",
state: "opened",
},
},
}) as Task);
expect(screen.queryByTestId("detail-inline-github-toggle")).not.toBeInTheDocument();
});
});

View File

@@ -14,6 +14,7 @@ describe("TaskDetailModal lucide mock coverage", () => {
expect(lucideMock.ChevronRight).toBeDefined();
expect(lucideMock.ChevronUp).toBeDefined();
expect(lucideMock.Maximize2).toBeDefined();
expect(lucideMock.Paperclip).toBeDefined();
expect(lucideMock.Pencil).toBeDefined();
expect(lucideMock.Workflow).toBeDefined();
expect(lucideMock.X).toBeDefined();

View File

@@ -59,6 +59,25 @@ async function openOversightMenu() {
}
describe("TaskDetailModal oversight controls", () => {
it("uses the Eye icon for the labeled oversight overflow trigger", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-8194", column: "in-progress", plannerOversightLevel: "observe" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
expect(trigger).toHaveTextContent("Oversight");
expect(trigger.querySelector('[data-testid="eye-icon"]')).toBeInTheDocument();
expect(trigger.querySelector('[data-testid="more-vertical-icon"]')).not.toBeInTheDocument();
});
beforeEach(async () => {
vi.clearAllMocks();
mockConfirm.mockResolvedValue(true);

View File

@@ -242,11 +242,14 @@ describe("TaskDetailModal", () => {
expect(css).not.toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.detail-timestamps\s*\{[^}]*flex-direction:\s*column;/);
});
it("keeps inline metadata controls in a single row without a narrow-screen column fallback", () => {
it("keeps inline metadata controls in a single row with a wrapping mobile fallback", () => {
const css = readDashboardStylesSource();
const mobileBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".detail-meta-inline-controls");
expectBaseRule(css, ".detail-meta-inline-controls", "display: flex;");
expectBaseRule(css, ".detail-meta-inline-controls", "flex-wrap: nowrap;");
expect(mobileBlock).toMatch(/\.detail-meta-inline-controls\s*\{[^}]*flex-wrap:\s*wrap;/);
expect(mobileBlock).not.toMatch(/\.detail-meta-inline-controls\s*\{[^}]*flex-direction:\s*column;/);
expect(css).not.toMatch(/@media \(max-width: 640px\)\s*\{[^}]*\.detail-meta-inline-controls\s*\{[^}]*flex-direction:\s*column;/);
});

View File

@@ -132,13 +132,13 @@ vi.mock("lucide-react", () => ({
Code2: () => null,
Cpu: () => null,
Bell: () => null,
// FNXC:PlannerOversight 2026-07-04-19:00: FN-7545 mobile oversight overflow-menu trigger icon.
MoreVertical: (props: any) => React.createElement("svg", { "data-testid": "more-vertical-icon", ...props }),
/*
FNXC:DashboardTests 2026-07-15-11:55:
Session-advisor toggle on TaskDetailContent uses Eye/EyeOff; missing exports break every
TaskDetailModal suite that mounts the session-advisor control (including oversight-mobile).
FNXC:DashboardTests 2026-07-16-16:00:
FN-8194 replaces the Task Detail oversight dots with Eye and adds Paperclip
to its inline action row. Keep both exports in the shared modal mock so every
focused TaskDetailModal suite mounts the production affordances.
*/
Paperclip: (props: any) => React.createElement("svg", { "data-testid": "paperclip-icon", ...props }),
Eye: (props: any) => React.createElement("svg", { "data-testid": "eye-icon", ...props }),
EyeOff: (props: any) => React.createElement("svg", { "data-testid": "eye-off-icon", ...props }),
// FNXC:Test 2026-07-05-11:20: FN-7579 added "ask-user"/"exit-gate" workflow node types to