fix(canvas): namespace WorkspacePanelTabs ids per instance (duplicate #tab-chat broke chat E2E) #2587

Merged
agent-researcher merged 1 commits from fix/duplicate-tab-ids-concierge-embed into main 2026-06-11 14:52:25 +00:00
3 changed files with 28 additions and 11 deletions
+7 -4
View File
@@ -418,7 +418,10 @@ test.describe("concierge Settings — two tabs", () => {
wsTablist,
"platform-agent Settings tab did not embed WorkspacePanelTabs",
).toBeVisible({ timeout: 15_000 });
await expect(page.locator("#tab-config")).toHaveAttribute(
// concierge-embedded WorkspacePanelTabs namespaces its ids with
// "concierge-" (duplicate-id fix); the bare #tab-* ids belong to the
// map SidePanel instance only.
await expect(page.locator("#concierge-tab-config")).toHaveAttribute(
"aria-selected",
"true",
);
@@ -434,8 +437,8 @@ test.describe("concierge Settings — two tabs", () => {
"schedule",
]) {
await expect(
page.locator(`#tab-${id}`),
`WorkspacePanelTabs is missing #tab-${id}`,
page.locator(`#concierge-tab-${id}`),
`WorkspacePanelTabs is missing #concierge-tab-${id}`,
).toHaveCount(1);
}
@@ -469,7 +472,7 @@ test.describe("concierge Settings — Config tab dropdowns", () => {
).toBeVisible({ timeout: 15_000 });
// The runtime <select> sits under the "Runtime" label inside the Config
// panel. Use the label association for a stable hook.
const runtimeByLabel = page.locator('#panel-config').getByLabel("Runtime", {
const runtimeByLabel = page.locator('#concierge-panel-config').getByLabel("Runtime", {
exact: true,
});
await expect(
+16 -6
View File
@@ -64,6 +64,16 @@ interface Props {
onTabChange?: (tab: PanelTab) => void;
/** Initial tab for the uncontrolled (local-state) mode. Defaults to "chat". */
defaultTab?: PanelTab;
/**
* Namespace for the tab/panel element ids (`{idPrefix}tab-chat`,
* `{idPrefix}panel-chat`). The primary map SidePanel keeps the default ""
* (stable `#tab-chat` hooks for tests/tools); any EMBEDDED instance MUST
* pass a unique prefix — two instances with the default collide on
* duplicate DOM ids, which is invalid HTML, breaks aria-controls, and
* broke the chat E2E with a Playwright strict-mode violation
* (`locator('#tab-chat') resolved to 2 elements`).
*/
idPrefix?: string;
}
/**
@@ -75,7 +85,7 @@ interface Props {
* Does NOT render the workspace header / meta pills / resize handle / footer —
* those are host chrome and stay in the host (SidePanel for the map).
*/
export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab = "chat" }: Props) {
export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab = "chat", idPrefix = "" }: Props) {
const restartWorkspace = useCanvasStore((s) => s.restartWorkspace);
// Controlled when both props are present; otherwise own the state locally.
@@ -109,7 +119,7 @@ export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab =
else if (e.key === "End") { e.preventDefault(); next = WORKSPACE_PANEL_TABS.length - 1; }
if (next !== null) {
setTab(WORKSPACE_PANEL_TABS[next].id);
requestAnimationFrame(() => { const el = document.getElementById(`tab-${WORKSPACE_PANEL_TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
requestAnimationFrame(() => { const el = document.getElementById(`${idPrefix}tab-${WORKSPACE_PANEL_TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
}
}}
>
@@ -117,10 +127,10 @@ export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab =
<button
type="button"
key={t.id}
id={`tab-${t.id}`}
id={`${idPrefix}tab-${t.id}`}
role="tab"
aria-selected={tab === t.id}
aria-controls={`panel-${t.id}`}
aria-controls={`${idPrefix}panel-${t.id}`}
tabIndex={tab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 ${
@@ -167,8 +177,8 @@ export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab =
{/* Tab Content */}
<div
role="tabpanel"
id={`panel-${tab}`}
aria-labelledby={`tab-${tab}`}
id={`${idPrefix}panel-${tab}`}
aria-labelledby={`${idPrefix}tab-${tab}`}
tabIndex={0}
className="flex-1 overflow-y-auto focus:outline-none"
>
@@ -595,7 +595,11 @@ export function ConciergeShell() {
</div>
{platformRoot ? (
<div className={s.embedPanel}>
<WorkspacePanelTabs key={platformRoot.id} node={platformRoot} defaultTab="config" />
{/* idPrefix: this is a SECOND WorkspacePanelTabs instance —
without a namespace its tab/panel ids collide with the
map SidePanel's (#tab-chat duplicated → invalid HTML,
broken aria-controls, Playwright strict-mode failures). */}
<WorkspacePanelTabs key={platformRoot.id} node={platformRoot} defaultTab="config" idPrefix="concierge-" />
</div>
) : (
<div className={s.scardDesc}>