@nextclaw/ui 0.5.0 → 0.5.2

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.
package/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-1h_LfFkZ.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-Wn63frSd.css">
9
+ <script type="module" crossorigin src="/assets/index-D8W5lAHk.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CKTsCtI-.css">
11
11
  </head>
12
12
 
13
13
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,6 +2,8 @@ import { api } from './client';
2
2
  import type {
3
3
  MarketplaceInstallRequest,
4
4
  MarketplaceInstallResult,
5
+ MarketplaceManageRequest,
6
+ MarketplaceManageResult,
5
7
  MarketplaceInstalledView,
6
8
  MarketplaceItemType,
7
9
  MarketplaceItemView,
@@ -108,3 +110,11 @@ export async function fetchMarketplaceInstalled(): Promise<MarketplaceInstalledV
108
110
  }
109
111
  return response.data;
110
112
  }
113
+
114
+ export async function manageMarketplaceItem(request: MarketplaceManageRequest): Promise<MarketplaceManageResult> {
115
+ const response = await api.post<MarketplaceManageResult>('/api/marketplace/manage', request);
116
+ if (!response.ok) {
117
+ throw new Error(response.error.message);
118
+ }
119
+ return response.data;
120
+ }
package/src/api/types.ts CHANGED
@@ -244,9 +244,9 @@ export type WsEvent =
244
244
 
245
245
  export type MarketplaceItemType = 'plugin' | 'skill';
246
246
 
247
- export type MarketplaceSort = 'relevance' | 'updated' | 'downloads';
247
+ export type MarketplaceSort = 'relevance' | 'updated';
248
248
 
249
- export type MarketplaceInstallKind = 'npm' | 'clawhub' | 'git';
249
+ export type MarketplaceInstallKind = 'npm' | 'clawhub' | 'git' | 'builtin';
250
250
 
