@nextclaw/ui 0.13.12 → 0.13.13

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 (147) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/assets/{api-DmxQnm30.js → api-D0hARyyl.js} +1 -1
  3. package/dist/assets/app-presenter-provider-C7EsUuEJ.js +3 -0
  4. package/dist/assets/arrow-left-CzSkR5L6.js +1 -0
  5. package/dist/assets/book-open-DmeQPqkd.js +1 -0
  6. package/dist/assets/{channels-list-page-D-WYFmcG.js → channels-list-page-B0yiiH01.js} +2 -2
  7. package/dist/assets/chat-page-ChPjx_X9.js +105 -0
  8. package/dist/assets/{config-split-page-CdJEkWxv.js → config-split-page-BzfZSyAr.js} +1 -1
  9. package/dist/assets/{confirm-dialog-BWRNsRgb.js → confirm-dialog-CTcaBp-8.js} +1 -1
  10. package/dist/assets/desktop-update-config-CExpdkqD.js +1 -0
  11. package/dist/assets/{dist-CyWdaJvr.js → dist-C59BKqbX.js} +1 -1
  12. package/dist/assets/{dist-DzM2ZEVN.js → dist-Di6EHp2x.js} +1 -1
  13. package/dist/assets/{doc-browser-DewXa6oX.js → doc-browser-EZbnEcdk.js} +1 -1
  14. package/dist/assets/doc-browser-XIwe1Tg-.js +1 -0
  15. package/dist/assets/{doc-browser-context-CpL2m4Sp.js → doc-browser-context-BzEA1lm1.js} +1 -1
  16. package/dist/assets/{ellipsis-BDgTYNDX.js → ellipsis-hXUNwi63.js} +1 -1
  17. package/dist/assets/{es2015-WqekV0OK.js → es2015-CJlVSTh-.js} +1 -1
  18. package/dist/assets/{external-link-BLjo8z2a.js → external-link-J0fCK-vu.js} +1 -1
  19. package/dist/assets/host-capabilities-DjiRQ0Q9.js +1 -0
  20. package/dist/assets/index-DfZTO6Pi.js +101 -0
  21. package/dist/assets/index-mIb_YMrc.css +1 -0
  22. package/dist/assets/{key-round-Du8x3sWa.js → key-round-BzYZ0Nno.js} +1 -1
  23. package/dist/assets/loader-circle-BbdIlBbQ.js +1 -0
  24. package/dist/assets/mcp-marketplace-page-L22aCzHX.js +40 -0
  25. package/dist/assets/mcp-marketplace-page-f80Jmv37.js +1 -0
  26. package/dist/assets/model-config-DkD7vSQT.js +1 -0
  27. package/dist/assets/{notice-card-3a7FmAz-.js → notice-card-CD9Ok62G.js} +1 -1
  28. package/dist/assets/play-DHuSeKs8.js +1 -0
  29. package/dist/assets/plus-DrV685KC.js +1 -0
  30. package/dist/assets/{popover-BlJLguiZ.js → popover-CoBu3mbV.js} +1 -1
  31. package/dist/assets/{provider-scoped-model-input-IMD4W0jD.js → provider-scoped-model-input-DZlmTCJU.js} +1 -1
  32. package/dist/assets/providers-list-CVkWiqIy.js +1 -0
  33. package/dist/assets/{react-BVjl1ZaK.js → react-C7HsxuKH.js} +1 -1
  34. package/dist/assets/{refresh-cw-DFR87zei.js → refresh-cw-CVo4nWpq.js} +1 -1
  35. package/dist/assets/remote-Bznw9iji.js +1 -0
  36. package/dist/assets/rotate-cw-CXDpRO6D.js +1 -0
  37. package/dist/assets/{runtime-config-page-DzbTVebD.js → runtime-config-page-5IycbIxs.js} +1 -1
  38. package/dist/assets/{save-BDmIlHvN.js → save-Z1QTovIt.js} +1 -1
  39. package/dist/assets/search-8nY6PGm0.js +1 -0
  40. package/dist/assets/{search-config-CJ6OPxAf.js → search-config-DEH80WkC.js} +1 -1
  41. package/dist/assets/{secrets-config-CYOHWL7P.js → secrets-config-Gzo5nDJG.js} +1 -1
  42. package/dist/assets/{status-dot-SVPjIyG5.js → status-dot-C1-FKgvv.js} +1 -1
  43. package/dist/assets/{tabs-Czd2mj6z.js → tabs-BqkKwx1R.js} +1 -1
  44. package/dist/assets/{tabs-custom-Ix4nkp2h.js → tabs-custom-DOkaJZmt.js} +1 -1
  45. package/dist/assets/{tag-chip-Cdwb3JSH.js → tag-chip-3kpiMTST.js} +1 -1
  46. package/dist/assets/{tooltip-CKGxLGnG.js → tooltip-Bw-mXQAM.js} +1 -1
  47. package/dist/assets/{trash-2-yLA8BEcE.js → trash-2-CmIwAJIT.js} +1 -1
  48. package/dist/assets/x-CSRTOZnM.js +1 -0
  49. package/dist/index.html +27 -25
  50. package/package.json +8 -8
  51. package/src/features/account/components/account-panel.tsx +2 -2
  52. package/src/features/account/managers/account.manager.ts +6 -5
  53. package/src/features/chat/components/chat-session-workspace-panel-nav.tsx +45 -13
  54. package/src/features/chat/components/chat-session-workspace-panel.tsx +17 -8
  55. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +69 -0
  56. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +3 -0
  57. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  58. package/src/features/chat/managers/chat-session-list.manager.test.ts +4 -0
  59. package/src/features/chat/managers/chat-session-list.manager.ts +2 -0
  60. package/src/features/chat/managers/ncp-chat-thread.manager.test.ts +153 -25
  61. package/src/features/chat/managers/ncp-chat-thread.manager.ts +153 -7
  62. package/src/features/chat/stores/chat-thread.store.test.ts +148 -0
  63. package/src/features/chat/stores/chat-thread.store.ts +287 -9
  64. package/src/features/marketplace/components/marketplace-catalog-grid.tsx +19 -1
  65. package/src/features/marketplace/components/marketplace-external-skill-source-action.tsx +14 -0
  66. package/src/features/marketplace/components/marketplace-page-parts.tsx +10 -2
  67. package/src/features/marketplace/components/marketplace-page.test.tsx +37 -1
  68. package/src/features/marketplace/components/marketplace-page.tsx +38 -16
  69. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +1 -0
  70. package/src/features/marketplace/hooks/use-marketplace.test.tsx +109 -0
  71. package/src/features/marketplace/hooks/use-marketplace.ts +3 -5
  72. package/src/features/side-dock/components/side-dock.test.tsx +25 -0
  73. package/src/features/side-dock/components/side-dock.tsx +13 -0
  74. package/src/features/system-status/components/desktop-update-config.tsx +2 -1
  75. package/src/platforms/desktop/types/desktop-update.types.ts +9 -0
  76. package/src/shared/components/config/provider-form.tsx +2 -2
  77. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +39 -32
  78. package/src/shared/components/ui/action-link.tsx +23 -1
  79. package/src/shared/lib/host-capabilities/host-capability.manager.test.ts +50 -0
  80. package/src/shared/lib/host-capabilities/host-capability.manager.ts +62 -0
  81. package/src/shared/lib/host-capabilities/index.ts +1 -0
  82. package/src/shared/lib/i18n/README.md +16 -3
  83. package/src/shared/lib/i18n/index.ts +93 -426
  84. package/src/shared/lib/i18n/locales/en-US/agents.json +49 -0
  85. package/src/shared/lib/i18n/locales/en-US/channel-auth.json +51 -0
  86. package/src/shared/lib/i18n/locales/en-US/channels.json +50 -0
  87. package/src/shared/lib/i18n/locales/en-US/chat.json +181 -0
  88. package/src/shared/lib/i18n/locales/en-US/core.json +310 -0
  89. package/src/shared/lib/i18n/locales/en-US/cron.json +32 -0
  90. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +90 -0
  91. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +87 -0
  92. package/src/shared/lib/i18n/locales/en-US/marketplace.json +72 -0
  93. package/src/shared/lib/i18n/locales/en-US/path-picker.json +12 -0
  94. package/src/shared/lib/i18n/locales/en-US/pwa.json +26 -0
  95. package/src/shared/lib/i18n/locales/en-US/remote.json +168 -0
  96. package/src/shared/lib/i18n/locales/en-US/runtime-control.json +105 -0
  97. package/src/shared/lib/i18n/locales/en-US/search.json +28 -0
  98. package/src/shared/lib/i18n/locales/zh-CN/agents.json +49 -0
  99. package/src/shared/lib/i18n/locales/zh-CN/channel-auth.json +51 -0
  100. package/src/shared/lib/i18n/locales/zh-CN/channels.json +50 -0
  101. package/src/shared/lib/i18n/locales/zh-CN/chat.json +181 -0
  102. package/src/shared/lib/i18n/locales/zh-CN/core.json +310 -0
  103. package/src/shared/lib/i18n/locales/zh-CN/cron.json +32 -0
  104. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +90 -0
  105. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +87 -0
  106. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +72 -0
  107. package/src/shared/lib/i18n/locales/zh-CN/path-picker.json +12 -0
  108. package/src/shared/lib/i18n/locales/zh-CN/pwa.json +26 -0
  109. package/src/shared/lib/i18n/locales/zh-CN/remote.json +168 -0
  110. package/src/shared/lib/i18n/locales/zh-CN/runtime-control.json +105 -0
  111. package/src/shared/lib/i18n/locales/zh-CN/search.json +28 -0
  112. package/src/shared/lib/navigation-history/index.ts +1 -0
  113. package/src/shared/lib/navigation-history/navigation-history.utils.test.ts +60 -0
  114. package/src/shared/lib/navigation-history/navigation-history.utils.ts +105 -0
  115. package/dist/assets/app-presenter-provider-CDSjiqfE.js +0 -3
  116. package/dist/assets/arrow-left-Dd45Mvr1.js +0 -1
  117. package/dist/assets/chat-page-D1X8YNTC.js +0 -105
  118. package/dist/assets/desktop-update-config-DOi5R1fX.js +0 -1
  119. package/dist/assets/doc-browser-D5FJcAyz.js +0 -1
  120. package/dist/assets/index-Q891JiF_.css +0 -1
  121. package/dist/assets/index-ZDJP_uJc.js +0 -101
  122. package/dist/assets/loader-circle-Dz4FssYC.js +0 -1
  123. package/dist/assets/mcp-marketplace-page-LmMhKH5a.js +0 -1
  124. package/dist/assets/mcp-marketplace-page-RqISBSqO.js +0 -40
  125. package/dist/assets/model-config-JUsW1Khb.js +0 -1
  126. package/dist/assets/play-BziM-Y34.js +0 -1
  127. package/dist/assets/plus-DrpyArC8.js +0 -1
  128. package/dist/assets/providers-list-BsDpxNzy.js +0 -1
  129. package/dist/assets/remote-DjhVMpVd.js +0 -1
  130. package/dist/assets/rotate-cw-DMBnsYKm.js +0 -1
  131. package/dist/assets/search-CjHGYvTI.js +0 -1
  132. package/dist/assets/x-dpDISboO.js +0 -1
  133. package/src/shared/lib/i18n/agents.ts +0 -85
  134. package/src/shared/lib/i18n/channel-auth.constants.ts +0 -87
  135. package/src/shared/lib/i18n/channels.ts +0 -53
  136. package/src/shared/lib/i18n/chat-labels.utils.ts +0 -206
  137. package/src/shared/lib/i18n/cron-labels.utils.ts +0 -32
  138. package/src/shared/lib/i18n/desktop-update-labels.utils.ts +0 -165
  139. package/src/shared/lib/i18n/marketplace-labels.utils.ts +0 -78
  140. package/src/shared/lib/i18n/pwa.ts +0 -62
  141. package/src/shared/lib/i18n/remote.ts +0 -273
  142. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +0 -120
  143. package/src/shared/lib/i18n/runtime/i18n.path-picker.ts +0 -12
  144. package/src/shared/lib/i18n/runtime-control-labels.utils.ts +0 -180
  145. package/src/shared/lib/i18n/search.ts +0 -37
  146. /package/dist/assets/{config-hints-Ceiol9x4.js → config-hints-fGnUjDe9.js} +0 -0
  147. /package/dist/assets/{provider-models-C_yOh6DE.js → provider-models-C9VRroK3.js} +0 -0
