diff --git a/apps/desktop/src/main/__tests__/composer-mentions.test.ts b/apps/desktop/src/main/__tests__/composer-mentions.test.ts
new file mode 100644
index 0000000000..51f2316695
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/composer-mentions.test.ts
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { test, type TestContext } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { act, createElement, useLayoutEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { InvocableSkillEntry } from '@maka/runtime/skill-invocation';
+import {
+ ComposerMentionsProvider,
+ useComposerMentionsContext,
+ type ComposerMentions,
+} from '../../renderer/composer-mentions.js';
+
+interface CatalogObservation {
+ sessionId: string;
+ skills: ComposerMentions['mentionSkills'];
+ loading: boolean;
+ unavailable: boolean;
+}
+
+const skillA: InvocableSkillEntry = {
+ ref: 'workspace:skill-a',
+ id: 'skill-a',
+ name: 'Skill A',
+ description: 'Available in session A.',
+};
+const skillB: InvocableSkillEntry = {
+ ref: 'workspace:skill-b',
+ id: 'skill-b',
+ name: 'Skill B',
+ description: 'Available in session B.',
+};
+
+function installCatalogRenderer(t: TestContext) {
+ const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ HTMLElement: globalThis.HTMLElement,
+ HTMLIFrameElement: globalThis.HTMLIFrameElement,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+ };
+ const { document, window } = parseHTML('
');
+ Object.assign(globalThis, {
+ document,
+ window,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+
+ const pending: Array<{
+ sessionId: string;
+ resolve(skills: InvocableSkillEntry[]): void;
+ }> = [];
+ (window as unknown as { maka: unknown }).maka = {
+ skills: {
+ listInvocable: (sessionId: string) => new Promise((resolve) => {
+ pending.push({ sessionId, resolve });
+ }),
+ },
+ sessions: { subscribeChanges: () => () => {} },
+ mcp: { subscribeChanges: () => () => {} },
+ };
+
+ const observations: CatalogObservation[] = [];
+ function Consumer({ sessionId }: { sessionId: string }) {
+ const mentions = useComposerMentionsContext();
+ assert.ok(mentions);
+ useLayoutEffect(() => {
+ // Record what a consumer sees before the provider's passive effect can
+ // replace the previous context's catalog and hide a missing render guard.
+ observations.push({
+ sessionId,
+ skills: mentions.mentionSkills,
+ loading: mentions.mentionSkillsLoading,
+ unavailable: mentions.mentionSkillsUnavailable,
+ });
+ });
+ return null;
+ }
+
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ const root = createRoot(container);
+ t.after(async () => {
+ try {
+ await act(() => root.unmount());
+ } finally {
+ Object.assign(globalThis, originalGlobals);
+ }
+ });
+
+ return {
+ observations,
+ latest() {
+ const observation = observations.at(-1);
+ assert.ok(observation);
+ return observation;
+ },
+ async render(sessionId: string, skillCatalogRevision = 0) {
+ await act(() => root.render(createElement(ComposerMentionsProvider, {
+ sessionId,
+ skillCatalogRevision,
+ children: createElement(Consumer, { sessionId }),
+ })));
+ },
+ async settleNext(sessionId: string, skills: InvocableSkillEntry[]) {
+ const request = pending.shift();
+ assert.ok(request, 'A catalog request must be waiting for its response.');
+ assert.equal(request.sessionId, sessionId);
+ await act(async () => request.resolve(skills));
+ },
+ };
+}
+
+for (const previous of [
+ { name: 'populated', skills: [skillA] },
+ { name: 'empty', skills: [] },
+]) {
+ test(`a session switch immediately replaces the ${previous.name} catalog with loading`, async (t) => {
+ const renderer = installCatalogRenderer(t);
+ await renderer.render('session-a');
+ await renderer.settleNext('session-a', previous.skills);
+ assert.deepEqual(renderer.latest(), {
+ sessionId: 'session-a',
+ skills: previous.skills,
+ loading: false,
+ unavailable: previous.skills.length === 0,
+ });
+
+ const beforeSwitch = renderer.observations.length;
+ await renderer.render('session-b');
+ const firstInSessionB = renderer.observations
+ .slice(beforeSwitch)
+ .find((observation) => observation.sessionId === 'session-b');
+ const loadingCatalog = {
+ sessionId: 'session-b',
+ skills: [],
+ loading: true,
+ unavailable: false,
+ };
+ assert.deepEqual(firstInSessionB, loadingCatalog);
+ assert.deepEqual(renderer.latest(), loadingCatalog);
+
+ await renderer.settleNext('session-b', [skillB]);
+ assert.deepEqual(renderer.latest(), {
+ sessionId: 'session-b',
+ skills: [skillB],
+ loading: false,
+ unavailable: false,
+ });
+ });
+}
+
+test('a same-context refresh keeps the settled skills visible until it resolves', async (t) => {
+ const renderer = installCatalogRenderer(t);
+ await renderer.render('session-a');
+ await renderer.settleNext('session-a', [skillA]);
+
+ // A catalog revision reloads the same backend surface without changing its key.
+ await renderer.render('session-a', 1);
+ assert.deepEqual(renderer.latest(), {
+ sessionId: 'session-a',
+ skills: [skillA],
+ loading: true,
+ unavailable: false,
+ });
+
+ await renderer.settleNext('session-a', [skillB]);
+ assert.deepEqual(renderer.latest(), {
+ sessionId: 'session-a',
+ skills: [skillB],
+ loading: false,
+ unavailable: false,
+ });
+});