251
251
  export type MarketplaceInstallSpec = {
252
252
  kind: MarketplaceInstallKind;
@@ -254,11 +254,6 @@ export type MarketplaceInstallSpec = {
254
254
  command: string;
255
255
  };
256
256
 
257
- export type MarketplaceItemMetrics = {
258
- downloads30d?: number;
259
- stars?: number;
260
- };
261
-
262
257
  export type MarketplaceItemSummary = {
263
258
  id: string;
264
259
  slug: string;
@@ -268,7 +263,6 @@ export type MarketplaceItemSummary = {
268
263
  tags: string[];
269
264
  author: string;
270
265
  install: MarketplaceInstallSpec;
271
- metrics?: MarketplaceItemMetrics;
272
266
  updatedAt: string;
273
267
  };
274
268
 
@@ -299,10 +293,15 @@ export type MarketplaceRecommendationView = {
299
293
 
300
294
  export type MarketplaceInstalledRecord = {
301
295
  type: MarketplaceItemType;
296
+ id?: string;
302
297
  spec: string;
303
298
  label?: string;
304
299
  source?: string;
305
300
  installedAt?: string;
301
+ enabled?: boolean;
302
+ runtimeStatus?: string;
303
+ origin?: string;
304
+ installPath?: string;
306
305
  };
307
306
 
308
307
  export type MarketplaceInstalledView = {
@@ -315,6 +314,7 @@ export type MarketplaceInstalledView = {
315
314
  export type MarketplaceInstallRequest = {
316
315
  type: MarketplaceItemType;
317
316
  spec: string;
317
+ kind?: MarketplaceInstallKind;
318
318
  version?: string;
319
319
  registry?: string;
320
320
  force?: boolean;
@@ -326,3 +326,20 @@ export type MarketplaceInstallResult = {
326
326
  message: string;
327
327
  output?: string;
328
328
  };
329
+
330
+ export type MarketplaceManageAction = 'enable' | 'disable' | 'uninstall';
331
+
332
+ export type MarketplaceManageRequest = {
333
+ type: MarketplaceItemType;
334
+ action: MarketplaceManageAction;
335
+ id?: string;
336
+ spec?: string;
337
+ };
338
+
339
+ export type MarketplaceManageResult = {
340
+ type: MarketplaceItemType;
341
+ action: MarketplaceManageAction;
342
+ id: string;
343
+ message: string;
344
+ output?: string;
345
+ };
@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
13
13
  import { Input } from '@/components/ui/input';
14
14
  import { Label } from '@/components/ui/label';
15
15
  import { Switch } from '@/components/ui/switch';
16
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
16
17
  import { TagInput } from '@/components/common/TagInput';
17
18
  import { t } from '@/lib/i18n';
18
19
  import { hintForPath } from '@/lib/config-hints';
@@ -410,18 +411,21 @@ export function ChannelForm() {
410
411
  )}
411
412
 
412
413
  {field.type === 'select' && (
413
- <select
414
- id={field.name}
414
+ <Select
415
415
  value={(formData[field.name] as string) || ''}
416
- onChange={(e) => updateField(field.name, e.target.value)}
417
- className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
416
+ onValueChange={(v) => updateField(field.name, v)}
418
417
  >
419
- {(field.options ?? []).map((option) => (
420
- <option key={option.value} value={option.value}>
421
- {option.label}
422
- </option>
423
- ))}
424
- </select>
418
+ <SelectTrigger className="rounded-xl">
419
+ <SelectValue />
420
+ </SelectTrigger>
421
+ <SelectContent>
422
+ {(field.options ?? []).map((option) => (
423
+ <SelectItem key={option.value} value={option.value}>
424
+ {option.label}
425
+ </SelectItem>
426
+ ))}
427
+ </SelectContent>
428
+ </Select>
425
429
  )}
426
430
 
427
431
  {field.type === 'json' && (
@@ -12,6 +12,7 @@ import {
12
12
  import { Button } from '@/components/ui/button';
13
13
  import { Input } from '@/components/ui/input';
14
14
  import { Label } from '@/components/ui/label';
15
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
15
16
  import { MaskedInput } from '@/components/common/MaskedInput';
16
17
  import { KeyValueEditor } from '@/components/common/KeyValueEditor';
17
18
  import { t } from '@/lib/i18n';
@@ -149,22 +150,22 @@ export function ProviderForm() {
149
150
  <Hash className="h-3.5 w-3.5 text-gray-500" />
150
151
  {wireApiHint?.label ?? t('wireApi')}
151
152
  </Label>
152
- <select
153
- id="wireApi"
154
- value={wireApi}
155
- onChange={(e) => setWireApi(e.target.value as 'auto' | 'chat' | 'responses')}
156
- className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
157
- >
158
- {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
159
- <option key={option} value={option}>
160
- {option === 'chat'
161
- ? t('wireApiChat')
162
- : option === 'responses'
163
- ? t('wireApiResponses')
164
- : t('wireApiAuto')}
165
- </option>
166
- ))}
167
- </select>
153
+ <Select value={wireApi} onValueChange={(v) => setWireApi(v as 'auto' | 'chat' | 'responses')}>
154
+ <SelectTrigger className="rounded-xl">
155
+ <SelectValue />
156
+ </SelectTrigger>
157
+ <SelectContent>
158
+ {(providerSpec.wireApiOptions || ['auto', 'chat', 'responses']).map((option) => (
159
+ <SelectItem key={option} value={option}>
160
+ {option === 'chat'
161
+ ? t('wireApiChat')
162
+ : option === 'responses'
163
+ ? t('wireApiResponses')
164
+ : t('wireApiAuto')}
165
+ </SelectItem>
166
+ ))}
167
+ </SelectContent>
168
+ </Select>
168
169
  </div>
169
170
  )}
170
171
 
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
5
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
6
6
  import { Input } from '@/components/ui/input';
7
7
  import { Switch } from '@/components/ui/switch';
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
8
9
  import { hintForPath } from '@/lib/config-hints';
9
10
  import { Plus, Save, Trash2 } from 'lucide-react';
10
11
  import { toast } from 'sonner';
@@ -84,9 +85,9 @@ export function RuntimeConfig() {
84
85
  accountId: binding.match?.accountId ?? '',
85
86
  peer: binding.match?.peer
86
87
  ? {
87
- kind: binding.match.peer.kind,
88
- id: binding.match.peer.id
89
- }
88
+ kind: binding.match.peer.kind,
89
+ id: binding.match.peer.id
90
+ }
90
91
  : undefined
91
92
  }
92
93
  }))
@@ -260,17 +261,18 @@ export function RuntimeConfig() {
260
261
  </div>
261
262
  <div className="space-y-2">
262
263
  <label className="text-sm font-medium text-gray-800">{dmScopeHint?.label ?? 'DM Scope'}</label>
263
- <select
264
- value={dmScope}
265
- onChange={(event) => setDmScope(event.target.value as DmScope)}
266
- className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
267
- >
268
- {DM_SCOPE_OPTIONS.map((option) => (
269
- <option key={option.value} value={option.value}>
270
- {option.label}
271
- </option>
272
- ))}
273
- </select>
264
+ <Select value={dmScope} onValueChange={(v) => setDmScope(v as DmScope)}>
265
+ <SelectTrigger>
266
+ <SelectValue />
267
+ </SelectTrigger>
268
+ <SelectContent>
269
+ {DM_SCOPE_OPTIONS.map((option) => (
270
+ <SelectItem key={option.value} value={option.value}>
271
+ {option.label}
272
+ </SelectItem>
273
+ ))}
274
+ </SelectContent>
275
+ </Select>
274
276
  </div>
275
277
  <div className="space-y-2">
276
278
  <label className="text-sm font-medium text-gray-800">
@@ -429,10 +431,10 @@ export function RuntimeConfig() {
429
431
  }
430
432
  placeholder="Account ID (optional)"
431
433
  />
432
- <select
433
- value={peerKind}
434
- onChange={(event) => {
435
- const nextKind = event.target.value as PeerKind;
434
+ <Select
435
+ value={peerKind || '__none__'}
436
+ onValueChange={(v) => {
437
+ const nextKind = v === '__none__' ? '' : v as PeerKind;
436
438
  if (!nextKind) {
437
439
  updateBinding(index, {
438
440
  ...binding,
@@ -454,13 +456,17 @@ export function RuntimeConfig() {
454
456
  }
455
457
  });
456
458
  }}
457
- className="flex h-10 w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm"
458
459
  >
459
- <option value="">Peer kind (optional)</option>
460
- <option value="direct">direct</option>
461
- <option value="group">group</option>
462
- <option value="channel">channel</option>
463
- </select>
460
+ <SelectTrigger>
461
+ <SelectValue />
462
+ </SelectTrigger>
463
+ <SelectContent>
464
+ <SelectItem value="__none__">Peer kind (optional)</SelectItem>
465
+ <SelectItem value="direct">direct</SelectItem>
466
+ <SelectItem value="group">group</SelectItem>
467
+ <SelectItem value="channel">channel</SelectItem>
468
+ </SelectContent>
469
+ </Select>
464
470
  <Input
465
471
  value={binding.match.peer?.id ?? ''}
466
472
  onChange={(event) =>
@@ -470,9 +476,9 @@ export function RuntimeConfig() {
470
476
  ...binding.match,
471
477
  peer: peerKind
472
478
  ? {
473
- kind: peerKind,
474
- id: event.target.value
475
- }
479
+ kind: peerKind,
480
+ id: event.target.value
481
+ }
476
482
  : undefined
477
483
  }
478
484
  })
@@ -23,20 +23,26 @@ import {
23
23
  */
24
24
  export function DocBrowser() {
25
25
  const {
26
- isOpen, mode, currentUrl,
26
+ isOpen, mode, currentUrl, navVersion,
27
27
  close, toggleMode,
28
28
  goBack, goForward, canGoBack, canGoForward,
29
- navigate,
29
+ navigate, syncUrl,
30
30
  } = useDocBrowser();
31
31
 
32
32
  const [urlInput, setUrlInput] = useState('');
33
33
  const [isDragging, setIsDragging] = useState(false);
34
- const [floatPos, setFloatPos] = useState({ x: 120, y: 80 });
34
+ const [isResizing, setIsResizing] = useState(false);
35
+ const [floatPos, setFloatPos] = useState(() => ({
36
+ x: Math.max(40, window.innerWidth - 520),
37
+ y: 80,
38
+ }));
35
39
  const [floatSize, setFloatSize] = useState({ w: 480, h: 600 });
36
40
  const [dockedWidth, setDockedWidth] = useState(420);
37
41
  const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
38
42
  const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
39
43
  const dockResizeRef = useRef<{ startX: number; startW: number } | null>(null);
44
+ const iframeRef = useRef<HTMLIFrameElement>(null);
45
+ const prevNavVersionRef = useRef(navVersion);
40
46
 
41
47
  // Sync URL input with current URL
42
48
  useEffect(() => {
@@ -48,16 +54,43 @@ export function DocBrowser() {
48
54
  }
49
55
  }, [currentUrl]);
50
56
 
57
+ // When currentUrl changes without navVersion bump (goBack/goForward),
58
+ // use postMessage to SPA-navigate inside the iframe instead of remounting
59
+ useEffect(() => {
60
+ if (navVersion !== prevNavVersionRef.current) {
61
+ // navVersion changed — iframe will remount via key, no need to postMessage
62
+ prevNavVersionRef.current = navVersion;
63
+ return;
64
+ }
65
+ // Same navVersion — means goBack/goForward triggered
66
+ if (iframeRef.current?.contentWindow) {
67
+ try {
68
+ const path = new URL(currentUrl).pathname;
69
+ iframeRef.current.contentWindow.postMessage({ type: 'docs-navigate', path }, '*');
70
+ } catch { /* ignore */ }
71
+ }
72
+ }, [currentUrl, navVersion]);
73
+
74
+ // Reposition floating window near right edge when switching from docked
75
+ useEffect(() => {
76
+ if (mode === 'floating') {
77
+ setFloatPos(prev => ({
78
+ x: Math.max(40, window.innerWidth - floatSize.w - 40),
79
+ y: prev.y,
80
+ }));
81
+ }
82
+ }, [mode, floatSize.w]);
83
+
51
84
  // Listen for route changes from the iframe via postMessage
52
85
  useEffect(() => {
53
86
  const handler = (e: MessageEvent) => {
54
87
  if (e.data?.type === 'docs-route-change' && typeof e.data.url === 'string') {
55
- navigate(e.data.url);
88
+ syncUrl(e.data.url);
56
89
  }
57
90
  };
58
91
  window.addEventListener('message', handler);
59
92
  return () => window.removeEventListener('message', handler);
60
- }, [navigate]);
93
+ }, [syncUrl]);
61
94
 
62
95
  const handleUrlSubmit = useCallback((e: React.FormEvent) => {
63
96
  e.preventDefault();
@@ -105,10 +138,12 @@ export function DocBrowser() {
105
138
  };
106
139
  }, [isDragging]);
107
140
 
108
- // --- Resize logic (floating mode — bottom-right corner) ---
141
+ // --- Resize logic (floating mode) ---
109
142
  const onResizeStart = useCallback((e: React.MouseEvent) => {
110
143
  e.preventDefault();
111
144
  e.stopPropagation();
145
+ setIsResizing(true);
146
+ const axis = (e.currentTarget as HTMLElement).dataset.axis; // 'x', 'y', or undefined (both)
112
147
  resizeRef.current = {
113
148
  startX: e.clientX,
114
149
  startY: e.clientY,
@@ -117,12 +152,13 @@ export function DocBrowser() {
117
152
  };
118
153
  const onMove = (ev: MouseEvent) => {
119
154
  if (!resizeRef.current) return;
120
- setFloatSize({
121
- w: Math.max(360, resizeRef.current.startW + (ev.clientX - resizeRef.current.startX)),
122
- h: Math.max(400, resizeRef.current.startH + (ev.clientY - resizeRef.current.startY)),
123
- });
155
+ setFloatSize(prev => ({
156
+ w: axis === 'y' ? prev.w : Math.max(360, resizeRef.current!.startW + (ev.clientX - resizeRef.current!.startX)),
157
+ h: axis === 'x' ? prev.h : Math.max(400, resizeRef.current!.startH + (ev.clientY - resizeRef.current!.startY)),
158
+ }));
124
159
  };
125
160
  const onUp = () => {
161
+ setIsResizing(false);
126
162
  resizeRef.current = null;
127
163
  window.removeEventListener('mousemove', onMove);
128
164
  window.removeEventListener('mouseup', onUp);
@@ -135,14 +171,15 @@ export function DocBrowser() {
135
171
  const onDockResizeStart = useCallback((e: React.MouseEvent) => {
136
172
  e.preventDefault();
137
173
  e.stopPropagation();
174
+ setIsResizing(true);
138
175
  dockResizeRef.current = { startX: e.clientX, startW: dockedWidth };
139
176
  const onMove = (ev: MouseEvent) => {
140
177
  if (!dockResizeRef.current) return;
141
- // Dragging left should increase width (since resize handle is on the left edge)
142
178
  const delta = dockResizeRef.current.startX - ev.clientX;
143
179
  setDockedWidth(Math.max(320, Math.min(800, dockResizeRef.current.startW + delta)));
144
180
  };
145
181
  const onUp = () => {
182
+ setIsResizing(false);
146
183
  dockResizeRef.current = null;
147
184
  window.removeEventListener('mousemove', onMove);
148
185
  window.removeEventListener('mouseup', onUp);
@@ -151,6 +188,29 @@ export function DocBrowser() {
151
188
  window.addEventListener('mouseup', onUp);
152
189
  }, [dockedWidth]);
153
190
 
191
+ // --- Left-edge resize logic (floating mode — adjusts both position and width) ---
192
+ const onLeftResizeStart = useCallback((e: React.MouseEvent) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ setIsResizing(true);
196
+ const startX = e.clientX;
197
+ const startW = floatSize.w;
198
+ const startPosX = floatPos.x;
199
+ const onMove = (ev: MouseEvent) => {
200
+ const delta = startX - ev.clientX;
201
+ const newW = Math.max(360, startW + delta);
202
+ setFloatSize(prev => ({ ...prev, w: newW }));
203
+ setFloatPos(prev => ({ ...prev, x: startPosX - (newW - startW) }));
204
+ };
205
+ const onUp = () => {
206
+ setIsResizing(false);
207
+ window.removeEventListener('mousemove', onMove);
208
+ window.removeEventListener('mouseup', onUp);
209
+ };
210
+ window.addEventListener('mousemove', onMove);
211
+ window.addEventListener('mouseup', onUp);
212
+ }, [floatSize.w, floatPos.x]);
213
+
154
214
  if (!isOpen) return null;
155
215
 
156
216
  const isDocked = mode === 'docked';
@@ -246,13 +306,18 @@ export function DocBrowser() {
246
306
  {/* Iframe Content */}
247
307
  <div className="flex-1 relative overflow-hidden">
248
308
  <iframe
249
- key={currentUrl}
309
+ ref={iframeRef}
310
+ key={navVersion}
250
311
  src={currentUrl}
251
312
  className="absolute inset-0 w-full h-full border-0"
252
313
  title="NextClaw Documentation"
253
314
  sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
254
315
  allow="clipboard-read; clipboard-write"
255
316
  />
317
+ {/* Transparent overlay during resize to prevent iframe from stealing mouse events */}
318
+ {(isResizing || isDragging) && (
319
+ <div className="absolute inset-0 z-10" />
320
+ )}
256
321
  </div>
257
322
 
258
323
  {/* Footer */}
@@ -261,6 +326,7 @@ export function DocBrowser() {
261
326
  href={currentUrl}
262
327
  target="_blank"
263
328
  rel="noopener noreferrer"
329
+ data-doc-external
264
330
  className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
265
331
  >
266
332
  {t('docBrowserOpenExternal')}
@@ -268,14 +334,23 @@ export function DocBrowser() {
268
334
  </a>
269
335
  </div>
270
336
 
271
- {/* Resize Handle (floating only — bottom-right corner) */}
337
+ {/* Resize Handles (floating only) */}
272
338
  {!isDocked && (
273
- <div
274
- className="absolute bottom-0 right-0 w-5 h-5 cursor-se-resize flex items-center justify-center text-gray-300 hover:text-gray-500 transition-colors"
275
- onMouseDown={onResizeStart}
276
- >
277
- <GripVertical className="w-3 h-3 rotate-[-45deg]" />
278
- </div>
339
+ <>
340
+ {/* Left edge */}
341
+ <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} />
342
+ {/* Right edge */}
343
+ <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" />
344
+ {/* Bottom edge */}
345
+ <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" />
346
+ {/* Bottom-right corner */}
347
+ <div
348
+ 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"
349
+ onMouseDown={onResizeStart}
350
+ >
351
+ <GripVertical className="w-3 h-3 rotate-[-45deg]" />
352
+ </div>
353
+ </>
279
354
  )}
280
355
  </div>
281
356
  );
@@ -1,24 +1,31 @@
1
1
  import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react';
2
2
 
3
3
  const DOCS_PRIMARY_DOMAIN = 'docs.nextclaw.io';
4
- const DOCS_FALLBACK_DOMAIN = 'nextclaw-docs.pages.dev';
4
+ const DOCS_PAGES_DEV = 'nextclaw-docs.pages.dev';
5
5
  const DOCS_HOSTS = new Set([
6
6
  DOCS_PRIMARY_DOMAIN,
7
7
  `www.${DOCS_PRIMARY_DOMAIN}`,
8
- DOCS_FALLBACK_DOMAIN,
9
- `www.${DOCS_FALLBACK_DOMAIN}`,
8
+ DOCS_PAGES_DEV,
9
+ `www.${DOCS_PAGES_DEV}`,
10
10
  ]);
11
11
 
12
- export const DOCS_DEFAULT_BASE_URL = `https://${DOCS_FALLBACK_DOMAIN}`;
12
+ export const DOCS_DEFAULT_BASE_URL = `https://${DOCS_PRIMARY_DOMAIN}`;
13
13
 
14
14
  export type DocBrowserMode = 'floating' | 'docked';
15
15
 
16
+ /** Normalize URL for comparison: strip .html and trailing slash */
17
+ function normalizeDocUrl(u: string): string {
18
+ try { return new URL(u).pathname.replace(/\.html$/, '').replace(/\/$/, ''); } catch { return u; }
19
+ }
20
+
16
21
  interface DocBrowserState {
17
22
  isOpen: boolean;
18
23
  mode: DocBrowserMode;
19
24
  currentUrl: string;
20
25
  history: string[];
21
26
  historyIndex: number;
27
+ /** Increments on parent-initiated navigation (navigate/goBack/goForward) */
28
+ navVersion: number;
22
29
  }
23
30
 
24
31
  interface DocBrowserActions {
@@ -26,7 +33,10 @@ interface DocBrowserActions {
26
33
  close: () => void;
27
34
  toggleMode: () => void;
28
35
  setMode: (mode: DocBrowserMode) => void;
36
+ /** Parent-initiated navigation — will cause iframe to reload to this URL */
29
37
  navigate: (url: string) => void;
38
+ /** Iframe-initiated sync — records URL to history without reloading iframe */
39
+ syncUrl: (url: string) => void;
30
40
  goBack: () => void;
31
41
  goForward: () => void;
32
42
  canGoBack: boolean;
@@ -60,6 +70,7 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
60
70
  currentUrl: `${DOCS_DEFAULT_BASE_URL}/guide/getting-started`,
61
71
  history: [`${DOCS_DEFAULT_BASE_URL}/guide/getting-started`],
62
72
  historyIndex: 0,
73
+ navVersion: 0,
63
74
  });
64
75
 
65
76
  const open = useCallback((url?: string) => {
@@ -70,6 +81,7 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
70
81
  currentUrl: targetUrl,
71
82
  history: [...prev.history.slice(0, prev.historyIndex + 1), targetUrl],
72
83
  historyIndex: prev.historyIndex + 1,
84
+ navVersion: prev.navVersion + 1,
73
85
  }));
74
86
  }, [state.currentUrl]);
75
87
 
@@ -85,14 +97,24 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
85
97
  setState(prev => ({ ...prev, mode }));
86
98
  }, []);
87
99
 
100
+ /** Parent-initiated: push to history AND bump navVersion so iframe reloads */
88
101
  const navigate = useCallback((url: string) => {
89
102
  setState(prev => {
90
- // Normalize URLs for comparison (strip trailing slash, .html suffix)
91
- const normalize = (u: string) => {
92
- try { return new URL(u).pathname.replace(/\.html$/, '').replace(/\/$/, ''); } catch { return u; }
103
+ if (normalizeDocUrl(url) === normalizeDocUrl(prev.currentUrl)) return prev;
104
+ return {
105
+ ...prev,
106
+ currentUrl: url,
107
+ history: [...prev.history.slice(0, prev.historyIndex + 1), url],
108
+ historyIndex: prev.historyIndex + 1,
109
+ navVersion: prev.navVersion + 1,
93
110
  };
94
- // Skip if same as current URL (prevents loop from postMessage echo)
95
- if (normalize(url) === normalize(prev.currentUrl)) return prev;
111
+ });
112
+ }, []);
113
+
114
+ /** Iframe-initiated: push to history but do NOT bump navVersion (no iframe reload) */
115
+ const syncUrl = useCallback((url: string) => {
116
+ setState(prev => {
117
+ if (normalizeDocUrl(url) === normalizeDocUrl(prev.currentUrl)) return prev;
96
118
  return {
97
119
  ...prev,
98
120
  currentUrl: url,
@@ -128,11 +150,12 @@ export function DocBrowserProvider({ children }: { children: ReactNode }) {
128
150
  toggleMode,
129
151
  setMode,
130
152
  navigate,
153
+ syncUrl,
131
154
  goBack,
132
155
  goForward,
133
156
  canGoBack,
134
157
  canGoForward,
135
- }), [state, open, close, toggleMode, setMode, navigate, goBack, goForward, canGoBack, canGoForward]);
158
+ }), [state, open, close, toggleMode, setMode, navigate, syncUrl, goBack, goForward, canGoBack, canGoForward]);
136
159
 
137
160
  return (
138
161
  <DocBrowserContext.Provider value={value}>