@nextclaw/ui 0.13.4 → 0.13.6

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 (142) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/assets/api-BCDb13Uv.js +8 -0
  3. package/dist/assets/app-presenter-provider-C-QMjK7m.js +3 -0
  4. package/dist/assets/arrow-left-rIXFA2Zy.js +1 -0
  5. package/dist/assets/channels-list-page-DnqwsKGe.js +8 -0
  6. package/dist/assets/chat-page-CsQx20xL.js +105 -0
  7. package/dist/assets/config-split-page-BwhGqmUS.js +1 -0
  8. package/dist/assets/confirm-dialog-DBkKcQ1q.js +5 -0
  9. package/dist/assets/desktop-update-config-BE9KjiXi.js +1 -0
  10. package/dist/assets/{dist-CpwDZDtv.js → dist-BI0bX8jv.js} +1 -1
  11. package/dist/assets/dist-C8JRt-tf.js +1 -0
  12. package/dist/assets/doc-browser-context-VHUOIUXL.js +1 -0
  13. package/dist/assets/doc-browser-eqdNEerh.js +1 -0
  14. package/dist/assets/doc-browser-lpF_pyx4.js +1 -0
  15. package/dist/assets/ellipsis-C9VB2CxT.js +1 -0
  16. package/dist/assets/es2015-BCw3Q7nY.js +41 -0
  17. package/dist/assets/external-link-Bp7_4s9t.js +1 -0
  18. package/dist/assets/index-BCq7kbC8.css +1 -0
  19. package/dist/assets/index-DTdPDqto.js +101 -0
  20. package/dist/assets/key-round-Ccy6oND3.js +1 -0
  21. package/dist/assets/loader-circle-OpH4EErP.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-Dvi8mCBd.js → mcp-marketplace-page-B-GxKOqS.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Dm-0-w51.js +1 -0
  24. package/dist/assets/model-config-BXPnV_7n.js +1 -0
  25. package/dist/assets/notice-card-Dsbkl7u7.js +1 -0
  26. package/dist/assets/play-CvgYhNbo.js +1 -0
  27. package/dist/assets/plus-DHcP-K1U.js +1 -0
  28. package/dist/assets/popover-BTLZE5Iz.js +1 -0
  29. package/dist/assets/provider-models-C_yOh6DE.js +1 -0
  30. package/dist/assets/provider-scoped-model-input-DKI9KN7N.js +1 -0
  31. package/dist/assets/providers-list-Bko6B7dE.js +1 -0
  32. package/dist/assets/react-BqS8oMyM.js +8 -0
  33. package/dist/assets/refresh-cw-BIaj7txb.js +1 -0
  34. package/dist/assets/remote-DHn_b8QU.js +1 -0
  35. package/dist/assets/rotate-cw-DMqSkRJQ.js +1 -0
  36. package/dist/assets/runtime-config-page-DX_wSK1J.js +1 -0
  37. package/dist/assets/save-BEoORten.js +1 -0
  38. package/dist/assets/search-COFh_SFI.js +1 -0
  39. package/dist/assets/{search-config-G2O1fcRb.js → search-config-DA5By-tE.js} +1 -1
  40. package/dist/assets/{secrets-config-TenzxYbE.js → secrets-config-eDxG7iN4.js} +2 -2
  41. package/dist/assets/{status-dot-BSMG7ZaC.js → status-dot-oWBjTovo.js} +1 -1
  42. package/dist/assets/{tabs-D1CjpWNX.js → tabs-CGaY0BVO.js} +1 -1
  43. package/dist/assets/{tabs-custom-CRSN1ekg.js → tabs-custom-D2fPYb4G.js} +1 -1
  44. package/dist/assets/{tag-chip-DEashiw6.js → tag-chip-TVkgofS1.js} +1 -1
  45. package/dist/assets/tooltip-BwHPKBv9.js +1 -0
  46. package/dist/assets/trash-2-D3psAGrz.js +1 -0
  47. package/dist/assets/x-CHAxzyp2.js +1 -0
  48. package/dist/index.html +25 -23
  49. package/dist/logos/mimo.svg +9 -0
  50. package/package.json +8 -8
  51. package/public/logos/mimo.svg +9 -0
  52. package/src/app/components/layout/app-layout.tsx +10 -2
  53. package/src/features/agents/components/agents-page.tsx +6 -4
  54. package/src/features/chat/components/chat-welcome.test.tsx +15 -0
  55. package/src/features/chat/components/chat-welcome.tsx +4 -7
  56. package/src/features/chat/hooks/use-ncp-chat-page-data.ts +17 -10
  57. package/src/features/marketplace/components/marketplace-list-card.tsx +22 -1
  58. package/src/features/marketplace/components/marketplace-page.tsx +3 -1
  59. package/src/features/marketplace/hooks/use-marketplace.ts +3 -1
  60. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +8 -0
  61. package/src/features/right-panel-resources/configs/right-panel-resource-routes.config.ts +70 -6
  62. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.test.ts +25 -1
  63. package/src/features/right-panel-resources/utils/right-panel-resource-route-resolver.utils.ts +1 -1
  64. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.test.ts +42 -0
  65. package/src/features/right-panel-resources/utils/right-panel-resource-uri.utils.ts +22 -0
  66. package/src/features/service-apps/components/service-apps-panel.test.tsx +88 -0
  67. package/src/features/service-apps/components/service-apps-panel.tsx +246 -56
  68. package/src/features/service-apps/hooks/use-service-apps.ts +14 -0
  69. package/src/features/side-dock/components/side-dock.test.tsx +58 -3
  70. package/src/features/side-dock/components/side-dock.tsx +77 -24
  71. package/src/features/side-dock/configs/side-dock-built-in-items.config.ts +37 -34
  72. package/src/features/side-dock/index.ts +2 -0
  73. package/src/features/side-dock/managers/side-dock.manager.test.ts +84 -3
  74. package/src/features/side-dock/managers/side-dock.manager.ts +135 -12
  75. package/src/features/side-dock/types/side-dock.types.ts +11 -4
  76. package/src/features/side-dock/utils/side-dock-item.utils.test.ts +8 -8
  77. package/src/features/side-dock/utils/side-dock-item.utils.ts +13 -5
  78. package/src/platforms/desktop/components/desktop-app-shell.tsx +5 -2
  79. package/src/platforms/mobile/components/mobile-app-shell.tsx +4 -1
  80. package/src/shared/components/config/provider-form-support.test.ts +21 -0
  81. package/src/shared/components/config/provider-form-support.ts +38 -78
  82. package/src/shared/components/config/provider-form.tsx +54 -67
  83. package/src/shared/components/config/providers-list.tsx +211 -73
  84. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +73 -0
  85. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  86. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +184 -98
  87. package/src/shared/components/doc-browser/doc-browser.test.tsx +168 -43
  88. package/src/shared/components/doc-browser/doc-browser.tsx +38 -7
  89. package/src/shared/components/doc-browser/index.ts +1 -0
  90. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +30 -6
  91. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +29 -0
  92. package/src/shared/components/doc-browser/types/doc-browser.types.ts +23 -0
  93. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +7 -0
  94. package/src/shared/components/model-config.tsx +5 -4
  95. package/src/shared/components/provider-auth-section.tsx +2 -2
  96. package/src/shared/components/ui/switch.tsx +5 -2
  97. package/src/shared/hooks/use-config.ts +27 -6
  98. package/src/shared/lib/api/marketplace.types.ts +193 -0
  99. package/src/shared/lib/api/types.ts +23 -200
  100. package/src/shared/lib/api/utils/config.utils.ts +17 -7
  101. package/src/shared/lib/i18n/index.ts +10 -12
  102. package/src/shared/lib/i18n/{marketplace.ts → marketplace-labels.utils.ts} +3 -0
  103. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +46 -2
  104. package/src/shared/lib/provider-models/index.test.ts +93 -15
  105. package/src/shared/lib/provider-models/index.ts +35 -20
  106. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  107. package/dist/assets/api-DruAcori.js +0 -15
  108. package/dist/assets/app-presenter-provider-DIcnNB9s.js +0 -3
  109. package/dist/assets/book-open-CRotBOF2.js +0 -1
  110. package/dist/assets/boxes-BvA-I63l.js +0 -1
  111. package/dist/assets/channels-list-page-wK3l7p5R.js +0 -8
  112. package/dist/assets/chat-page-DgehvdJT.js +0 -105
  113. package/dist/assets/config-split-page-BnTO1mAM.js +0 -1
  114. package/dist/assets/confirm-dialog-CGrKHbAW.js +0 -5
  115. package/dist/assets/createLucideIcon-Br1EFMEr.js +0 -1
  116. package/dist/assets/desktop-update-config-CehWxSSg.js +0 -1
  117. package/dist/assets/dist-rzakR_bu.js +0 -41
  118. package/dist/assets/doc-browser-BgxmVsTx.js +0 -1
  119. package/dist/assets/doc-browser-CZn5-Qbu.js +0 -1
  120. package/dist/assets/doc-browser-context-B8gk7HMr.js +0 -1
  121. package/dist/assets/external-link-DHjFdCpy.js +0 -1
  122. package/dist/assets/index-DORmh0Cy.css +0 -1
  123. package/dist/assets/index-DcFpvnge.js +0 -101
  124. package/dist/assets/key-round-D41Oe35x.js +0 -1
  125. package/dist/assets/mcp-marketplace-page-DBLhJWOJ.js +0 -1
  126. package/dist/assets/model-config-B-EJXGtO.js +0 -1
  127. package/dist/assets/play-C-0vo7hi.js +0 -1
  128. package/dist/assets/plus-Cr_akLp2.js +0 -1
  129. package/dist/assets/popover-YieElO8c.js +0 -1
  130. package/dist/assets/provider-scoped-model-input-BPRNGqYn.js +0 -1
  131. package/dist/assets/providers-list-CtBpL1ZY.js +0 -1
  132. package/dist/assets/react-B2X_ph0a.js +0 -1
  133. package/dist/assets/remote-BGHbHoHf.js +0 -1
  134. package/dist/assets/rotate-cw-jueVCNJQ.js +0 -1
  135. package/dist/assets/runtime-config-page-C1Hv7mad.js +0 -1
  136. package/dist/assets/save-aYdSdK97.js +0 -1
  137. package/dist/assets/search-CdhgFNwf.js +0 -1
  138. package/dist/assets/setting-row-BNu0qhOM.js +0 -1
  139. package/dist/assets/trash-2-DSWTsARW.js +0 -1
  140. package/dist/assets/x-D1v_vb54.js +0 -1
  141. package/src/shared/components/provider-enabled-field.tsx +0 -23
  142. /package/dist/assets/{config-hints-A7G_jMTU.js → config-hints-Ceiol9x4.js} +0 -0
