@nextclaw/ui 0.13.4 → 0.13.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/assets/api-BAyyGaoz.js +15 -0
  3. package/dist/assets/app-presenter-provider-DqHHfC1K.js +3 -0
  4. package/dist/assets/arrow-left-Bn-3Pj3p.js +1 -0
  5. package/dist/assets/{boxes-BvA-I63l.js → boxes-ClVuotcz.js} +1 -1
  6. package/dist/assets/channels-list-page-BnGxv6ma.js +8 -0
  7. package/dist/assets/chat-page-DInyWLb4.js +105 -0
  8. package/dist/assets/config-split-page-CWN2YG9U.js +1 -0
  9. package/dist/assets/confirm-dialog-CVbgKuHp.js +5 -0
  10. package/dist/assets/{createLucideIcon-Br1EFMEr.js → createLucideIcon-CjPUPAAK.js} +1 -1
  11. package/dist/assets/desktop-update-config-abmYzHSy.js +1 -0
  12. package/dist/assets/{dist-rzakR_bu.js → dist-hkrb6Ynt.js} +1 -1
  13. package/dist/assets/doc-browser-BlrpUKVJ.js +1 -0
  14. package/dist/assets/doc-browser-context-Cb1sEUT0.js +1 -0
  15. package/dist/assets/doc-browser-kJ2ClG0H.js +1 -0
  16. package/dist/assets/ellipsis-CdkxFkVZ.js +1 -0
  17. package/dist/assets/{external-link-DHjFdCpy.js → external-link-CT9_qFwN.js} +1 -1
  18. package/dist/assets/index-BGXTR0F_.css +1 -0
  19. package/dist/assets/index-Ccbswzt9.js +101 -0
  20. package/dist/assets/{key-round-D41Oe35x.js → key-round-xJfR4iRe.js} +1 -1
  21. package/dist/assets/{book-open-CRotBOF2.js → loader-circle-BI6HJDg5.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-Dvi8mCBd.js → mcp-marketplace-page-CmUKUeno.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-DH11bSVx.js +1 -0
  24. package/dist/assets/model-config-Dbo3zWDl.js +1 -0
  25. package/dist/assets/notice-card-CFTFtJky.js +1 -0
  26. package/dist/assets/play-DyBWW2j4.js +1 -0
  27. package/dist/assets/plus-BlnBmcKE.js +1 -0
  28. package/dist/assets/popover-DPqa_Mfb.js +1 -0
  29. package/dist/assets/provider-models-CXUgDxGR.js +1 -0
  30. package/dist/assets/provider-scoped-model-input-C6xL7zSJ.js +1 -0
  31. package/dist/assets/providers-list-DOj_7GL8.js +1 -0
  32. package/dist/assets/react-oAHNoVya.js +1 -0
  33. package/dist/assets/refresh-cw-o8NSmIYV.js +1 -0
  34. package/dist/assets/remote-fCE5fLsj.js +1 -0
  35. package/dist/assets/rotate-cw-DA8EqNAH.js +1 -0
  36. package/dist/assets/runtime-config-page-Dg8aEgWx.js +1 -0
  37. package/dist/assets/{save-aYdSdK97.js → save-D4-g0EAq.js} +1 -1
  38. package/dist/assets/search-G2cMn3M5.js +1 -0
  39. package/dist/assets/{search-config-G2O1fcRb.js → search-config-DAibVFGR.js} +1 -1
  40. package/dist/assets/{secrets-config-TenzxYbE.js → secrets-config-CoJzy13_.js} +2 -2
  41. package/dist/assets/{status-dot-BSMG7ZaC.js → status-dot-D8xx5ZR8.js} +1 -1
  42. package/dist/assets/{tabs-D1CjpWNX.js → tabs-DxmND60Q.js} +1 -1
  43. package/dist/assets/{tabs-custom-CRSN1ekg.js → tabs-custom-Cjo0bHw8.js} +1 -1
  44. package/dist/assets/{tag-chip-DEashiw6.js → tag-chip-DnNWFRMu.js} +1 -1
  45. package/dist/assets/{trash-2-DSWTsARW.js → trash-2-C9tc-9sQ.js} +1 -1
  46. package/dist/assets/x-DmLl2yG6.js +1 -0
  47. package/dist/index.html +24 -23
  48. package/dist/logos/mimo.svg +9 -0
  49. package/package.json +8 -8
  50. package/public/logos/mimo.svg +9 -0
  51. package/src/app/components/layout/app-layout.tsx +10 -2
  52. package/src/features/agents/components/agents-page.tsx +6 -4
  53. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +17 -10
  54. package/src/features/marketplace/components/marketplace-list-card.tsx +22 -1
  55. package/src/features/marketplace/components/marketplace-page.tsx +3 -1
  56. package/src/features/marketplace/hooks/use-marketplace.ts +3 -1
  57. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +8 -0
  58. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +70 -6
  59. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +25 -1
  60. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +1 -1
  61. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +42 -0
  62. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +22 -0
  63. package/src/features/service-apps/components/service-apps-panel.test.tsx +88 -0
  64. package/src/features/service-apps/components/service-apps-panel.tsx +246 -56
  65. package/src/features/service-apps/hooks/use-service-apps.ts +14 -0
  66. package/src/features/side-dock/components/side-dock.test.tsx +58 -3
  67. package/src/features/side-dock/components/side-dock.tsx +77 -24
  68. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +37 -34
  69. package/src/features/side-dock/index.ts +2 -0
  70. package/src/features/side-dock/managers/side-dock.manager.test.ts +84 -3
  71. package/src/features/side-dock/managers/side-dock.manager.ts +135 -12
  72. package/src/features/side-dock/types/side-dock.types.ts +11 -4
  73. package/src/features/side-dock/utils/side-dock-item.utils.test.ts +8 -8
  74. package/src/features/side-dock/utils/side-dock-item.utils.ts +13 -5
  75. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -2
  76. package/src/platforms/mobile/components/mobile-app-shell.tsx +4 -1
  77. package/src/shared/components/config/provider-form-support.test.ts +21 -0
  78. package/src/shared/components/config/provider-form-support.ts +38 -78
  79. package/src/shared/components/config/provider-form.tsx +54 -67
  80. package/src/shared/components/config/providers-list.tsx +211 -73
  81. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +73 -0
  82. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  83. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +25 -2
  84. package/src/shared/components/doc-browser/doc-browser.test.tsx +42 -0
  85. package/src/shared/components/doc-browser/doc-browser.tsx +38 -7
  86. package/src/shared/components/doc-browser/index.ts +1 -0
  87. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +30 -6
  88. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +29 -0
  89. package/src/shared/components/doc-browser/types/doc-browser.types.ts +23 -0
  90. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +7 -0
  91. package/src/shared/components/model-config.tsx +5 -4
  92. package/src/shared/components/provider-auth-section.tsx +2 -2
  93. package/src/shared/components/ui/switch.tsx +5 -2
  94. package/src/shared/hooks/use-config.ts +27 -6
  95. package/src/shared/lib/api/marketplace.types.ts +193 -0
  96. package/src/shared/lib/api/types.ts +23 -200
  97. package/src/shared/lib/api/utils/config.utils.ts +17 -7
  98. package/src/shared/lib/i18n/index.ts +10 -12
  99. package/src/shared/lib/i18n/{marketplace.ts → marketplace-labels.utils.ts} +3 -0
  100. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +46 -2
  101. package/src/shared/lib/provider-models/index.test.ts +93 -15
  102. package/src/shared/lib/provider-models/index.ts +35 -20
  103. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  104. package/dist/assets/api-DruAcori.js +0 -15
  105. package/dist/assets/app-presenter-provider-DIcnNB9s.js +0 -3
  106. package/dist/assets/channels-list-page-wK3l7p5R.js +0 -8
  107. package/dist/assets/chat-page-DgehvdJT.js +0 -105
  108. package/dist/assets/config-split-page-BnTO1mAM.js +0 -1
  109. package/dist/assets/confirm-dialog-CGrKHbAW.js +0 -5
  110. package/dist/assets/desktop-update-config-CehWxSSg.js +0 -1
  111. package/dist/assets/dist-CpwDZDtv.js +0 -1
  112. package/dist/assets/doc-browser-BgxmVsTx.js +0 -1
  113. package/dist/assets/doc-browser-CZn5-Qbu.js +0 -1
  114. package/dist/assets/doc-browser-context-B8gk7HMr.js +0 -1
  115. package/dist/assets/index-DORmh0Cy.css +0 -1
  116. package/dist/assets/index-DcFpvnge.js +0 -101
  117. package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +0 -1
  118. package/dist/assets/model-config-B-EJXGtO.js +0 -1
  119. package/dist/assets/play-C-0vo7hi.js +0 -1
  120. package/dist/assets/plus-Cr_akLp2.js +0 -1
  121. package/dist/assets/popover-YieElO8c.js +0 -1
  122. package/dist/assets/provider-scoped-model-input-BPRNGqYn.js +0 -1
  123. package/dist/assets/providers-list-CtBpL1ZY.js +0 -1
  124. package/dist/assets/react-B2X_ph0a.js +0 -1
  125. package/dist/assets/remote-BGHbHoHf.js +0 -1
  126. package/dist/assets/rotate-cw-jueVCNJQ.js +0 -1
  127. package/dist/assets/runtime-config-page-C1Hv7mad.js +0 -1
  128. package/dist/assets/search-CdhgFNwf.js +0 -1
  129. package/dist/assets/setting-row-BNu0qhOM.js +0 -1
  130. package/dist/assets/x-D1v_vb54.js +0 -1
  131. package/src/shared/components/provider-enabled-field.tsx +0 -23
  132. /package/dist/assets/{config-hints-A7G_jMTU.js → config-hints-TmNgpFHv.js} +0 -0
@@ -1,7 +1,7 @@
1
- import { AppWindow, BookOpen, Boxes, Plus } from 'lucide-react';
1
+ import { AppWindow, BookOpen, Boxes, Plus, X } from 'lucide-react';
2
2
  import type { LucideIcon } from 'lucide-react';
3
3
  import type { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
4
- import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
4
+ import { getSideDockBuiltInItems } from '@/features/side-dock/configs/side-dock-built-in-items.config';
5
5
  import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
6
6
  import type {
7
7
  SideDockIconName,
@@ -30,6 +30,7 @@ const SIDE_DOCK_ICON_COMPONENTS: Record<SideDockIconName, LucideIcon> = {
30
30
  apps: Boxes,
31
31
  docs: BookOpen,
32
32
  'new-tab': Plus,
33
+ 'panel-app': AppWindow,
33
34
  'service-apps': AppWindow,
34
35
  };
35
36
 
@@ -37,6 +38,13 @@ function SideDockItemIconView({ icon }: { icon: SideDockItemIcon }) {
37
38
  if (icon.type === 'url') {
38
39
  return <img src={icon.url} alt="" className="h-5 w-5 rounded object-cover" />;
39
40
  }
41
+ if (icon.type === 'text') {
42
+ return (
43
+ <span className="max-w-7 truncate text-center text-[13px] font-semibold leading-none" aria-hidden="true">
44
+ {icon.value}
45
+ </span>
46
+ );
47
+ }
40
48
 
41
49
  const Icon = SIDE_DOCK_ICON_COMPONENTS[icon.name];
42
50
  return <Icon className="h-5 w-5" aria-hidden="true" />;
@@ -46,56 +54,100 @@ function SideDockButton({
46
54
  active,
47
55
  item,
48
56
  onOpen,
57
+ onUnpin,
49
58
  }: {
50
59
  active: boolean;
51
60
  item: SideDockItem;
52
61
  onOpen: (item: SideDockItem) => void;
62
+ onUnpin: (item: SideDockItem) => void;
53
63
  }) {
54
- const label = t(item.labelKey);
64
+ const { label } = item;
55
65
 
56
66
  return (
57
67
  <Tooltip>
58
- <TooltipTrigger asChild>
59
- <button
60
- type="button"
61
- aria-label={label}
62
- title={label}
63
- data-side-dock-item-id={item.id}
64
- onClick={() => onOpen(item)}
65
- className={cn(
66
- 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors',
67
- 'hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
68
- active
69
- ? 'bg-primary/10 text-primary'
70
- : 'bg-transparent',
71
- )}
72
- >
73
- <SideDockItemIconView icon={item.icon} />
74
- </button>
75
- </TooltipTrigger>
68
+ <div className="group relative">
69
+ <TooltipTrigger asChild>
70
+ <button
71
+ type="button"
72
+ aria-label={label}
73
+ title={label}
74
+ data-side-dock-item-id={item.id}
75
+ onClick={() => onOpen(item)}
76
+ className={cn(
77
+ 'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors',
78
+ 'hover:bg-primary/10 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
79
+ active
80
+ ? 'bg-primary/10 text-primary'
81
+ : 'bg-transparent',
82
+ )}
83
+ >
84
+ <SideDockItemIconView icon={item.icon} />
85
+ </button>
86
+ </TooltipTrigger>
87
+ {item.removable ? (
88
+ <button
89
+ type="button"
90
+ aria-label={t('sideDockUnpinItem')}
91
+ title={t('sideDockUnpinItem')}
92
+ onClick={(event) => {
93
+ event.stopPropagation();
94
+ onUnpin(item);
95
+ }}
96
+ className="absolute -right-1 -top-1 hidden h-4 w-4 items-center justify-center rounded-full bg-background text-muted-foreground shadow-sm ring-1 ring-border/70 transition-colors hover:bg-muted hover:text-foreground group-hover:flex focus-visible:flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
97
+ >
98
+ <X className="h-3 w-3" aria-hidden="true" />
99
+ </button>
100
+ ) : null}
101
+ </div>
76
102
  <TooltipContent side="left">{label}</TooltipContent>
77
103
  </Tooltip>
78
104
  );
79
105
  }
80
106
 
107
+ function normalizeSideDockUri(uri?: string): string {
108
+ return uri?.trim() ?? '';
109
+ }
110
+
81
111
  function isSideDockItemActive(
82
112
  item: SideDockItem,
83
113
  isDocBrowserOpen: boolean,
84
114
  currentTab?: DocBrowserTab,
115
+ hasExactActiveItem = false,
85
116
  ): boolean {
86
117
  if (!isDocBrowserOpen || !currentTab) {
87
118
  return false;
88
119
  }
89
- if (currentTab.currentUrl === item.target.uri) {
120
+ const itemUri = normalizeSideDockUri(item.target.uri);
121
+ const currentResourceUri = normalizeSideDockUri(currentTab.resourceUri);
122
+ if (
123
+ (currentResourceUri && currentResourceUri === itemUri)
124
+ || normalizeSideDockUri(currentTab.currentUrl) === itemUri
125
+ ) {
90
126
  return true;
91
127
  }
128
+ if (hasExactActiveItem) {
129
+ return false;
130
+ }
92
131
  return item.id === 'docs' && currentTab.kind === 'docs';
93
132
  }
94
133
 
134
+ function hasExactActiveSideDockItem(items: SideDockItem[], currentTab?: DocBrowserTab): boolean {
135
+ if (!currentTab) {
136
+ return false;
137
+ }
138
+ const currentResourceUri = normalizeSideDockUri(currentTab.resourceUri);
139
+ const currentUrl = normalizeSideDockUri(currentTab.currentUrl);
140
+ return items.some((item) => {
141
+ const itemUri = normalizeSideDockUri(item.target.uri);
142
+ return itemUri === currentResourceUri || itemUri === currentUrl;
143
+ });
144
+ }
145
+
95
146
  export function SideDock({ manager }: SideDockProps) {
96
147
  const pinnedItems = useSideDockStore((state) => state.pinnedItems);
97
148
  const { currentTab, isOpen } = useDocBrowser();
98
- const items = mergeSideDockItems(SIDE_DOCK_BUILT_IN_ITEMS, pinnedItems);
149
+ const items = mergeSideDockItems(getSideDockBuiltInItems(), pinnedItems);
150
+ const hasExactActiveItem = hasExactActiveSideDockItem(items, currentTab);
99
151
 
100
152
  return (
101
153
  <TooltipProvider delayDuration={250}>
@@ -106,9 +158,10 @@ export function SideDock({ manager }: SideDockProps) {
106
158
  {items.map((item) => (
107
159
  <SideDockButton
108
160
  key={item.id}
109
- active={isSideDockItemActive(item, isOpen, currentTab)}
161
+ active={isSideDockItemActive(item, isOpen, currentTab, hasExactActiveItem)}
110
162
  item={item}
111
163
  onOpen={manager.openItem}
164
+ onUnpin={(dockItem) => manager.unpinItem(dockItem.id)}
112
165
  />
113
166
  ))}
114
167
  </aside>
@@ -4,40 +4,43 @@ import {
4
4
  RIGHT_PANEL_SERVICE_APPS_URL,
5
5
  } from '@/features/right-panel-resources';
6
6
  import type { SideDockItem } from '@/features/side-dock/types/side-dock.types';
7
+ import { t } from '@/shared/lib/i18n';
7
8
 
8
9
  export const SIDE_DOCK_DOCS_URL = 'nextclaw://docs';
9
10
 
10
- export const SIDE_DOCK_BUILT_IN_ITEMS: SideDockItem[] = [
11
- {
12
- builtIn: true,
13
- icon: { type: 'builtin', name: 'apps' },
14
- id: 'apps',
15
- labelKey: 'appsTitle',
16
- removable: false,
17
- target: { type: 'right-panel-resource', uri: RIGHT_PANEL_APPS_URL },
18
- },
19
- {
20
- builtIn: true,
21
- icon: { type: 'builtin', name: 'service-apps' },
22
- id: 'service-apps',
23
- labelKey: 'serviceAppsTitle',
24
- removable: false,
25
- target: { type: 'right-panel-resource', uri: RIGHT_PANEL_SERVICE_APPS_URL },
26
- },
27
- {
28
- builtIn: true,
29
- icon: { type: 'builtin', name: 'docs' },
30
- id: 'docs',
31
- labelKey: 'docBrowserHelp',
32
- removable: false,
33
- target: { type: 'right-panel-resource', uri: SIDE_DOCK_DOCS_URL },
34
- },
35
- {
36
- builtIn: true,
37
- icon: { type: 'builtin', name: 'new-tab' },
38
- id: 'new-tab',
39
- labelKey: 'docBrowserHomeTitle',
40
- removable: false,
41
- target: { type: 'right-panel-resource', uri: RIGHT_PANEL_HOME_URL },
42
- },
43
- ];
11
+ export function getSideDockBuiltInItems(): SideDockItem[] {
12
+ return [
13
+ {
14
+ builtIn: true,
15
+ icon: { type: 'builtin', name: 'apps' },
16
+ id: 'apps',
17
+ label: t('appsTitle'),
18
+ removable: false,
19
+ target: { type: 'right-panel-resource', uri: RIGHT_PANEL_APPS_URL },
20
+ },
21
+ {
22
+ builtIn: true,
23
+ icon: { type: 'builtin', name: 'service-apps' },
24
+ id: 'service-apps',
25
+ label: t('serviceAppsTitle'),
26
+ removable: false,
27
+ target: { type: 'right-panel-resource', uri: RIGHT_PANEL_SERVICE_APPS_URL },
28
+ },
29
+ {
30
+ builtIn: true,
31
+ icon: { type: 'builtin', name: 'docs' },
32
+ id: 'docs',
33
+ label: t('docBrowserHelp'),
34
+ removable: false,
35
+ target: { type: 'right-panel-resource', uri: SIDE_DOCK_DOCS_URL },
36
+ },
37
+ {
38
+ builtIn: true,
39
+ icon: { type: 'builtin', name: 'new-tab' },
40
+ id: 'new-tab',
41
+ label: t('docBrowserHomeTitle'),
42
+ removable: false,
43
+ target: { type: 'right-panel-resource', uri: RIGHT_PANEL_HOME_URL },
44
+ },
45
+ ];
46
+ }
@@ -1,5 +1,6 @@
1
1
  export { SideDock } from '@/features/side-dock/components/side-dock';
2
2
  export { SideDockManager } from '@/features/side-dock/managers/side-dock.manager';
3
+ export { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
3
4
  export type {
4
5
  SideDockIconName,
5
6
  SideDockItem,
@@ -7,4 +8,5 @@ export type {
7
8
  SideDockItemId,
8
9
  SideDockItemTarget,
9
10
  SideDockPinnedItem,
11
+ SideDockResourceDockState,
10
12
  } from '@/features/side-dock/types/side-dock.types';
@@ -8,7 +8,7 @@ const customItem: SideDockItem = {
8
8
  builtIn: false,
9
9
  icon: { type: 'builtin', name: 'docs' },
10
10
  id: 'custom-docs',
11
- labelKey: 'docBrowserHelp',
11
+ label: 'Help Docs',
12
12
  removable: true,
13
13
  target: { type: 'right-panel-resource', uri: 'nextclaw://docs/custom' },
14
14
  };
@@ -25,7 +25,10 @@ describe('SideDockManager', () => {
25
25
 
26
26
  manager.openItem(customItem);
27
27
 
28
- expect(open).toHaveBeenCalledWith('nextclaw://docs/custom');
28
+ expect(open).toHaveBeenCalledWith('nextclaw://docs/custom', {
29
+ dockIcon: { type: 'builtin', name: 'docs' },
30
+ title: 'Help Docs',
31
+ });
29
32
  });
30
33
 
31
34
  it('pins removable items and keeps built-in items immutable', () => {
@@ -39,12 +42,90 @@ describe('SideDockManager', () => {
39
42
  createdAt: '2026-06-02T00:00:00.000Z',
40
43
  icon: { type: 'builtin', name: 'docs' },
41
44
  id: 'custom-docs',
42
- labelKey: 'docBrowserHelp',
45
+ label: 'Help Docs',
43
46
  target: { type: 'right-panel-resource', uri: 'nextclaw://docs/custom' },
44
47
  },
45
48
  ]);
46
49
  });
47
50
 
51
+ it('pins and unpins the current doc browser tab by resource URI', () => {
52
+ const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
53
+
54
+ manager.pinTab({
55
+ currentUrl: 'https://docs.nextclaw.ai/guide/getting-started',
56
+ history: ['https://docs.nextclaw.ai/guide/getting-started'],
57
+ historyIndex: 0,
58
+ id: 'tab-1',
59
+ kind: 'docs',
60
+ navVersion: 0,
61
+ resourceUri: 'nextclaw://docs/guide/getting-started',
62
+ title: 'Getting Started',
63
+ }, '2026-06-02T00:00:00.000Z');
64
+
65
+ expect(useSideDockStore.getState().pinnedItems).toMatchObject([
66
+ {
67
+ createdAt: '2026-06-02T00:00:00.000Z',
68
+ label: 'Getting Started',
69
+ target: { type: 'right-panel-resource', uri: 'nextclaw://docs/guide/getting-started' },
70
+ },
71
+ ]);
72
+
73
+ manager.unpinTab({
74
+ currentUrl: 'https://docs.nextclaw.ai/guide/getting-started',
75
+ history: ['https://docs.nextclaw.ai/guide/getting-started'],
76
+ historyIndex: 0,
77
+ id: 'tab-1',
78
+ kind: 'docs',
79
+ navVersion: 0,
80
+ resourceUri: 'nextclaw://docs/guide/getting-started',
81
+ title: 'Getting Started',
82
+ });
83
+
84
+ expect(useSideDockStore.getState().pinnedItems).toEqual([]);
85
+ });
86
+
87
+ it('pins panel app tabs with their own dock icon metadata', () => {
88
+ const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
89
+
90
+ manager.pinTab({
91
+ currentUrl: '/api/panel-apps/demo/content',
92
+ dockIcon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
93
+ history: ['/api/panel-apps/demo/content'],
94
+ historyIndex: 0,
95
+ id: 'tab-1',
96
+ kind: 'panel-app',
97
+ navVersion: 0,
98
+ resourceUri: 'nextclaw://panel-app/demo',
99
+ title: 'Demo App',
100
+ }, '2026-06-02T00:00:00.000Z');
101
+
102
+ expect(useSideDockStore.getState().pinnedItems).toMatchObject([
103
+ {
104
+ icon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
105
+ label: 'Demo App',
106
+ target: { type: 'right-panel-resource', uri: 'nextclaw://panel-app/demo' },
107
+ },
108
+ ]);
109
+ });
110
+
111
+ it('pins panel app text icons instead of falling back to a generic icon', () => {
112
+ const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
113
+
114
+ manager.pinTab({
115
+ currentUrl: '/api/panel-apps/compass/content',
116
+ dockIcon: { type: 'text', value: 'C' },
117
+ history: ['/api/panel-apps/compass/content'],
118
+ historyIndex: 0,
119
+ id: 'tab-1',
120
+ kind: 'panel-app',
121
+ navVersion: 0,
122
+ resourceUri: 'nextclaw://panel-app/compass',
123
+ title: 'Compass',
124
+ }, '2026-06-02T00:00:00.000Z');
125
+
126
+ expect(useSideDockStore.getState().pinnedItems[0]?.icon).toEqual({ type: 'text', value: 'C' });
127
+ });
128
+
48
129
  it('unpins and reorders pinned items', () => {
49
130
  const manager = new SideDockManager({ open: vi.fn() } as unknown as DocBrowserManager);
50
131
 
@@ -1,43 +1,155 @@
1
- import { SIDE_DOCK_BUILT_IN_ITEMS } from '@/features/side-dock/configs/side-dock-built-in-items.config';
1
+ import { getSideDockBuiltInItems } from '@/features/side-dock/configs/side-dock-built-in-items.config';
2
2
  import { useSideDockStore } from '@/features/side-dock/stores/side-dock.store';
3
- import type { SideDockItem, SideDockPinnedItem } from '@/features/side-dock/types/side-dock.types';
3
+ import type {
4
+ SideDockIconName,
5
+ SideDockItem,
6
+ SideDockPinnedItem,
7
+ SideDockResourceDockState,
8
+ } from '@/features/side-dock/types/side-dock.types';
4
9
  import {
5
10
  createPinnedSideDockItem,
6
11
  mergeSideDockItems,
7
12
  } from '@/features/side-dock/utils/side-dock-item.utils';
8
13
  import type { DocBrowserManager } from '@/shared/components/doc-browser/managers/doc-browser.manager';
14
+ import type {
15
+ DocBrowserDockIcon,
16
+ DocBrowserTab,
17
+ } from '@/shared/components/doc-browser/types/doc-browser.types';
18
+
19
+ const SIDE_DOCK_PINNED_ITEM_ID_PREFIX = 'pinned';
20
+ const SIDE_DOCK_ICON_NAMES = new Set<SideDockIconName>([
21
+ 'apps',
22
+ 'docs',
23
+ 'new-tab',
24
+ 'panel-app',
25
+ 'service-apps',
26
+ ]);
27
+
28
+ function normalizeSideDockResourceUri(uri: string): string {
29
+ return uri.trim();
30
+ }
31
+
32
+ function createPinnedItemId(uri: string): string {
33
+ let hash = 0;
34
+ Array.from(normalizeSideDockResourceUri(uri)).forEach((char) => {
35
+ hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
36
+ });
37
+ const slug = normalizeSideDockResourceUri(uri)
38
+ .replace(/[^a-zA-Z0-9]+/g, '-')
39
+ .replace(/^-+|-+$/g, '')
40
+ .toLowerCase();
41
+ return `${SIDE_DOCK_PINNED_ITEM_ID_PREFIX}-${slug || 'resource'}-${hash.toString(36)}`;
42
+ }
43
+
44
+ function resolveSideDockIcon(icon?: DocBrowserDockIcon): SideDockItem['icon'] | null {
45
+ if (icon?.type === 'builtin' && SIDE_DOCK_ICON_NAMES.has(icon.name as SideDockIconName)) {
46
+ return { type: 'builtin', name: icon.name as SideDockIconName };
47
+ }
48
+ if (icon?.type === 'url' && icon.url.trim().length > 0) {
49
+ return { type: 'url', url: icon.url.trim() };
50
+ }
51
+ if (icon?.type === 'text' && icon.value.trim().length > 0) {
52
+ return { type: 'text', value: icon.value.trim() };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function getFallbackIconNameForTab(tab: DocBrowserTab): SideDockIconName {
58
+ if (tab.kind === 'docs') {
59
+ return 'docs';
60
+ }
61
+ if (tab.kind === 'home') {
62
+ return 'new-tab';
63
+ }
64
+ if (tab.kind === 'apps') {
65
+ return tab.resourceUri?.includes('service-apps') ? 'service-apps' : 'apps';
66
+ }
67
+ if (tab.kind === 'panel-app') {
68
+ return 'panel-app';
69
+ }
70
+ return 'apps';
71
+ }
9
72
 
10
73
  export class SideDockManager {
11
74
  constructor(private readonly docBrowserManager: DocBrowserManager) {}
12
75
 
13
- readonly getItems = (): SideDockItem[] => mergeSideDockItems(
14
- SIDE_DOCK_BUILT_IN_ITEMS,
76
+ getItems = (): SideDockItem[] => mergeSideDockItems(
77
+ getSideDockBuiltInItems(),
15
78
  useSideDockStore.getState().pinnedItems,
16
79
  );
17
80
 
18
- readonly openItem = (item: Pick<SideDockItem, 'target'>): void => {
81
+ openItem = (item: Pick<SideDockItem, 'icon' | 'label' | 'target'>): void => {
19
82
  if (item.target.type === 'right-panel-resource') {
20
- this.docBrowserManager.open(item.target.uri);
83
+ this.docBrowserManager.open(item.target.uri, {
84
+ dockIcon: item.icon,
85
+ title: item.label,
86
+ });
87
+ }
88
+ };
89
+
90
+ getDockState = (tab?: DocBrowserTab): SideDockResourceDockState => {
91
+ const uri = this.getTabResourceUri(tab);
92
+ if (!uri) {
93
+ return {
94
+ canDock: false,
95
+ isDocked: false,
96
+ removable: false,
97
+ };
21
98
  }
99
+
100
+ const item = this.findItemByUri(uri);
101
+ return {
102
+ canDock: true,
103
+ isDocked: !!item,
104
+ removable: item?.removable === true,
105
+ };
22
106
  };
23
107
 
24
- readonly pinItem = (item: SideDockItem, createdAt: string = new Date().toISOString()): void => {
108
+ pinTab = (tab?: DocBrowserTab, createdAt: string = new Date().toISOString()): void => {
109
+ const uri = this.getTabResourceUri(tab);
110
+ if (!tab || !uri || this.findItemByUri(uri)) {
111
+ return;
112
+ }
113
+ this.pinItem({
114
+ builtIn: false,
115
+ icon: resolveSideDockIcon(tab.dockIcon) ?? { type: 'builtin', name: getFallbackIconNameForTab(tab) },
116
+ id: createPinnedItemId(uri),
117
+ label: tab.title.trim() || uri,
118
+ removable: true,
119
+ target: { type: 'right-panel-resource', uri },
120
+ }, createdAt);
121
+ };
122
+
123
+ unpinTab = (tab?: DocBrowserTab): void => {
124
+ const uri = this.getTabResourceUri(tab);
125
+ const item = uri ? this.findItemByUri(uri) : undefined;
126
+ if (!item?.removable) {
127
+ return;
128
+ }
129
+ this.unpinItem(item.id);
130
+ };
131
+
132
+ pinItem = (item: SideDockItem, createdAt: string = new Date().toISOString()): void => {
25
133
  if (!item.removable) {
26
134
  return;
27
135
  }
136
+ const uri = normalizeSideDockResourceUri(item.target.uri);
28
137
  this.setPinnedItems((items) => {
29
- if (items.some((pinnedItem) => pinnedItem.id === item.id || pinnedItem.target.uri === item.target.uri)) {
138
+ if (items.some((pinnedItem) => pinnedItem.id === item.id || normalizeSideDockResourceUri(pinnedItem.target.uri) === uri)) {
30
139
  return items;
31
140
  }
32
- return [...items, createPinnedSideDockItem(item, createdAt)];
141
+ return [...items, createPinnedSideDockItem({
142
+ ...item,
143
+ target: { ...item.target, uri },
144
+ }, createdAt)];
33
145
  });
34
146
  };
35
147
 
36
- readonly unpinItem = (itemId: string): void => {
148
+ unpinItem = (itemId: string): void => {
37
149
  this.setPinnedItems((items) => items.filter((item) => item.id !== itemId));
38
150
  };
39
151
 
40
- readonly reorderPinnedItems = (itemIds: string[]): void => {
152
+ reorderPinnedItems = (itemIds: string[]): void => {
41
153
  this.setPinnedItems((items) => {
42
154
  const itemsById = new Map(items.map((item) => [item.id, item]));
43
155
  const orderedItems = itemIds
@@ -49,10 +161,21 @@ export class SideDockManager {
49
161
  });
50
162
  };
51
163
 
52
- private readonly setPinnedItems = (
164
+ private setPinnedItems = (
53
165
  updater: (items: SideDockPinnedItem[]) => SideDockPinnedItem[],
54
166
  ): void => {
55
167
  const { pinnedItems, setPinnedItems } = useSideDockStore.getState();
56
168
  setPinnedItems(updater(pinnedItems));
57
169
  };
170
+
171
+ private getTabResourceUri = (tab?: DocBrowserTab): string | null => {
172
+ const uri = tab?.resourceUri ?? tab?.currentUrl;
173
+ const normalized = uri ? normalizeSideDockResourceUri(uri) : '';
174
+ return normalized.length > 0 ? normalized : null;
175
+ };
176
+
177
+ private findItemByUri = (uri: string): SideDockItem | undefined => {
178
+ const normalized = normalizeSideDockResourceUri(uri);
179
+ return this.getItems().find((item) => normalizeSideDockResourceUri(item.target.uri) === normalized);
180
+ };
58
181
  }
@@ -5,17 +5,18 @@ export type SideDockItemTarget = {
5
5
  uri: string;
6
6
  };
7
7
 
8
- export type SideDockIconName = 'apps' | 'docs' | 'new-tab' | 'service-apps';
8
+ export type SideDockIconName = 'apps' | 'docs' | 'new-tab' | 'panel-app' | 'service-apps';
9
9
 
10
10
  export type SideDockItemIcon =
11
11
  | { type: 'builtin'; name: SideDockIconName }
12
- | { type: 'url'; url: string };
12
+ | { type: 'url'; url: string }
13
+ | { type: 'text'; value: string };
13
14
 
14
15
  export type SideDockItem = {
15
16
  builtIn: boolean;
16
17
  icon: SideDockItemIcon;
17
18
  id: SideDockItemId;
18
- labelKey: string;
19
+ label: string;
19
20
  removable: boolean;
20
21
  target: SideDockItemTarget;
21
22
  };
@@ -24,6 +25,12 @@ export type SideDockPinnedItem = {
24
25
  createdAt: string;
25
26
  icon: SideDockItemIcon;
26
27
  id: SideDockItemId;
27
- labelKey: string;
28
+ label: string;
28
29
  target: SideDockItemTarget;
29
30
  };
31
+
32
+ export type SideDockResourceDockState = {
33
+ canDock: boolean;
34
+ isDocked: boolean;
35
+ removable: boolean;
36
+ };
@@ -10,7 +10,7 @@ const builtInItem: SideDockItem = {
10
10
  builtIn: true,
11
11
  icon: { type: 'builtin', name: 'apps' },
12
12
  id: 'apps',
13
- labelKey: 'appsTitle',
13
+ label: 'Apps',
14
14
  removable: false,
15
15
  target: { type: 'right-panel-resource', uri: 'nextclaw://apps' },
16
16
  };
@@ -20,16 +20,16 @@ describe('side-dock-item utils', () => {
20
20
  const items = normalizeSideDockPinnedItems([
21
21
  {
22
22
  createdAt: '2026-06-02T00:00:00.000Z',
23
- icon: { type: 'builtin', name: 'docs' },
23
+ icon: { type: 'text', value: 'D' },
24
24
  id: 'docs-custom',
25
- labelKey: 'docBrowserHelp',
25
+ label: 'Help Docs',
26
26
  target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
27
27
  },
28
28
  {
29
29
  createdAt: '2026-06-02T00:00:00.000Z',
30
30
  icon: { type: 'builtin', name: 'unknown' },
31
31
  id: 'bad-icon',
32
- labelKey: 'bad',
32
+ label: 'Bad',
33
33
  target: { type: 'right-panel-resource', uri: 'nextclaw://bad' },
34
34
  },
35
35
  ]);
@@ -37,9 +37,9 @@ describe('side-dock-item utils', () => {
37
37
  expect(items).toEqual([
38
38
  {
39
39
  createdAt: '2026-06-02T00:00:00.000Z',
40
- icon: { type: 'builtin', name: 'docs' },
40
+ icon: { type: 'text', value: 'D' },
41
41
  id: 'docs-custom',
42
- labelKey: 'docBrowserHelp',
42
+ label: 'Help Docs',
43
43
  target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
44
44
  },
45
45
  ]);
@@ -58,14 +58,14 @@ describe('side-dock-item utils', () => {
58
58
  createdAt: '2026-06-02T00:00:00.000Z',
59
59
  icon: { type: 'builtin', name: 'docs' },
60
60
  id: 'docs-custom',
61
- labelKey: 'docBrowserHelp',
61
+ label: 'Help Docs',
62
62
  target: { type: 'right-panel-resource', uri: 'nextclaw://docs' },
63
63
  },
64
64
  {
65
65
  createdAt: '2026-06-02T00:00:00.000Z',
66
66
  icon: { type: 'builtin', name: 'apps' },
67
67
  id: 'apps-copy',
68
- labelKey: 'appsTitle',
68
+ label: 'Apps',
69
69
  target: { type: 'right-panel-resource', uri: 'nextclaw://apps' },
70
70
  },
71
71
  ],