From 8ded031e690c01d9f74bc90413bfbc4365bf7b3c Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Thu, 3 Sep 2026 15:41:58 +0800 Subject: [PATCH] fix(hub-ui): keep grouped actions out of dock memory --- .../hub-ui/src/client/state/context.test.ts | 25 +++++++++++++++++++ packages/hub-ui/src/client/state/context.ts | 11 ++++---- .../src/client/state/dock-settings.test.ts | 15 +++++++++++ .../hub-ui/src/client/state/dock-settings.ts | 8 +++--- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 957f29da5..eed68cadd 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -233,6 +233,31 @@ describe('createDocksContext', () => { expect(context.docks.selected?.id).toBe('nuxt:modules') }) + it('does not remember a grouped action as the group\'s last-opened member', async () => { + expect.assertions(1) + + const { rpc, sharedStates, trust } = createStubRpc() + const session = ref({ + open: false, + selectedDockId: null, + selectedDockRoute: null, + groupLastChildIds: {}, + }) + const context = await createDocksContext('embedded', rpc, undefined, session) + + trust() + sharedStates.get('devframe:docks')!.push([ + { id: 'tools', type: 'group', title: 'Tools', icon: 'ph:wrench-duotone' }, + { id: 'tools:run', type: 'action', action: { importFrom: '/action.js' }, title: 'Run', icon: 'ph:play-duotone', groupId: 'tools' }, + ] satisfies DevframeDockEntry[]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + + await context.docks.switchEntry('tools:run') + + expect(session.value.groupLastChildIds).toEqual({}) + }) + it('keeps a dock closed when the user closes it before initialization finishes', async () => { expect.assertions(2) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 23d373fea..202554aba 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -243,15 +243,14 @@ export async function createDocksContext( await executeSetupScript(entry, scriptContext) } - // Remember this selection for later redirects: a member tab (carries its - // anchor's `frameId`) as the frame's live tab, a grouped member as its - // group's last-opened child. Only iframes own an address-bar route, so clear - // a stale route for anything else. Guarded: a store predating these fields - // has no map yet. + // Remember selection redirects: a member tab as its frame's live tab, and a + // grouped non-action member as its group's last-opened child. One-shot actions + // leave the preferred panel unchanged. Only iframes own an address-bar route, + // so clear a stale route for anything else. const rememberEntrySelection = (entry: DevframeDockEntry) => { if (entry.type === 'iframe' && entry.frameId && !entry.subTabs) frameNavCurrentMember.set(entry.frameId, entry.id) - if (entry.groupId) + if (entry.type !== 'action' && entry.groupId) (sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id if (entry.type !== 'iframe') sessionStore.value.selectedDockRoute = null diff --git a/packages/hub-ui/src/client/state/dock-settings.test.ts b/packages/hub-ui/src/client/state/dock-settings.test.ts index 6c1a48b65..1d4f9d6d4 100644 --- a/packages/hub-ui/src/client/state/dock-settings.test.ts +++ b/packages/hub-ui/src/client/state/dock-settings.test.ts @@ -11,6 +11,10 @@ function group(id: string, extra: Partial = {}): DevframeDock return { id, type: 'group', title: id.toUpperCase(), icon: 'ph:folder-duotone', ...extra } as DevframeDockEntry } +function action(id: string, extra: Partial = {}): DevframeDockEntry { + return { id, type: 'action', action: { importFrom: '/action.js' }, title: id.toUpperCase(), icon: 'ph:play-duotone', ...extra } as DevframeDockEntry +} + function ids(groups: DevframeDockEntriesGrouped): string[] { return groups.flatMap(([, items]) => items.map(item => item.id)) } @@ -129,10 +133,21 @@ describe('resolveGroupPreferredChild', () => { expect(resolveGroupPreferredChild([g, defaultMember, gated], g, 'g:gated', whenContext)).toBe(defaultMember) }) + it('falls back to defaultChildId when the remembered member is an action', () => { + const rememberedAction = action('g:run', { groupId: 'g' }) + expect(resolveGroupPreferredChild([...entries, rememberedAction], g, 'g:run')).toBe(defaultMember) + }) + it('resolves nothing for a popover-only group without memory', () => { const bare = group('bare') as DevframeViewGroup expect(resolveGroupPreferredChild([bare, iframe('bare:x', { groupId: 'bare' })], bare, undefined)).toBeUndefined() }) + + it('ignores a remembered action for a popover-only group', () => { + const bare = group('bare') as DevframeViewGroup + const rememberedAction = action('bare:run', { groupId: 'bare' }) + expect(resolveGroupPreferredChild([bare, rememberedAction], bare, 'bare:run')).toBeUndefined() + }) }) describe('resolveRecentDockEntry', () => { diff --git a/packages/hub-ui/src/client/state/dock-settings.ts b/packages/hub-ui/src/client/state/dock-settings.ts index 364d4e4f3..961346480 100644 --- a/packages/hub-ui/src/client/state/dock-settings.ts +++ b/packages/hub-ui/src/client/state/dock-settings.ts @@ -222,9 +222,10 @@ export function resolveGroupDefaultChild( /** * Resolve the member a group activation opens, layering the per-tab "last * opened member" memory (`DockSessionStorage.groupLastChildIds`) over the - * author's `defaultChildId`. The remembered member wins while it still + * author's `defaultChildId`. A remembered non-action member wins while it still * resolves (it exists in the group and its `when` clause holds), so reopening - * a group lands back on the member the developer last used; otherwise the + * a group lands back on the panel the developer last used. One-shot actions are + * skipped, including values persisted by an older client. Otherwise the * `defaultChildId` target is tried under the same rules (both via * {@link resolveGroupDefaultChild}, so the render-only `visibility` clause is * ignored for either candidate). Returns `undefined` when neither resolves: @@ -237,7 +238,8 @@ export function resolveGroupPreferredChild( lastChildId: string | undefined, whenContext?: WhenContext, ): DevframeDockEntry | undefined { - return resolveGroupDefaultChild(entries, group.id, lastChildId, whenContext) + const remembered = resolveGroupDefaultChild(entries, group.id, lastChildId, whenContext) + return (remembered?.type === 'action' ? undefined : remembered) ?? resolveGroupDefaultChild(entries, group.id, group.defaultChildId, whenContext) }