@@ -1,19 +1,19 @@
1
1
  import { useMemo, useState } from 'react';
2
- import { KeyRound, Plus, Search } from 'lucide-react';
3
- import { useConfig, useConfigMeta, useConfigSchema, useCreateProvider } from '@/shared/hooks/use-config';
2
+ import { KeyRound, MoreHorizontal, Plus, Search, Trash2 } from 'lucide-react';
3
+ import { useCreateProvider, useDeleteProvider, useProviders, useProviderTemplates, useUpdateProvider } from '@/shared/hooks/use-config';
4
4
  import { LogoBadge } from '@/shared/components/common/logo-badge';
5
5
  import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
6
6
  import { Button } from '@/shared/components/ui/button';
7
7
  import { Input } from '@/shared/components/ui/input';
8
- import { Tabs } from '@/shared/components/ui/tabs-custom';
8
+ import { Switch } from '@/shared/components/ui/switch';
9
+ import { Popover, PopoverContent, PopoverTrigger } from '@/shared/components/ui/popover';
10
+ import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
9
11
  import { StatusDot } from '@/shared/components/ui/status-dot';
10
- import { hintForPath } from '@/shared/lib/config-hints';
11
12
  import { t } from '@/shared/lib/i18n';
12
13
  import { cn } from '@/shared/lib/utils';