@@ -42,6 +42,7 @@ import {
42
42
  MarketplaceCuratedSceneView,
43
43
  MarketplaceCuratedShelves,
44
44
  } from "@/features/marketplace/components/curated-shelves/marketplace-curated-shelves";
45
+ import { MarketplaceExternalSkillSourceAction } from "@/features/marketplace/components/marketplace-external-skill-source-action";
45
46
  import { useMarketplaceCuratedSceneRoute } from "@/features/marketplace/hooks/use-marketplace-curated-scene-route";
46
47
  import { t } from "@/shared/lib/i18n";
47
48
  import { PageLayout } from "@/app/components/layout/page-layout";
@@ -73,6 +74,8 @@ function getMarketplaceCopyKeys() {
73
74
  emptyData: "marketplaceNoSkills",
74
75
  emptyInstalled: "marketplaceNoInstalledSkills",
75
76
  installedCountSuffix: "marketplaceInstalledSkillsCountSuffix",
77
+ allSkills: "marketplaceAllSkills",
78
+ refreshingResults: "marketplaceRefreshingResults",
76
79
  };
77
80
  }
78
81
 
@@ -201,6 +204,12 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
201
204
  };
202
205
  const showListSkeleton =
203
206
  skeletonState.showCatalog || skeletonState.showInstalled;
