You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/24 10:47:12 UTC

(maka) branch main updated: fix(desktop): keep the slash picker stable across same-context refreshes (#2768)

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/main by this push:
     new 8801edc32 fix(desktop): keep the slash picker stable across same-context refreshes (#2768)
8801edc32 is described below

commit 8801edc32bd83740a2062b63e644ca5ca255e282
Author: Bo Liu <be...@gmail.com>
AuthorDate: Mon Aug 24 19:47:07 2026 +0900

    fix(desktop): keep the slash picker stable across same-context refreshes (#2768)
    
    An open `/` menu alternated between its commands-only and
    commands-plus-skills geometries on every session or MCP event (#2667):
    each refresh cleared the invocable-Skill catalog fail-closed, so the
    popup lost and regained its Skills group for the length of one IPC round
    trip.
    
    Fail closed only when the context key actually changes. A same-context
    refresh keeps the Skills already on screen, and a settled refresh that
    returned an identical list keeps the previous array identity, so the
    composer's trigger memo and menu-replay effect stay quiet. A Skill
    withdrawn inside that stale window still fails safely, because selection
    resolves through the Runtime resolver that no longer knows it.
    
    The + menu's separate fail-closed path is unchanged: a Plan toggle moves
    the context key, so it still clears and still reads `settled`.
---
 apps/desktop/e2e/slash-command-menu.spec.ts        | 75 ++++++++++++++++++++++
 apps/desktop/src/renderer/use-composer-mentions.ts | 57 ++++++++++++----
 2 files changed, 121 insertions(+), 11 deletions(-)

diff --git a/apps/desktop/e2e/slash-command-menu.spec.ts b/apps/desktop/e2e/slash-command-menu.spec.ts
index 49c705992..e66adc28b 100644
--- a/apps/desktop/e2e/slash-command-menu.spec.ts
+++ b/apps/desktop/e2e/slash-command-menu.spec.ts
@@ -162,3 +162,78 @@ test('dispatches /side instead of steering it into a running turn', async ({
   await expect(page.locator('.maka-quote-workbar-panel')).toHaveCount(1);
   await page.getByRole('button', { name: '停止' }).click();
 });
+
+test('an open menu keeps its container and skills group across projection refreshes', async ({
+  invocableSkillsWindow: page,
+}) => {
+  const composer = page.locator(COMPOSER_INPUT);
+  await composer.fill('seed session');
+  await composer.press('Enter');
+  await expect(page.getByText('Fake backend received: seed session')).toBeVisible();
+
+  await composer.click();
+  await composer.pressSequentially('/');
+  const menu = page.getByRole('listbox', { name: '命令和技能' });
+  await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
+
+  // Armed before the refresh: the flicker was the skills group (and with it
+  // the listbox geometry) being torn down and re-created when the projection
+  // cleared and repopulated, so any removal during the refresh is the
+  // regression (#2667).
+  await page.evaluate(() => {
+    const state = { removals: 0 };
+    (globalThis as unknown as { __slashMenuWatch?: unknown }).__slashMenuWatch = state;
+    const observer = new MutationObserver((mutations) => {
+      for (const mutation of mutations) {
+        for (const node of mutation.removedNodes) {
+          if (!(node instanceof HTMLElement)) continue;
+          if (
+            node.matches('[role="listbox"], [role="group"]') ||
+            node.querySelector('[role="listbox"], [role="group"]') !== null
+          ) {
+            state.removals += 1;
+          }
+        }
+      }
+    });
+    observer.observe(document.body, { childList: true, subtree: true });
+  });
+
+  // A thinking-level change publishes the session's 'updated' event and
+  // reloads the Skill projection without changing what the menu shows: the
+  // exact same-content refresh that used to alternate the popup (#2667).
+  const sessionId = await page.evaluate(async () => {
+    const sessions = await (
+      window as unknown as {
+        maka: { sessions: { list(): Promise<Array<{ id: string }>> } };
+      }
+    ).maka.sessions.list();
+    return sessions[0]?.id;
+  });
+  for (let round = 0; round < 3; round += 1) {
+    await page.evaluate(
+      (id) =>
+        (
+          window as unknown as {
+            maka: { sessions: { setThinkingLevel(id: string, level?: null): Promise<unknown> } };
+          }
+        ).maka.sessions.setThinkingLevel(id!, null),
+      sessionId,
+    );
+  }
+  // The refresh round trip is IPC-fast; the poll below gives it room while
+  // asserting the menu never lost its skills group.
+  await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
+  await expect
+    .poll(
+      () =>
+        page.evaluate(
+          () =>
+            (globalThis as unknown as { __slashMenuWatch: { removals: number } }).__slashMenuWatch
+              .removals,
+        ),
+      { timeout: 3_000 },
+    )
+    .toBe(0);
+  await expect(menu.getByRole('group', { name: 'Skills' })).toBeVisible();
+});
diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts
index a4061b491..2d31f9505 100644
--- a/apps/desktop/src/renderer/use-composer-mentions.ts
+++ b/apps/desktop/src/renderer/use-composer-mentions.ts
@@ -26,6 +26,27 @@ import type { DesktopNewTaskTarget } from '../preload/bridge-contract.js';
 /** One frozen identity, so a context-mismatch render does not churn props. */
 const EMPTY_SKILLS: InvocableSkillEntry[] = [];
 
+/**
+ * Whether a reloaded projection describes the same Skills as the one on
+ * screen, so an unchanged refresh can keep the array it already published.
+ */
+function invocableSkillListsEqual(
+  current: readonly InvocableSkillEntry[],
+  next: readonly InvocableSkillEntry[],
+): boolean {
+  if (current.length !== next.length) return false;
+  return current.every((skill, index) => {
+    const other = next[index];
+    return (
+      other !== undefined &&
+      skill.ref === other.ref &&
+      skill.id === other.id &&
+      skill.name === other.name &&
+      skill.description === other.description
+    );
+  });
+}
+
 /**
  * Owns the composer mention popup wiring so app-shell.tsx keeps no inline
  * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives
@@ -105,14 +126,21 @@ export function useComposerMentions(options: {
     let requestVersion = 0;
     const refresh = () => {
       const version = ++requestVersion;
-      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: [],
-      }));
+      setCatalog((previous) =>
+        previous.contextKey === contextKey
+          ? // A same-context refresh keeps both its settled verdict and the
+            // Skills already on screen. Clearing here is what made an open `/`
+            // menu alternate between its commands-only and commands-plus-skills
+            // geometries on every session or MCP event (#2667). The backend
+            // surface has not changed, so there is nothing to fail closed
+            // against; and a Skill withdrawn inside the one-IPC-round-trip
+            // stale window still fails safely, because selection resolves
+            // through the Runtime resolver that no longer knows it.
+            { ...previous, loading: true }
+          : // A context switch has nothing settled to hold, and its Skills
+            // belong to the surface being left behind.
+            { contextKey, loading: true, settled: undefined, skills: [] },
+      );
       const context = {
         ...(newSessionModel ?? {}),
         collaborationMode: newSessionCollaborationMode ?? 'agent',
@@ -128,12 +156,19 @@ export function useComposerMentions(options: {
       void request.then(
         (next) => {
           if (cancelled || version !== requestVersion) return;
-          setCatalog({
+          setCatalog((previous) => ({
             contextKey,
             loading: false,
             settled: next.length === 0 ? 'empty' : 'populated',
-            skills: next,
-          });
+            // A refresh that changed nothing keeps the previous array
+            // identity, so the composer's trigger memo and the menu-replay
+            // effect stay quiet instead of remounting the popup.
+            skills:
+              previous.contextKey === contextKey &&
+              invocableSkillListsEqual(previous.skills, next)
+                ? previous.skills
+                : [...next],
+          }));
         },
         () => {
           // Fail soft: an unavailable projection leaves `/` with no suggestions.


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org