13
14
  import { ProviderForm } from '@/shared/components/config/provider-form';
14
15
  import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
15
16
  import {
16
- ConfigSelectionCard,
17
17
  ConfigSplitEmptyState,
18
18
  ConfigSplitPage,
19
19
  ConfigSplitPaneBody,
@@ -33,53 +33,84 @@ function formatBasePreview(base?: string | null) {
33
33
  }
34
34
  }
35
35
 
36
- function sortProvidersForDisplay<T extends { name: string }>(providers: T[]) {
36
+ function sortProvidersForDisplay<T extends { providerId: string; providerType: string | null }>(
37
+ providers: T[],
38
+ pinnedProviderIds: string[] = [],
39
+ ) {
40
+ const pinnedOrder = new Map(pinnedProviderIds.map((providerId, index) => [providerId, index]));
37
41
  return providers
38
42
  .map((provider, index) => ({ provider, index }))
39
43
  .sort((left, right) => {
40
- const leftPriority = left.provider.name === 'nextclaw' ? 1 : 0;
41
- const rightPriority = right.provider.name === 'nextclaw' ? 1 : 0;
44
+ const leftPinned = pinnedOrder.get(left.provider.providerId);
45
+ const rightPinned = pinnedOrder.get(right.provider.providerId);
46
+ if (leftPinned !== undefined || rightPinned !== undefined) {
47
+ return (leftPinned ?? Number.MAX_SAFE_INTEGER) - (rightPinned ?? Number.MAX_SAFE_INTEGER);
48
+ }
49
+ const leftPriority = left.provider.providerType === 'nextclaw' ? 1 : 0;
50
+ const rightPriority = right.provider.providerType === 'nextclaw' ? 1 : 0;
42
51
  return leftPriority !== rightPriority ? leftPriority - rightPriority : left.index - right.index;
43
52
  })
44
53
  .map(({ provider }) => provider);
45
54
  }
46
55
 
56
+ function sortTemplatesForPicker<T extends { providerType: string }>(templates: T[]) {
57
+ const zhipuIndex = templates.findIndex((template) => template.providerType === 'zhipu');
58
+ const dashScopeIndex = templates.findIndex((template) => template.providerType === 'dashscope-coding-plan');
59
+ return templates
60
+ .map((template, index) => ({ template, index }))
61
+ .sort((left, right) => {
62
+ const resolveWeight = ({ template, index }: { template: T; index: number }) => {
63
+ if (template.providerType === 'nextclaw') return Number.MAX_SAFE_INTEGER;
64
+ if (template.providerType === 'deepseek' && dashScopeIndex >= 0) return dashScopeIndex - 0.5;
65
+ if (template.providerType === 'mimo' && zhipuIndex >= 0) return zhipuIndex + 0.5;
66
+ return index;
67
+ };
68
+ return resolveWeight(left) - resolveWeight(right);
69
+ })
70
+ .map(({ template }) => template);
71
+ }
72
+
47
73
  export function ProvidersList() {
48
74
  const { isMobile } = useViewportLayout();
49
- const { data: config } = useConfig();
50
- const { data: meta } = useConfigMeta();
51
- const { data: schema } = useConfigSchema();
75
+ const { data: providersView } = useProviders();
76
+ const { data: templatesView } = useProviderTemplates();
52
77
  const createProvider = useCreateProvider();
53
- const [activeTab, setActiveTab] = useState('installed');
78
+ const deleteProvider = useDeleteProvider();
79
+ const updateProvider = useUpdateProvider();
54
80
  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
81
+ const [providerToDelete, setProviderToDelete] = useState<string | null>(null);
82
+ const [providerPickerOpen, setProviderPickerOpen] = useState(false);
83
+ const [recentProviderIds, setRecentProviderIds] = useState<string[]>([]);
55
84
  const [query, setQuery] = useState('');
56
- const providers = useMemo(() => sortProvidersForDisplay(meta?.providers ?? []), [meta?.providers]);
57
- const providersConfig = config?.providers ?? {};
85
+ const providers = useMemo(
86
+ () => sortProvidersForDisplay(Object.values(providersView?.providers ?? {}), recentProviderIds),
87
+ [providersView?.providers, recentProviderIds],
88
+ );
89
+ const templates = useMemo(() => templatesView?.providerTemplates ?? [], [templatesView?.providerTemplates]);
90
+ const pickerTemplates = useMemo(() => sortTemplatesForPicker(templates), [templates]);
91
+ const templateByType = useMemo(() => new Map(templates.map((template) => [template.providerType, template])), [templates]);
58
92
 
59
93
  const filteredProviders = useMemo(() => {
60
94
  const keyword = query.trim().toLowerCase();
61
95
  return providers
62
- .filter((provider) =>
63
- activeTab !== 'installed' || Boolean(providersConfig[provider.name]?.enabled !== false && providersConfig[provider.name]?.apiKeySet),
64
- )
65
96
  .filter((provider) => {
66
97
  if (!keyword) {
67
98
  return true;
68
99
  }
69
- const display =
70
- providersConfig[provider.name]?.displayName?.trim() || provider.displayName || provider.name;
71
- return display.toLowerCase().includes(keyword) || provider.name.toLowerCase().includes(keyword);
100
+ const template = provider.providerType ? templateByType.get(provider.providerType) : null;
101
+ const display = provider.displayName?.trim() || template?.displayName || provider.providerId;
102
+ return display.toLowerCase().includes(keyword) || provider.providerId.toLowerCase().includes(keyword);
72
103
  });
73
- }, [activeTab, providers, providersConfig, query]);
104
+ }, [providers, query, templateByType]);
74
105
 