207
+ const isRefreshingList =
208
+ scope === "all" &&
209
+ !showListSkeleton &&
210
+ Boolean(itemsQuery.data) &&
211
+ itemsQuery.isFetching &&
212
+ !itemsQuery.isFetchingNextPage;
204
213
 
205
214
  const listSummary = useMemo(() => {
206
215
  if (scope === "installed") {
@@ -424,23 +433,27 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
424
433
  <PageLayout className="flex h-full min-h-0 flex-col pb-0">
425
434
  {!curatedSceneRoute.isSceneRoute && (
426
435
  <>
427
- <Tabs
428
- tabs={[
429
- { id: "all", label: t(copyKeys.tabMarketplace) },
430
- {
431
- id: "installed",
432
- label: t(copyKeys.tabInstalled),
433
- count: installedQuery.data?.total ?? 0,
434
- },
435
- ]}
436
- activeTab={scope}
437
- onChange={(value) => setScope(value as ScopeType)}
438
- className="mb-3"
439
- />
436
+ <div className="mb-3 flex items-start justify-between gap-3 border-b border-gray-200/60">
437
+ <Tabs
438
+ tabs={[
439
+ { id: "all", label: t(copyKeys.tabMarketplace) },
440
+ {
441
+ id: "installed",
442
+ label: t(copyKeys.tabInstalled),
443
+ count: installedQuery.data?.total ?? 0,
444
+ },
445
+ ]}
446
+ activeTab={scope}
447
+ onChange={(value) => setScope(value as ScopeType)}
448
+ className="mb-0 flex-1 border-b-0"
449
+ />
450
+ <MarketplaceExternalSkillSourceAction />
451
+ </div>
440
452
 
441
453
  <FilterPanel
442
454
  scope={scope}
443
455
  searchText={searchText}
456
+ isRefreshing={isRefreshingList}
444
457
  searchPlaceholder={t(copyKeys.searchPlaceholder)}
445
458
  sort={sort}
446
459
  onSearchTextChange={setSearchText}
@@ -457,9 +470,16 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
457
470
  <h3 className="text-[14px] font-semibold text-gray-900">
458
471
  {scope === "installed"
459
472
  ? t(copyKeys.sectionInstalled)
460
- : language.startsWith("zh") ? "全部技能" : "All Skills"}
473
+ : t(copyKeys.allSkills)}
461
474
  </h3>
462
- <span className="text-[12px] text-gray-500">{listSummary}</span>
475
+ <div className="flex items-center gap-2 text-[12px] text-gray-500">
476
+ {isRefreshingList && (
477
+ <span className="font-medium text-primary">
478
+ {t(copyKeys.refreshingResults)}
479
+ </span>
480
+ )}
481
+ <span>{listSummary}</span>
482
+ </div>
463
483
  </div>
464
484
  )}
465
485
 
@@ -515,11 +535,13 @@ export function MarketplacePage(props: MarketplacePageProps = {}) {
515
535
  title={
516
536
  scope === "installed"
517
537
  ? t(copyKeys.sectionInstalled)
518
- : language.startsWith("zh") ? "全部技能" : "All Skills"
538
+ : t(copyKeys.allSkills)
519
539
  }
520
540
  summary={listSummary}
541
+ refreshingLabel={t(copyKeys.refreshingResults)}
521
542
  showTitle={curatedSceneRoute.showShelves}
522
543
  showListSkeleton={showListSkeleton}
544
+ isRefreshing={isRefreshingList}
523
545
  skeletonCardCount={SKELETON_CARD_COUNT}
524
546
  allItems={allItems}
525
547
  installedEntries={installedEntries}
@@ -288,6 +288,7 @@ export function McpMarketplacePage() {
288
288
  <FilterPanel
289
289
  scope={scope === "catalog" ? "all" : "installed"}
290
290
  searchText={searchText}
291
+ isRefreshing={false}
291
292
  searchPlaceholder={t("marketplaceMcpSearchPlaceholder")}
292
293
  sort={sort}
293
294
  onSearchTextChange={setSearchText}
@@ -0,0 +1,109 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { useMarketplaceItems } from "@/features/marketplace/hooks/use-marketplace";
5
+ import type { MarketplaceListView } from "@/shared/lib/api";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ fetchMarketplaceItems: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@/shared/lib/api", async () => {
12
+ const actual = await vi.importActual("@/shared/lib/api");
13
+ return {
14
+ ...(actual as object),
15
+ fetchMarketplaceItems: mocks.fetchMarketplaceItems,
16
+ };
17
+ });
18
+
19
+ function createMarketplaceList(name: string): MarketplaceListView {
20
+ return {
21
+ total: 1,
22
+ page: 1,
23
+ pageSize: 20,
24
+ totalPages: 1,
25
+ sort: "relevance",
26
+ items: [
27
+ {
28
+ id: name,
29
+ slug: name,
30
+ type: "skill",
31
+ name,
32
+ summary: name,
33
+ summaryI18n: { en: name },
34
+ tags: [],
35
+ author: "NextClaw",
36
+ install: {
37
+ kind: "marketplace",
38
+ spec: `@nextclaw/${name}`,
39
+ command: `nextclaw skills install @nextclaw/${name}`,
40
+ },
41
+ updatedAt: "2026-06-08T00:00:00.000Z",
42
+ },
43
+ ],
44
+ };
45
+ }
46
+
47
+ function createWrapper() {
48
+ const queryClient = new QueryClient({
49
+ defaultOptions: {
50
+ queries: {
51
+ retry: false,
52
+ },
53
+ },
54
+ });
55
+
56
+ return function Wrapper({ children }: { children: ReactNode }) {
57
+ return (
58
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
59
+ );
60
+ };
61
+ }
62
+
63
+ describe("useMarketplaceItems", () => {
64
+ beforeEach(() => {
65
+ mocks.fetchMarketplaceItems.mockReset();
66
+ });
67
+
68
+ it("keeps previous marketplace items while search results refresh", async () => {
69
+ let resolveSearch:
70
+ | ((value: MarketplaceListView) => void)
71
+ | undefined;
72
+ mocks.fetchMarketplaceItems
73
+ .mockResolvedValueOnce(createMarketplaceList("Initial Skill"))
74
+ .mockReturnValueOnce(
75
+ new Promise<MarketplaceListView>((resolve) => {
76
+ resolveSearch = resolve;
77
+ }),
78
+ );
79
+
80
+ const { result, rerender } = renderHook(
81
+ ({ q }: { q?: string }) =>
82
+ useMarketplaceItems({
83
+ type: "skill",
84
+ q,
85
+ sort: "relevance",
86
+ pageSize: 20,
87
+ }),
88
+ {
89
+ initialProps: {},
90
+ wrapper: createWrapper(),
91
+ },
92
+ );
93
+
94
+ await waitFor(() => {
95
+ expect(result.current.data?.items[0]?.name).toBe("Initial Skill");
96
+ });
97
+
98
+ rerender({ q: "search" });
99
+
100
+ expect(result.current.data?.items[0]?.name).toBe("Initial Skill");
101
+ expect(result.current.isFetching).toBe(true);
102
+
103
+ resolveSearch?.(createMarketplaceList("Search Skill"));
104
+
105
+ await waitFor(() => {
106
+ expect(result.current.data?.items[0]?.name).toBe("Search Skill");
107
+ });
108
+ });
109
+ });
@@ -38,7 +38,7 @@ export function useMarketplaceItems(params: MarketplaceListParams) {
38
38
  }
39
39
 
40
40
  const previousType = 'type' in previousParams ? previousParams.type : undefined;
41
- return previousType === params.type && hasSameMarketplaceListIdentity(
41
+ return previousType === params.type && hasSameMarketplaceListSurface(
42
42
  previousParams as MarketplaceListParams,
43
43
  params
44
44
  )
@@ -56,14 +56,12 @@ export function useMarketplaceItems(params: MarketplaceListParams) {
56
56
  };
57
57
  }
58
58
 
59
- function hasSameMarketplaceListIdentity(
59
+ function hasSameMarketplaceListSurface(
60
60
  previousParams: MarketplaceListParams,
61
61
  nextParams: MarketplaceListParams
62
62
  ): boolean {
63
- return previousParams.q === nextParams.q
64
- && previousParams.tag === nextParams.tag
63
+ return previousParams.tag === nextParams.tag
65
64
  && previousParams.scene === nextParams.scene
66
- && previousParams.sort === nextParams.sort
67
65
  && previousParams.pageSize === nextParams.pageSize;
68
66
  }
69
67
 
@@ -110,4 +110,29 @@ describe('SideDock', () => {
110
110
 
111
111
  expect(screen.getByText('D')).toBeTruthy();
112
112
  });
113
+
114
+ it('renders emoji icons as visual dock icons instead of small text labels', () => {
115
+ const manager = {
116
+ openItem: vi.fn(),
117
+ unpinItem: vi.fn(),
118
+ } as unknown as SideDockManager;
119
+ const docBrowserManager = new DocBrowserManager();
120
+ useSideDockStore.getState().setPinnedItems([
121
+ {
122
+ createdAt: '2026-06-02T00:00:00.000Z',
123
+ icon: { type: 'text', value: '🎨' },
124
+ id: 'pinned-panel-app-palette',
125
+ label: 'Palette App',
126
+ target: { type: 'right-panel-resource', uri: 'nextclaw://panel-app/palette' },
127
+ },
128
+ ]);
129
+
130
+ render(
131
+ <DocBrowserProvider manager={docBrowserManager}>
132
+ <SideDock manager={manager} />
133
+ </DocBrowserProvider>,
134
+ );
135
+
136
+ expect(screen.getByText('🎨').className).toContain('text-[20px]');
137
+ });
113
138
  });
@@ -34,11 +34,24 @@ const SIDE_DOCK_ICON_COMPONENTS: Record<SideDockIconName, LucideIcon> = {
34
34
  'service-apps': AppWindow,
35
35
  };
36
36
 
37
+ const SIDE_DOCK_EMOJI_ICON_PATTERN = /^\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?)*$/u;
38
+
39
+ function isSideDockEmojiIcon(value: string): boolean {
40
+ return SIDE_DOCK_EMOJI_ICON_PATTERN.test(value.trim());
41
+ }
42
+
37
43
  function SideDockItemIconView({ icon }: { icon: SideDockItemIcon }) {
38
44
  if (icon.type === 'url') {
39
45
  return <img src={icon.url} alt="" className="h-5 w-5 rounded object-cover" />;
40
46
  }
41
47
  if (icon.type === 'text') {
48
+ if (isSideDockEmojiIcon(icon.value)) {
49
+ return (
50
+ <span className="flex h-7 w-7 items-center justify-center text-[20px] leading-none" aria-hidden="true">
51
+ {icon.value}
52
+ </span>
53
+ );
54
+ }
42
55
  return (
43
56
  <span className="max-w-7 truncate text-center text-[13px] font-semibold leading-none" aria-hidden="true">
44
57
  {icon.value}
@@ -8,6 +8,7 @@ import { Switch } from '@/shared/components/ui/switch';
8
8
  import { PageHeader, PageLayout } from '@/app/components/layout/page-layout';
9
9
  import { formatDateTime, t } from '@/shared/lib/i18n';
10
10
  import { cn } from '@/shared/lib/utils';
11
+ import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
11
12
  import { Download, ExternalLink, RefreshCw, RotateCw } from 'lucide-react';
12
13
 
13
14
  const STATUS_LABEL_KEYS: Record<string, string> = {
@@ -249,7 +250,7 @@ export function DesktopUpdateConfig() {
249
250
  <RotateCw className={cn('mr-2 h-4 w-4', isApplying && 'animate-spin')} />
250
251
  {t('runtimeUpdatesApplyNow')}
251
252
  </Button>
252
- {snapshot.releaseNotesUrl ? <Button variant="ghost" onClick={() => window.open(snapshot.releaseNotesUrl ?? '', '_blank', 'noopener,noreferrer')}><ExternalLink className="mr-2 h-4 w-4" />{t('desktopUpdatesReleaseNotes')}</Button> : null}
253
+ {snapshot.releaseNotesUrl ? <Button variant="ghost" onClick={() => void hostCapabilityManager.openExternalUrl(snapshot.releaseNotesUrl ?? '')}><ExternalLink className="mr-2 h-4 w-4" />{t('desktopUpdatesReleaseNotes')}</Button> : null}
253
254
  </CardContent>
254
255
  </Card>
255
256
  </PageLayout>
@@ -52,6 +52,14 @@ export type DesktopWindowControlAction = 'minimize' | 'toggle-maximize' | 'close
52
52
 
53
53
  export type DesktopWindowStateSnapshot = { isMaximized: boolean };
54
54
 
55
+ export type DesktopOpenExternalUrlResult =
56
+ | { opened: true }
57
+ | { opened: false; reason: 'unsupported-url' | 'popup-blocked' | 'bridge-failed' };
58
+
59
+ export type DesktopHostBridge = {
60
+ openExternalUrl: (url: string) => Promise<DesktopOpenExternalUrlResult>;
61
+ };
62
+
55
63
  export type NextClawDesktopBridge = {
56
64
  platform: string;
57
65
  version: string;
@@ -69,6 +77,7 @@ export type NextClawDesktopBridge = {
69
77
  setLocalePreference?: (language: DesktopUiLanguagePreference | null) => Promise<DesktopUiLanguagePreference | null>;
70
78
  controlWindow?: (action: DesktopWindowControlAction) => Promise<void>;
71
79
  getWindowState?: () => Promise<DesktopWindowStateSnapshot>;
80
+ host?: DesktopHostBridge;
72
81
  onWindowStateChanged?: (listener: (snapshot: DesktopWindowStateSnapshot) => void) => () => void;
73
82
  onUpdateStateChanged: (listener: (snapshot: DesktopUpdateSnapshot) => void) => () => void;
74
83
  };
@@ -51,7 +51,7 @@ import {
51
51
  import { ProviderModelsSection } from './provider-models-section';
52
52
  import type { PillSelectOption } from '@/shared/components/provider-pill-selector';
53
53
  import { ProviderStatusBadge } from '@/shared/components/provider-status-badge';
54
-
54
+ import { hostCapabilityManager } from '@/shared/lib/host-capabilities';
55
55
  type ProviderFormProps = {
56
56
  providerName?: string;
57
57
  onProviderDeleted?: (providerName: string) => void;
@@ -488,7 +488,7 @@ export function ProviderForm({ providerName, onProviderDeleted }: ProviderFormPr
488
488
  }
489
489
  setAuthSessionId(result.sessionId);
490
490
  setAuthStatusMessage(`${t('providerAuthOpenPrompt')}${result.userCode}${t('providerAuthOpenPromptSuffix')}`);
491
- window.open(result.verificationUri, '_blank', 'noopener,noreferrer');
491
+ await hostCapabilityManager.openExternalUrl(result.verificationUri);
492
492
  scheduleProviderAuthPoll(result.sessionId, result.intervalMs);
493
493
  } catch (error) {
494
494
  const message = error instanceof Error ? error.message : String(error);
@@ -19,6 +19,11 @@ import {
19
19
  } from '@/shared/components/doc-browser/utils/doc-browser-url.utils';
20
20
  import { DefaultDocBrowserRouteResolver } from '@/shared/components/doc-browser/utils/doc-browser-route-resolver.utils';
21
21
  import { useDocBrowserStore } from '@/shared/components/doc-browser/stores/doc-browser.store';
22
+ import {
23
+ filterNavigationHistoryEntries,
24
+ pushNavigationHistoryEntry,
25
+ stepNavigationHistory,
26
+ } from '@/shared/lib/navigation-history';
22
27
 
23
28
  const defaultDocBrowserRouteResolver = new DefaultDocBrowserRouteResolver();
24
29
 
@@ -74,15 +79,15 @@ function pushActiveHistory(
74
79
  state: DocBrowserState,
75
80
  entry: DocBrowserActiveHistoryEntry,
76
81
  ): DocBrowserState {
77
- const current = state.activeHistory[state.activeHistoryIndex];
78
- if (current && areActiveHistoryEntriesEquivalent(routeResolver, current, entry)) {
79
- return state;
80
- }
81
- const activeHistory = [...state.activeHistory.slice(0, state.activeHistoryIndex + 1), entry];
82
+ const activeHistory = pushNavigationHistoryEntry(
83
+ { entries: state.activeHistory, index: state.activeHistoryIndex },
84
+ entry,
85
+ (current, next) => areActiveHistoryEntriesEquivalent(routeResolver, current, next),
86
+ );
82
87
  return {
83
88
  ...state,
84
- activeHistory,
85
- activeHistoryIndex: activeHistory.length - 1,
89
+ activeHistory: [...activeHistory.entries],
90
+ activeHistoryIndex: activeHistory.index,
86
91
  };
87
92
  }
88
93
 
@@ -366,29 +371,35 @@ export class DocBrowserManager {
366
371
 
367
372
  readonly goBack = (): void => {
368
373
  this.setSnapshot((prev) => {
369
- if (prev.activeHistoryIndex <= 0) {
370
- return prev;
371
- }
372
- const activeHistoryIndex = prev.activeHistoryIndex - 1;
373
- const entry = prev.activeHistory[activeHistoryIndex];
374
- if (!entry) {
374
+ const step = stepNavigationHistory(
375
+ { entries: prev.activeHistory, index: prev.activeHistoryIndex },
376
+ 'back',
377
+ );
378
+ if (!step) {
375
379
  return prev;
376
380
  }
377
- return restoreActiveHistoryEntry(this.routeResolver, { ...prev, activeHistoryIndex }, entry);
381
+ return restoreActiveHistoryEntry(this.routeResolver, {
382
+ ...prev,
383
+ activeHistory: [...step.history.entries],
384
+ activeHistoryIndex: step.history.index,
385
+ }, step.entry);
378
386
  });
379
387
  };
380
388
 
381
389
  readonly goForward = (): void => {
382
390
  this.setSnapshot((prev) => {
383
- if (prev.activeHistoryIndex >= prev.activeHistory.length - 1) {
384
- return prev;
385
- }
386
- const activeHistoryIndex = prev.activeHistoryIndex + 1;
387
- const entry = prev.activeHistory[activeHistoryIndex];
388
- if (!entry) {
391
+ const step = stepNavigationHistory(
392
+ { entries: prev.activeHistory, index: prev.activeHistoryIndex },
393
+ 'forward',
394
+ );
395
+ if (!step) {
389
396
  return prev;
390
397
  }
391
- return restoreActiveHistoryEntry(this.routeResolver, { ...prev, activeHistoryIndex }, entry);
398
+ return restoreActiveHistoryEntry(this.routeResolver, {
399
+ ...prev,
400
+ activeHistory: [...step.history.entries],
401
+ activeHistoryIndex: step.history.index,
402
+ }, step.entry);
392
403
  });
393
404
  };
394
405
 
@@ -417,20 +428,16 @@ export class DocBrowserManager {
417
428
  tabs: nextTabs,
418
429
  activeTabId: nextActiveId,
419
430
  };
420
- const activeHistory = nextState.activeHistory.filter((entry) => entry.tabId !== tabId);
421
- const activeHistoryBeforeIndex = nextState.activeHistory
422
- .slice(0, nextState.activeHistoryIndex + 1)
423
- .filter((entry) => entry.tabId !== tabId);
424
431
  const nextActiveTab = nextTabs.find((tab) => tab.id === nextActiveId) ?? nextTabs[0];
432
+ const activeHistory = filterNavigationHistoryEntries(
433
+ { entries: nextState.activeHistory, index: nextState.activeHistoryIndex },
434
+ (entry) => entry.tabId !== tabId,
435
+ createDocBrowserActiveHistoryEntry(nextActiveTab),
436
+ );
425
437
  const reconciledState = {
426
438
  ...nextState,
427
- activeHistory: activeHistory.length > 0
428
- ? activeHistory
429
- : [createDocBrowserActiveHistoryEntry(nextActiveTab)],
430
- activeHistoryIndex: Math.min(
431
- Math.max(0, activeHistoryBeforeIndex.length - 1),
432
- Math.max(0, activeHistory.length - 1),
433
- ),
439
+ activeHistory: [...activeHistory.entries],
440
+ activeHistoryIndex: activeHistory.index,
434
441
  };
435
442
  return prev.activeTabId === tabId
436
443
  ? pushActiveHistory(this.routeResolver, reconciledState, createDocBrowserActiveHistoryEntry(nextActiveTab))
@@ -1,5 +1,5 @@
1
1
  import { cn } from '@/shared/lib/utils';
2
- import { ArrowRight } from 'lucide-react';
2
+ import { ArrowRight, ExternalLink } from 'lucide-react';
3
3
 
4
4
  interface ActionLinkProps {
5
5
  label: string;
@@ -7,6 +7,12 @@ interface ActionLinkProps {
7
7
  onClick?: () => void;
8
8
  }
9
9
 
10
+ interface ExternalActionLinkProps {
11
+ label: string;
12
+ className?: string;
13
+ onClick: () => void;
14
+ }
15
+
10
16
  /**
11
17
  * Unified action link with arrow indicator.
12
18
  * Used in card footers for "Configure →", "Enable →", etc.
@@ -25,3 +31,19 @@ export function ActionLink({ label, className, onClick }: ActionLinkProps) {
25
31
  </span>
26
32
  );
27
33
  }
34
+
35
+ export function ExternalActionLink({ label, className, onClick }: ExternalActionLinkProps) {
36
+ return (
37
+ <button
38
+ type="button"
39
+ className={cn(
40
+ 'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md px-1.5 text-[13px] font-medium text-primary transition-colors hover:text-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
41
+ className
42
+ )}
43
+ onClick={onClick}
44
+ >
45
+ <ExternalLink className="h-3.5 w-3.5" />
46
+ {label}
47
+ </button>
48
+ );
49
+ }
@@ -0,0 +1,50 @@
1
+ import { HostCapabilityManager } from "@/shared/lib/host-capabilities";
2
+
3
+ describe("HostCapabilityManager", () => {
4
+ it("uses the desktop host bridge when available", async () => {
5
+ const openExternalUrl = vi.fn(async () => ({ opened: true as const }));
6
+ const manager = new HostCapabilityManager(() => ({
7
+ open: vi.fn(),
8
+ nextclawDesktop: {
9
+ host: { openExternalUrl }
10
+ }
11
+ } as unknown as Window));
12
+
13
+ await expect(manager.openExternalUrl("https://skillhub.cn")).resolves.toEqual({ opened: true });
14
+ expect(openExternalUrl).toHaveBeenCalledWith("https://skillhub.cn/");
15
+ });
16
+
17
+ it("falls back to window.open outside the desktop host", async () => {
18
+ const open = vi.fn(() => ({ closed: false }));
19
+ const manager = new HostCapabilityManager(() => ({
20
+ open
21
+ } as unknown as Window));
22
+
23
+ await expect(manager.openExternalUrl("https://skillhub.cn")).resolves.toEqual({ opened: true });
24
+ expect(open).toHaveBeenCalledWith("https://skillhub.cn/", "_blank", "noopener,noreferrer");
25
+ });
26
+
27
+ it("reports unsupported URLs without opening them", async () => {
28
+ const open = vi.fn();
29
+ const manager = new HostCapabilityManager(() => ({
30
+ open
31
+ } as unknown as Window));
32
+
33
+ await expect(manager.openExternalUrl("javascript:alert(1)")).resolves.toEqual({
34
+ opened: false,
35
+ reason: "unsupported-url"
36
+ });
37
+ expect(open).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("reports popup-blocked when the web fallback cannot open a window", async () => {
41
+ const manager = new HostCapabilityManager(() => ({
42
+ open: vi.fn(() => null)
43
+ } as unknown as Window));
44
+
45
+ await expect(manager.openExternalUrl("https://skillhub.cn")).resolves.toEqual({
46
+ opened: false,
47
+ reason: "popup-blocked"
48
+ });
49
+ });
50
+ });
@@ -0,0 +1,62 @@
1
+ export type OpenExternalUrlFailureReason =
2
+ | "unsupported-url"
3
+ | "popup-blocked"
4
+ | "bridge-failed";
5
+
6
+ export type OpenExternalUrlResult =
7
+ | { opened: true }
8
+ | { opened: false; reason: OpenExternalUrlFailureReason };
9
+
10
+ type HostWindow = Pick<Window, "open"> & {
11
+ nextclawDesktop?: {
12
+ host?: {
13
+ openExternalUrl?: (url: string) => Promise<OpenExternalUrlResult>;
14
+ };
15
+ };
16
+ };
17
+
18
+ export class HostCapabilityManager {
19
+ constructor(private readonly getWindow = (): HostWindow | null => {
20
+ return typeof window === "undefined" ? null : window;
21
+ }) {}
22
+
23
+ openExternalUrl = async (rawUrl: string): Promise<OpenExternalUrlResult> => {
24
+ const url = normalizeExternalHttpUrl(rawUrl);
25
+ if (!url) {
26
+ return { opened: false, reason: "unsupported-url" };
27
+ }
28
+
29
+ const hostWindow = this.getWindow();
30
+ if (!hostWindow) {
31
+ return { opened: false, reason: "bridge-failed" };
32
+ }
33
+
34
+ try {
35
+ const desktopOpen = hostWindow.nextclawDesktop?.host?.openExternalUrl;
36
+ if (desktopOpen) {
37
+ return await desktopOpen(url);
38
+ }
39
+ const openedWindow = hostWindow.open(url, "_blank", "noopener,noreferrer");
40
+ if (!openedWindow) {
41
+ return { opened: false, reason: "popup-blocked" };
42
+ }
43
+ return { opened: true };
44
+ } catch {
45
+ return { opened: false, reason: "bridge-failed" };
46
+ }
47
+ };
48
+ }
49
+
50
+ export const hostCapabilityManager = new HostCapabilityManager();
51
+
52
+ function normalizeExternalHttpUrl(rawUrl: string): string | null {
53
+ try {
54
+ const url = new URL(rawUrl);
55
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
56
+ return null;
57
+ }
58
+ return url.toString();
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1 @@
1
+ export * from './host-capability.manager';
@@ -1,7 +1,20 @@
1
1
  # i18n owner
2
2
 
3
- This directory temporarily keeps domain label owner modules next to the i18n runtime entrypoint.
3
+ This module owns the NextClaw UI language runtime and locale catalogs.
4
4
 
5
- ## 目录预算豁免
5
+ ## 文案维护方式
6
6
 
7
- - 原因:i18n 文案正在从单一超大 `index.ts` 逐步拆到领域 owner 文件,当前新增 cron 文案先落到独立 owner,避免继续膨胀入口文件;后续应迁移为每个语言独立 JSON,由 runtime 统一加载。
7
+ - User-visible labels live in `locales/<locale>/*.json`.
8
+ - `index.ts` only owns catalog assembly, formatting helpers, and the stable `t(key)` API.
9
+ - Keep current flat keys when moving existing labels; semantic nested keys can migrate by domain during future feature work.
10
+ - When adding or editing copy, update both `zh-CN` and `en-US`.
11
+
12
+ ## 验证
13
+
14
+ Run:
15
+
16
+ ```bash
17
+ node scripts/smoke/i18n/nextclaw-ui-i18n-check.mjs
18
+ ```
19
+
20
+ The check verifies that both locales have the same JSON files, the same message keys, and matching `{placeholder}` names.