@nextclaw/ui 0.12.35-beta.6 → 0.12.35

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 (85) hide show
  1. package/CHANGELOG.md +482 -0
  2. package/dist/assets/api-DruAcori.js +15 -0
  3. package/dist/assets/{book-open-qc0UVn6m.js → book-open-cPX0Za7Z.js} +1 -1
  4. package/dist/assets/channels-list-page-DXH1qYLs.js +8 -0
  5. package/dist/assets/chat-page-Cpo0AFHb.js +105 -0
  6. package/dist/assets/{config-split-page-DW8tP9Bc.js → config-split-page-BFh_Qixx.js} +1 -1
  7. package/dist/assets/confirm-dialog-B0TP-vPd.js +5 -0
  8. package/dist/assets/{createLucideIcon-CA0sgoMJ.js → createLucideIcon-Br1EFMEr.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DARSwQG8.js → desktop-update-config-Be8x1jNy.js} +1 -1
  10. package/dist/assets/{dist-D5zsy7pX.js → dist-CpwDZDtv.js} +1 -1
  11. package/dist/assets/{dist-DNxIa3pe.js → dist-rzakR_bu.js} +1 -1
  12. package/dist/assets/doc-browser-BI7HSaMK.js +3 -0
  13. package/dist/assets/doc-browser-context-DB6TTHeE.js +1 -0
  14. package/dist/assets/doc-browser-route-registry.utils-x5ijvk0u.js +1 -0
  15. package/dist/assets/doc-browser-yTfN7UBH.js +1 -0
  16. package/dist/assets/{external-link-C9fbyi6k.js → external-link-C3usm3se.js} +1 -1
  17. package/dist/assets/index-GVTiCf1s.js +101 -0
  18. package/dist/assets/index-v0fAGXQW.css +1 -0
  19. package/dist/assets/{mcp-marketplace-page-BiExW5wq.js → mcp-marketplace-page-DDjeHd1I.js} +2 -2
  20. package/dist/assets/mcp-marketplace-page-WUkc7n94.js +1 -0
  21. package/dist/assets/model-config-BjXMpU9U.js +1 -0
  22. package/dist/assets/play-CqNi0u02.js +1 -0
  23. package/dist/assets/plus-BcIC8dG1.js +1 -0
  24. package/dist/assets/{provider-scoped-model-input-Bk8jlpRm.js → provider-scoped-model-input-t1tCP5y5.js} +1 -1
  25. package/dist/assets/providers-list-C1D5p9kK.js +1 -0
  26. package/dist/assets/react-B2X_ph0a.js +1 -0
  27. package/dist/assets/remote-BHeENKxL.js +1 -0
  28. package/dist/assets/runtime-config-page--O0Wq01S.js +1 -0
  29. package/dist/assets/{save-CG4Hb1u8.js → save-D10M5vQg.js} +1 -1
  30. package/dist/assets/{search-lWpOIHEn.js → search-CdhgFNwf.js} +1 -1
  31. package/dist/assets/search-config-Dzpizo-_.js +1 -0
  32. package/dist/assets/{secrets-config-ZbLzjIRx.js → secrets-config-D1AUSEXX.js} +1 -1
  33. package/dist/assets/{setting-row-CNfTKDjc.js → setting-row-8VkukJoH.js} +1 -1
  34. package/dist/assets/{tabs-custom-BbZoysAl.js → tabs-custom-BBXu1OGs.js} +1 -1
  35. package/dist/assets/{tag-chip-Dl8JbhC7.js → tag-chip-ix15P-r4.js} +1 -1
  36. package/dist/assets/wrench-qVIwShTF.js +1 -0
  37. package/dist/assets/x-Chb07O-Y.js +1 -0
  38. package/dist/index.html +17 -15
  39. package/package.json +8 -8
  40. package/src/features/panel-apps/components/panel-app-list-item.test.tsx +60 -0
  41. package/src/features/panel-apps/components/panel-app-list-item.tsx +120 -30
  42. package/src/features/panel-apps/components/panel-apps-list.tsx +8 -1
  43. package/src/features/panel-apps/hooks/use-panel-apps.ts +10 -0
  44. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +153 -1
  45. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +126 -9
  46. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +7 -3
  47. package/src/features/panel-apps/utils/panel-app-view.utils.test.ts +66 -0
  48. package/src/features/panel-apps/utils/panel-app-view.utils.ts +13 -2
  49. package/src/index.css +23 -0
  50. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +170 -0
  51. package/src/shared/components/doc-browser/doc-browser-context.tsx +3 -0
  52. package/src/shared/components/doc-browser/doc-browser-home-page.tsx +95 -0
  53. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +3 -97
  54. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +1 -0
  55. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +117 -44
  56. package/src/shared/components/doc-browser/doc-browser.test.tsx +159 -1
  57. package/src/shared/components/doc-browser/doc-browser.tsx +46 -27
  58. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +183 -71
  59. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +52 -1
  60. package/src/shared/components/doc-browser/types/doc-browser.types.ts +9 -0
  61. package/src/shared/components/doc-browser/utils/doc-browser-route-registry.utils.ts +232 -0
  62. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +11 -0
  63. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +2 -0
  64. package/src/shared/lib/api/types.ts +1 -0
  65. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +10 -0
  66. package/dist/assets/api-BTZui7nj.js +0 -15
  67. package/dist/assets/channels-list-page-DBtU_M1Y.js +0 -8
  68. package/dist/assets/chat-page-BcoJYo91.js +0 -105
  69. package/dist/assets/doc-browser-B9skxzVo.js +0 -1
  70. package/dist/assets/doc-browser-CwyITRXx.js +0 -1
  71. package/dist/assets/doc-browser-context-Do2qSrB8.js +0 -1
  72. package/dist/assets/index-BYRP_BMj.css +0 -1
  73. package/dist/assets/index-DckPGTdM.js +0 -103
  74. package/dist/assets/mcp-marketplace-page-Cj6MDSkE.js +0 -1
  75. package/dist/assets/model-config-DbdY7fF1.js +0 -1
  76. package/dist/assets/notice-card-CyiVcu_Z.js +0 -5
  77. package/dist/assets/play-D-s0C4MN.js +0 -1
  78. package/dist/assets/plus-DOwjM_kF.js +0 -1
  79. package/dist/assets/providers-list-CJjNStDV.js +0 -1
  80. package/dist/assets/react-BaRotFd6.js +0 -1
  81. package/dist/assets/remote-gl8QckNc.js +0 -1
  82. package/dist/assets/runtime-config-page-BMFc1yzf.js +0 -1
  83. package/dist/assets/search-config-DUOR26if.js +0 -1
  84. package/dist/assets/x-D6GwT_8k.js +0 -1
  85. /package/dist/assets/{config-hints-CTqBnCDp.js → config-hints-CY1lg9IE.js} +0 -0