75
106
  const resolvedSelectedProvider = useMemo(() => {
76
- if (selectedProvider && filteredProviders.some((provider) => provider.name === selectedProvider)) {
107
+ if (selectedProvider && filteredProviders.some((provider) => provider.providerId === selectedProvider)) {
77
108
  return selectedProvider;
78
109
  }
79
- return isMobile ? null : filteredProviders[0]?.name ?? null;
110
+ return isMobile ? null : filteredProviders[0]?.providerId ?? null;
80
111
  }, [filteredProviders, isMobile, selectedProvider]);
81
112
 
82
- if (!config || !meta) {
113
+ if (!providersView || !templatesView) {
83
114
  return <div className="p-8">{t('providersLoading')}</div>;
84
115
  }
85
116
 
@@ -94,38 +125,67 @@ export function ProvidersList() {
94
125
  >
95
126
  <ConfigSplitSidebar>
96
127
  <ConfigSplitPaneHeader className="space-y-3 px-4 pb-3 pt-4">
97
- <Tabs
98
- tabs={[
99
- {
100
- id: 'installed',
101
- label: t('providersTabConfigured'),
102
- count: providers.filter((provider) => providersConfig[provider.name]?.enabled !== false && providersConfig[provider.name]?.apiKeySet).length,
103
- },
104
- { id: 'all', label: t('providersTabAll'), count: providers.length },
105
- ]}
106
- activeTab={activeTab}
107
- onChange={setActiveTab}
108
- className="mb-0"
109
- />
110
- <Button
111
- type="button"
112
- variant="outline"
113
- className="w-full justify-center"
114
- onClick={async () => {
115
- try {
116
- const result = await createProvider.mutateAsync({ data: {} });
117
- setActiveTab('all');
118
- setQuery('');
119
- setSelectedProvider(result.name);
120
- } catch {
121
- // toast handled in hook
122
- }
123
- }}
124
- disabled={createProvider.isPending}
125
- >
126
- <Plus className="mr-2 h-4 w-4" />
127
- {createProvider.isPending ? t('saving') : t('providerAddCustom')}
128
- </Button>
128
+ <Popover open={providerPickerOpen} onOpenChange={setProviderPickerOpen}>
129
+ <PopoverTrigger asChild>
130
+ <Button type="button" variant="outline" className="w-full justify-center" disabled={createProvider.isPending}>
131
+ <Plus className="mr-2 h-4 w-4" />
132
+ {createProvider.isPending ? t('saving') : t('providerAdd')}
133
+ </Button>
134
+ </PopoverTrigger>
135
+ <PopoverContent className="w-[42rem] max-w-[calc(100vw-2rem)] p-3" align="start">
136
+ <div className="mb-2 px-1 text-xs font-semibold text-gray-500">{t('providerTemplatePickerTitle')}</div>
137
+ <div className="grid max-h-[24rem] grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3">
138
+ <button
139
+ type="button"
140
+ className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-dashed border-gray-300 bg-white px-3 py-2 text-left hover:border-primary/40 hover:bg-gray-50"
141
+ onClick={async () => {
142
+ try {
143
+ const result = await createProvider.mutateAsync({ data: { providerType: null } });
144
+ setQuery('');
145
+ setSelectedProvider(result.providerId);
146
+ setRecentProviderIds((current) => [result.providerId, ...current.filter((providerId) => providerId !== result.providerId)]);
147
+ setProviderPickerOpen(false);
148
+ } catch {
149
+ // toast handled in hook
150
+ }
151
+ }}
152
+ >
153
+ <LogoBadge name="custom" className="h-8 w-8 rounded-lg border border-gray-200 bg-white" />
154
+ <span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-gray-800">
155
+ {t('providerAddCustom')}
156
+ </span>
157
+ </button>
158
+ {pickerTemplates.map((template) => (
159
+ <button
160
+ key={template.providerType}
161
+ type="button"
162
+ className="flex min-h-20 w-full flex-col items-start gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-left hover:border-primary/40 hover:bg-gray-50"
163
+ onClick={async () => {
164
+ try {
165
+ const result = await createProvider.mutateAsync({ data: { providerType: template.providerType } });
166
+ setQuery('');
167
+ setSelectedProvider(result.providerId);
168
+ setRecentProviderIds((current) => [result.providerId, ...current.filter((providerId) => providerId !== result.providerId)]);
169
+ setProviderPickerOpen(false);
170
+ } catch {
171
+ // toast handled in hook
172
+ }
173
+ }}
174
+ >
175
+ <LogoBadge
176
+ name={template.providerType}
177
+ src={template.logo ? `/logos/${template.logo}` : null}
178
+ className="h-8 w-8 rounded-lg border border-gray-200 bg-white"
179
+ imgClassName="h-4 w-4 object-contain"
180
+ />
181
+ <span className="line-clamp-2 min-w-0 text-xs font-semibold leading-4 text-gray-800">
182
+ {template.displayName || template.providerType}
183
+ </span>
184
+ </button>
185
+ ))}
186
+ </div>
187
+ </PopoverContent>
188
+ </Popover>
129
189
  </ConfigSplitPaneHeader>
130
190
 
131
191
  <div className="border-b border-gray-100 px-4 py-3">
@@ -142,42 +202,98 @@ export function ProvidersList() {
142
202
 
143
203
  <ConfigSplitPaneBody className="space-y-2 p-3">
144
204
  {filteredProviders.map((provider) => {
145
- const providerConfig = providersConfig[provider.name];
146
- const isEnabled = providerConfig?.enabled !== false;
147
- const isReady = Boolean(isEnabled && providerConfig?.apiKeySet);
148
- const providerLabel = providerConfig?.displayName?.trim() || provider.displayName || provider.name;
149
- const description = formatBasePreview(providerConfig?.apiBase || provider.defaultApiBase || '') || hintForPath(`providers.${provider.name}`, schema?.uiHints)?.help || t('providersDefaultDescription');
205
+ const template = provider.providerType ? templateByType.get(provider.providerType) : null;
206
+ const isEnabled = provider.enabled !== false;
207
+ const isReady = Boolean(isEnabled && provider.apiKeySet);
208
+ const providerLabel = provider.displayName?.trim() || template?.displayName || provider.providerId;
209
+ const description = formatBasePreview(provider.apiBase || template?.defaultApiBase || '') || t('providersDefaultDescription');
150
210
 
151
211
  return (
152
- <ConfigSelectionCard
153
- key={provider.name}
154
- onClick={() => setSelectedProvider(provider.name)}
155
- active={resolvedSelectedProvider === provider.name}
212
+ <div
213
+ key={provider.providerId}
214
+ onClick={() => setSelectedProvider(provider.providerId)}
215
+ onKeyDown={(event) => {
216
+ if (event.key === 'Enter' || event.key === ' ') {
217
+ event.preventDefault();
218
+ setSelectedProvider(provider.providerId);
219
+ }
220
+ }}
221
+ role="button"
222
+ tabIndex={0}
223
+ className={cn(
224
+ 'w-full rounded-xl border p-2.5 text-left transition-all',
225
+ resolvedSelectedProvider === provider.providerId
226
+ ? 'border-primary/30 bg-primary-50/40 shadow-sm'
227
+ : 'border-gray-200/70 bg-white hover:border-gray-300 hover:bg-gray-50/70',
228
+ )}
156
229
  >
157
- <div className="flex items-start justify-between gap-3">
158
- <div className="flex min-w-0 items-center gap-3">
230
+ <div className="relative min-h-10">
231
+ <div className="flex min-w-0 items-center gap-3 pr-20">
159
232
  <LogoBadge
160
- name={provider.name}
161
- src={provider.logo ? `/logos/${provider.logo}` : null}
233
+ name={provider.providerType ?? provider.providerId}
234
+ src={template?.logo ? `/logos/${template.logo}` : null}
162
235
  className={cn(
163
236
  'h-10 w-10 rounded-lg border',
164
237
  isReady ? 'border-primary/30 bg-white' : 'border-gray-200/70 bg-white',
165
238
  )}
166
239
  imgClassName="h-5 w-5 object-contain"
167
- fallback={<span className="text-sm font-semibold uppercase text-gray-500">{provider.name[0]}</span>}
240
+ fallback={<span className="text-sm font-semibold uppercase text-gray-500">{provider.providerId[0]}</span>}
168
241
  />
169
242
  <div className="min-w-0">
170
243
  <p className="truncate text-sm font-semibold text-gray-900">{providerLabel}</p>
171
- <p className="line-clamp-1 text-[11px] text-gray-500">{description}</p>
244
+ <p className="truncate font-mono text-[10px] text-gray-400">{provider.providerId}</p>
245
+ <p className="line-clamp-1 text-[11px] text-gray-500">
246
+ {description} · {provider.models?.length ?? 0}
247
+ </p>
248
+ </div>
249
+ </div>
250
+ <div className="absolute right-0 top-0 flex items-center gap-1.5">
251
+ <div onClick={(event) => event.stopPropagation()}>
252
+ <Switch
253
+ checked={isEnabled}
254
+ disabled={updateProvider.isPending}
255
+ title={isEnabled ? t('disabled') : t('enabled')}
256
+ className="h-[18px] w-8"
257
+ thumbClassName="h-4 w-4 data-[state=checked]:translate-x-4"
258
+ onCheckedChange={(enabled) => {
259
+ void updateProvider.mutateAsync({
260
+ provider: provider.providerId,
261
+ data: { enabled },
262
+ silentSuccess: true,
263
+ });
264
+ }}
265
+ />
172
266
  </div>
267
+ <Popover>
268
+ <PopoverTrigger asChild>
269
+ <button
270
+ type="button"
271
+ className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-700"
272
+ onClick={(event) => event.stopPropagation()}
273
+ title={t('more')}
274
+ >
275
+ <MoreHorizontal className="h-4 w-4" />
276
+ </button>
277
+ </PopoverTrigger>
278
+ <PopoverContent className="w-40 p-1" align="end" onClick={(event) => event.stopPropagation()}>
279
+ <button
280
+ type="button"
281
+ className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-sm text-red-600 hover:bg-red-50"
282
+ onClick={() => setProviderToDelete(provider.providerId)}
283
+ >
284
+ <Trash2 className="h-4 w-4" />
285
+ {t('providerDelete')}
286
+ </button>
287
+ </PopoverContent>
288
+ </Popover>
173
289
  </div>
174
290
  <StatusDot
175
291
  status={isEnabled ? (isReady ? 'ready' : 'setup') : 'inactive'}
176
292
  label={isEnabled ? (isReady ? t('statusReady') : t('statusSetup')) : t('disabled')}
177
- className="min-w-[56px] justify-center"
293
+ className="absolute bottom-0 right-0 justify-end"
178
294
  />
179
295
  </div>
180
- </ConfigSelectionCard>
296
+ </div>
181
297
  );
182
298
  })}
183
299
 
@@ -190,12 +306,34 @@ export function ProvidersList() {
190
306
  <ProviderForm
191
307
  providerName={resolvedSelectedProvider ?? undefined}
192
308
  onProviderDeleted={(deletedProvider) => {
309
+ setRecentProviderIds((current) => current.filter((providerId) => providerId !== deletedProvider));
193
310
  if (deletedProvider === resolvedSelectedProvider) {
194
311
  setSelectedProvider(null);
195
312
  }
196
313
  }}
197
314
  />
198
315
  </ConfigSplitPage>
316
+ <ConfirmDialog
317
+ open={Boolean(providerToDelete)}
318
+ onOpenChange={(open) => {
319
+ if (!open) setProviderToDelete(null);
320
+ }}
321
+ title={t('providerDelete')}
322
+ description={providerToDelete ?? undefined}
323
+ confirmLabel={t('delete')}
324
+ variant="destructive"
325
+ onCancel={() => setProviderToDelete(null)}
326
+ onConfirm={() => {
327
+ if (!providerToDelete) return;
328
+ void deleteProvider.mutateAsync({ provider: providerToDelete }).then(() => {
329
+ setRecentProviderIds((current) => current.filter((providerId) => providerId !== providerToDelete));
330
+ if (providerToDelete === resolvedSelectedProvider) {
331
+ setSelectedProvider(null);
332
+ }
333
+ setProviderToDelete(null);
334
+ });
335
+ }}
336
+ />
199
337
  </PageLayout>
200
338
  );
201
339
  }
@@ -164,7 +164,9 @@ describe('DocBrowserProvider dedupe keys', () => {
164
164
  kind: 'apps',
165
165
  });
166
166
  });
167
+ });
167
168
 
