@nextclaw/ui 0.12.35-beta.2 → 0.12.35-beta.4

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 (118) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/assets/api-BTZui7nj.js +15 -0
  3. package/dist/assets/{book-open--T9dJHnp.js → book-open-qc0UVn6m.js} +1 -1
  4. package/dist/assets/channels-list-page-DBtU_M1Y.js +8 -0
  5. package/dist/assets/{chat-page-Doi-sasO.js → chat-page-BcoJYo91.js} +2 -2
  6. package/dist/assets/config-split-page-DW8tP9Bc.js +1 -0
  7. package/dist/assets/createLucideIcon-CA0sgoMJ.js +1 -0
  8. package/dist/assets/desktop-update-config-DARSwQG8.js +1 -0
  9. package/dist/assets/dist-D5zsy7pX.js +1 -0
  10. package/dist/assets/dist-DNxIa3pe.js +41 -0
  11. package/dist/assets/doc-browser-B9skxzVo.js +1 -0
  12. package/dist/assets/doc-browser-CwyITRXx.js +1 -0
  13. package/dist/assets/doc-browser-context-Do2qSrB8.js +1 -0
  14. package/dist/assets/{external-link-CZ9bQY0L.js → external-link-C9fbyi6k.js} +1 -1
  15. package/dist/assets/index-BYRP_BMj.css +1 -0
  16. package/dist/assets/index-DckPGTdM.js +103 -0
  17. package/dist/assets/mcp-marketplace-page-BiExW5wq.js +40 -0
  18. package/dist/assets/mcp-marketplace-page-Cj6MDSkE.js +1 -0
  19. package/dist/assets/model-config-DbdY7fF1.js +1 -0
  20. package/dist/assets/notice-card-CyiVcu_Z.js +5 -0
  21. package/dist/assets/play-D-s0C4MN.js +1 -0
  22. package/dist/assets/plus-DOwjM_kF.js +1 -0
  23. package/dist/assets/provider-scoped-model-input-Bk8jlpRm.js +1 -0
  24. package/dist/assets/providers-list-CJjNStDV.js +1 -0
  25. package/dist/assets/react-BaRotFd6.js +1 -0
  26. package/dist/assets/remote-gl8QckNc.js +1 -0
  27. package/dist/assets/runtime-config-page-BMFc1yzf.js +1 -0
  28. package/dist/assets/{save-B6wbBLkU.js → save-CG4Hb1u8.js} +1 -1
  29. package/dist/assets/{search-config-BtCNY431.js → search-config-DUOR26if.js} +1 -1
  30. package/dist/assets/{search-Czito3Dk.js → search-lWpOIHEn.js} +1 -1
  31. package/dist/assets/secrets-config-ZbLzjIRx.js +3 -0
  32. package/dist/assets/setting-row-CNfTKDjc.js +1 -0
  33. package/dist/assets/tabs-custom-BbZoysAl.js +1 -0
  34. package/dist/assets/tag-chip-Dl8JbhC7.js +1 -0
  35. package/dist/assets/x-D6GwT_8k.js +1 -0
  36. package/dist/index.html +15 -17
  37. package/package.json +8 -9
  38. package/src/app/components/app-presenter-provider.tsx +21 -0
  39. package/src/app/components/layout/sidebar.layout.test.tsx +2 -2
  40. package/src/app/components/layout/sidebar.tsx +3 -3
  41. package/src/app/index.tsx +5 -3
  42. package/src/app/presenters/app.presenter.ts +24 -0
  43. package/src/features/account/components/account-panel.tsx +11 -11
  44. package/src/features/account/index.ts +1 -4
  45. package/src/features/account/managers/account.manager.ts +52 -27
  46. package/src/features/account/stores/account.store.ts +0 -15
  47. package/src/features/apps/components/apps-panel.tsx +43 -0
  48. package/src/features/apps/index.ts +1 -0
  49. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +8 -8
  50. package/src/features/chat/components/layout/chat-sidebar.tsx +3 -2
  51. package/src/features/chat/managers/chat-session-list.manager.test.ts +58 -0
  52. package/src/features/chat/stores/chat-session-list.store.ts +58 -7
  53. package/src/features/panel-apps/components/panel-app-toolbar.tsx +4 -4
  54. package/src/features/panel-apps/components/panel-apps-list.tsx +6 -2
  55. package/src/features/panel-apps/index.ts +3 -1
  56. package/src/features/panel-apps/managers/panel-app-bridge.manager.test.ts +111 -0
  57. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +247 -0
  58. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.test.ts +23 -0
  59. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +42 -16
  60. package/src/features/remote/components/remote-access-page.test.tsx +2 -2
  61. package/src/features/remote/components/remote-access-page.tsx +9 -9
  62. package/src/features/remote/managers/remote-access.manager.ts +24 -41
  63. package/src/features/service-apps/components/service-action-authorization-dialog.tsx +78 -0
  64. package/src/features/service-apps/components/service-apps-panel.tsx +290 -0
  65. package/src/features/service-apps/hooks/use-service-apps.ts +66 -0
  66. package/src/features/service-apps/index.ts +3 -0
  67. package/src/features/service-apps/managers/service-action-authorization.manager.ts +10 -0
  68. package/src/features/service-apps/stores/service-action-authorization.store.ts +53 -0
  69. package/src/shared/components/doc-browser/doc-browser-context.test.tsx +150 -1
  70. package/src/shared/components/doc-browser/doc-browser-context.tsx +34 -443
  71. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +2 -2
  72. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +7 -0
  73. package/src/shared/components/doc-browser/doc-browser.test.tsx +82 -2
  74. package/src/shared/components/doc-browser/doc-browser.tsx +147 -135
  75. package/src/shared/components/doc-browser/managers/doc-browser.manager.ts +272 -0
  76. package/src/shared/components/doc-browser/stores/doc-browser.store.ts +140 -0
  77. package/src/shared/components/doc-browser/types/doc-browser.types.ts +47 -0
  78. package/src/shared/components/doc-browser/utils/doc-browser-state.utils.ts +47 -0
  79. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +95 -0
  80. package/src/shared/lib/api/managers/client.manager.ts +2 -1
  81. package/src/shared/lib/i18n/runtime/doc-browser-labels.utils.ts +31 -0
  82. package/src/shared/lib/transport/local-transport.service.ts +18 -2
  83. package/src/shared/lib/transport/remote-transport.service.ts +19 -3
  84. package/src/shared/lib/transport/request-raw-api-response.utils.ts +1 -1
  85. package/dist/assets/api-D3uoaOR8.js +0 -15
  86. package/dist/assets/channels-list-page-CzDMCXR_.js +0 -8
  87. package/dist/assets/config-split-page-Dutq3R5o.js +0 -1
  88. package/dist/assets/createLucideIcon-Cwr2o6Zq.js +0 -1
  89. package/dist/assets/desktop-update-config-Dd4DdPH3.js +0 -1
  90. package/dist/assets/dist-e5WATjIW.js +0 -1
  91. package/dist/assets/doc-browser-DInzGY0W.js +0 -1
  92. package/dist/assets/doc-browser-DxVvmYQk.js +0 -1
  93. package/dist/assets/doc-browser-context-CLczyX2Z.js +0 -1
  94. package/dist/assets/es2015-BJw-hrex.js +0 -41
  95. package/dist/assets/i18n-tWKGEGxB.js +0 -1
  96. package/dist/assets/index-HvJDzfSU.css +0 -1
  97. package/dist/assets/index-dHwM4pEn.js +0 -103
  98. package/dist/assets/key-round-B8tTb3ZJ.js +0 -1
  99. package/dist/assets/mcp-marketplace-page-D-dydvbL.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-n9Z25Cyk.js +0 -40
  101. package/dist/assets/model-config-8BVoSVXQ.js +0 -1
  102. package/dist/assets/notice-card-DUDbOvqx.js +0 -5
  103. package/dist/assets/play-CV_kwi9X.js +0 -1
  104. package/dist/assets/plus-BqfNq7Tu.js +0 -1
  105. package/dist/assets/provider-scoped-model-input-eO4XY0iY.js +0 -1
  106. package/dist/assets/providers-list-CjJmW-od.js +0 -1
  107. package/dist/assets/remote-DxTFnQm8.js +0 -1
  108. package/dist/assets/rotate-cw-BKEhWuWp.js +0 -1
  109. package/dist/assets/runtime-config-page-Ba6atCMq.js +0 -1
  110. package/dist/assets/secrets-config-Ddh05-J5.js +0 -3
  111. package/dist/assets/select-CmmDgG8o.js +0 -1
  112. package/dist/assets/setting-row-BUO6FeN1.js +0 -1
  113. package/dist/assets/tabs-custom-D3IZL9WX.js +0 -1
  114. package/dist/assets/tag-chip-Dm_pOtE3.js +0 -1
  115. package/dist/assets/x-DBP3WiC9.js +0 -1
  116. package/src/app/components/app-manager-provider.tsx +0 -20
  117. package/src/app/managers/app.manager.ts +0 -12
  118. /package/dist/assets/{config-hints-DGfwbumO.js → config-hints-CTqBnCDp.js} +0 -0
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useCallback, useEffect, type CSSProperties } from 'react';
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import {
3
3
  DOCS_DEFAULT_BASE_URL,
4
4
  useDocBrowser,
@@ -20,30 +20,22 @@ type DocBrowserProps = {
20
20
  displayMode?: 'desktop' | 'fullscreen';
21
21
  };
22
22
 
23
- function getPanelClassName(isFullscreen: boolean): string {
24
- return cn(
25
- 'flex flex-col bg-white overflow-hidden relative',
26
- isFullscreen
27
- ? 'fixed inset-0 z-[9999] h-[100dvh] w-screen rounded-none border-0 shadow-2xl'
28
- : 'rounded-2xl shadow-2xl border border-gray-200',
29
- );
30
- }
23
+ type FloatingPanelRect = { x: number; y: number; w: number; h: number };
24
+ type FloatingPanelResizeEdge = 'left' | 'right' | 'top' | 'bottom' | 'bottom-right';
25
+ type FloatingPanelInteraction = {
26
+ kind: 'drag' | 'resize';
27
+ edge?: FloatingPanelResizeEdge;
28
+ startX: number;
29
+ startY: number;
30
+ startRect: FloatingPanelRect;
31
+ };
32
+
33
+ const FLOATING_PANEL_MARGIN = 40;
34
+ const FLOATING_PANEL_MIN_WIDTH = 360;
35
+ const FLOATING_PANEL_MIN_HEIGHT = 400;
31
36
 
32
- function getPanelStyle(params: {
33
- isFullscreen: boolean;
34
- floatPos: { x: number; y: number };
35
- floatSize: { w: number; h: number };
36
- }): CSSProperties | undefined {
37
- const { isFullscreen, floatPos, floatSize } = params;
38
- if (isFullscreen) return undefined;
39
- return {
40
- position: 'fixed',
41
- left: floatPos.x,
42
- top: floatPos.y,
43
- width: floatSize.w,
44
- height: floatSize.h,
45
- zIndex: 9999,
46
- };
37
+ function clamp(value: number, min: number, max: number): number {
38
+ return Math.min(max, Math.max(min, value));
47
39
  }
48
40
 
49
41
  export function DocBrowser({
@@ -68,21 +60,20 @@ export function DocBrowser({
68
60
  } = useDocBrowser();
69
61
 
70
62
  const [urlInput, setUrlInput] = useState('');
71
- const [isDragging, setIsDragging] = useState(false);
72
- const [isResizing, setIsResizing] = useState(false);
73
63
  const [iframeReloadVersion, setIframeReloadVersion] = useState(0);
74
- const [floatPos, setFloatPos] = useState(() => ({
75
- x: Math.max(40, window.innerWidth - 520),
64
+ const [floatRect, setFloatRect] = useState<FloatingPanelRect>(() => ({
65
+ x: Math.max(FLOATING_PANEL_MARGIN, window.innerWidth - 520),
76
66
  y: 80,
67
+ w: 480,
68
+ h: 600,
77
69
  }));
78
- const [floatSize, setFloatSize] = useState({ w: 480, h: 600 });
79
- const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
80
- const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
70
+ const [floatInteraction, setFloatInteraction] = useState<FloatingPanelInteraction | null>(null);
81
71
  const iframeRef = useRef<HTMLIFrameElement>(null);
82
72
  const currentUrl = currentTab?.currentUrl ?? DOCS_DEFAULT_BASE_URL;
83
73
  const navVersion = currentTab?.navVersion ?? 0;
84
74
  const prevNavVersionRef = useRef(navVersion);
85
75
  const isDocsTab = currentTab?.kind === 'docs';
76
+ const customRenderer = currentTab ? customTabRenderers[currentTab.kind] : undefined;
86
77
 
87
78
  useEffect(() => {
88
79
  if (!isDocsTab) {
@@ -118,15 +109,6 @@ export function DocBrowser({
118
109
  }
119
110
  }, [currentUrl, navVersion, isDocsTab]);
120
111
 
121
- useEffect(() => {
122
- if (mode === 'floating') {
123
- setFloatPos((prev) => ({
124
- x: Math.max(40, window.innerWidth - floatSize.w - 40),
125
- y: prev.y,
126
- }));
127
- }
128
- }, [mode, floatSize.w]);
129
-
130
112
  useEffect(() => {
131
113
  const handler = (e: MessageEvent) => {
132
114
  if (!isDocsTab) {
@@ -140,6 +122,91 @@ export function DocBrowser({
140
122
  return () => window.removeEventListener('message', handler);
141
123
  }, [syncUrl, isDocsTab]);
142
124
 
125
+ useEffect(() => {
126
+ if (!currentTab || !customRenderer?.onIframeMessage) {
127
+ return;
128
+ }
129
+ const handler = (event: MessageEvent) => {
130
+ customRenderer.onIframeMessage?.({
131
+ event,
132
+ iframe: iframeRef.current,
133
+ tab: currentTab,
134
+ });
135
+ };
136
+ window.addEventListener('message', handler);
137
+ return () => window.removeEventListener('message', handler);
138
+ }, [currentTab, customRenderer]);
139
+
140
+ const startFloatDrag = useCallback((event: React.PointerEvent<HTMLElement>) => {
141
+ event.preventDefault();
142
+ setFloatInteraction({
143
+ kind: 'drag',
144
+ startX: event.clientX,
145
+ startY: event.clientY,
146
+ startRect: floatRect,
147
+ });
148
+ }, [floatRect]);
149
+
150
+ const startFloatResize = useCallback((edge: FloatingPanelResizeEdge) => (event: React.PointerEvent<HTMLElement>) => {
151
+ event.preventDefault();
152
+ event.stopPropagation();
153
+ setFloatInteraction({
154
+ kind: 'resize',
155
+ edge,
156
+ startX: event.clientX,
157
+ startY: event.clientY,
158
+ startRect: floatRect,
159
+ });
160
+ }, [floatRect]);
161
+
162
+ useEffect(() => {
163
+ if (!floatInteraction) return;
164
+
165
+ const onMove = (event: PointerEvent) => {
166
+ const { startRect, startX, startY } = floatInteraction;
167
+ const dx = event.clientX - startX;
168
+ const dy = event.clientY - startY;
169
+
170
+ if (floatInteraction.kind === 'drag') {
171
+ setFloatRect({
172
+ ...startRect,
173
+ x: clamp(startRect.x + dx, FLOATING_PANEL_MARGIN, window.innerWidth - FLOATING_PANEL_MARGIN - startRect.w),
174
+ y: clamp(startRect.y + dy, FLOATING_PANEL_MARGIN, window.innerHeight - FLOATING_PANEL_MARGIN - startRect.h),
175
+ });
176
+ return;
177
+ }
178
+
179
+ if (floatInteraction.edge === 'left' || floatInteraction.edge === 'top') {
180
+ const isLeftEdge = floatInteraction.edge === 'left';
181
+ const fixedEdge = isLeftEdge ? startRect.x + startRect.w : startRect.y + startRect.h;
182
+ const movingEdge = isLeftEdge ? startRect.x + dx : startRect.y + dy;
183
+ const minSize = isLeftEdge ? FLOATING_PANEL_MIN_WIDTH : FLOATING_PANEL_MIN_HEIGHT;
184
+ const nextEdge = clamp(movingEdge, FLOATING_PANEL_MARGIN, fixedEdge - minSize);
185
+ setFloatRect(isLeftEdge ? { ...startRect, x: nextEdge, w: fixedEdge - nextEdge } : { ...startRect, y: nextEdge, h: fixedEdge - nextEdge });
186
+ return;
187
+ }
188
+
189
+ const right = clamp(startRect.x + startRect.w + dx, startRect.x + FLOATING_PANEL_MIN_WIDTH, window.innerWidth - FLOATING_PANEL_MARGIN);
190
+ const bottom = clamp(startRect.y + startRect.h + dy, startRect.y + FLOATING_PANEL_MIN_HEIGHT, window.innerHeight - FLOATING_PANEL_MARGIN);
191
+ setFloatRect({
192
+ ...startRect,
193
+ w: floatInteraction.edge === 'bottom' ? startRect.w : right - startRect.x,
194
+ h: floatInteraction.edge === 'right' ? startRect.h : bottom - startRect.y,
195
+ });
196
+ };
197
+
198
+ const onEnd = () => setFloatInteraction(null);
199
+
200
+ window.addEventListener('pointermove', onMove);
201
+ window.addEventListener('pointerup', onEnd);
202
+ window.addEventListener('pointercancel', onEnd);
203
+ return () => {
204
+ window.removeEventListener('pointermove', onMove);
205
+ window.removeEventListener('pointerup', onEnd);
206
+ window.removeEventListener('pointercancel', onEnd);
207
+ };
208
+ }, [floatInteraction]);
209
+
143
210
  const handleUrlSubmit = useCallback((e: React.FormEvent) => {
144
211
  e.preventDefault();
145
212
  if (!isDocsTab) return;
@@ -154,88 +221,6 @@ export function DocBrowser({
154
221
  }
155
222
  }, [urlInput, navigate, isDocsTab]);
156
223
 
157
- const onDragStart = useCallback((e: React.MouseEvent) => {
158
- if (mode !== 'floating') return;
159
- setIsDragging(true);
160
- dragRef.current = {
161
- startX: e.clientX,
162
- startY: e.clientY,
163
- startPosX: floatPos.x,
164
- startPosY: floatPos.y,
165
- };
166
- }, [mode, floatPos]);
167
-
168
- useEffect(() => {
169
- if (!isDragging) return;
170
- const onMove = (e: MouseEvent) => {
171
- if (!dragRef.current) return;
172
- setFloatPos({
173
- x: dragRef.current.startPosX + (e.clientX - dragRef.current.startX),
174
- y: dragRef.current.startPosY + (e.clientY - dragRef.current.startY),
175
- });
176
- };
177
- const onUp = () => {
178
- setIsDragging(false);
179
- dragRef.current = null;
180
- };
181
- window.addEventListener('mousemove', onMove);
182
- window.addEventListener('mouseup', onUp);
183
- return () => {
184
- window.removeEventListener('mousemove', onMove);
185
- window.removeEventListener('mouseup', onUp);
186
- };
187
- }, [isDragging]);
188
-
189
- const onResizeStart = useCallback((e: React.MouseEvent) => {
190
- e.preventDefault();
191
- e.stopPropagation();
192
- setIsResizing(true);
193
- const { axis } = (e.currentTarget as HTMLElement).dataset;
194
- resizeRef.current = {
195
- startX: e.clientX,
196
- startY: e.clientY,
197
- startW: floatSize.w,
198
- startH: floatSize.h,
199
- };
200
- const onMove = (ev: MouseEvent) => {
201
- if (!resizeRef.current) return;
202
- setFloatSize((prev) => ({
203
- w: axis === 'y' ? prev.w : Math.max(360, resizeRef.current!.startW + (ev.clientX - resizeRef.current!.startX)),
204
- h: axis === 'x' ? prev.h : Math.max(400, resizeRef.current!.startH + (ev.clientY - resizeRef.current!.startY)),
205
- }));
206
- };
207
- const onUp = () => {
208
- setIsResizing(false);
209
- resizeRef.current = null;
210
- window.removeEventListener('mousemove', onMove);
211
- window.removeEventListener('mouseup', onUp);
212
- };
213
- window.addEventListener('mousemove', onMove);
214
- window.addEventListener('mouseup', onUp);
215
- }, [floatSize]);
216
-
217
- const onLeftResizeStart = useCallback((e: React.MouseEvent) => {
218
- e.preventDefault();
219
- e.stopPropagation();
220
- setIsResizing(true);
221
- const startX = e.clientX;
222
- const startW = floatSize.w;
223
- const startPosX = floatPos.x;
224
- const onMove = (ev: MouseEvent) => {
225
- const delta = startX - ev.clientX;
226
- const newW = Math.max(360, startW + delta);
227
- setFloatSize((prev) => ({ ...prev, w: newW }));
228
- setFloatPos((prev) => ({ ...prev, x: startPosX - (newW - startW) }));
229
- };
230
- const onUp = () => {
231
- setIsResizing(false);
232
- window.removeEventListener('mousemove', onMove);
233
- window.removeEventListener('mouseup', onUp);
234
- };
235
- window.addEventListener('mousemove', onMove);
236
- window.addEventListener('mouseup', onUp);
237
- }, [floatSize.w, floatPos.x]);
238
-
239
224
  const refreshIframe = useCallback(() => {
240
225
  setIframeReloadVersion((version) => version + 1);
241
226
  }, []);
@@ -244,7 +229,6 @@ export function DocBrowser({
244
229
 
245
230
  const isDocked = mode === 'docked';
246
231
  const isFullscreen = displayMode === 'fullscreen';
247
- const customRenderer = currentTab ? customTabRenderers[currentTab.kind] : undefined;
248
232
  const customRenderParams = currentTab ? {
249
233
  currentUrl,
250
234
  open,
@@ -265,7 +249,7 @@ export function DocBrowser({
265
249
  isDocked={isDocked}
266
250
  isFullscreen={isFullscreen}
267
251
  onClose={close}
268
- onDragStart={onDragStart}
252
+ onDragStart={startFloatDrag}
269
253
  onToggleMode={toggleMode}
270
254
  />
271
255
 
@@ -297,8 +281,8 @@ export function DocBrowser({
297
281
  iframeRef={iframeRef}
298
282
  iframeReloadVersion={iframeReloadVersion}
299
283
  iframeSandbox={iframeSandbox}
300
- isDragging={isDragging}
301
- isResizing={isResizing}
284
+ isDragging={floatInteraction?.kind === 'drag'}
285
+ isResizing={floatInteraction?.kind === 'resize'}
302
286
  navVersion={navVersion}
303
287
  />
304
288
 
@@ -319,22 +303,52 @@ export function DocBrowser({
319
303
  );
320
304
  }
321
305
 
322
- const panel = (
306
+ return (
323
307
  <div
324
308
  data-testid="doc-browser-panel"
325
- className={getPanelClassName(isFullscreen)}
326
- style={getPanelStyle({ isFullscreen, floatPos, floatSize })}
309
+ className={cn(
310
+ 'flex flex-col bg-white overflow-hidden relative',
311
+ isFullscreen
312
+ ? 'fixed inset-0 z-[9999] h-[100dvh] w-screen rounded-none border-0 shadow-2xl'
313
+ : 'rounded-2xl shadow-2xl border border-gray-200',
314
+ )}
315
+ style={
316
+ isFullscreen
317
+ ? undefined
318
+ : {
319
+ position: 'fixed',
320
+ left: floatRect.x,
321
+ top: floatRect.y,
322
+ width: floatRect.w,
323
+ height: floatRect.h,
324
+ zIndex: 9999,
325
+ }
326
+ }
327
327
  >
328
328
  {panelContent}
329
329
 
330
330
  {!isDocked && !isFullscreen && (
331
331
  <>
332
- <div className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onLeftResizeStart} />
333
- <div className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onResizeStart} data-axis="x" />
334
- <div className="absolute bottom-0 left-0 h-1.5 w-full cursor-ns-resize z-20 hover:bg-primary/10 transition-colors" onMouseDown={onResizeStart} data-axis="y" />
332
+ <div className="absolute top-0 left-0 h-1.5 w-full cursor-ns-resize z-20 hover:bg-primary/10 transition-colors" data-testid="doc-browser-resize-top" onPointerDown={startFloatResize('top')} />
333
+ <div
334
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors"
335
+ data-testid="doc-browser-resize-left"
336
+ onPointerDown={startFloatResize('left')}
337
+ />
338
+ <div
339
+ className="absolute top-0 right-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors"
340
+ data-testid="doc-browser-resize-right"
341
+ onPointerDown={startFloatResize('right')}
342
+ />
343
+ <div
344
+ className="absolute bottom-0 left-0 h-1.5 w-full cursor-ns-resize z-20 hover:bg-primary/10 transition-colors"
345
+ data-testid="doc-browser-resize-bottom"
346
+ onPointerDown={startFloatResize('bottom')}
347
+ />
335
348
  <div
336
349
  className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize z-30 flex items-center justify-center text-gray-300 hover:text-gray-500 transition-colors"
337
- onMouseDown={onResizeStart}
350
+ data-testid="doc-browser-resize-bottom-right"
351
+ onPointerDown={startFloatResize('bottom-right')}
338
352
  >
339
353
  <GripVertical className="w-3 h-3 rotate-[-45deg]" />
340
354
  </div>
@@ -342,6 +356,4 @@ export function DocBrowser({
342
356
  )}
343
357
  </div>
344
358
  );
345
-
346
- return panel;
347
359
  }
@@ -0,0 +1,272 @@
1
+ import type {
2
+ DocBrowserOpenOptions,
3
+ DocBrowserState,
4
+ DocBrowserStateUpdate,
5
+ DocBrowserTab,
6
+ DocBrowserTabKind,
7
+ } from '@/shared/components/doc-browser/types/doc-browser.types';
8
+ import {
9
+ createDefaultDocBrowserState,
10
+ createDocBrowserTab,
11
+ } from '@/shared/components/doc-browser/utils/doc-browser-state.utils';
12
+ import {
13
+ getDefaultDocsUrl,
14
+ inferTabKind,
15
+ normalizeDocUrl,
16
+ normalizeUrlByKind,
17
+ } from '@/shared/components/doc-browser/utils/doc-browser-url.utils';
18
+ import { useDocBrowserStore } from '@/shared/components/doc-browser/stores/doc-browser.store';
19
+
20
+ function updateTab(
21
+ state: DocBrowserState,
22
+ tabId: string,
23
+ updater: (tab: DocBrowserTab) => DocBrowserTab,
24
+ ): DocBrowserState {
25
+ return {
26
+ ...state,
27
+ tabs: state.tabs.map((tab) => (tab.id === tabId ? updater(tab) : tab)),
28
+ };
29
+ }
30
+
31
+ function updateActiveTab(state: DocBrowserState, updater: (tab: DocBrowserTab) => DocBrowserTab): DocBrowserState {
32
+ return updateTab(state, state.activeTabId, updater);
33
+ }
34
+
35
+ function areOpenUrlsEquivalent(
36
+ currentUrl: string,
37
+ nextUrl: string,
38
+ currentKind: DocBrowserTabKind,
39
+ nextKind: DocBrowserTabKind,
40
+ ): boolean {
41
+ if (currentKind === 'docs' && nextKind === 'docs') {
42
+ return normalizeDocUrl(currentUrl) === normalizeDocUrl(nextUrl);
43
+ }
44
+ return currentUrl === nextUrl;
45
+ }
46
+
47
+ function updateTabForOpen(
48
+ tab: DocBrowserTab,
49
+ url: string,
50
+ kind: DocBrowserTabKind,
51
+ options?: DocBrowserOpenOptions,
52
+ dedupeKey?: string,
53
+ ): DocBrowserTab {
54
+ const baseTab = {
55
+ ...tab,
56
+ title: options?.title || tab.title,
57
+ kind,
58
+ dedupeKey,
59
+ };
60
+
61
+ if (areOpenUrlsEquivalent(tab.currentUrl, url, tab.kind, kind)) {
62
+ return baseTab;
63
+ }
64
+
65
+ return {
66
+ ...baseTab,
67
+ currentUrl: url,
68
+ history: [...tab.history.slice(0, tab.historyIndex + 1), url],
69
+ historyIndex: tab.historyIndex + 1,
70
+ navVersion: tab.navVersion + 1,
71
+ };
72
+ }
73
+
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
+ function openDocBrowserState(
91
+ prev: DocBrowserState,
92
+ url?: string,
93
+ options?: DocBrowserOpenOptions,
94
+ ): DocBrowserState {
95
+ const {
96
+ activate,
97
+ dedupeKey: rawDedupeKey,
98
+ kind,
99
+ newTab: shouldForceNewTab,
100
+ title,
101
+ } = options ?? {};
102
+ 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;
106
+ const matchedTab = dedupeKey
107
+ ? prev.tabs.find((tab) => tab.dedupeKey === dedupeKey)
108
+ : undefined;
109
+
110
+ if (matchedTab) {
111
+ const next = updateTab(
112
+ prev,
113
+ matchedTab.id,
114
+ (tab) => updateTabForOpen(tab, targetUrl, targetKind, options, dedupeKey),
115
+ );
116
+ return {
117
+ ...next,
118
+ activeTabId: activate === false ? prev.activeTabId : matchedTab.id,
119
+ isOpen: true,
120
+ };
121
+ }
122
+
123
+ if (shouldForceNewTab || dedupeKey || !activeTab || activeTab.kind !== targetKind) {
124
+ const newTab = createDocBrowserTab(targetUrl, targetKind, title, dedupeKey);
125
+ return {
126
+ ...prev,
127
+ isOpen: true,
128
+ tabs: [...prev.tabs, newTab],
129
+ activeTabId: newTab.id,
130
+ };
131
+ }
132
+
133
+ const next = updateActiveTab(
134
+ prev,
135
+ (tab) => updateTabForOpen(tab, targetUrl, targetKind, options, dedupeKey),
136
+ );
137
+ return { ...next, isOpen: true };
138
+ }
139
+
140
+ export class DocBrowserManager {
141
+ private readonly setSnapshot = (next: DocBrowserStateUpdate): void => {
142
+ useDocBrowserStore.getState().setSnapshot(next);
143
+ };
144
+
145
+ readonly open = (url?: string, options?: DocBrowserOpenOptions): void => {
146
+ this.setSnapshot((prev) => openDocBrowserState(prev, url, options));
147
+ };
148
+
149
+ readonly close = (): void => {
150
+ this.setSnapshot((prev) => ({ ...prev, isOpen: false }));
151
+ };
152
+
153
+ readonly toggleMode = (): void => {
154
+ this.setSnapshot((prev) => ({ ...prev, mode: prev.mode === 'floating' ? 'docked' : 'floating' }));
155
+ };
156
+
157
+ readonly navigate = (url: string): void => {
158
+ this.setSnapshot((prev) => {
159
+ if (!prev.tabs.length) {
160
+ const fallbackTab = createDocBrowserTab(getDefaultDocsUrl(), 'docs', 'Docs');
161
+ return {
162
+ ...prev,
163
+ tabs: [fallbackTab],
164
+ activeTabId: fallbackTab.id,
165
+ isOpen: true,
166
+ };
167
+ }
168
+
169
+ return updateActiveTab(prev, (tab) => {
170
+ if (tab.kind !== 'docs') {
171
+ return tab;
172
+ }
173
+
174
+ const targetUrl = normalizeUrlByKind(url, 'docs');
175
+ if (normalizeDocUrl(targetUrl) === normalizeDocUrl(tab.currentUrl)) {
176
+ return tab;
177
+ }
178
+
179
+ return {
180
+ ...tab,
181
+ currentUrl: targetUrl,
182
+ history: [...tab.history.slice(0, tab.historyIndex + 1), targetUrl],
183
+ historyIndex: tab.historyIndex + 1,
184
+ navVersion: tab.navVersion + 1,
185
+ };
186
+ });
187
+ });
188
+ };
189
+
190
+ readonly syncUrl = (url: string): void => {
191
+ this.setSnapshot((prev) => {
192
+ if (!prev.tabs.length) {
193
+ const fallbackTab = createDocBrowserTab(getDefaultDocsUrl(), 'docs', 'Docs');
194
+ return {
195
+ ...prev,
196
+ tabs: [fallbackTab],
197
+ activeTabId: fallbackTab.id,
198
+ };
199
+ }
200
+
201
+ return updateActiveTab(prev, (tab) => {
202
+ if (tab.kind !== 'docs') {
203
+ return tab;
204
+ }
205
+
206
+ if (normalizeDocUrl(url) === normalizeDocUrl(tab.currentUrl)) {
207
+ return tab;
208
+ }
209
+
210
+ return {
211
+ ...tab,
212
+ currentUrl: url,
213
+ history: [...tab.history.slice(0, tab.historyIndex + 1), url],
214
+ historyIndex: tab.historyIndex + 1,
215
+ };
216
+ });
217
+ });
218
+ };
219
+
220
+ 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
+ }));
226
+ };
227
+
228
+ 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
+ }));
234
+ };
235
+
236
+ readonly closeTab = (tabId: string): void => {
237
+ this.setSnapshot((prev) => {
238
+ if (prev.tabs.length <= 1) {
239
+ const fallbackState = createDefaultDocBrowserState();
240
+ return {
241
+ ...fallbackState,
242
+ isOpen: false,
243
+ };
244
+ }
245
+
246
+ const index = prev.tabs.findIndex((tab) => tab.id === tabId);
247
+ if (index < 0) {
248
+ return prev;
249
+ }
250
+
251
+ const nextTabs = prev.tabs.filter((tab) => tab.id !== tabId);
252
+ const nextActiveId = prev.activeTabId === tabId
253
+ ? nextTabs[Math.max(0, index - 1)]?.id ?? nextTabs[0].id
254
+ : prev.activeTabId;
255
+
256
+ return {
257
+ ...prev,
258
+ tabs: nextTabs,
259
+ activeTabId: nextActiveId,
260
+ };
261
+ });
262
+ };
263
+
264
+ readonly setActiveTab = (tabId: string): void => {
265
+ this.setSnapshot((prev) => {
266
+ if (!prev.tabs.some((tab) => tab.id === tabId)) {
267
+ return prev;
268
+ }
269
+ return { ...prev, activeTabId: tabId, isOpen: true };
270
+ });
271
+ };
272
+ }