Files
fusion/packages/dashboard/app/components/GroupTaskModal.tsx
gsxdsm 7fde4bb3ad fix(a11y): my #2965 gave six dialogs two elements with the same accessible name (#2977)
**This fixes a regression I introduced in #2965, found by re-running the
full dashboard lane on `main` rather than trusting the targeted runs I
did at the time.**

`AddNodeModal` and `ConnectNodeModal` are red on main:

```
→ Found multiple elements with the text of: Add Node
→ Found multiple elements with the text of: Connect to Node
```

### Cause

#2965 dropped the redundant `" dialog"` suffix from each
`FloatingWindow`'s `ariaLabel`. That was correct — `role="dialog"`
already conveys it. What I missed is that six of those modals **also**
put an `aria-label` with the *same* text on their own inner `<div>`:

```jsx
<FloatingWindow ariaLabel={t("nodes.addNode", "Add Node")} …>
  <div className="modal modal-md add-node-modal" aria-label={t("nodes.addNode", "Add Node")}>
```

Before #2965 the two differed (`"Add Node dialog"` vs `"Add Node"`), so
`getByLabelText("Add Node")` matched exactly one element. Now both
match.

### Why the inner one goes, not the dialog's

Those inner labels sit on **role-less `<div>`s**, where assistive
technology ignores `aria-label` entirely — it was never conveying
anything to anyone. Removing it restores a single accessible name per
dialog and needs no test changes.

### Surface enumeration — four of the six were latent

Only two surfaced as failures; the other four have no test querying by
that name, so they would have shipped a duplicate accessible name
silently. Found by scanning every component for an inner `aria-label`
whose expression matches its own `ariaLabel` prop:

| modal | was it red? |
|---|---|
| `AddNodeModal` | red on main |
| `ConnectNodeModal` | red on main |
| `GroupTaskModal` | latent |
| `NodeDetailModal` | latent |
| `ScriptsModal` | latent |
| `WorkflowAddStepModal` | latent |

### Five more, deliberately untouched

`AgentDetailView`, `PlanningModeModal`, `SettingsModal`
(`role="region"`), `ScheduledTasksModal` (`role="listbox"`) and
`NewTaskModal` (`role="dialog"`) also carry their dialog's name on an
inner element — but those elements **have a role**, so the label is
meaningful rather than dead markup. A listbox named "Automations" inside
a dialog named "Automations" is redundant, not broken, and renaming it
is a UX decision rather than a cleanup. Left alone and recorded here.

**Verified:** 93/93 across `AddNodeModal`, `ConnectNodeModal`,
`NodesView`, `GroupTaskModal`, `ScriptsModal` and the #2965 aria guard;
`tsc -p tsconfig.app.json` 0 errors; lint clean; FNXC gate exit 0.

Product-code change to a11y markup, so this is user-visible but needs no
operator-facing note — say the word if you want a changeset.

**Measured dashboard-lane state on main before this PR:** `3 failed |
11173 passed`. Two are these; the third is
`MainContent.planning-project-remount`, which belongs to #2420 and is
detailed there.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:46:08 -07:00

194 lines
9.4 KiB
TypeScript