169
+ describe('DocBrowserProvider built-in routes', () => {
168
170
  it('opens the built-in start page without falling back to docs', () => {
169
171
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
170
172
 
@@ -211,15 +213,19 @@ describe('DocBrowserProvider dedupe keys', () => {
211
213
  title: 'Service Apps',
212
214
  });
213
215
  });
216
+ });
214
217
 
218
+ describe('DocBrowserProvider panel app resources', () => {
215
219
  it('opens an already resolved panel app target without reparsing its content URL', () => {
216
220
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
217
221
 
218
222
  act(() => {
219
223
  result.current.openTarget({
220
224
  dedupeKey: 'panel-app:demo',
225
+ dockIcon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
221
226
  historyPolicy: 'managed',
222
227
  kind: 'panel-app',
228
+ resourceUri: 'nextclaw://panel-app/demo',
223
229
  title: 'Demo Panel App',
224
230
  url: '/api/panel-apps/demo/content',
225
231
  });
@@ -228,11 +234,15 @@ describe('DocBrowserProvider dedupe keys', () => {
228
234
  expect(result.current.currentTab).toMatchObject({
229
235
  currentUrl: '/api/panel-apps/demo/content',
230
236
  dedupeKey: 'panel-app:demo',
237
+ dockIcon: { type: 'url', url: '/api/panel-apps/demo/assets/icon.png' },
231
238
  kind: 'panel-app',
239
+ resourceUri: 'nextclaw://panel-app/demo',
232
240
  title: 'Demo Panel App',
233
241
  });
234
242
  });
243
+ });
235
244
 
245
+ describe('DocBrowserProvider managed history', () => {
236
246
  it('uses managed history for non-doc routes', () => {
237
247
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
238
248
 
@@ -335,6 +345,44 @@ describe('DocBrowserProvider dedupe keys', () => {
335
345
  });
336
346
  });
337
347
 
348
+ it('restores panel app history by stable resource URI instead of reparsing content URL as an app id', () => {
349
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
350
+
351
+ act(() => {
352
+ result.current.openTarget({
353
+ dedupeKey: 'panel-app:demo',
354
+ historyPolicy: 'managed',
355
+ kind: 'panel-app',
356
+ resourceUri: 'nextclaw://panel-app/demo',
357
+ title: 'Demo Panel App',
358
+ url: '/api/panel-apps/demo/content',
359
+ });
360
+ result.current.openNewTab();
361
+ });
362
+
363
+ expect(result.current.currentTab).toMatchObject({
364
+ currentUrl: 'nextclaw://new-tab',
365
+ kind: 'home',
366
+ });
367
+
368
+ act(() => {
369
+ result.current.goBack();
370
+ });
371
+
372
+ expect(result.current.currentTab).toMatchObject({
373
+ currentUrl: '/api/panel-apps/demo/content',
374
+ dedupeKey: 'panel-app:demo',
375
+ kind: 'panel-app',
376
+ resourceUri: 'nextclaw://panel-app/demo',
377
+ title: 'Demo Panel App',
378
+ });
379
+ expect(result.current.currentTab?.currentUrl).not.toBe('/api/panel-apps/api/content');
380
+ expect(result.current.activeHistory[result.current.activeHistoryIndex]).toMatchObject({
381
+ resourceUri: 'nextclaw://panel-app/demo',
382
+ url: '/api/panel-apps/demo/content',
383
+ });
384
+ });
385
+
338
386
  it('truncates forward history before appending manual navigation after going back', () => {
339
387
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
340
388
 
@@ -377,6 +425,31 @@ describe('DocBrowserProvider dedupe keys', () => {
377
425
  });
378
426
 
379
427
  describe('DocBrowserProvider persistence', () => {
428
+ it('persists dock icon metadata for restored tabs', async () => {
429
+ const { result } = renderHook(() => useDocBrowser(), { wrapper });
430
+
431
+ act(() => {
432
+ result.current.openTarget({
433
+ dedupeKey: 'panel-app:demo',
434
+ dockIcon: { type: 'text', value: 'D' },
435
+ historyPolicy: 'managed',
436
+ kind: 'panel-app',
437
+ resourceUri: 'nextclaw://panel-app/demo',
438
+ title: 'Demo App',
439
+ url: '/api/panel-apps/demo/content',
440
+ });
441
+ });
442
+
443
+ const savedState = window.localStorage.getItem(docBrowserStorageKey);
444
+ await resetMemoryThenRehydrate(savedState);
445
+ const restored = renderHook(() => useDocBrowser(), { wrapper });
446
+
447
+ expect(restored.result.current.currentTab).toMatchObject({
448
+ dockIcon: { type: 'text', value: 'D' },
449
+ resourceUri: 'nextclaw://panel-app/demo',
450
+ });
451
+ });
452
+
380
453
  it('restores the open panel and active tab from the persisted Zustand store', async () => {
381
454
  const { result } = renderHook(() => useDocBrowser(), { wrapper });
382
455
 
@@ -6,6 +6,9 @@ import type { DocBrowserContextValue } from '@/shared/components/doc-browser/typ
6
6
  export type {
7
7
  DocBrowserActions,
8
8
  DocBrowserContextValue,
9
+ DocBrowserDockControls,
10
+ DocBrowserDockIcon,
11
+ DocBrowserDockState,
9
12
  DocBrowserMode,
10
13
  DocBrowserOpenOptions,
11
14
  DocBrowserRouteResolver,