@@ -1,14 +1,16 @@
1
1
  import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import {
3
3
  DOCS_DEFAULT_BASE_URL,
4
+ DOC_BROWSER_HOME_TAB_KIND,
4
5
  useDocBrowser,
5
6
  } from './doc-browser-context';
7
+ import { normalizeDocUrl } from './utils/doc-browser-url.utils';
8
+ import { DocBrowserHomePage } from './doc-browser-home-page';
6
9
  import type { DocBrowserCustomTabRenderers } from './doc-browser-renderer.types';
7
10
  import {
8
11
  DocBrowserDocsToolbar,
9
12
  DocBrowserExternalLink,
10
13
  DocBrowserFrameContent,
11
- DocBrowserPanelHeader,
12
14
  } from './doc-browser-panel-parts';
13
15
  import { DocBrowserTabStrip } from './doc-browser-tab-strip';
14
16
  import { ResizableRightPanel } from '@/shared/components/resizable-right-panel/resizable-right-panel';
@@ -38,6 +40,15 @@ function clamp(value: number, min: number, max: number): number {
38
40
  return Math.min(max, Math.max(min, value));
39
41
  }
40
42
 
43
+ function createInitialFloatingPanelRect(): FloatingPanelRect {
44
+ return {
45
+ x: Math.max(FLOATING_PANEL_MARGIN, window.innerWidth - 520),
46
+ y: 80,
47
+ w: 480,
48
+ h: 600,
49
+ };
50
+ }
51
+
41
52
  export function DocBrowser({
42
53
  customTabRenderers = {},
43
54
  displayMode = 'desktop',
@@ -47,8 +58,11 @@ export function DocBrowser({
47
58
  mode,
48
59
  tabs,
49
60
  activeTabId,
61
+ activeHistory,
62
+ activeHistoryIndex,
50
63
  currentTab,
51
64
  open,
65
+ openNewTab,
52
66
  close,
53
67
  toggleMode,
54
68
  goBack,
@@ -61,18 +75,18 @@ export function DocBrowser({
61
75
 
62
76
  const [urlInput, setUrlInput] = useState('');
63
77
  const [iframeReloadVersion, setIframeReloadVersion] = useState(0);
64
- const [floatRect, setFloatRect] = useState<FloatingPanelRect>(() => ({
65
- x: Math.max(FLOATING_PANEL_MARGIN, window.innerWidth - 520),
66
- y: 80,
67
- w: 480,
68
- h: 600,
69
- }));
78
+ const [floatRect, setFloatRect] = useState<FloatingPanelRect>(createInitialFloatingPanelRect);
70
79
  const [floatInteraction, setFloatInteraction] = useState<FloatingPanelInteraction | null>(null);
71
80
  const iframeRef = useRef<HTMLIFrameElement>(null);
72
81
  const currentUrl = currentTab?.currentUrl ?? DOCS_DEFAULT_BASE_URL;
73
82
  const navVersion = currentTab?.navVersion ?? 0;
83
+ const iframeInstanceId = `${activeTabId}:${navVersion}:${iframeReloadVersion}`;
84
+ const pendingParentDocsUrlRef = useRef<string | null>(null);
74
85
  const prevNavVersionRef = useRef(navVersion);
75
86
  const isDocsTab = currentTab?.kind === 'docs';
87
+ const isHomeTab = currentTab?.kind === DOC_BROWSER_HOME_TAB_KIND;
88
+ const canGoBack = activeHistoryIndex > 0;
89
+ const canGoForward = activeHistoryIndex < activeHistory.length - 1;
76
90
  const customRenderer = currentTab ? customTabRenderers[currentTab.kind] : undefined;
77
91
 
78
92
  useEffect(() => {
@@ -92,16 +106,19 @@ export function DocBrowser({
92
106
  // use postMessage to SPA-navigate inside the iframe instead of remounting.
93
107
  useEffect(() => {
94
108
  if (!isDocsTab) {
109
+ pendingParentDocsUrlRef.current = null;
95
110
  return;
96
111
  }
97
112
  if (navVersion !== prevNavVersionRef.current) {
98
113
  prevNavVersionRef.current = navVersion;
114
+ pendingParentDocsUrlRef.current = normalizeDocUrl(currentUrl);
99
115
  return;
100
116
  }
101
117
 
102
118
  if (iframeRef.current?.contentWindow) {
103
119
  try {
104
120
  const path = new URL(currentUrl).pathname;
121
+ pendingParentDocsUrlRef.current = normalizeDocUrl(currentUrl);
105
122
  iframeRef.current.contentWindow.postMessage({ type: 'docs-navigate', path }, '*');
106
123
  } catch {
107
124
  // ignore postMessage errors
@@ -115,12 +132,17 @@ export function DocBrowser({
115
132
  return;
116
133
  }
117
134
  if (e.data?.type === 'docs-route-change' && typeof e.data.url === 'string') {
135
+ const eventUrl = normalizeDocUrl(e.data.url);
136
+ if (pendingParentDocsUrlRef.current && eventUrl !== pendingParentDocsUrlRef.current) {
137
+ return;
138
+ }
139
+ pendingParentDocsUrlRef.current = null;
118
140
  syncUrl(e.data.url);
119
141
  }
120
142
  };
121
143
  window.addEventListener('message', handler);
122
144
  return () => window.removeEventListener('message', handler);
123
- }, [syncUrl, isDocsTab]);
145
+ }, [currentUrl, syncUrl, isDocsTab]);
124
146
 
125
147
  useEffect(() => {
126
148
  if (!currentTab || !customRenderer?.onIframeMessage) {
@@ -130,12 +152,13 @@ export function DocBrowser({
130
152
  customRenderer.onIframeMessage?.({
131
153
  event,
132
154
  iframe: iframeRef.current,
155
+ iframeInstanceId,
133
156
  tab: currentTab,
134
157
  });
135
158
  };
136
159
  window.addEventListener('message', handler);
137
160
  return () => window.removeEventListener('message', handler);
138
- }, [currentTab, customRenderer]);
161
+ }, [currentTab, customRenderer, iframeInstanceId]);
139
162
 
140
163
  const startFloatDrag = useCallback((event: React.PointerEvent<HTMLElement>) => {
141
164
  event.preventDefault();
@@ -236,36 +259,34 @@ export function DocBrowser({
236
259
  tab: currentTab,
237
260
  } : undefined;
238
261
  const customToolbar = customRenderParams ? customRenderer?.renderToolbar?.(customRenderParams) : null;
239
- const customContent = customRenderParams ? customRenderer?.renderContent?.(customRenderParams) : null;
262
+ const customContent = customRenderParams
263
+ ? customRenderer?.renderContent?.(customRenderParams) ?? (isHomeTab ? <DocBrowserHomePage open={open} /> : null)
264
+ : null;
240
265
  const iframeSandbox = currentTab
241
266
  ? customRenderer?.getIframeSandbox?.(currentTab) ?? 'allow-same-origin allow-scripts allow-popups allow-forms'
242
267
  : 'allow-same-origin allow-scripts allow-popups allow-forms';
243
268
 
244
269
  const panelContent = (
245
270
  <>
246
- <DocBrowserPanelHeader
247
- currentTab={currentTab}
248
- customRenderer={customRenderer}
249
- isDocked={isDocked}
250
- isFullscreen={isFullscreen}
251
- onClose={close}
252
- onDragStart={startFloatDrag}
253
- onToggleMode={toggleMode}
254
- />
255
-
256
271
  <DocBrowserTabStrip
257
272
  tabs={tabs}
258
273
  activeTabId={activeTabId}
259
- onOpenDocs={() => open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
274
+ canGoBack={canGoBack}
275
+ canGoForward={canGoForward}
276
+ isDocked={isDocked}
277
+ isFullscreen={isFullscreen}
278
+ onGoBack={goBack}
279
+ onGoForward={goForward}
280
+ onOpenNewTab={openNewTab}
260
281
  onSetActiveTab={setActiveTab}
261
282
  onCloseTab={closeTab}
283
+ onClose={close}
284
+ onDragStart={startFloatDrag}
285
+ onToggleMode={toggleMode}
262
286
  />
263
287
 
264
288
  <DocBrowserDocsToolbar
265
- currentTab={currentTab}
266
289
  isDocsTab={isDocsTab}
267
- onBack={goBack}
268
- onForward={goForward}
269
290
  onSubmit={handleUrlSubmit}
270
291
  onUrlInputChange={setUrlInput}
271
292
  urlInput={urlInput}
@@ -274,16 +295,14 @@ export function DocBrowser({
274
295
  {customToolbar}
275
296
 
276
297
  <DocBrowserFrameContent
277
- activeTabId={activeTabId}
278
298
  currentTab={currentTab}
279
299
  currentUrl={currentUrl}
280
300
  customContent={customContent}
281
301
  iframeRef={iframeRef}
282
- iframeReloadVersion={iframeReloadVersion}
302
+ iframeInstanceId={iframeInstanceId}
283
303
  iframeSandbox={iframeSandbox}
284
304
  isDragging={floatInteraction?.kind === 'drag'}
285
305
  isResizing={floatInteraction?.kind === 'resize'}
286
- navVersion={navVersion}
287
306
  />
288
307
 
289
308
  <DocBrowserExternalLink currentUrl={currentUrl} isDocsTab={isDocsTab} />
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ DocBrowserActiveHistoryEntry,
2
3
  DocBrowserOpenOptions,
3
4
  DocBrowserState,
4
5
  DocBrowserStateUpdate,
@@ -6,15 +7,17 @@ import type {
6
7
  DocBrowserTabKind,
7
8
  } from '@/shared/components/doc-browser/types/doc-browser.types';
8
9
  import {
10
+ createDocBrowserActiveHistoryEntry,
9
11
  createDefaultDocBrowserState,
10
12
  createDocBrowserTab,
11
13
  } from '@/shared/components/doc-browser/utils/doc-browser-state.utils';
12
14
  import {
15
+ DOC_BROWSER_HOME_TAB_KIND,
13
16
  getDefaultDocsUrl,
14
- inferTabKind,
15
- normalizeDocUrl,
16
- normalizeUrlByKind,
17
17
  } from '@/shared/components/doc-browser/utils/doc-browser-url.utils';
18
+ import {
19
+ docBrowserRouteRegistry,
20
+ } from '@/shared/components/doc-browser/utils/doc-browser-route-registry.utils';
18
21
  import { useDocBrowserStore } from '@/shared/components/doc-browser/stores/doc-browser.store';
19
22
 
20
23
  function updateTab(
@@ -32,61 +35,103 @@ function updateActiveTab(state: DocBrowserState, updater: (tab: DocBrowserTab) =
32
35
  return updateTab(state, state.activeTabId, updater);
33
36
  }
34
37
 
35
- function areOpenUrlsEquivalent(
36
- currentUrl: string,
37
- nextUrl: string,
38
- currentKind: DocBrowserTabKind,
39
- nextKind: DocBrowserTabKind,
38
+ function appendManualNavigation(tab: DocBrowserTab, url: string): Pick<DocBrowserTab, 'history' | 'historyIndex'> {
39
+ const history = [...tab.history.slice(0, tab.historyIndex + 1), url];
40
+ return {
41
+ history,
42
+ historyIndex: history.length - 1,
43
+ };
44
+ }
45
+
46
+ function areActiveHistoryEntriesEquivalent(
47
+ current: DocBrowserActiveHistoryEntry,
48
+ next: DocBrowserActiveHistoryEntry,
40
49
  ): boolean {
41
- if (currentKind === 'docs' && nextKind === 'docs') {
42
- return normalizeDocUrl(currentUrl) === normalizeDocUrl(nextUrl);
50
+ return current.tabId === next.tabId
51
+ && current.kind === next.kind
52
+ && docBrowserRouteRegistry.areUrlsEquivalent(current.url, next.url, current.kind, next.kind);
53
+ }
54
+
55
+ function pushActiveHistory(state: DocBrowserState, entry: DocBrowserActiveHistoryEntry): DocBrowserState {
56
+ const current = state.activeHistory[state.activeHistoryIndex];
57
+ if (current && areActiveHistoryEntriesEquivalent(current, entry)) {
58
+ return state;
59
+ }
60
+ const activeHistory = [...state.activeHistory.slice(0, state.activeHistoryIndex + 1), entry];
61
+ return {
62
+ ...state,
63
+ activeHistory,
64
+ activeHistoryIndex: activeHistory.length - 1,
65
+ };
66
+ }
67
+
68
+ function findTabHistoryIndex(tab: DocBrowserTab, url: string): number {
69
+ for (let index = tab.history.length - 1; index >= 0; index -= 1) {
70
+ if (docBrowserRouteRegistry.areUrlsEquivalent(tab.history[index], url, tab.kind, tab.kind)) {
71
+ return index;
72
+ }
43
73
  }
44
- return currentUrl === nextUrl;
74
+ return -1;
75
+ }
76
+
77
+ function restoreActiveHistoryEntry(state: DocBrowserState, entry: DocBrowserActiveHistoryEntry): DocBrowserState {
78
+ const tab = state.tabs.find((item) => item.id === entry.tabId);
79
+ if (!tab) {
80
+ return state;
81
+ }
82
+ const target = docBrowserRouteRegistry.resolveOpenTarget({
83
+ activeTab: tab,
84
+ kind: entry.kind,
85
+ url: entry.url,
86
+ });
87
+ const historyIndex = findTabHistoryIndex(tab, target.url);
88
+ return updateTab(
89
+ {
90
+ ...state,
91
+ activeTabId: tab.id,
92
+ isOpen: true,
93
+ },
94
+ tab.id,
95
+ (currentTab) => ({
96
+ ...currentTab,
97
+ currentUrl: target.url,
98
+ dedupeKey: target.dedupeKey,
99
+ historyIndex: historyIndex >= 0 ? historyIndex : currentTab.historyIndex,
100
+ kind: target.kind,
101
+ title: target.title,
102
+ }),
103
+ );
45
104
  }
46
105
 
47
106
  function updateTabForOpen(
48
107
  tab: DocBrowserTab,
49
- url: string,
50
- kind: DocBrowserTabKind,
108
+ target: {
109
+ kind: DocBrowserTabKind;
110
+ title: string;
111
+ url: string;
112
+ },
51
113
  options?: DocBrowserOpenOptions,
52
114
  dedupeKey?: string,
53
115
  ): DocBrowserTab {
54
116
  const baseTab = {
55
117
  ...tab,
56
- title: options?.title || tab.title,
57
- kind,
118
+ title: options?.title || target.title || tab.title,
119
+ kind: target.kind,
58
120
  dedupeKey,
59
121
  };
60
122
 
61
- if (areOpenUrlsEquivalent(tab.currentUrl, url, tab.kind, kind)) {
123
+ if (docBrowserRouteRegistry.areUrlsEquivalent(tab.currentUrl, target.url, tab.kind, target.kind)) {
62
124
  return baseTab;
63
125
  }
64
126
 
65
127
  return {
66
128
  ...baseTab,
67
- currentUrl: url,
68
- history: [...tab.history.slice(0, tab.historyIndex + 1), url],
69
- historyIndex: tab.historyIndex + 1,
129
+ currentUrl: target.url,
130
+ ...appendManualNavigation(tab, target.url),
70
131
  navVersion: tab.navVersion + 1,
71
132
  };
72
133
  }
73
134
 
74
- function resolveOpenTargetUrl(params: {
75
- url?: string;
76
- kind: DocBrowserTabKind;
77
- activeTab?: DocBrowserTab;
78
- }): string {
79
- const { activeTab, kind, url } = params;
80
- if (url && url.trim().length > 0) {
81
- return normalizeUrlByKind(url, kind);
82
- }
83
-
84
- if (kind === 'docs') {
85
- return getDefaultDocsUrl();
86
- }
87
- return activeTab?.currentUrl ?? getDefaultDocsUrl();
88
- }
89
-
90
135
  function openDocBrowserState(
91
136
  prev: DocBrowserState,
92
137
  url?: string,
@@ -100,9 +145,12 @@ function openDocBrowserState(
100
145
  title,
101
146
  } = options ?? {};
102
147
  const activeTab = prev.tabs.find((tab) => tab.id === prev.activeTabId) ?? prev.tabs[0];
103
- const targetKind = kind ?? (url ? inferTabKind(url) : activeTab?.kind ?? 'docs');
104
- const targetUrl = resolveOpenTargetUrl({ url, kind: targetKind, activeTab });
105
- const dedupeKey = rawDedupeKey?.trim() || undefined;
148
+ const target = docBrowserRouteRegistry.resolveOpenTarget({
149
+ activeTab,
150
+ kind,
151
+ url,
152
+ });
153
+ const dedupeKey = rawDedupeKey?.trim() || target.dedupeKey;
106
154
  const matchedTab = dedupeKey
107
155
  ? prev.tabs.find((tab) => tab.dedupeKey === dedupeKey)
108
156
  : undefined;
@@ -111,30 +159,40 @@ function openDocBrowserState(
111
159
  const next = updateTab(
112
160
  prev,
113
161
  matchedTab.id,
114
- (tab) => updateTabForOpen(tab, targetUrl, targetKind, options, dedupeKey),
162
+ (tab) => updateTabForOpen(tab, target, options, dedupeKey),
115
163
  );
116
- return {
164
+ const shouldActivate = activate !== false;
165
+ const activeTabId = shouldActivate ? matchedTab.id : prev.activeTabId;
166
+ const nextState = {
117
167
  ...next,
118
- activeTabId: activate === false ? prev.activeTabId : matchedTab.id,
168
+ activeTabId,
119
169
  isOpen: true,
120
170
  };
171
+ const shouldPushActiveHistory = shouldActivate || matchedTab.id === prev.activeTabId;
172
+ return shouldPushActiveHistory
173
+ ? pushActiveHistory(nextState, { kind: target.kind, tabId: matchedTab.id, url: target.url })
174
+ : nextState;
121
175
  }
122
176
 
123
- if (shouldForceNewTab || dedupeKey || !activeTab || activeTab.kind !== targetKind) {
124
- const newTab = createDocBrowserTab(targetUrl, targetKind, title, dedupeKey);
125
- return {
177
+ if (shouldForceNewTab || dedupeKey || !activeTab || activeTab.kind !== target.kind) {
178
+ const newTab = createDocBrowserTab(target.url, target.kind, title ?? target.title, dedupeKey);
179
+ const nextState = {
126
180
  ...prev,
127
181
  isOpen: true,
128
182
  tabs: [...prev.tabs, newTab],
129
183
  activeTabId: newTab.id,
130
184
  };
185
+ return pushActiveHistory(nextState, createDocBrowserActiveHistoryEntry(newTab));
131
186
  }
132
187
 
133
188
  const next = updateActiveTab(
134
189
  prev,
135
- (tab) => updateTabForOpen(tab, targetUrl, targetKind, options, dedupeKey),
190
+ (tab) => updateTabForOpen(tab, target, options, dedupeKey),
191
+ );
192
+ return pushActiveHistory(
193
+ { ...next, isOpen: true },
194
+ { kind: target.kind, tabId: prev.activeTabId, url: target.url },
136
195
  );
137
- return { ...next, isOpen: true };
138
196
  }
139
197
 
140
198
  export class DocBrowserManager {
@@ -146,6 +204,10 @@ export class DocBrowserManager {
146
204
  this.setSnapshot((prev) => openDocBrowserState(prev, url, options));
147
205
  };
148
206
 
207
+ readonly openNewTab = (): void => {
208
+ this.open(undefined, { kind: DOC_BROWSER_HOME_TAB_KIND, newTab: true });
209
+ };
210
+
149
211
  readonly close = (): void => {
150
212
  this.setSnapshot((prev) => ({ ...prev, isOpen: false }));
151
213
  };
@@ -166,24 +228,30 @@ export class DocBrowserManager {
166
228
  };
167
229
  }
168
230
 
169
- return updateActiveTab(prev, (tab) => {
170
- if (tab.kind !== 'docs') {
231
+ const next = updateActiveTab(prev, (tab) => {
232
+ if (!docBrowserRouteRegistry.usesManagedHistory(tab)) {
171
233
  return tab;
172
234
  }
235
+ const target = docBrowserRouteRegistry.resolveOpenTarget({ activeTab: tab, url });
173
236
 
174
- const targetUrl = normalizeUrlByKind(url, 'docs');
175
- if (normalizeDocUrl(targetUrl) === normalizeDocUrl(tab.currentUrl)) {
237
+ if (docBrowserRouteRegistry.areUrlsEquivalent(tab.currentUrl, target.url, tab.kind, target.kind)) {
176
238
  return tab;
177
239
  }
178
240
 
179
241
  return {
180
242
  ...tab,
181
- currentUrl: targetUrl,
182
- history: [...tab.history.slice(0, tab.historyIndex + 1), targetUrl],
183
- historyIndex: tab.historyIndex + 1,
243
+ currentUrl: target.url,
244
+ dedupeKey: target.dedupeKey,
245
+ ...appendManualNavigation(tab, target.url),
246
+ kind: target.kind,
184
247
  navVersion: tab.navVersion + 1,
248
+ title: target.title,
185
249
  };
186
250
  });
251
+ const currentTab = next.tabs.find((tab) => tab.id === next.activeTabId);
252
+ return currentTab
253
+ ? pushActiveHistory(next, createDocBrowserActiveHistoryEntry(currentTab))
254
+ : next;
187
255
  });
188
256
  };
189
257
 
@@ -198,39 +266,58 @@ export class DocBrowserManager {
198
266
  };
199
267
  }
200
268
 
201
- return updateActiveTab(prev, (tab) => {
202
- if (tab.kind !== 'docs') {
269
+ const next = updateActiveTab(prev, (tab) => {
270
+ if (!docBrowserRouteRegistry.usesManagedHistory(tab)) {
203
271
  return tab;
204
272
  }
273
+ const target = docBrowserRouteRegistry.resolveOpenTarget({ activeTab: tab, kind: tab.kind, url });
205
274
 
206
- if (normalizeDocUrl(url) === normalizeDocUrl(tab.currentUrl)) {
275
+ if (docBrowserRouteRegistry.areUrlsEquivalent(tab.currentUrl, target.url, tab.kind, target.kind)) {
207
276
  return tab;
208
277
  }
209
278
 
210
279
  return {
211
280
  ...tab,
212
- currentUrl: url,
213
- history: [...tab.history.slice(0, tab.historyIndex + 1), url],
214
- historyIndex: tab.historyIndex + 1,
281
+ currentUrl: target.url,
282
+ dedupeKey: target.dedupeKey,
283
+ ...appendManualNavigation(tab, target.url),
284
+ kind: target.kind,
285
+ title: target.title,
215
286
  };
216
287
  });
288
+ const currentTab = next.tabs.find((tab) => tab.id === next.activeTabId);
289
+ return currentTab
290
+ ? pushActiveHistory(next, createDocBrowserActiveHistoryEntry(currentTab))
291
+ : next;
217
292
  });
218
293
  };
219
294
 
220
295
  readonly goBack = (): void => {
221
- this.setSnapshot((prev) => updateActiveTab(prev, (tab) => {
222
- if (tab.kind !== 'docs' || tab.historyIndex <= 0) return tab;
223
- const newIndex = tab.historyIndex - 1;
224
- return { ...tab, historyIndex: newIndex, currentUrl: tab.history[newIndex] };
225
- }));
296
+ this.setSnapshot((prev) => {
297
+ if (prev.activeHistoryIndex <= 0) {
298
+ return prev;
299
+ }
300
+ const activeHistoryIndex = prev.activeHistoryIndex - 1;
301
+ const entry = prev.activeHistory[activeHistoryIndex];
302
+ if (!entry) {
303
+ return prev;
304
+ }
305
+ return restoreActiveHistoryEntry({ ...prev, activeHistoryIndex }, entry);
306
+ });
226
307
  };
227
308
 
228
309
  readonly goForward = (): void => {
229
- this.setSnapshot((prev) => updateActiveTab(prev, (tab) => {
230
- if (tab.kind !== 'docs' || tab.historyIndex >= tab.history.length - 1) return tab;
231
- const newIndex = tab.historyIndex + 1;
232
- return { ...tab, historyIndex: newIndex, currentUrl: tab.history[newIndex] };
233
- }));
310
+ this.setSnapshot((prev) => {
311
+ if (prev.activeHistoryIndex >= prev.activeHistory.length - 1) {
312
+ return prev;
313
+ }
314
+ const activeHistoryIndex = prev.activeHistoryIndex + 1;
315
+ const entry = prev.activeHistory[activeHistoryIndex];
316
+ if (!entry) {
317
+ return prev;
318
+ }
319
+ return restoreActiveHistoryEntry({ ...prev, activeHistoryIndex }, entry);
320
+ });
234
321
  };
235
322
 
236
323
  readonly closeTab = (tabId: string): void => {
@@ -253,11 +340,29 @@ export class DocBrowserManager {
253
340
  ? nextTabs[Math.max(0, index - 1)]?.id ?? nextTabs[0].id
254
341
  : prev.activeTabId;
255
342
 
256
- return {
343
+ const nextState = {
257
344
  ...prev,
258
345
  tabs: nextTabs,
259
346
  activeTabId: nextActiveId,
260
347
  };
348
+ const activeHistory = nextState.activeHistory.filter((entry) => entry.tabId !== tabId);
349
+ const activeHistoryBeforeIndex = nextState.activeHistory
350
+ .slice(0, nextState.activeHistoryIndex + 1)
351
+ .filter((entry) => entry.tabId !== tabId);
352
+ const nextActiveTab = nextTabs.find((tab) => tab.id === nextActiveId) ?? nextTabs[0];
353
+ const reconciledState = {
354
+ ...nextState,
355
+ activeHistory: activeHistory.length > 0
356
+ ? activeHistory
357
+ : [createDocBrowserActiveHistoryEntry(nextActiveTab)],
358
+ activeHistoryIndex: Math.min(
359
+ Math.max(0, activeHistoryBeforeIndex.length - 1),
360
+ Math.max(0, activeHistory.length - 1),
361
+ ),
362
+ };
363
+ return prev.activeTabId === tabId
364
+ ? pushActiveHistory(reconciledState, createDocBrowserActiveHistoryEntry(nextActiveTab))
365
+ : reconciledState;
261
366
  });
262
367
  };
263
368
 
@@ -266,7 +371,14 @@ export class DocBrowserManager {
266
371
  if (!prev.tabs.some((tab) => tab.id === tabId)) {
267
372
  return prev;
268
373
  }
269
- return { ...prev, activeTabId: tabId, isOpen: true };
374
+ const tab = prev.tabs.find((item) => item.id === tabId);
375
+ if (!tab) {
376
+ return prev;
377
+ }
378
+ return pushActiveHistory(
379
+ { ...prev, activeTabId: tabId, isOpen: true },
380
+ createDocBrowserActiveHistoryEntry(tab),
381
+ );
270
382
  });
271
383
  };
272
384
  }
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand';
2
2
  import { createJSONStorage, persist } from 'zustand/middleware';
3
3
  import type {
4
+ DocBrowserActiveHistoryEntry,
4
5
  DocBrowserMode,
5
6
  DocBrowserState,
6
7
  DocBrowserStateUpdate,
@@ -71,6 +72,36 @@ function normalizePersistedDocBrowserTab(value: unknown): DocBrowserTab | null {
71
72
  };
72
73
  }
73
74
 
75
+ function createActiveHistoryEntryFromTab(tab: DocBrowserTab): DocBrowserActiveHistoryEntry {
76
+ return {
77
+ kind: tab.kind,
78
+ tabId: tab.id,
79
+ url: tab.currentUrl,
80
+ };
81
+ }
82
+
83
+ function normalizePersistedActiveHistoryEntry(
84
+ value: unknown,
85
+ tabsById: Map<string, DocBrowserTab>,
86
+ ): DocBrowserActiveHistoryEntry | null {
87
+ if (
88
+ !isRecord(value)
89
+ || typeof value.tabId !== 'string'
90
+ || typeof value.kind !== 'string'
91
+ || typeof value.url !== 'string'
92
+ || value.url.trim().length === 0
93
+ || !tabsById.has(value.tabId)
94
+ ) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ kind: value.kind,
100
+ tabId: value.tabId,
101
+ url: value.url,
102
+ };
103
+ }
104
+
74
105
  function normalizePersistedDocBrowserState(value: unknown): DocBrowserState | null {
75
106
  if (!isRecord(value)) {
76
107
  return null;
@@ -88,12 +119,30 @@ function normalizePersistedDocBrowserState(value: unknown): DocBrowserState | nu
88
119
  const activeTabId = tabs.some((tab) => tab.id === persistedActiveTabId)
89
120
  ? persistedActiveTabId ?? tabs[0].id
90
121
  : tabs[0].id;
122
+ const tabsById = new Map(tabs.map((tab) => [tab.id, tab]));
123
+ const activeHistory = Array.isArray(value.activeHistory)
124
+ ? value.activeHistory
125
+ .map((entry) => normalizePersistedActiveHistoryEntry(entry, tabsById))
126
+ .filter((entry): entry is DocBrowserActiveHistoryEntry => entry !== null)
127
+ : [];
128
+ const fallbackHistory = [createActiveHistoryEntryFromTab(tabsById.get(activeTabId) ?? tabs[0])];
129
+ const resolvedActiveHistory = activeHistory.length > 0 ? activeHistory : fallbackHistory;
130
+ const activeHistoryIndex = normalizePersistedNumber(
131
+ value.activeHistoryIndex,
132
+ 0,
133
+ resolvedActiveHistory.length - 1,
134
+ resolvedActiveHistory.length - 1,
135
+ );
136
+ const activeHistoryEntry = resolvedActiveHistory[activeHistoryIndex];
137
+ const resolvedActiveTabId = tabsById.has(activeHistoryEntry.tabId) ? activeHistoryEntry.tabId : activeTabId;
91
138
 
92
139
  return {
93
140
  isOpen: value.isOpen === true,
94
141
  mode: isDocBrowserMode(value.mode) ? value.mode : 'docked',
95
142
  tabs,
96
- activeTabId,
143
+ activeTabId: resolvedActiveTabId,
144
+ activeHistory: resolvedActiveHistory,
145
+ activeHistoryIndex,
97
146
  };
98
147
  }
99
148
 
@@ -122,6 +171,8 @@ export const useDocBrowserStore = create<DocBrowserStore>()(
122
171
  mode: state.snapshot.mode,
123
172
  tabs: state.snapshot.tabs.slice(-DOC_BROWSER_MAX_PERSISTED_TABS),
124
173
  activeTabId: state.snapshot.activeTabId,
174
+ activeHistory: state.snapshot.activeHistory,
175
+ activeHistoryIndex: state.snapshot.activeHistoryIndex,
125
176
  },
126
177
  }),
127
178
  merge: (persistedState, currentState) => {