From a48c7f104d81c25171993272c379e4229ebe39b2 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 27 Aug 2026 09:34:44 +0000 Subject: [PATCH] feat(hub-ui): reopen a group's last-opened member ahead of defaultChildId --- docs/content/1.guide/16.hub.md | 2 +- .../dock/DockGroupButton.stories.ts | 10 +++-- .../components/dock/DockGroupButton.vue | 15 ++++---- .../hub-ui/src/client/state/context.test.ts | 37 +++++++++++++++++++ packages/hub-ui/src/client/state/context.ts | 22 +++++++---- .../src/client/state/dock-settings.test.ts | 33 ++++++++++++++++- .../hub-ui/src/client/state/dock-settings.ts | 22 +++++++++++ packages/hub-ui/src/client/state/docks.ts | 1 + packages/hub/src/client/docks.ts | 11 ++++++ .../@devframes/hub/client.snapshot.d.ts | 1 + 10 files changed, 134 insertions(+), 20 deletions(-) diff --git a/docs/content/1.guide/16.hub.md b/docs/content/1.guide/16.hub.md index 86667ebf..bd04fa69 100644 --- a/docs/content/1.guide/16.hub.md +++ b/docs/content/1.guide/16.hub.md @@ -214,7 +214,7 @@ ctx.docks.register({ }) ``` -Group and members stay independent top-level entries in `devframe:docks`; `defaultChildId` opens on activation. Grouping affects the dock rail, not iframes — to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)). +Group and members stay independent top-level entries in `devframe:docks`. Activating the group reopens the member last opened in it (remembered per tab), and `defaultChildId` before any member has been opened. Grouping affects the dock rail, not iframes — to share **one** soft-navigated iframe, give docks a shared `frameId` and mark the anchor with `subTabs` ([Shared-iframe soft navigation](/guide/client-context#shared-iframe-soft-navigation)). ### The dual role of `category` diff --git a/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts b/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts index bd2f6d76..e03bdeff 100644 --- a/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts +++ b/packages/hub-ui/src/client/components/dock/DockGroupButton.stories.ts @@ -20,7 +20,7 @@ const meta = { parameters: { docs: { description: { - component: 'The dock-bar button representing a group. Click behaviour depends on the group: a group with `defaultChildId` opens that member directly, otherwise it reveals a popover of members. `FloatingElements` is mounted alongside so the popover renders.', + component: 'The dock-bar button representing a group. Clicking opens the member last opened in the group (remembered per tab), then the group\'s `defaultChildId`; with neither it reveals a popover of members. `FloatingElements` is mounted alongside so the popover renders.', }, }, }, @@ -30,8 +30,9 @@ export default meta type Story = StoryObj /** - * A popover-only group (no `defaultChildId`): clicking reveals the member - * popover. + * A popover-only group (no `defaultChildId`): the first click reveals the + * member popover. Picking a member records it as the group's last-opened + * child, so later clicks reopen it directly. */ export const PopoverOnly: Story = { render: () => ({ @@ -53,7 +54,8 @@ export const PopoverOnly: Story = { /** * A group with a `defaultChildId`: clicking opens that member straight away - * instead of showing the popover. + * instead of showing the popover — until another member becomes the group's + * last-opened child, which then takes precedence. */ export const WithDefaultChild: Story = { render: () => ({ diff --git a/packages/hub-ui/src/client/components/dock/DockGroupButton.vue b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue index 293ff1bf..3b3a2d69 100644 --- a/packages/hub-ui/src/client/components/dock/DockGroupButton.vue +++ b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue @@ -3,7 +3,7 @@ import type { DevframeDockEntry, DevframeViewGroup } from '@devframes/hub' import type { DocksContext } from '@devframes/hub/client' import { watchDebounced } from '@vueuse/core' import { computed, h, ref, useTemplateRef } from 'vue' -import { getGroupMembers, getGroupMembersGrouped, resolveGroupDefaultChild } from '../../state/dock-settings' +import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings' import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip' import { useSettings } from '../../state/settings-defaults' import { accentVarStyle } from '../../utils/accent-color' @@ -101,13 +101,14 @@ function onClick() { emit('select', undefined!) return } - // `defaultChildId` opens its member directly; otherwise reveal the popover. - // Resolved regardless of the target's render-only `visibility` (a hidden - // button must still fire), but honoring its `when` clause. - const fallback = resolveGroupDefaultChild( + // The member last opened in this group this tab — then the author's + // `defaultChildId` — opens directly; otherwise reveal the popover. Resolved + // regardless of the target's render-only `visibility` (a hidden button must + // still fire), but honoring its `when` clause. + const fallback = resolveGroupPreferredChild( props.context.docks.entries, - props.group.id, - props.group.defaultChildId, + props.group, + props.context.panel.session.groupLastChildIds?.[props.group.id], props.context.when.context, ) if (fallback) { diff --git a/packages/hub-ui/src/client/state/context.test.ts b/packages/hub-ui/src/client/state/context.test.ts index 7df400d9..f22e5b7d 100644 --- a/packages/hub-ui/src/client/state/context.test.ts +++ b/packages/hub-ui/src/client/state/context.test.ts @@ -131,6 +131,43 @@ describe('createDocksContext', () => { expect(session.value.open).toBe(true) }) + it('reopens a group\'s last-opened member ahead of defaultChildId', async () => { + expect.assertions(4) + + const { rpc, sharedStates, trust } = createStubRpc() + // No `groupLastChildIds` seed — mirrors a session store persisted before + // the field existed. + const session = ref({ + open: false, + selectedDockId: null, + selectedDockRoute: null, + }) + const context = await createDocksContext('embedded', rpc, undefined, session) + + trust() + sharedStates.get('devframe:docks')!.push([ + { id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone', defaultChildId: 'nuxt:overview' }, + { id: 'nuxt:overview', type: 'iframe', url: '/', title: 'Overview', icon: 'ph:cube-duotone', groupId: 'nuxt' }, + { id: 'nuxt:modules', type: 'iframe', url: '/', title: 'Modules', icon: 'ph:cube-duotone', groupId: 'nuxt' }, + ] satisfies DevframeDockEntry[]) + sharedStates.get('devframe:dock-renderers')!.push({}) + await flushRestore() + + // Without memory the group activation resolves to `defaultChildId`. + await context.docks.switchEntry('nuxt') + expect(context.docks.selected?.id).toBe('nuxt:overview') + + // Opening another member records it as the group's last-opened child. + await context.docks.switchEntry('nuxt:modules') + expect(session.value.groupLastChildIds).toEqual({ nuxt: 'nuxt:modules' }) + + // Closing and re-activating the group reopens the remembered member. + await context.docks.switchEntry(null) + expect(context.docks.selected).toBeNull() + await context.docks.switchEntry('nuxt') + expect(context.docks.selected?.id).toBe('nuxt:modules') + }) + 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 e40743d7..6ac2b64d 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -11,7 +11,7 @@ import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vu import { BUILTIN_ENTRIES, BUILTIN_ENTRY_SETTINGS, DEFAULT_CATEGORIES_ORDER, HUB_UI_HIDE_EVENT } from '../constants' import { useBranding } from './branding' import { createCommandsContext } from './commands' -import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupDefaultChild } from './dock-settings' +import { docksGroupByCategories, getCategoryLabel, getGroupMembers, getGroupMembersGrouped, getRegisteredGroupIds, resolveCommandIcon, resolveGroupPreferredChild } from './dock-settings' import { createDockEntryState, DEFAULT_DOCK_PANEL_STORE, DEFAULT_DOCK_SESSION_STORE, sharedStateToRef, useDocksEntries, waitForInitialSharedStateSync } from './docks' import { createClientMessagesClient } from './messages-client' import { registerMainFrameDockActionHandler, triggerMainFrameDockAction, useIsDockPopupOpen } from './popup' @@ -233,13 +233,14 @@ export async function createDocksContext( return false // A group has no view of its own — resolve to the member it represents. - // Prefer the author's `defaultChildId` (honoring its `when` clause but - // ignoring its render-only `visibility` — see `resolveGroupDefaultChild`), - // otherwise the first member. With neither, the group is popover-only and - // selecting it is a no-op here (the dock-bar group button opens the - // member popover instead). + // Prefer the member last opened in this group this tab, then the author's + // `defaultChildId` (each honoring its `when` clause but ignoring its + // render-only `visibility` — see `resolveGroupPreferredChild`), otherwise + // the first member. With none, the group is popover-only and selecting it + // is a no-op here (the dock-bar group button opens the member popover + // instead). if (entry.type === 'group') { - const target = resolveGroupDefaultChild(entries.value, entry.id, entry.defaultChildId, getWhenContext())?.id + const target = resolveGroupPreferredChild(entries.value, entry, sessionStore.value.groupLastChildIds?.[entry.id], getWhenContext())?.id ?? getGroupMembers(entries.value, entry.id)[0]?.id if (!target) return false @@ -291,6 +292,13 @@ export async function createDocksContext( if (entry.type === 'iframe' && entry.frameId && !entry.subTabs) frameNavCurrentMember.set(entry.frameId, entry.id) + // Remember a grouped member as its group's last-opened child so the next + // activation of the group reopens it directly, ahead of `defaultChildId` + // (see `resolveGroupPreferredChild`). Guarded assignment: a session store + // persisted before this field existed has no map yet. + if (entry.groupId) + (sessionStore.value.groupLastChildIds ??= {})[entry.groupId] = entry.id + initialRestorePending.value = false selectedDockId.value = entry.id sessionStore.value.open = true 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 b42413cf..10e284ef 100644 --- a/packages/hub-ui/src/client/state/dock-settings.test.ts +++ b/packages/hub-ui/src/client/state/dock-settings.test.ts @@ -1,6 +1,7 @@ import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub' +import type { WhenContext } from 'devframe/utils/when' import { describe, expect, it } from 'vitest' -import { docksSplitGroupsWithCapacity, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings' +import { docksSplitGroupsWithCapacity, resolveGroupPreferredChild, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings' function iframe(id: string, extra: Partial = {}): DevframeDockEntry { return { id, type: 'iframe', url: '/', title: id.toUpperCase(), icon: 'ph:cube-duotone', ...extra } as DevframeDockEntry @@ -104,6 +105,36 @@ describe('resolveNextRecentDockId', () => { }) }) +describe('resolveGroupPreferredChild', () => { + const g = group('g', { defaultChildId: 'g:default' }) as DevframeViewGroup + const defaultMember = iframe('g:default', { groupId: 'g' }) + const otherMember = iframe('g:other', { groupId: 'g' }) + const entries = [a, g, defaultMember, otherMember] + + it('prefers the last-opened member over defaultChildId', () => { + expect(resolveGroupPreferredChild(entries, g, 'g:other')).toBe(otherMember) + }) + + it('falls back to defaultChildId before any member has been opened', () => { + expect(resolveGroupPreferredChild(entries, g, undefined)).toBe(defaultMember) + }) + + it('falls back to defaultChildId when the remembered member is gone', () => { + expect(resolveGroupPreferredChild([a, g, defaultMember], g, 'g:other')).toBe(defaultMember) + }) + + it('falls back to defaultChildId when the remembered member fails its when clause', () => { + const whenContext: WhenContext = { clientType: 'standalone', dockOpen: false, paletteOpen: false, dockSelectedId: '' } + const gated = iframe('g:gated', { groupId: 'g', when: 'clientType == embedded' }) + expect(resolveGroupPreferredChild([g, defaultMember, gated], g, 'g:gated', whenContext)).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() + }) +}) + describe('resolveRecentDockEntry', () => { const g = group('g') const member = iframe('g:member', { groupId: 'g' }) diff --git a/packages/hub-ui/src/client/state/dock-settings.ts b/packages/hub-ui/src/client/state/dock-settings.ts index 1b989551..f0de3999 100644 --- a/packages/hub-ui/src/client/state/dock-settings.ts +++ b/packages/hub-ui/src/client/state/dock-settings.ts @@ -219,6 +219,28 @@ export function resolveGroupDefaultChild( return member } +/** + * 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 + * 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 + * `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 — + * the caller falls back to its own behavior (the dock-bar group button opens + * the member popover; `switchEntry` picks the first member). + */ +export function resolveGroupPreferredChild( + entries: DevframeDockEntry[], + group: DevframeViewGroup, + lastChildId: string | undefined, + whenContext?: WhenContext, +): DevframeDockEntry | undefined { + return resolveGroupDefaultChild(entries, group.id, lastChildId, whenContext) + ?? resolveGroupDefaultChild(entries, group.id, group.defaultChildId, whenContext) +} + /** * Group and sort dock entries based on user settings. * Filters out hidden entries and categories, then sorts by custom order and diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts index 54552961..e2cd4726 100644 --- a/packages/hub-ui/src/client/state/docks.ts +++ b/packages/hub-ui/src/client/state/docks.ts @@ -30,6 +30,7 @@ export function DEFAULT_DOCK_SESSION_STORE(): DockSessionStorage { selectedDockId: null, selectedDockRoute: null, recentDockId: null, + groupLastChildIds: {}, } } diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index 019fae37..eefc0943 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -50,6 +50,17 @@ export interface DockSessionStorage { * field existed) when no entry has been raised. */ recentDockId?: string | null + /** + * The member most recently opened in each dock group, keyed by group id. + * Recorded whenever a grouped member is selected (from the group popover, + * the group sidebar, the command palette, or an RPC activation), and read + * back when the group is activated again: the remembered member reopens + * directly, taking precedence over the group's own + * {@link import('../types/docks').DevframeViewGroup.defaultChildId defaultChildId}. + * A group is only listed once one of its members has been opened this tab + * (absent for stores persisted before this field existed). + */ + groupLastChildIds?: Record } export type DockClientType = 'embedded' | 'standalone' diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index 42fca8c4..2bbbb5b6 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -111,6 +111,7 @@ export interface DockSessionStorage { selectedDockId: string | null; selectedDockRoute: string | null; recentDockId?: string | null; + groupLastChildIds?: Record; } export interface DocksPanelContext { store: DockPanelStorage;