import "./GroupTaskModal.css";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, CircleDashed, ExternalLink, Loader2, X } from "lucide-react";
import { apiAbandonBranchGroup, apiGetBranchGroup, apiPromoteBranchGroup, type BranchGroupSummary } from "../api";
import { subscribeSse } from "../sse-bus";
import { BRANCH_GROUP_REFRESH_TASK_EVENTS, shouldRefreshBranchGroupForTaskEvent } from "../utils/branchGroupSse";
import { FloatingWindow } from "./FloatingWindow";
interface GroupTaskModalProps {
isOpen: boolean;
onClose: () => void;
groupId: string | null;
projectId?: string;
onOpenMemberTask: (taskId: string) => void;
}
export function GroupTaskModal({ isOpen, onClose, groupId, projectId, onOpenMemberTask }: GroupTaskModalProps) {
const { t } = useTranslation("app");
const [group, setGroup] = useState<BranchGroupSummary | null>(null);
const [loading, setLoading] = useState(false);
const [promoting, setPromoting] = useState(false);
const [abandoning, setAbandoning] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadGroup = useCallback(async () => {
if (!groupId) return;
setLoading(true);
try {
const response = await apiGetBranchGroup(groupId, projectId);
setGroup(response.group);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("groupTask.errorLoading", "Failed to load branch group"));
} finally {
setLoading(false);
}
}, [groupId, projectId]);
const loadGroupRef = useRef(loadGroup);
useEffect(() => {
loadGroupRef.current = loadGroup;
}, [loadGroup]);
useEffect(() => {
if (!isOpen || !groupId) return;
void loadGroup();
}, [groupId, isOpen, loadGroup]);
useEffect(() => {
if (!isOpen || !groupId) return;
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const refreshFromCurrentGroup = (event?: MessageEvent) => {
if (event && !shouldRefreshBranchGroupForTaskEvent(event, projectId)) {
return;
}
void loadGroupRef.current();
};
/*
FNXC:BranchGroupDetails 2026-06-30-18:04:
The full branch group modal shares task-derived completion state with task details, so it listens to the same lifecycle SSE set while preserving its open/closed guard.
*/
const events = Object.fromEntries(BRANCH_GROUP_REFRESH_TASK_EVENTS.map((eventName) => [eventName, refreshFromCurrentGroup]));
return subscribeSse(`/api/events${query}`, {
events,
onReconnect: () => refreshFromCurrentGroup(),
});
}, [groupId, isOpen, projectId]);
const completionText = useMemo(() => {
if (!group) return "";
return t("groupTask.completionText", "{{landed}} of {{total}} members finished", { landed: group.completion.landed, total: group.completion.total });
}, [group, t]);
const completionPercent = useMemo(() => {
if (!group || group.completion.total <= 0) return 0;
return (group.completion.landed / group.completion.total) * 100;
}, [group]);
const onPromote = useCallback(async () => {
if (!groupId) return;
setPromoting(true);
try {
await apiPromoteBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setPromoting(false);
}
}, [groupId, loadGroup, projectId]);
const onAbandon = useCallback(async () => {
if (!groupId) return;
setAbandoning(true);
try {
await apiAbandonBranchGroup(groupId, projectId);
await loadGroup();
} finally {
setAbandoning(false);
}
}, [groupId, loadGroup, projectId]);
if (!isOpen || !groupId) return null;
return (
/* FNXC:ModalTouchGeometry 2026-07-26-13:15: Shared FloatingWindow owns this modal's touch drag, resize, clamping, and persistence while phone and short viewports retain their sheet behavior. */
<FloatingWindow windowKey="group-task" title={t("groupTask.title", "Branch Group")} ariaLabel={t("groupTask.ariaLabel", "Branch group details")} onClose={onClose} hideHeader dragHandleSelector=".modal-header" className="floating-window--group-task" defaultSize={{ width: 720, height: 560 }} minSize={{ width: 360, height: 280 }} persistGeometryKey="floating-window:group-task" suspendGeometryPersistenceOnMobile suspendGeometryPersistenceOnShortViewport closeOnOutsidePointerDown>
<div className="modal modal-lg group-task-modal">
<div className="modal-header">
<h2>{t("groupTask.title", "Branch Group {{id}}", { id: groupId })}</h2>
<button type="button" className="modal-close" onClick={onClose} aria-label={t("actions.closeModal", "Close modal")}>
<X />
</button>
</div>
<div className="modal-body group-task-modal-body">
{loading && (
<div className="card group-task-modal-state"><Loader2 className="spin" /> {t("groupTask.loading", "Loading branch group…")}</div>
)}
{!loading && error && <div className="card group-task-modal-state group-task-modal-error">{error}</div>}
{!loading && !error && !group && <div className="card group-task-modal-state">{t("groupTask.unavailable", "Branch group unavailable")}</div>}
{!loading && !error && group && (
<>
<section className="card group-task-modal-summary">
<div className="group-task-modal-summary-row">
<span className="group-task-modal-label">{t("groupTask.sharedBranch", "Shared branch")}</span>
<strong>{group.branchName}</strong>
</div>
<div className="group-task-modal-summary-row">
<span className="group-task-modal-label">{t("groupTask.status", "Status")}</span>
<span className="badge">{group.status}</span>
</div>
<div className="group-task-modal-progress-text">{completionText}</div>
<div className="branch-group-card-progress" role="progressbar" aria-valuenow={group.completion.landed} aria-valuemin={0} aria-valuemax={group.completion.total}>
<span className="branch-group-card-progress-fill" style={{ width: `${completionPercent}%` }} />
</div>
</section>
<section className="card group-task-modal-members-card">
<h3>{t("groupTask.members", "Members")}</h3>
<ul className="group-task-modal-members">
{group.members.map((member) => (
<li key={member.taskId} className="group-task-modal-member">
<span className={`status-dot ${member.landed ? "status-dot--online" : "status-dot--pending"}`} />
<span className="group-task-modal-member-main">{member.taskId} · {member.title}</span>
<span className="badge">{member.column}</span>
<span className="group-task-modal-member-status">{member.landed ? <CheckCircle2 /> : <CircleDashed />}</span>
<button type="button" className="btn btn-sm" onClick={() => onOpenMemberTask(member.taskId)}>{t("groupTask.openTask", "Open task")}</button>
</li>
))}
</ul>
</section>
{group.prUrl && (
<section className="card group-task-modal-pr">
<a className="btn" href={group.prUrl} target="_blank" rel="noreferrer">
{t("groups.prLinkLabel", "PR #{{number}} ({{state}})", { number: group.prNumber ?? "—", state: group.prState })} <ExternalLink />
</a>
</section>
)}
{(group.prState === "merged" || group.prState === "closed") && (
<section className="card group-task-modal-actions">
<span className="badge">{group.prState === "merged" ? t("groupTask.prMerged", "Group PR merged") : t("groupTask.prClosed", "Group PR closed")}</span>
</section>
)}
{(group.completion.complete || group.prState === "open") && group.prState !== "merged" && group.prState !== "closed" && (
<section className="card group-task-modal-actions">
{/* Promote stays gated on completion; Abandon below is reachable
whenever the PR is open, even if completion later reverts. */}
{group.completion.complete && (group.autoMerge ? (
<span className="badge">{t("groupTask.autoMergeEnabled", "Auto-merge enabled")}</span>
) : (
<button type="button" className="btn" onClick={() => void onPromote()} disabled={promoting}>
{promoting ? <Loader2 className="spin" /> : null}
{group.prState === "none" ? t("groupTask.openPR", "Open PR") : t("groupTask.mergeIntoMain", "Merge group into main")}
</button>
))}
{group.prState === "open" && (
<button type="button" className="btn btn-danger" onClick={() => void onAbandon()} disabled={abandoning}>
{abandoning ? <Loader2 className="spin" /> : null}
{t("groupTask.abandonGroup", "Abandon group")}
</button>
)}
</section>
)}
</>
)}
</div>
</div>
</FloatingWindow>
);
}