From ab36871f8ebc742cd412a00684a23fe8e77dca88 Mon Sep 17 00:00:00 2001 From: fernando_jacob Date: Sat, 22 Aug 2026 15:09:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(desktop):=20keep=20the=20=EF=BC=8B=20me?= =?UTF-8?q?nu=20still=20while=20the=20Skill=20catalog=20refreshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling Plan (or changing the model or mode) re-fetches the invocable-Skill projection, and the fail-closed clear made the + menu's Skills row read the in-flight `[]` as "no skills available": it grayed out and grew a description line for the length of the IPC round trip, so the open menu visibly jumped on every Plan click — in a new chat and in a session alike. The hook now also returns a settled verdict, `mentionSkillsUnavailable`, that only moves when a request resolves. The Skills row renders from that verdict, so its presentation holds until the catalog's emptiness actually changed, while the `/` popup keeps reading the fail-closed list. Hosts that never clear the list mid-flight can omit the flag; the row then falls back to `length === 0` as before. The new e2e spec arms an in-page MutationObserver before the click and fails on the pre-fix build with exactly this signal. Co-Authored-By: Claude Fable 5 Generated-by: Claude Code --- .../e2e/composer-plus-menu-stability.spec.ts | 114 ++++++++++++++++++ apps/desktop/src/renderer/app-shell.tsx | 4 +- apps/desktop/src/renderer/chat-workbar.tsx | 2 + .../src/renderer/quote-companion-panel.tsx | 2 + apps/desktop/src/renderer/session-workbar.tsx | 2 + .../src/renderer/use-composer-mentions.ts | 17 ++- .../src/__tests__/composer-plus-menu.test.tsx | 46 +++++++ packages/ui/src/composer.tsx | 32 ++++- 8 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/e2e/composer-plus-menu-stability.spec.ts diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts new file mode 100644 index 00000000000..2e5f43c23c6 --- /dev/null +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, waitForInvocableSkills } from './fixtures'; + +/** + * Toggling Plan from the + menu must not move the menu. + * + * The toggle changes the new chat's collaboration mode, which re-fetches the + * invocable-Skill projection. That refresh clears the list fail-closed for the + * `/` popup, and the regression this spec pins is the Skills row reading the + * transient `[]` as "no skills available": it grayed out and grew a + * description line for the length of the round trip, so the open menu's + * geometry blinked on every Plan click (MatrixA/fix-plan-click-flicker). + * + * The watcher is armed in-page BEFORE the click: the blink lives inside one + * IPC round trip and is gone by the time a polling assertion could look. + */ + +interface PlusMenuWatch { + noSkillsTextAppeared: boolean; + skillsRowDisabled: boolean; + planRowDisabled: boolean; + heights: number[]; +} + +declare global { + interface Window { + __plusMenuWatch?: PlusMenuWatch; + __plusMenuWatchStop?: () => void; + } +} + +test('toggling Plan keeps the + menu open, enabled and the same size', async ({ + invocableSkillsWindow: page, +}) => { + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + await expect(menu).toBeVisible(); + + const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); + const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); + // The seeded catalog has settled before the fixture yields the page, so the + // baseline is an enabled row with no caveat — what must survive the toggle. + await expect(skillsRow).not.toHaveAttribute('aria-disabled', 'true'); + await expect(menu).not.toContainText('当前没有可用技能'); + + // The layer scales in on open (translate + scale 0.95 → 1), and a bounding + // box read mid-entrance is smaller than the resting one. Let the entrance + // finish so the recorded baseline is the height the menu must keep. + await menu.evaluate(async (menuElement) => { + const layer = menuElement.closest('[popover]') ?? menuElement; + await Promise.all( + layer.getAnimations().map((animation) => animation.finished.catch(() => {})), + ); + }); + + await menu.evaluate((menuElement) => { + const watch: PlusMenuWatch = { + noSkillsTextAppeared: false, + skillsRowDisabled: false, + planRowDisabled: false, + heights: [menuElement.getBoundingClientRect().height], + }; + const inspect = () => { + if (menuElement.textContent?.includes('当前没有可用技能')) { + watch.noSkillsTextAppeared = true; + } + for (const row of menuElement.querySelectorAll('[aria-disabled="true"]')) { + if (row.textContent?.includes('选择技能')) watch.skillsRowDisabled = true; + if (row.getAttribute('role') === 'menuitemcheckbox') watch.planRowDisabled = true; + } + const height = menuElement.getBoundingClientRect().height; + const last = watch.heights[watch.heights.length - 1] ?? height; + if (Math.abs(height - last) > 0.5) watch.heights.push(height); + }; + const observer = new MutationObserver(inspect); + observer.observe(menuElement, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + window.__plusMenuWatch = watch; + window.__plusMenuWatchStop = () => { + inspect(); + observer.disconnect(); + }; + }); + + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); + // The mode mark lands on the footer while the menu stays where it was. + await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toBeVisible(); + await expect(menu).toBeVisible(); + + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); + await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0); + await expect(menu).toBeVisible(); + + // Both refreshes the two toggles kicked off have reached the backend once + // this resolves; the margin covers the renderer commit that follows. + await waitForInvocableSkills(page, ['project-only', 'workspace-only']); + await page.waitForTimeout(250); + + const watch = await page.evaluate(() => { + window.__plusMenuWatchStop?.(); + return window.__plusMenuWatch; + }); + expect(watch, 'the in-page watcher survived the journey').toBeTruthy(); + expect(watch?.noSkillsTextAppeared, 'no transient "no skills" line').toBe(false); + expect(watch?.skillsRowDisabled, 'the Skills row never grayed out').toBe(false); + expect(watch?.planRowDisabled, 'the Plan row never grayed out').toBe(false); + expect(watch?.heights, 'the menu kept one height throughout').toHaveLength(1); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 6303fcc602f..621da879cc0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2074,7 +2074,7 @@ function AppShellContent({ // Composer mention popups: `/` uses Runtime's session/project-aware, // host-compatible projection; `@` uses workspace file search. Keep the // resolved project path as a refresh key for new-chat project changes. - const { mentionSkills, searchMentionFiles } = useComposerMentions({ + const { mentionSkills, mentionSkillsUnavailable, searchMentionFiles } = useComposerMentions({ skills, sessionId: activeId, projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, @@ -3213,6 +3213,7 @@ function AppShellContent({ : undefined } mentionSkills={mentionSkills} + mentionSkillsUnavailable={mentionSkillsUnavailable} slashCommands={desktopSlashCommands} onSearchMentionFiles={searchMentionFiles} pendingAttachments={pendingAttachments} @@ -3609,6 +3610,7 @@ function AppShellContent({ sourceSession={activeSessionForView} modelChoices={chatModelChoices} mentionSkills={mentionSkills} + mentionSkillsUnavailable={mentionSkillsUnavailable} onSearchMentionFiles={searchMentionFiles} /> )} diff --git a/apps/desktop/src/renderer/chat-workbar.tsx b/apps/desktop/src/renderer/chat-workbar.tsx index df6b6da9a28..44ee30febc6 100644 --- a/apps/desktop/src/renderer/chat-workbar.tsx +++ b/apps/desktop/src/renderer/chat-workbar.tsx @@ -95,6 +95,7 @@ interface ChatWorkbarProps { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; + mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; } @@ -162,6 +163,7 @@ export function ChatWorkbar(props: ChatWorkbarProps) { sourceSession={props.sourceSession} modelChoices={props.modelChoices} mentionSkills={props.mentionSkills} + mentionSkillsUnavailable={props.mentionSkillsUnavailable} onSearchMentionFiles={props.onSearchMentionFiles} /> diff --git a/apps/desktop/src/renderer/quote-companion-panel.tsx b/apps/desktop/src/renderer/quote-companion-panel.tsx index 0d78b4db4b6..c10879a686c 100644 --- a/apps/desktop/src/renderer/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/quote-companion-panel.tsx @@ -47,6 +47,7 @@ export function QuoteCompanionPanel(props: { /** Shared global choice list, only used to render the inherited model's label. */ modelChoices: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; + mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; @@ -241,6 +242,7 @@ export function QuoteCompanionPanel(props: { pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} mentionSkills={props.mentionSkills} + mentionSkillsUnavailable={props.mentionSkillsUnavailable} onSearchMentionFiles={props.onSearchMentionFiles} pendingQuotes={props.quotes.map((quote) => quote.value)} contextDrawerDefaultCollapsed diff --git a/apps/desktop/src/renderer/session-workbar.tsx b/apps/desktop/src/renderer/session-workbar.tsx index 1b54d6e2e3b..fdc17fcfe0b 100644 --- a/apps/desktop/src/renderer/session-workbar.tsx +++ b/apps/desktop/src/renderer/session-workbar.tsx @@ -685,6 +685,7 @@ export function SessionWorkbar(props: { sourceSession?: SessionSummary; modelChoices?: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; + mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; }) { const copy = getDesktopConversationCopy(useUiLocale()).workbar; @@ -838,6 +839,7 @@ export function SessionWorkbar(props: { sourceSession={props.sourceSession} modelChoices={props.modelChoices ?? []} mentionSkills={props.mentionSkills} + mentionSkillsUnavailable={props.mentionSkillsUnavailable} onSearchMentionFiles={props.onSearchMentionFiles} onQuotesConsumed={props.onQuotesConsumed ?? (() => {})} onRemoveQuote={props.onRemoveQuote} diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts index 8537561dd6a..09e3cdca1e2 100644 --- a/apps/desktop/src/renderer/use-composer-mentions.ts +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -22,6 +22,7 @@ export function useComposerMentions(options: { newTaskTarget?: DesktopNewTaskTarget; }): { mentionSkills: ReadonlyArray<{ ref?: string; id: string; name: string; description?: string }>; + mentionSkillsUnavailable: boolean; searchMentionFiles(query: string): Promise>; } { const { @@ -34,6 +35,14 @@ export function useComposerMentions(options: { newTaskTarget, } = options; const [mentionSkills, setMentionSkills] = useState([]); + // The last SETTLED truth about the catalog, held across refreshes. The list + // itself is cleared while a refresh is in flight (fail-closed, for the `/` + // popup), so `length === 0` cannot tell "re-fetching" from "nothing to + // offer" — and the + menu's Skills row repainting on that transient empty + // is a visible blink of the open menu on every mode, model or turn change. + // This flag only moves when a request resolves, so the row's presentation + // is stable until the catalog's emptiness actually changed. + const [mentionSkillsUnavailable, setMentionSkillsUnavailable] = useState(false); useEffect(() => { let cancelled = false; @@ -59,11 +68,15 @@ export function useComposerMentions(options: { : Promise.resolve([]); void request.then( (next) => { - if (!cancelled && version === requestVersion) setMentionSkills(next); + if (cancelled || version !== requestVersion) return; + setMentionSkills(next); + setMentionSkillsUnavailable(next.length === 0); }, () => { // Fail soft: an unavailable projection leaves `/` with no suggestions. // Direct `/skill:` input still reaches the same Runtime resolver. + if (cancelled || version !== requestVersion) return; + setMentionSkillsUnavailable(true); }, ); }; @@ -125,5 +138,5 @@ export function useComposerMentions(options: { ], ); - return { mentionSkills, searchMentionFiles }; + return { mentionSkills, mentionSkillsUnavailable, searchMentionFiles }; } diff --git a/packages/ui/src/__tests__/composer-plus-menu.test.tsx b/packages/ui/src/__tests__/composer-plus-menu.test.tsx index ee58cc530a4..fb86b24b29a 100644 --- a/packages/ui/src/__tests__/composer-plus-menu.test.tsx +++ b/packages/ui/src/__tests__/composer-plus-menu.test.tsx @@ -74,6 +74,52 @@ test('each mode row is the control its field is, and none of them is on', () => assert.equal(count(menu, 'aria-checked="true"'), 0, 'nothing on is nothing checked'); }); +/** The Skills entry is the menu's only plain-menuitem row under these props. */ +function skillsRow(menu: string): string { + const rows = tagsWith(menu, 'role="menuitem"'); + assert.equal(rows.length, 1, 'expected the Skills row and nothing else'); + return rows[0] ?? ''; +} + +test('a refreshing skill catalog is not "no skills": the row stays put', () => { + // The host clears `mentionSkills` while it re-fetches the projection (a + // Plan toggle or model change does that with this menu open) but holds its + // settled verdict steady. Painting the transient `[]` as "no skills + // available" grows the row by a description line and grays it, then snaps + // back — the menu visibly jumps. + const menu = plusMenu({ ...base, mentionSkills: [], mentionSkillsUnavailable: false }); + assert.equal(count(menu, 'Choose skills'), 1, 'the Skills row is rendered'); + assert.equal(count(menu, 'No skills available'), 0, 'no transient empty-state line'); + assert.equal( + skillsRow(menu).includes('aria-disabled="true"'), + false, + 'the row does not gray out mid-refresh', + ); +}); + +test('a settled empty skill catalog still says why the row is unavailable', () => { + for (const props of [ + // A host that never clears the list mid-flight wires no verdict; the row + // falls back to the list itself. + { ...base, mentionSkills: [] }, + { ...base, mentionSkills: [], mentionSkillsUnavailable: true }, + ]) { + const menu = plusMenu(props); + assert.ok(count(menu, 'No skills available') > 0, 'the empty state says why'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), true); + } +}); + +test('a populated skill catalog renders the row enabled with no caveat', () => { + const menu = plusMenu({ + ...base, + mentionSkills: [{ id: 'demo', name: 'Demo' }], + }); + assert.equal(count(menu, 'Choose skills'), 1); + assert.equal(count(menu, 'No skills available'), 0); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); +}); + test('Plan and an orchestration mode are both on at once', () => { const markup = render({ ...base, planModeActive: true, orchestrationMode: 'swarm' }); const menu = markup.split('maka-composer-plus-menu')[1] ?? ''; diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 3e9b428e98c..08e7ae98de8 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -398,6 +398,18 @@ export const Composer = forwardRef< * - `onSearchMentionFiles` powers the `@` popup. */ mentionSkills?: ReadonlyArray; + /** + * The host's SETTLED verdict on whether the catalog has anything to offer, + * held steady across refreshes. The host clears `mentionSkills` while + * re-fetching it (the `/` popup must stay fail-closed), so the list being + * empty cannot tell "refreshing" from "no skills" — and reading the + * transient `[]` as the latter disables the + menu's Skills row and grows + * it by a description line for the length of the round trip, blinking the + * open menu's geometry on every mode or model change. Hosts that never + * clear the list mid-flight can omit this; the row then falls back to + * `mentionSkills.length === 0`. + */ + mentionSkillsUnavailable?: boolean; slashCommands?: ReadonlyArray; onSearchMentionFiles?(query: string): Promise>; } @@ -1682,9 +1694,25 @@ export const Composer = forwardRef< // a stray slash and popping an empty menu. Say why it is // unavailable: the panel this replaced showed "no skills // available", and a silent grey row answers nothing. - isDisabled={props.disabled || props.mentionSkills.length === 0} + // + // Only a SETTLED empty catalog counts. The host clears + // the list while re-fetching it (a Plan toggle or model + // change does that with this menu open), and rendering + // that transient `[]` as "no skills" grows this row by + // a description line and back — the menu visibly jumps. + // So the verdict is the host's settled flag when it + // supplies one, and the row repaints only when the + // catalog's emptiness actually changed. + isDisabled={ + props.disabled + || (props.mentionSkillsUnavailable + ?? props.mentionSkills.length === 0) + } description={ - props.mentionSkills.length === 0 ? copy.noSkillsAvailable : undefined + (props.mentionSkillsUnavailable + ?? props.mentionSkills.length === 0) + ? copy.noSkillsAvailable + : undefined } onClick={openSkillMenu} /> From 1beb6d2b399856bc39ac73e60efafc1d32dee2e1 Mon Sep 17 00:00:00 2001 From: fernando_jacob Date: Sat, 22 Aug 2026 15:09:37 +0800 Subject: [PATCH 2/4] fix(desktop): stop dimming the mode rows while a toggle commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending registries already swallow re-entrant Plan/orchestration toggles, so disabling the + menu rows and the footer mode marks for the length of the commit round trip protected nothing — it dimmed them to half opacity and back on every click, a gray blink in the very menu the user was looking at (three sequential IPCs when a session is active). Drop the pending-keyed disable and the `modeChanging` reason; the rows repaint once the write lands. The pending state stays as a write-only store because the shared pending-action helpers need one. Co-Authored-By: Claude Fable 5 Generated-by: Claude Code --- apps/desktop/src/renderer/app-shell.tsx | 30 ++++++++++--------------- packages/ui/src/composer.tsx | 9 ++++---- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 621da879cc0..8c03d0e534e 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -427,8 +427,12 @@ function AppShellContent({ const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); - const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); - const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); + // Write-only: the pending-action helpers need a store, but nothing renders + // from these — a mode toggle's guard is the registry ref, and painting the + // round trip (disable/dim, then restore) is exactly the + menu blink that + // MatrixA/fix-plan-click-flicker removed. + const [, setPendingCollaborationModeBySession] = useState>({}); + const [, setPendingOrchestrationModeBySession] = useState>({}); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); @@ -3292,26 +3296,16 @@ function AppShellContent({ : undefined } planModeActive={activePlanMode} - planModePending={activeId - ? pendingCollaborationModeBySession[activeId] === true - : false} - planModeDisabledReason={ - activeId && pendingCollaborationModeBySession[activeId] === true - ? shellCopy.modeChanging - : modeChangeDisabledReason - } + // No pending-keyed disable while a toggle commits: the + // pending registries already swallow re-entrant toggles, and + // a reason here would gray the row mid-click — the blink + // this control had. The rows repaint when the write lands. + planModeDisabledReason={modeChangeDisabledReason} onPlanModeChange={(active) => { void setPlanMode(active); }} orchestrationMode={activeOrchestrationMode} - orchestrationModePending={activeId - ? pendingOrchestrationModeBySession[activeId] === true - : false} - orchestrationModeDisabledReason={ - activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.modeChanging - : modeChangeDisabledReason - } + orchestrationModeDisabledReason={modeChangeDisabledReason} onOrchestrationModeChange={(mode) => { void setOrchestrationMode(mode); }} diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 08e7ae98de8..f9638d22233 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -346,7 +346,6 @@ export const Composer = forwardRef< * as a resting mode the user must leave by hand. */ planModeActive?: boolean; - planModePending?: boolean; planModeDisabledReason?: string; onPlanModeChange?(active: boolean): void | Promise; /** @@ -366,7 +365,6 @@ export const Composer = forwardRef< * neither control writes the other's field. */ orchestrationMode?: OrchestrationMode; - orchestrationModePending?: boolean; orchestrationModeDisabledReason?: string; onOrchestrationModeChange?(mode: OrchestrationMode): void | Promise; /** @@ -1363,15 +1361,18 @@ export const Composer = forwardRef< ]; /** A host that passes no handler cannot be in a mode this control can leave. */ const planModeActive = props.onPlanModeChange !== undefined && props.planModeActive === true; + // Deliberately NOT disabled while the host commits a toggle. The host + // already drops re-entrant toggles itself, so a disable during its short + // IPC round trip carries no protection — it only dims the row (and the + // footer mark) to half opacity and back on every click, a visible blink + // in the very menu the user is looking at. const planModeDisabled = props.disabled === true - || props.planModePending === true || Boolean(props.planModeDisabledReason); const orchestrationMode: OrchestrationMode = props.onOrchestrationModeChange ? props.orchestrationMode ?? 'default' : 'default'; const orchestrationModeDisabled = props.disabled === true - || props.orchestrationModePending === true || Boolean(props.orchestrationModeDisabledReason); /** * The marks at the tail of the footer's left controls are the resting From acf2344e8f2149d8ed489091e9560a6dad98e0c2 Mon Sep 17 00:00:00 2001 From: fernando_jacob Date: Sat, 22 Aug 2026 16:21:45 +0800 Subject: [PATCH 3/4] fix(desktop): settle the Skill catalog into one state and keep the last mode ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on both P2s: The catalog is now one explicit value — loading, settled empty, or settled populated — held in a single piece of state so a refresh cannot tear its facets apart. `mentionSkillsUnavailable` still paints the + menu's Skills row from the last settled verdict; the new `mentionSkillsLoading` gates what a click DOES. Mid-refresh (including the very first fetch, where nothing has settled and the row previously looked actionable) the row ignores clicks and stops closing the menu, so nothing writes a stray `/` against the fail-closed list. The held state is observable as `maka-composer-skills-loading`. A mode toggle landing while a commit is in flight is no longer silently dropped: each session keeps the LATEST asked value, and the in-flight commit's finally block applies it when the settled state does not already satisfy it — a quick "on, then off" now finishes off, for Plan and orchestration alike. A new preload affordance under the isolated-E2E gate latches single bridge calls, so the two new e2e specs hold these in-flight windows open deterministically; both fail without their fix. Co-Authored-By: Claude Fable 5 Generated-by: Claude Code --- .../e2e/composer-plus-menu-stability.spec.ts | 102 +++++++++++++++++- apps/desktop/src/preload/preload.ts | 48 +++++++++ apps/desktop/src/renderer/app-shell.tsx | 49 ++++++++- apps/desktop/src/renderer/chat-workbar.tsx | 2 + .../src/renderer/quote-companion-panel.tsx | 2 + apps/desktop/src/renderer/session-workbar.tsx | 2 + .../src/renderer/use-composer-mentions.ts | 51 +++++---- .../src/__tests__/composer-plus-menu.test.tsx | 31 ++++++ packages/ui/src/composer.tsx | 30 +++++- 9 files changed, 294 insertions(+), 23 deletions(-) diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts index 2e5f43c23c6..7d70c4af12c 100644 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -1,4 +1,43 @@ -import { expect, test, waitForInvocableSkills } from './fixtures'; +import { expect, test, COMPOSER_INPUT, waitForInvocableSkills } from './fixtures'; + +type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; + +declare global { + interface Window { + /** E2E-only preload affordance; see the MAKA_E2E block in preload.ts. */ + makaE2eLatch?: { + arm(key: LatchKey, options?: { oneShot?: boolean }): void; + release(key: LatchKey): void; + }; + } +} + +/** + * Hold the next call (or every call) to one bridge method until released, so + * an IPC in-flight window can be observed deterministically instead of raced + * against the fake backend's near-instant replies. Installed by the preload + * under the isolated-E2E gate; its absence in an E2E window is a wiring bug, + * not a reason to skip. + */ +async function armBridgeLatch( + page: import('@playwright/test').Page, + key: LatchKey, + options?: { oneShot?: boolean }, +): Promise { + const armed = await page.evaluate(({ key: latchKey, options: latchOptions }) => { + if (!window.makaE2eLatch) return false; + window.makaE2eLatch.arm(latchKey, latchOptions); + return true; + }, { key, options }); + expect(armed, 'the preload E2E latch is installed').toBe(true); +} + +async function releaseBridgeLatch( + page: import('@playwright/test').Page, + key: LatchKey, +): Promise { + await page.evaluate((latchKey) => window.makaE2eLatch?.release(latchKey), key); +} /** * Toggling Plan from the + menu must not move the menu. @@ -112,3 +151,64 @@ test('toggling Plan keeps the + menu open, enabled and the same size', async ( expect(watch?.planRowDisabled, 'the Plan row never grayed out').toBe(false); expect(watch?.heights, 'the menu kept one height throughout').toHaveLength(1); }); + +test('a Skills click during the catalog refresh does nothing, then works settled', async ({ + invocableSkillsWindow: page, +}) => { + // The row's enabled look mid-refresh is a held presentation of the previous + // catalog; acting on it would type a stray `/` against the fail-closed + // list. Hold the refresh open on a latch and click straight into it. + await armBridgeLatch(page, 'newTasks.listInvocableSkills'); + + const composer = page.locator(COMPOSER_INPUT); + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); + const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); + + // The Plan toggle starts the (now latched) refresh; the row announces the + // held state and a click inside the window has no effect at all. + await planRow.click(); + await expect(skillsRow).toHaveClass(/maka-composer-skills-loading/); + await skillsRow.click(); + await expect(menu).toBeVisible(); + await expect(composer).toHaveText(''); + await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0); + + // Released, the same click opens the `/` popup as usual. + await releaseBridgeLatch(page, 'newTasks.listInvocableSkills'); + await expect(skillsRow).not.toHaveClass(/maka-composer-skills-loading/); + await skillsRow.click(); + await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); +}); + +test('two rapid Plan toggles land on the last requested state', async ({ + invocableSkillsWindow: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('alpha-marker'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); + + // The session-list refresh is the tail of a Plan commit: latching its next + // call keeps the commit pending — deterministically — after the mode has + // already landed and the row repainted checked. Armed only now, so the + // send's own refreshes above cannot consume the latch. + await armBridgeLatch(page, 'sessions.list', { oneShot: true }); + + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); + + // Second click while the first commit is still pending: the latest ask is + // OFF, and it must not be dropped just because the registry is busy. + await planRow.click(); + + // The queued intent drains after the in-flight commit settles: OFF wins. + await releaseBridgeLatch(page, 'sessions.list'); + await expect(planRow).toHaveAttribute('aria-checked', 'false'); +}); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 231dacbf2b5..19e02088a2b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2951,4 +2951,52 @@ const makaBridge = { }, } satisfies MakaBridge; +// E2E-only IPC latches. Real users never get these: the preload mirrors the +// main process's isolated-E2E gate (startup-context.ts) — MAKA_E2E alone is +// not enough without the throwaway profile dir. An armed latch holds the next +// call (or every call) to one bridge method until the test releases it, so +// Playwright gets a deterministic in-flight window instead of racing the fake +// backend's near-instant replies. The wrappers must be installed BEFORE +// exposeInMainWorld: the bridge is cloned into the main world at expose time, +// and the exposed clone is sealed against later patching. +if (process.env.MAKA_E2E === '1' && process.env.MAKA_E2E_USER_DATA_DIR) { + type LatchKey = 'newTasks.listInvocableSkills' | 'sessions.list'; + const gates = new Map; oneShot: boolean }>(); + const releases = new Map void>(); + const wrapLatched = ( + call: (...args: Args) => Promise, + key: LatchKey, + ) => async (...args: Args): Promise => { + const gate = gates.get(key); + if (gate) { + if (gate.oneShot) gates.delete(key); + await gate.promise; + } + return call(...args); + }; + makaBridge.newTasks.listInvocableSkills = wrapLatched( + makaBridge.newTasks.listInvocableSkills.bind(makaBridge.newTasks), + 'newTasks.listInvocableSkills', + ); + makaBridge.sessions.list = wrapLatched( + makaBridge.sessions.list.bind(makaBridge.sessions), + 'sessions.list', + ); + contextBridge.exposeInMainWorld('makaE2eLatch', { + arm(key: LatchKey, options?: { oneShot?: boolean }) { + let release: () => void = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + gates.set(key, { promise, oneShot: options?.oneShot === true }); + releases.set(key, release); + }, + release(key: LatchKey) { + releases.get(key)?.(); + releases.delete(key); + gates.delete(key); + }, + }); +} + contextBridge.exposeInMainWorld('maka', makaBridge); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 8c03d0e534e..c5f6a20b81b 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -433,6 +433,12 @@ function AppShellContent({ // MatrixA/fix-plan-click-flicker removed. const [, setPendingCollaborationModeBySession] = useState>({}); const [, setPendingOrchestrationModeBySession] = useState>({}); + // The rows stay interactive while a commit runs, so a click landing in that + // window is the user updating their mind — not noise to drop. Each map holds + // only the LATEST ask per session; the in-flight commit's finally block + // applies it if the settled state does not already satisfy it. + const queuedCollaborationModeBySession = useRef(new Map()); + const queuedOrchestrationModeBySession = useRef(new Map()); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); @@ -1050,7 +1056,15 @@ function AppShellContent({ sessionId, collaborationModeChangeRegistry.keysRef, setPendingCollaborationModeBySession, - )) return false; + )) { + // A commit is in flight and the rows stay interactive (no pending + // repaint), so this click is the user updating their mind, not noise. + // Latest intent wins: remember only the newest value and let the + // in-flight commit's finally block apply it, so a quick "on, then off" + // finishes as "off". + queuedCollaborationModeBySession.current.set(sessionId, active); + return true; + } try { const planState = await window.maka.sessions.getPlanState(sessionId); @@ -1104,6 +1118,19 @@ function AppShellContent({ collaborationModeChangeRegistry.keysRef, setPendingCollaborationModeBySession, ); + // Whatever the user last asked for while this commit ran is the state + // they expect to land on. Read the settled mode through the ref — the + // closure's projection predates this commit — and only re-apply when + // the intent is not already satisfied. + const queued = queuedCollaborationModeBySession.current.get(sessionId); + queuedCollaborationModeBySession.current.delete(sessionId); + if (queued !== undefined) { + const settledActive = ( + sessionsRef.current.find((session) => session.id === sessionId)?.collaborationMode + ?? 'agent' + ) === 'plan'; + if (queued !== settledActive) void applyPlanMode(queued, sessionId); + } } } @@ -1122,7 +1149,13 @@ function AppShellContent({ sessionId, orchestrationModeChangeRegistry.keysRef, setPendingOrchestrationModeBySession, - )) return false; + )) { + // Same latest-intent contract as applyPlanMode: the rows stay + // interactive while a commit runs, so keep the newest ask and apply it + // from the in-flight commit's finally block. + queuedOrchestrationModeBySession.current.set(sessionId, mode); + return true; + } try { const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); @@ -1144,6 +1177,14 @@ function AppShellContent({ orchestrationModeChangeRegistry.keysRef, setPendingOrchestrationModeBySession, ); + const queued = queuedOrchestrationModeBySession.current.get(sessionId); + queuedOrchestrationModeBySession.current.delete(sessionId); + if (queued !== undefined) { + const settled = sessionsRef.current.find( + (session) => session.id === sessionId, + )?.orchestrationMode ?? 'default'; + if (queued !== settled) void applyOrchestrationMode(queued, sessionId); + } } } @@ -2078,7 +2119,7 @@ function AppShellContent({ // Composer mention popups: `/` uses Runtime's session/project-aware, // host-compatible projection; `@` uses workspace file search. Keep the // resolved project path as a refresh key for new-chat project changes. - const { mentionSkills, mentionSkillsUnavailable, searchMentionFiles } = useComposerMentions({ + const { mentionSkills, mentionSkillsUnavailable, mentionSkillsLoading, searchMentionFiles } = useComposerMentions({ skills, sessionId: activeId, projectPath: activeId ? projectInfo?.projectPath : newTask.projectPath, @@ -3218,6 +3259,7 @@ function AppShellContent({ } mentionSkills={mentionSkills} mentionSkillsUnavailable={mentionSkillsUnavailable} + mentionSkillsLoading={mentionSkillsLoading} slashCommands={desktopSlashCommands} onSearchMentionFiles={searchMentionFiles} pendingAttachments={pendingAttachments} @@ -3605,6 +3647,7 @@ function AppShellContent({ modelChoices={chatModelChoices} mentionSkills={mentionSkills} mentionSkillsUnavailable={mentionSkillsUnavailable} + mentionSkillsLoading={mentionSkillsLoading} onSearchMentionFiles={searchMentionFiles} /> )} diff --git a/apps/desktop/src/renderer/chat-workbar.tsx b/apps/desktop/src/renderer/chat-workbar.tsx index 44ee30febc6..3079553fcc4 100644 --- a/apps/desktop/src/renderer/chat-workbar.tsx +++ b/apps/desktop/src/renderer/chat-workbar.tsx @@ -96,6 +96,7 @@ interface ChatWorkbarProps { modelChoices?: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; } @@ -164,6 +165,7 @@ export function ChatWorkbar(props: ChatWorkbarProps) { modelChoices={props.modelChoices} mentionSkills={props.mentionSkills} mentionSkillsUnavailable={props.mentionSkillsUnavailable} + mentionSkillsLoading={props.mentionSkillsLoading} onSearchMentionFiles={props.onSearchMentionFiles} /> diff --git a/apps/desktop/src/renderer/quote-companion-panel.tsx b/apps/desktop/src/renderer/quote-companion-panel.tsx index c10879a686c..a9353c7768e 100644 --- a/apps/desktop/src/renderer/quote-companion-panel.tsx +++ b/apps/desktop/src/renderer/quote-companion-panel.tsx @@ -48,6 +48,7 @@ export function QuoteCompanionPanel(props: { modelChoices: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; onQuotesConsumed: (snapshot: CompanionQuoteSnapshot) => void; onRemoveQuote?: (target: CompanionQuoteTarget) => void; @@ -243,6 +244,7 @@ export function QuoteCompanionPanel(props: { onRemoveAttachment={removeAttachment} mentionSkills={props.mentionSkills} mentionSkillsUnavailable={props.mentionSkillsUnavailable} + mentionSkillsLoading={props.mentionSkillsLoading} onSearchMentionFiles={props.onSearchMentionFiles} pendingQuotes={props.quotes.map((quote) => quote.value)} contextDrawerDefaultCollapsed diff --git a/apps/desktop/src/renderer/session-workbar.tsx b/apps/desktop/src/renderer/session-workbar.tsx index fdc17fcfe0b..60a71961fe6 100644 --- a/apps/desktop/src/renderer/session-workbar.tsx +++ b/apps/desktop/src/renderer/session-workbar.tsx @@ -686,6 +686,7 @@ export function SessionWorkbar(props: { modelChoices?: readonly ChatModelChoice[]; mentionSkills?: ComponentProps['mentionSkills']; mentionSkillsUnavailable?: ComponentProps['mentionSkillsUnavailable']; + mentionSkillsLoading?: ComponentProps['mentionSkillsLoading']; onSearchMentionFiles?: ComponentProps['onSearchMentionFiles']; }) { const copy = getDesktopConversationCopy(useUiLocale()).workbar; @@ -840,6 +841,7 @@ export function SessionWorkbar(props: { modelChoices={props.modelChoices ?? []} mentionSkills={props.mentionSkills} mentionSkillsUnavailable={props.mentionSkillsUnavailable} + mentionSkillsLoading={props.mentionSkillsLoading} onSearchMentionFiles={props.onSearchMentionFiles} onQuotesConsumed={props.onQuotesConsumed ?? (() => {})} onRemoveQuote={props.onRemoveQuote} diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts index 09e3cdca1e2..18ab4363174 100644 --- a/apps/desktop/src/renderer/use-composer-mentions.ts +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -23,6 +23,7 @@ export function useComposerMentions(options: { }): { mentionSkills: ReadonlyArray<{ ref?: string; id: string; name: string; description?: string }>; mentionSkillsUnavailable: boolean; + mentionSkillsLoading: boolean; searchMentionFiles(query: string): Promise>; } { const { @@ -34,26 +35,32 @@ export function useComposerMentions(options: { newSessionPermissionMode, newTaskTarget, } = options; - const [mentionSkills, setMentionSkills] = useState([]); - // The last SETTLED truth about the catalog, held across refreshes. The list - // itself is cleared while a refresh is in flight (fail-closed, for the `/` - // popup), so `length === 0` cannot tell "re-fetching" from "nothing to - // offer" — and the + menu's Skills row repainting on that transient empty - // is a visible blink of the open menu on every mode, model or turn change. - // This flag only moves when a request resolves, so the row's presentation - // is stable until the catalog's emptiness actually changed. - const [mentionSkillsUnavailable, setMentionSkillsUnavailable] = useState(false); + // One explicit representation of the Skill catalog — in flight, settled + // empty, or settled populated — held as a single value so a refresh can + // never tear its facets apart. + // + // `skills` is the live, fail-closed list the `/` popup reads: it is cleared + // the moment a refresh starts, because a visible popup must never advertise + // a Skill the new backend surface may not carry. That clear is exactly why + // `length === 0` cannot tell "re-fetching" from "nothing to offer", so the + // + menu's Skills row renders from `settled` — the last RESOLVED verdict, + // held across refreshes — and repaints only when the catalog's emptiness + // actually changed. `loading` gates interaction: while a request is in + // flight (including the very first, before anything has settled), a click + // on the row must have no side effect — the held presentation is the OLD + // catalog's look, not a promise the current one can honor. + const [catalog, setCatalog] = useState<{ + loading: boolean; + settled?: 'empty' | 'populated'; + skills: InvocableSkillEntry[]; + }>({ loading: true, skills: [] }); useEffect(() => { let cancelled = false; let requestVersion = 0; - // Do not briefly advertise the previous session/project's Skills while the - // authoritative projection is being refreshed. The same fail-closed reset - // applies when a model/mode/plan or MCP event changes the backend surface: - // a visible popup must never retain a now-unavailable Skill. const refresh = () => { const version = ++requestVersion; - setMentionSkills([]); + setCatalog((previous) => ({ loading: true, settled: previous.settled, skills: [] })); const context = { ...(newSessionModel ?? {}), collaborationMode: newSessionCollaborationMode ?? 'agent', @@ -69,14 +76,17 @@ export function useComposerMentions(options: { void request.then( (next) => { if (cancelled || version !== requestVersion) return; - setMentionSkills(next); - setMentionSkillsUnavailable(next.length === 0); + setCatalog({ + loading: false, + settled: next.length === 0 ? 'empty' : 'populated', + skills: next, + }); }, () => { // Fail soft: an unavailable projection leaves `/` with no suggestions. // Direct `/skill:` input still reaches the same Runtime resolver. if (cancelled || version !== requestVersion) return; - setMentionSkillsUnavailable(true); + setCatalog({ loading: false, settled: 'empty', skills: [] }); }, ); }; @@ -138,5 +148,10 @@ export function useComposerMentions(options: { ], ); - return { mentionSkills, mentionSkillsUnavailable, searchMentionFiles }; + return { + mentionSkills: catalog.skills, + mentionSkillsUnavailable: catalog.settled === 'empty', + mentionSkillsLoading: catalog.loading, + searchMentionFiles, + }; } diff --git a/packages/ui/src/__tests__/composer-plus-menu.test.tsx b/packages/ui/src/__tests__/composer-plus-menu.test.tsx index fb86b24b29a..6f7e258f497 100644 --- a/packages/ui/src/__tests__/composer-plus-menu.test.tsx +++ b/packages/ui/src/__tests__/composer-plus-menu.test.tsx @@ -118,6 +118,37 @@ test('a populated skill catalog renders the row enabled with no caveat', () => { assert.equal(count(menu, 'Choose skills'), 1); assert.equal(count(menu, 'No skills available'), 0); assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); + assert.equal(menu.includes('maka-composer-skills-loading'), false); +}); + +test('a loading catalog holds the row still and marks the held state', () => { + // Mid-refresh the row keeps the previous catalog's look (here: populated), + // and the loading class is the observable contract that a click acts as a + // no-op rather than writing a stray `/` against the fail-closed list. + const menu = plusMenu({ + ...base, + mentionSkills: [], + mentionSkillsUnavailable: false, + mentionSkillsLoading: true, + }); + assert.equal(count(menu, 'No skills available'), 0, 'geometry does not grow mid-refresh'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); + assert.equal( + skillsRow(menu).includes('maka-composer-skills-loading'), + true, + 'the loading state is observable on the row', + ); +}); + +test('a loading refresh from a settled-empty catalog holds the empty look', () => { + const menu = plusMenu({ + ...base, + mentionSkills: [], + mentionSkillsUnavailable: true, + mentionSkillsLoading: true, + }); + assert.ok(count(menu, 'No skills available') > 0, 'the settled caveat stays put'); + assert.equal(skillsRow(menu).includes('aria-disabled="true"'), true); }); test('Plan and an orchestration mode are both on at once', () => { diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index f9638d22233..4073195ff11 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -408,6 +408,17 @@ export const Composer = forwardRef< * `mentionSkills.length === 0`. */ mentionSkillsUnavailable?: boolean; + /** + * True while the host is re-fetching `mentionSkills`. The row's LOOK is + * governed by `mentionSkillsUnavailable` and does not move during a + * refresh; this flag governs what a click DOES. Mid-refresh the enabled + * look is a held presentation of the previous catalog, not a promise the + * current one can honor — acting on it would write a stray `/` into the + * draft and pop an empty menu — so the row ignores clicks (and stops + * closing the menu, so the click can simply be retried) until the catalog + * settles. + */ + mentionSkillsLoading?: boolean; slashCommands?: ReadonlyArray; onSearchMentionFiles?(query: string): Promise>; } @@ -1715,7 +1726,24 @@ export const Composer = forwardRef< ? copy.noSkillsAvailable : undefined } - onClick={openSkillMenu} + // Mid-refresh the row LOOKS like the previous catalog + // but cannot act for the current one: opening the `/` + // menu now would write a stray slash against a list + // that is fail-closed empty. A loading click is a + // complete no-op — nothing typed, menu left open — so + // the same click a beat later simply works. The class + // is the observable contract for that state. + className={ + props.mentionSkillsLoading === true + ? 'maka-composer-skills-loading' + : undefined + } + hasCloseOnSelect={props.mentionSkillsLoading !== true} + onClick={ + props.mentionSkillsLoading === true + ? undefined + : openSkillMenu + } /> ) : null} {props.onSetGoal ? ( From 731beea1a28c131dc1a32b1854ef139c16765443 Mon Sep 17 00:00:00 2001 From: fernando_jacob Date: Sat, 22 Aug 2026 17:42:40 +0800 Subject: [PATCH 4/4] fix(desktop): key the Skill catalog by context and say busy while it holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round — one P2 and three P3s: The geometry-stable Skills row now carries `aria-busy` while the catalog refreshes, via a new vendor seam: `DropdownMenuItem` forwards the attribute to its row (patches/README.md). A row that held its look but announced plain "available" would silently swallow the activation it defers. The activation gate moved inside one always-present handler — swapping `onClick` between undefined and a function remounts Item's internals and drops keyboard focus mid-refresh. The catalog value now names its context: a session/project/model switch renders once before the passive effect clears the list, and deriving through the key makes that very render fail closed instead of showing the old context's Skills for a frame. A same-context refresh still holds its settled verdict; a context switch has nothing settled to hold. Queued mode intents die with the Session's renderer lifecycle: `clearSessionRendererState` drops them alongside the pending registries, so an in-flight commit's finally cannot replay an old ask against a removed Session. The write-only pending states are gone — the registry ref is the one re-entrancy authority, and the pending-action helpers take the setter only where something renders from it (permission, model, retry). New coverage: keyboard activation is a no-op mid-refresh and works once settled; a context switch re-enters loading instead of holding the old catalog; deleting the session while a toggle is pending settles clean; SSR pins `aria-busy` on and off. Co-Authored-By: Claude Fable 5 Generated-by: Claude Code --- .../e2e/composer-plus-menu-stability.spec.ts | 96 ++++++++++++++++++- apps/desktop/src/renderer/app-shell.tsx | 35 +++---- .../src/renderer/use-composer-mentions.ts | 56 ++++++++--- .../src/__tests__/composer-plus-menu.test.tsx | 22 ++++- packages/ui/src/composer.tsx | 25 +++-- patches/@astryxdesign+core+0.4.0.patch | 83 +++++++++++++++- patches/README.md | 6 +- 7 files changed, 278 insertions(+), 45 deletions(-) diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts index 7d70c4af12c..7ead41c706f 100644 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -167,17 +167,64 @@ test('a Skills click during the catalog refresh does nothing, then works settled const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); // The Plan toggle starts the (now latched) refresh; the row announces the - // held state and a click inside the window has no effect at all. + // held state — busy to assistive technology, a class for styling and tests + // — and a click inside the window has no effect at all. await planRow.click(); await expect(skillsRow).toHaveClass(/maka-composer-skills-loading/); + await expect(skillsRow).toHaveAttribute('aria-busy', 'true'); await skillsRow.click(); await expect(menu).toBeVisible(); await expect(composer).toHaveText(''); await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0); - // Released, the same click opens the `/` popup as usual. + // Keyboard activation is the same no-op: Enter on the focused row neither + // closes the menu nor writes the slash. + await skillsRow.focus(); + await page.keyboard.press('Enter'); + await expect(menu).toBeVisible(); + await expect(composer).toHaveText(''); + await expect(page.getByRole('listbox', { name: /技能/ })).toHaveCount(0); + + // Released, the same activation opens the `/` popup as usual. Re-focus the + // row first: the settle re-renders the composer input's trigger config, + // which takes focus back to the editor (long-standing behavior on every + // catalog refresh, independent of this journey). await releaseBridgeLatch(page, 'newTasks.listInvocableSkills'); await expect(skillsRow).not.toHaveClass(/maka-composer-skills-loading/); + await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true'); + await skillsRow.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); +}); + +test('a context switch re-enters loading instead of holding the old catalog', async ({ + invocableSkillsWindow: page, +}) => { + // Populate and settle a session-scoped catalog first. + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('alpha-marker'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + + // Switching to a new chat is a context change: the new-task catalog is + // latched, so the Skills row must present as loading — deferring activation + // — rather than as the previous session's settled, actionable catalog. + await armBridgeLatch(page, 'newTasks.listInvocableSkills'); + await page.getByRole('button', { name: '展开侧边栏' }).click(); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + await sidebar.getByRole('button', { name: '新任务', exact: true }).click(); + await expect(composer).toHaveText(''); + + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const skillsRow = menu.getByRole('menuitem', { name: /选择技能/ }); + await expect(skillsRow).toHaveAttribute('aria-busy', 'true'); + await skillsRow.click(); + await expect(menu).toBeVisible(); + await expect(composer).toHaveText(''); + + await releaseBridgeLatch(page, 'newTasks.listInvocableSkills'); + await expect(skillsRow).not.toHaveAttribute('aria-busy', 'true'); await skillsRow.click(); await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible(); }); @@ -194,6 +241,9 @@ test('two rapid Plan toggles land on the last requested state', async ({ const menu = page.getByRole('menu', { name: '添加上下文' }); const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); await expect(planRow).toHaveAttribute('aria-checked', 'false'); + // The echo can land while the turn is still settling, and mode changes are + // refused mid-turn; wait for the row to become actionable. + await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); // The session-list refresh is the tail of a Plan commit: latching its next // call keeps the commit pending — deterministically — after the mode has @@ -212,3 +262,45 @@ test('two rapid Plan toggles land on the last requested state', async ({ await releaseBridgeLatch(page, 'sessions.list'); await expect(planRow).toHaveAttribute('aria-checked', 'false'); }); + +test('deleting the session while a toggle is pending settles clean', async ({ + invocableSkillsWindow: page, +}) => { + // pending → cleanup → settle: the Session's renderer lifecycle ends while a + // mode commit (and a queued follow-up intent) is still in flight. Cleanup + // must drop the queued ask with the rest of the Session state, so the + // commit's tail has nothing to replay against the removed Session. + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('alpha-marker'); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: alpha-marker/)).toBeVisible(); + + await page.getByRole('button', { name: '添加上下文' }).click(); + const menu = page.getByRole('menu', { name: '添加上下文' }); + const planRow = menu.getByRole('menuitemcheckbox', { name: 'Plan' }); + // Mode changes are refused mid-turn; wait for the row to become actionable. + await expect(planRow).not.toHaveAttribute('aria-disabled', 'true'); + await armBridgeLatch(page, 'sessions.list', { oneShot: true }); + await planRow.click(); + await expect(planRow).toHaveAttribute('aria-checked', 'true'); + await planRow.click(); + await page.keyboard.press('Escape'); + await expect(menu).not.toBeVisible(); + + await page.getByRole('button', { name: '展开侧边栏' }).click(); + const sidebar = page.getByRole('navigation', { name: '任务列表' }); + const row = sidebar.locator('[data-maka-contract="session-row"]').first(); + await row.hover(); + await row.getByRole('button', { name: '任务操作' }).click(); + await page.getByRole('menuitem', { name: '删除', exact: true }).click(); + const confirm = page.getByRole('alertdialog'); + await confirm.getByRole('button', { name: '删除', exact: true }).click(); + + await releaseBridgeLatch(page, 'sessions.list'); + await expect(sidebar.locator('[data-maka-contract="session-row"]')).toHaveCount(0); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + // The composer is back on a clean new chat: no error surfaced and no stale + // Plan state re-applied by the drained commit. + await expect(composer).toBeVisible(); + await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toHaveCount(0); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index c5f6a20b81b..54ce40dc541 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -427,16 +427,13 @@ function AppShellContent({ const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); - // Write-only: the pending-action helpers need a store, but nothing renders - // from these — a mode toggle's guard is the registry ref, and painting the - // round trip (disable/dim, then restore) is exactly the + menu blink that - // MatrixA/fix-plan-click-flicker removed. - const [, setPendingCollaborationModeBySession] = useState>({}); - const [, setPendingOrchestrationModeBySession] = useState>({}); // The rows stay interactive while a commit runs, so a click landing in that // window is the user updating their mind — not noise to drop. Each map holds // only the LATEST ask per session; the in-flight commit's finally block - // applies it if the settled state does not already satisfy it. + // applies it if the settled state does not already satisfy it. Nothing + // renders from a mode commit's round trip — painting it (disable/dim, then + // restore) is exactly the + menu blink MatrixA/fix-plan-click-flicker + // removed — so there is no pending state here, only the registry refs. const queuedCollaborationModeBySession = useRef(new Map()); const queuedOrchestrationModeBySession = useRef(new Map()); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = @@ -958,25 +955,30 @@ function AppShellContent({ return next; } + // The registry ref is the re-entrancy authority; the setter is only for + // actions whose pending state something actually renders (permission, model, + // message retry). Mode toggles pass none — their round trip is deliberately + // not painted, and a write-only state would still schedule a render per + // add/clear. function addPendingSessionAction( sessionId: string, pendingRef: { current: Set }, - setPendingBySession: (updater: (current: Record) => Record) => void, + setPendingBySession?: (updater: (current: Record) => Record) => void, ): boolean { if (pendingRef.current.has(sessionId)) return false; pendingRef.current.add(sessionId); - setPendingBySession((current) => ({ ...current, [sessionId]: true })); + setPendingBySession?.((current) => ({ ...current, [sessionId]: true })); return true; } function clearPendingSessionAction( sessionId: string, pendingRef: { current: Set }, - setPendingBySession: (updater: (current: Record) => Record) => void, + setPendingBySession?: (updater: (current: Record) => Record) => void, ): void { if (!pendingRef.current.has(sessionId)) return; pendingRef.current.delete(sessionId); - setPendingBySession((current) => omitSessionKey(current, sessionId)); + setPendingBySession?.((current) => omitSessionKey(current, sessionId)); } function clearSessionRendererState(sessionId: string): void { @@ -984,9 +986,12 @@ function AppShellContent({ turnActionRegistry.clearForSession(sessionId); permissionModeChangeRegistry.keysRef.current.delete(sessionId); collaborationModeChangeRegistry.keysRef.current.delete(sessionId); - setPendingCollaborationModeBySession((current) => omitSessionKey(current, sessionId)); orchestrationModeChangeRegistry.keysRef.current.delete(sessionId); - setPendingOrchestrationModeBySession((current) => omitSessionKey(current, sessionId)); + // Queued mode intents die with the Session's renderer lifecycle: an + // in-flight commit's finally would otherwise replay an old ask against a + // Session this cleanup has already let go of. + queuedCollaborationModeBySession.current.delete(sessionId); + queuedOrchestrationModeBySession.current.delete(sessionId); sessionModelChangeRegistry.keysRef.current.delete(sessionId); } @@ -1055,7 +1060,6 @@ function AppShellContent({ if (!addPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, - setPendingCollaborationModeBySession, )) { // A commit is in flight and the rows stay interactive (no pending // repaint), so this click is the user updating their mind, not noise. @@ -1116,7 +1120,6 @@ function AppShellContent({ clearPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, - setPendingCollaborationModeBySession, ); // Whatever the user last asked for while this commit ran is the state // they expect to land on. Read the settled mode through the ref — the @@ -1148,7 +1151,6 @@ function AppShellContent({ if (!addPendingSessionAction( sessionId, orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, )) { // Same latest-intent contract as applyPlanMode: the rows stay // interactive while a commit runs, so keep the newest ask and apply it @@ -1175,7 +1177,6 @@ function AppShellContent({ clearPendingSessionAction( sessionId, orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, ); const queued = queuedOrchestrationModeBySession.current.get(sessionId); queuedOrchestrationModeBySession.current.delete(sessionId); diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts index 18ab4363174..21b20c61d69 100644 --- a/apps/desktop/src/renderer/use-composer-mentions.ts +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -4,6 +4,9 @@ import type { SkillEntry } from '@maka/ui'; import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation'; import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js'; +/** One frozen identity, so a context-mismatch render does not churn props. */ +const EMPTY_SKILLS: InvocableSkillEntry[] = []; + /** * Owns the composer mention popup wiring so app-shell.tsx keeps no inline * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives @@ -44,23 +47,53 @@ export function useComposerMentions(options: { // a Skill the new backend surface may not carry. That clear is exactly why // `length === 0` cannot tell "re-fetching" from "nothing to offer", so the // + menu's Skills row renders from `settled` — the last RESOLVED verdict, - // held across refreshes — and repaints only when the catalog's emptiness - // actually changed. `loading` gates interaction: while a request is in - // flight (including the very first, before anything has settled), a click - // on the row must have no side effect — the held presentation is the OLD - // catalog's look, not a promise the current one can honor. + // held across refreshes of the SAME context — and repaints only when the + // catalog's emptiness actually changed. `loading` gates interaction: while + // a request is in flight (including the very first, before anything has + // settled), a click on the row must have no side effect — the held + // presentation is the old catalog's look, not a promise the current one + // can honor. + // + // `contextKey` names which backend surface the value describes. The clear + // above happens in a passive effect, one commit AFTER a session/project/ + // model switch has rendered — a window where the old context's Skills are + // still on screen for the new one. Deriving through the key below makes the + // render itself fail closed the moment the context changes, without waiting + // for the effect. + const contextKey = [ + sessionId ?? '', + projectPath ?? '', + newSessionModel?.llmConnectionSlug ?? '', + newSessionModel?.model ?? '', + newSessionCollaborationMode ?? 'agent', + newSessionPermissionMode ?? '', + newTaskTarget?.profileId ?? '', + newTaskTarget?.hostId ?? '', + newTaskTarget?.projectId ?? '', + ].join('\u0000'); const [catalog, setCatalog] = useState<{ + contextKey: string; loading: boolean; settled?: 'empty' | 'populated'; skills: InvocableSkillEntry[]; - }>({ loading: true, skills: [] }); + }>({ contextKey, loading: true, skills: [] }); + const liveCatalog = catalog.contextKey === contextKey + ? catalog + : { contextKey, loading: true, settled: undefined, skills: EMPTY_SKILLS }; useEffect(() => { let cancelled = false; let requestVersion = 0; const refresh = () => { const version = ++requestVersion; - setCatalog((previous) => ({ loading: true, settled: previous.settled, skills: [] })); + setCatalog((previous) => ({ + contextKey, + loading: true, + // A same-context refresh keeps its settled verdict; a context switch + // has nothing settled to hold. + settled: previous.contextKey === contextKey ? previous.settled : undefined, + skills: [], + })); const context = { ...(newSessionModel ?? {}), collaborationMode: newSessionCollaborationMode ?? 'agent', @@ -77,6 +110,7 @@ export function useComposerMentions(options: { (next) => { if (cancelled || version !== requestVersion) return; setCatalog({ + contextKey, loading: false, settled: next.length === 0 ? 'empty' : 'populated', skills: next, @@ -86,7 +120,7 @@ export function useComposerMentions(options: { // Fail soft: an unavailable projection leaves `/` with no suggestions. // Direct `/skill:` input still reaches the same Runtime resolver. if (cancelled || version !== requestVersion) return; - setCatalog({ loading: false, settled: 'empty', skills: [] }); + setCatalog({ contextKey, loading: false, settled: 'empty', skills: [] }); }, ); }; @@ -149,9 +183,9 @@ export function useComposerMentions(options: { ); return { - mentionSkills: catalog.skills, - mentionSkillsUnavailable: catalog.settled === 'empty', - mentionSkillsLoading: catalog.loading, + mentionSkills: liveCatalog.skills, + mentionSkillsUnavailable: liveCatalog.settled === 'empty', + mentionSkillsLoading: liveCatalog.loading, searchMentionFiles, }; } diff --git a/packages/ui/src/__tests__/composer-plus-menu.test.tsx b/packages/ui/src/__tests__/composer-plus-menu.test.tsx index 6f7e258f497..de7394d2ce5 100644 --- a/packages/ui/src/__tests__/composer-plus-menu.test.tsx +++ b/packages/ui/src/__tests__/composer-plus-menu.test.tsx @@ -122,9 +122,11 @@ test('a populated skill catalog renders the row enabled with no caveat', () => { }); test('a loading catalog holds the row still and marks the held state', () => { - // Mid-refresh the row keeps the previous catalog's look (here: populated), - // and the loading class is the observable contract that a click acts as a - // no-op rather than writing a stray `/` against the fail-closed list. + // Mid-refresh the row keeps the previous catalog's look (here: populated). + // `aria-busy` is what assistive technology gets instead of a geometry + // change: activation is deferred, and a row that still announced plain + // "available" would silently ignore it. The class is the same contract for + // tests and styling. const menu = plusMenu({ ...base, mentionSkills: [], @@ -133,6 +135,11 @@ test('a loading catalog holds the row still and marks the held state', () => { }); assert.equal(count(menu, 'No skills available'), 0, 'geometry does not grow mid-refresh'); assert.equal(skillsRow(menu).includes('aria-disabled="true"'), false); + assert.equal( + skillsRow(menu).includes('aria-busy="true"'), + true, + 'the deferred activation is announced', + ); assert.equal( skillsRow(menu).includes('maka-composer-skills-loading'), true, @@ -140,6 +147,15 @@ test('a loading catalog holds the row still and marks the held state', () => { ); }); +test('a settled catalog carries no busy announcement', () => { + for (const props of [ + { ...base, mentionSkills: [{ id: 'demo', name: 'Demo' }], mentionSkillsLoading: false }, + { ...base, mentionSkills: [{ id: 'demo', name: 'Demo' }] }, + ]) { + assert.equal(plusMenu(props).includes('aria-busy'), false); + } +}); + test('a loading refresh from a settled-empty catalog holds the empty look', () => { const menu = plusMenu({ ...base, diff --git a/packages/ui/src/composer.tsx b/packages/ui/src/composer.tsx index 4073195ff11..4978bf96e06 100644 --- a/packages/ui/src/composer.tsx +++ b/packages/ui/src/composer.tsx @@ -1729,21 +1729,28 @@ export const Composer = forwardRef< // Mid-refresh the row LOOKS like the previous catalog // but cannot act for the current one: opening the `/` // menu now would write a stray slash against a list - // that is fail-closed empty. A loading click is a - // complete no-op — nothing typed, menu left open — so - // the same click a beat later simply works. The class - // is the observable contract for that state. + // that is fail-closed empty. A loading click or Enter + // is a complete no-op — nothing typed, menu left open — + // so the same activation a beat later simply works. + // `aria-busy` says so to assistive technology (the + // geometry-stable row would otherwise announce + // "available" and silently ignore the action); the + // class is the same contract for tests and styling. + // The gate lives INSIDE one always-present handler: + // swapping the prop between undefined and a function + // changes Item's internal structure, and the remount + // would drop keyboard focus mid-refresh. + aria-busy={props.mentionSkillsLoading === true ? true : undefined} className={ props.mentionSkillsLoading === true ? 'maka-composer-skills-loading' : undefined } hasCloseOnSelect={props.mentionSkillsLoading !== true} - onClick={ - props.mentionSkillsLoading === true - ? undefined - : openSkillMenu - } + onClick={() => { + if (props.mentionSkillsLoading === true) return; + openSkillMenu(); + }} /> ) : null} {props.onSetGoal ? ( diff --git a/patches/@astryxdesign+core+0.4.0.patch b/patches/@astryxdesign+core+0.4.0.patch index 6cb20dd5310..ccca067fb4a 100644 --- a/patches/@astryxdesign+core+0.4.0.patch +++ b/patches/@astryxdesign+core+0.4.0.patch @@ -132,6 +132,52 @@ index 8c509bc..4b09993 100644 }; } \ No newline at end of file +diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts +index 82ae62c..a97eff5 100644 +--- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts ++++ b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts +@@ -42,6 +42,12 @@ export interface DropdownMenuItemProps extends Pick + * ``` + */ +-export declare function DropdownMenuItem({ icon, label, description, onClick, isDisabled, endContent, hasCloseOnSelect, variant, xstyle, className, style, }: DropdownMenuItemProps): import("react").JSX.Element; ++export declare function DropdownMenuItem({ icon, 'aria-busy': ariaBusy, label, description, onClick, isDisabled, endContent, hasCloseOnSelect, variant, xstyle, className, style, }: DropdownMenuItemProps): import("react").JSX.Element; + export declare namespace DropdownMenuItem { + var displayName: string; + } +diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js +index 5bf241b..f773a97 100644 +--- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js ++++ b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.js +@@ -91,6 +91,7 @@ const itemSizeStyles = { + */ + export function DropdownMenuItem({ + icon, ++ 'aria-busy': ariaBusy, + label, + description, + onClick, +@@ -117,6 +118,7 @@ export function DropdownMenuItem({ + const isDestructive = variant === 'destructive'; + return /*#__PURE__*/_jsx(Item, { + role: "menuitem", ++ 'aria-busy': ariaBusy, + tabIndex: isDisabled ? undefined : -1, + onPointerMove: handlePointerMove, + startContent: icon ? renderIconSlot(icon, { diff --git a/node_modules/@astryxdesign/core/dist/Kbd/Kbd.js b/node_modules/@astryxdesign/core/dist/Kbd/Kbd.js index 33ed78d..0ecbce6 100644 --- a/node_modules/@astryxdesign/core/dist/Kbd/Kbd.js @@ -283,7 +329,7 @@ index 867ed5a..4b8f835 100644 Markdown.displayName = 'Markdown'; \ No newline at end of file diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts -index 16107cf..57aeb6c 100644 +index 16107cf..8a26b5d 100644 --- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts +++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.d.ts @@ -61,6 +61,12 @@ export interface SideNavItemProps extends BaseProps { @@ -309,7 +355,7 @@ index 16107cf..57aeb6c 100644 var displayName: string; } diff --git a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js -index a72d264..c65bd32 100644 +index a72d264..778e0e2 100644 --- a/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js +++ b/node_modules/@astryxdesign/core/dist/SideNav/SideNavItem.js @@ -159,6 +159,7 @@ export function SideNavItem({ @@ -526,6 +572,39 @@ index a2da3d9..91a020e 100644 aria-expanded={hasDetail ? isDetailOpen : undefined} aria-controls={hasDetail && isDetailOpen ? detailId : undefined} onClick={toggleDetail} +diff --git a/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx b/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx +index 8dbf5f6..b778ca9 100644 +--- a/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx ++++ b/node_modules/@astryxdesign/core/src/DropdownMenu/DropdownMenuItem.tsx +@@ -108,6 +108,12 @@ export interface DropdownMenuItemProps extends Pick< + * @default true + */ + hasCloseOnSelect?: boolean; ++ /** ++ * Exposes WAI-ARIA busy semantics on the row (maka patch): a host that ++ * holds the row's look steady while its backing data refreshes needs to ++ * say so to assistive technology, since activation is deferred meanwhile. ++ */ ++ 'aria-busy'?: boolean; + /** + * Visual variant. `'destructive'` renders the label, description, and icon in + * the error color for dangerous actions (e.g. Delete). @default 'default' +@@ -131,6 +137,7 @@ export interface DropdownMenuItemProps extends Pick< + */ + export function DropdownMenuItem({ + icon, ++ 'aria-busy': ariaBusy, + label, + description, + onClick, +@@ -165,6 +172,7 @@ export function DropdownMenuItem({ + return ( +