From f324c4ac7cfddbb81c39f58252250ea4036b0a73 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 30 Oct 2026 00:23:19 +0000 Subject: [PATCH] feat(hub-ui): pin group members to the dock bar and keep recent tools - Add Pin/Unpin to the dock context menu, and open the menu on group popover and group sidebar members. The menu takes focus, supports arrow keys, and Escape closes it and returns focus to the trigger. - A pinned group member now also gets its own dock bar button, so a tool such as Nuxt > Components is one click away. - The float bar keeps up to `docksRecentLimit` (default 3) tools last opened from a group popover or the overflow menu, in slots after the bar items. Recent group members show even when the bar does not overflow. The session store gains `recentDockIds`; `recentDockId` is deprecated and still read. Co-Authored-By: Claude Opus 5.5 --- docs/content/1.guide/16.hub.md | 9 + .../client/components/dock/Dock.stories.ts | 21 +- .../src/client/components/dock/Dock.vue | 55 ++-- .../client/components/dock/DockContextMenu.ts | 79 +++++- .../components/dock/DockGroupPopover.vue | 11 + .../components/dock/DockGroupSidebar.vue | 12 + .../views-builtin/SettingsAppearance.vue | 27 ++ .../hub-ui/src/client/i18n/locales/de.json | 3 + .../hub-ui/src/client/i18n/locales/en.json | 3 + .../hub-ui/src/client/i18n/locales/es.json | 3 + .../hub-ui/src/client/i18n/locales/fr.json | 3 + .../hub-ui/src/client/i18n/locales/ja.json | 3 + .../hub-ui/src/client/i18n/locales/ko.json | 3 + .../hub-ui/src/client/i18n/locales/pt-BR.json | 3 + .../hub-ui/src/client/i18n/locales/ru.json | 3 + .../hub-ui/src/client/i18n/locales/zh-CN.json | 3 + .../hub-ui/src/client/i18n/locales/zh-TW.json | 3 + packages/hub-ui/src/client/state/commands.ts | 4 + packages/hub-ui/src/client/state/context.ts | 2 +- .../src/client/state/dock-settings.test.ts | 176 ++++++++----- .../hub-ui/src/client/state/dock-settings.ts | 247 ++++++++++-------- packages/hub-ui/src/client/state/docks.ts | 2 +- .../src/client/state/settings-defaults.ts | 8 +- packages/hub-ui/src/client/types.ts | 7 + packages/hub/src/client/docks.ts | 19 +- .../@devframes/hub/client.snapshot.d.ts | 1 + 26 files changed, 501 insertions(+), 209 deletions(-) diff --git a/docs/content/1.guide/16.hub.md b/docs/content/1.guide/16.hub.md index 7cd1250dc..a48fcaf92 100644 --- a/docs/content/1.guide/16.hub.md +++ b/docs/content/1.guide/16.hub.md @@ -245,6 +245,15 @@ The first time any member of a group opens in a session, the hub also loads the Declare `defaultChildId` when one member is the natural landing spot; leave it off when the members are peers. +### Pinned and recent tools + +The reference hub UI (`@devframes/hub-ui`) lets users reach a group member in one click. A devframe does not need to set anything for this. + +- **Pin.** Right-click a dock button (on the bar, in a group popover, or in the group sidebar) and pick **Pin**. Keyboard users press Shift+F10 or the Menu key on a focused button. A pinned entry moves to the front of the bar. A pinned group member also gets its own bar button, and it still leads inside its group. Opening it opens that member, and its group button shows as active. Pins are stored in the `docksPinned` user setting. +- **Recent.** When a user opens a tool from a group popover or the overflow menu, the float bar keeps it in a slot after the bar items, newest first. The slots are added on top of the bar's capacity, so opening a tool never pushes another button into the overflow. A tool that is already on the bar (for example a pinned member) does not take a slot. The list is kept per browser tab, in the `recentDockIds` field of the session store. + +The `docksRecentLimit` user setting sets how many recent slots the bar keeps. The default is `3`. `0` turns the slots off. Users change it under **Settings > Appearance > Recent tools on the dock bar**. The edge-mode toolbar shows pinned entries but no recent slots. + ### The dual role of `category` `category` (ordered by `DEFAULT_CATEGORIES_ORDER`, default `'default'`) sets an **ungrouped** entry's outer dock-rail bucket. A **grouped** entry takes its outer bucket from the **group's** `category`, and its own `category` becomes an **in-group sub-category**; a member whose `groupId` never resolves renders top-level under its own `category`. diff --git a/packages/hub-ui/src/client/components/dock/Dock.stories.ts b/packages/hub-ui/src/client/components/dock/Dock.stories.ts index c07efad5a..529abfe26 100644 --- a/packages/hub-ui/src/client/components/dock/Dock.stories.ts +++ b/packages/hub-ui/src/client/components/dock/Dock.stories.ts @@ -92,6 +92,18 @@ export const WithGroups: Story = floatStory( { entries: groupedEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, ) +/** + * Pinned and recent group members: pinning Nuxt > Components gives it its own + * bar button (it also leads inside the Nuxt popover), and Nuxt > Pages, last + * opened from the popover, keeps a recent slot after the divider. + */ +export const WithPinnedAndRecentMembers: Story = floatStory({ + entries: groupedEntries, + settings: { docksPinned: ['nuxt:components'] }, + session: { recentDockIds: ['nuxt:pages'] }, + panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 }, +}) + /** Past capacity: an overflow button appears at the end of the bar. */ export const WithOverflow: Story = floatStory( { entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }, @@ -137,15 +149,16 @@ export const LowerCapacity: Story = { /** * A recent dock raised out of the overflow: selecting an entry from the - * overflow popover reserves the slot between the divider and the overflow - * button for it, and deselecting it (or selecting a visible neighbour) keeps - * it there instead of folding it straight back into the overflow. + * overflow popover gives it its own slot after the divider, and deselecting + * it (or selecting a visible neighbour) keeps it there instead of folding it + * straight back into the overflow. Up to `docksRecentLimit` (default 3) docks + * keep a slot, newest first. */ export const WithRecentDock: Story = { ...floatStory({ entries: overflowEntries, selectedId: 'network', - session: { recentDockId: 'network' }, + session: { recentDockIds: ['network'] }, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 }, }), args: { maxVisibleItems: 3 }, diff --git a/packages/hub-ui/src/client/components/dock/Dock.vue b/packages/hub-ui/src/client/components/dock/Dock.vue index 43453e8a6..0935b1d10 100644 --- a/packages/hub-ui/src/client/components/dock/Dock.vue +++ b/packages/hub-ui/src/client/components/dock/Dock.vue @@ -6,9 +6,9 @@ import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core' import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue' import { BUILTIN_ENTRY_CLIENT_AUTH_NOTICE } from '../../constants' import { t } from '../../i18n' -import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockId, resolveRecentDockEntry } from '../../state/dock-settings' -import { sharedStateToRef } from '../../state/docks' +import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockIds, resolveRecentDockEntries } from '../../state/dock-settings' import { setDocksOverflowPanel, useDocksGroupPanel } from '../../state/floating-tooltip' +import { useSettings } from '../../state/settings-defaults' import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted' import BrandMark from '../icons/BrandMark.vue' import { @@ -95,30 +95,35 @@ const isRpcTrusted = useIsRpcTrusted(context, (isTrusted) => { const groupedEntries = computed(() => context.docks.groupedEntries) -const settings = sharedStateToRef(context.docks.settings) - -// The recent dock: the entry last selected from beyond the bar (the overflow -// popover or a group popover), raised into its own slot between the visible -// items and the overflow button so deselecting it keeps it one click away. -// Persisted per-tab in the session store; resolved to `null` when the id no -// longer maps to a raisable entry. -const recentEntry = computed(() => resolveRecentDockEntry({ - entries: context.docks.entries, - groups: groupedEntries.value, - recentId: context.panel.session.recentDockId, - settings: settings.value, - whenContext: context.when.context, -})) +const settings = useSettings(context) + +// The recent docks: the entries last selected from beyond the bar (the +// overflow popover or a group popover), raised into their own slots after the +// visible items so they stay one click away. Persisted per-tab in the session +// store (newest first); ids that no longer map to a raisable entry drop out. +// `recentDockId` is the older single-entry field, read until a tab writes +// `recentDockIds`. +const recentEntries = computed(() => { + const session = context.panel.session + return resolveRecentDockEntries({ + entries: context.docks.entries, + groups: groupedEntries.value, + recentIds: session.recentDockIds ?? (session.recentDockId ? [session.recentDockId] : []), + limit: settings.value.docksRecentLimit, + settings: settings.value, + whenContext: context.when.context, + }) +}) const splitEntries = computed(() => { - return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntry.value) + return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntries.value) }) const selectedEntry = computed(() => { return context.docks.selected }) -// Remember the recent dock whenever the selection lands on an entry that has +// Remember a recent dock whenever the selection lands on an entry that has // no slot on the bar as currently rendered (grouped members always; top-level // entries when picked from the overflow popover). Observing the selection // (rather than the bar's own click handlers) also covers selections made via @@ -126,13 +131,15 @@ const selectedEntry = computed(() => { watch(selectedEntry, (entry) => { if (!entry) return - context.panel.session.recentDockId = resolveNextRecentDockId({ + context.panel.session.recentDockIds = resolveNextRecentDockIds({ groups: groupedEntries.value, capacity: layout.value.maxVisibleItems, - recentEntry: recentEntry.value, + recentEntries: recentEntries.value, selected: entry, selectedIsGroupMember: !!getEntryGroup(context.docks.entries, entry), + limit: settings.value.docksRecentLimit, }) + context.panel.session.recentDockId = undefined }) onMounted(async () => { @@ -344,16 +351,18 @@ onMounted(() => { @select="(e) => context.docks.switchEntry(e?.id)" /> -