@nextclaw/ui 0.3.16 → 0.3.17

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.
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
6
6
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
7
7
  import { cn } from '@/lib/utils';
8
8
  import { t } from '@/lib/i18n';
9
- import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle } from 'lucide-react';
9
+ import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
10
10
 
11
11
  const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
12
12
 
@@ -56,26 +56,28 @@ function SessionListItem({ session, channel, isSelected, onSelect }: SessionList
56
56
  <button
57
57
  onClick={onSelect}
58
58
  className={cn(
59
- "w-full text-left p-3 rounded-xl border transition-all duration-200 focus:outline-none",
59
+ "w-full text-left p-3.5 rounded-xl transition-all duration-200 outline-none focus:outline-none focus:ring-0 group",
60
60
  isSelected
61
- ? "bg-primary-50 border-primary-200 shadow-sm"
62
- : "bg-white border-transparent hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm"
61
+ ? "bg-brand-50 border border-brand-100/50"
62
+ : "bg-transparent border border-transparent hover:bg-gray-50/80"
63
63
  )}
64
64
  >
65
65
  <div className="flex items-start justify-between mb-1.5">
66
- <div className="font-medium text-gray-900 truncate pr-2 flex-1 text-sm">{displayName}</div>
67
- <div className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0 capitalize">
66
+ <div className={cn("font-semibold truncate pr-2 flex-1 text-sm", isSelected ? "text-brand-800" : "text-gray-900")}>
67
+ {displayName}
68
+ </div>
69
+ <div className={cn("text-[10px] font-bold px-2 py-0.5 rounded-full shrink-0 capitalize", isSelected ? "bg-white text-brand-600 shadow-[0_1px_2px_rgba(0,0,0,0.02)]" : "bg-gray-100 text-gray-500")}>
68
70
  {channelDisplay}
69
71
  </div>
70
72
  </div>
71
73
 
72
- <div className="flex items-center text-xs text-gray-500 justify-between">
74
+ <div className={cn("flex items-center text-xs justify-between mt-2 font-medium", isSelected ? "text-brand-600/80" : "text-gray-400")}>
73
75
  <div className="flex items-center gap-1.5">
74
- <Clock className="w-3.5 h-3.5" />
76
+ <Clock className="w-3.5 h-3.5 opacity-70" />
75
77
  <span className="truncate max-w-[100px]">{formatDate(session.updatedAt).split(' ')[0]}</span>
76
78
  </div>
77
79
  <div className="flex items-center gap-1">
78
- <MessageCircle className="w-3.5 h-3.5" />
80
+ <MessageCircle className="w-3.5 h-3.5 opacity-70" />
79
81
  <span>{session.messageCount}</span>
80
82
  </div>
81
83
  </div>
@@ -91,12 +93,12 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
91
93
  const isUser = message.role.toLowerCase() === 'user';
92
94
 
93
95
  return (
94
- <div className={cn("flex w-full mb-4", isUser ? "justify-end" : "justify-start")}>
96
+ <div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
95
97
  <div className={cn(
96
- "max-w-[85%] rounded-2xl p-4 flex gap-3 text-sm",
98
+ "max-w-[85%] rounded-[1.25rem] p-5 flex gap-3 text-sm",
97
99
  isUser
98
- ? "bg-primary text-white rounded-tr-sm shadow-sm"
99
- : "bg-white border border-gray-100 text-gray-800 rounded-tl-sm shadow-card-sm"
100
+ ? "bg-primary text-white rounded-tr-sm"
101
+ : "bg-gray-50 text-gray-800 rounded-tl-sm border border-gray-100/50"
100
102
  )}>
101
103
  <div className="shrink-0 pt-0.5">
102
104
  {isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
@@ -110,7 +112,7 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
110
112
  {formatDate(message.timestamp)}
111
113
  </span>
112
114
  </div>
113
- <div className="whitespace-pre-wrap break-words leading-relaxed">
115
+ <div className="whitespace-pre-wrap break-words leading-relaxed text-[15px]">
114
116
  {message.content}
115
117
  </div>
116
118
  </div>
@@ -133,6 +135,7 @@ export function SessionsConfig() {
133
135
  // Local state drafts for editing the currently selected session
134
136
  const [draftLabel, setDraftLabel] = useState('');
135
137
  const [draftModel, setDraftModel] = useState('');
138
+ const [isEditingMeta, setIsEditingMeta] = useState(false);
136
139
 
137
140
  const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
138
141
  const sessionsQuery = useSessions(sessionsParams);
@@ -170,6 +173,7 @@ export function SessionsConfig() {
170
173
  setDraftLabel('');
171
174
  setDraftModel('');
172
175
  }
176
+ setIsEditingMeta(false); // Reset editing state when switching sessions
173
177
  }, [selectedSession]);
174
178
 
175
179
  const handleSaveMeta = () => {
@@ -181,6 +185,7 @@ export function SessionsConfig() {
181
185
  preferredModel: draftModel.trim() || null
182
186
  }
183
187
  });
188
+ setIsEditingMeta(false); // Close editor on save
184
189
  };
185
190
 
186
191
  const handleClearHistory = () => {
@@ -205,53 +210,54 @@ export function SessionsConfig() {
205
210
  return (
206
211
  <div className="h-[calc(100vh-80px)] w-full max-w-[1400px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
207
212
 
208
- {/* Header */}
209
213
  <div className="flex items-center justify-between mb-6 shrink-0">
210
214
  <div>
211
215
  <h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('sessionsPageTitle')}</h2>
212
216
  <p className="text-sm text-gray-500 mt-1">{t('sessionsPageDescription')}</p>
213
217
  </div>
214
-
215
- {/* Global Toolbar */}
216
- <div className="flex items-center gap-3">
217
- <Select value={selectedChannel} onValueChange={setSelectedChannel}>
218
- <SelectTrigger className="w-[180px] h-9 rounded-full bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none font-medium text-gray-700">
219
- <SelectValue placeholder="All Channels" />
220
- </SelectTrigger>
221
- <SelectContent className="rounded-xl shadow-lg border-gray-100">
222
- <SelectItem value="all" className="rounded-lg">All Channels</SelectItem>
223
- {channels.map(c => (
224
- <SelectItem key={c} value={c} className="rounded-lg">{displayChannelName(c)}</SelectItem>
225
- ))}
226
- </SelectContent>
227
- </Select>
228
-
229
- <div className="relative w-64">
230
- <Search className="h-4 w-4 absolute left-3 top-2.5 text-gray-400" />
231
- <Input
232
- value={query}
233
- onChange={(e) => setQuery(e.target.value)}
234
- placeholder={t('sessionsSearchPlaceholder')}
235
- className="pl-9 h-9 rounded-full bg-gray-50/50 border-gray-200 focus-visible:bg-white"
236
- />
237
- </div>
238
- <Button variant="outline" size="icon" className="h-9 w-9 rounded-full text-gray-500" onClick={() => sessionsQuery.refetch()}>
239
- <RefreshCw className={cn("h-4 w-4", sessionsQuery.isFetching && "animate-spin")} />
240
- </Button>
241
- </div>
242
218
  </div>
243
219
 
244
220
  {/* Main Mailbox Layout */}
245
221
  <div className="flex-1 flex gap-6 min-h-0 relative">
246
222
 
247
- {/* LEFT COLUMN: List */}
248
- <div className="w-[320px] flex flex-col shrink-0">
249
- <div className="flex items-center justify-between px-1 mb-3 text-xs font-medium text-gray-500">
250
- <span>{sessions.length} {t('sessionsListTitle')}</span>
223
+ {/* LEFT COLUMN: List Card */}
224
+ <div className="w-[320px] flex flex-col shrink-0 bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
225
+
226
+ {/* List Card Header & Toolbar */}
227
+ <div className="px-4 py-4 border-b border-gray-100 bg-white z-10 shrink-0 space-y-3">
228
+ <div className="flex items-center justify-between">
229
+ <span className="text-[11px] font-semibold text-gray-400 uppercase tracking-wider">
230
+ {sessions.length} {t('sessionsListTitle')}
231
+ </span>
232
+ <Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100" onClick={() => sessionsQuery.refetch()}>
233
+ <RefreshCw className={cn("h-3.5 w-3.5", sessionsQuery.isFetching && "animate-spin")} />
234
+ </Button>
235
+ </div>
236
+
237
+ <Select value={selectedChannel} onValueChange={setSelectedChannel}>
238
+ <SelectTrigger className="w-full h-8.5 rounded-lg bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none text-xs font-medium text-gray-700">
239
+ <SelectValue placeholder="All Channels" />
240
+ </SelectTrigger>
241
+ <SelectContent className="rounded-xl shadow-lg border-gray-100 max-w-[280px]">
242
+ <SelectItem value="all" className="rounded-lg text-xs">All Channels</SelectItem>
243
+ {channels.map(c => (
244
+ <SelectItem key={c} value={c} className="rounded-lg text-xs truncate pr-6">{displayChannelName(c)}</SelectItem>
245
+ ))}
246
+ </SelectContent>
247
+ </Select>
248
+
249
+ <div className="relative w-full">
250
+ <Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
251
+ <Input
252
+ value={query}
253
+ onChange={(e) => setQuery(e.target.value)}
254
+ placeholder={t('sessionsSearchPlaceholder')}
255
+ className="pl-8 h-8.5 rounded-lg bg-gray-50/50 border-gray-200 focus-visible:bg-white text-xs"
256
+ />
257
+ </div>
251
258
  </div>
252
259
 
253
- <div className="flex-1 overflow-y-auto px-1.5 -mx-1.5 pt-1.5 -mt-1.5 space-y-2 pb-10
254
- [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:bg-gray-200 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-gray-300">
260
+ <div className="flex-1 overflow-y-auto p-3 space-y-1 pb-10 custom-scrollbar relative">
255
261
  {sessionsQuery.isLoading ? (
256
262
  <div className="text-sm text-gray-400 p-4 text-center">{t('sessionsLoading')}</div>
257
263
  ) : filteredSessions.length === 0 ? (
@@ -273,11 +279,11 @@ export function SessionsConfig() {
273
279
  </div>
274
280
  </div>
275
281
 
276
- {/* RIGHT COLUMN: Detail View */}
277
- <div className="flex-1 min-w-0 bg-gray-50/50 rounded-2xl border border-gray-200 flex flex-col overflow-hidden shadow-sm relative">
282
+ {/* RIGHT COLUMN: Detail View Card */}
283
+ <div className="flex-1 min-w-0 flex flex-col overflow-hidden relative bg-white rounded-2xl shadow-sm border border-gray-200">
278
284
 
279
285
  {(updateSession.isPending || deleteSession.isPending) && (
280
- <div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-10">
286
+ <div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-20">
281
287
  <div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
282
288
  </div>
283
289
  )}
@@ -285,53 +291,59 @@ export function SessionsConfig() {
285
291
  {selectedKey && selectedSession ? (
286
292
  <>
287
293
  {/* Detail Header / Metdata Editor */}
288
- <div className="shrink-0 bg-white border-b border-gray-200 p-5 shadow-sm z-10 space-y-4">
289
- <div className="flex items-start justify-between">
290
- <div className="flex items-center gap-3">
291
- <div className="h-10 w-10 rounded-xl bg-primary-50 flex items-center justify-center text-primary shrink-0">
292
- <Hash className="h-5 w-5" />
294
+ <div className="shrink-0 border-b border-gray-100 bg-white px-8 py-5 z-10 space-y-4">
295
+ <div className="flex items-center justify-between">
296
+ <div className="flex items-center gap-4">
297
+ <div className="h-12 w-12 rounded-[14px] bg-gray-50 border border-gray-100 flex items-center justify-center text-gray-400 shrink-0">
298
+ <Hash className="h-6 w-6" />
293
299
  </div>
294
300
  <div>
295
- <div className="flex items-center gap-2 mb-1">
296
- <h3 className="text-base font-bold text-gray-900 leading-none">
301
+ <div className="flex items-center gap-2.5 mb-1.5">
302
+ <h3 className="text-lg font-bold text-gray-900 tracking-tight">
297
303
  {selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
298
304
  </h3>
299
- <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 uppercase tracking-wide">
305
+ <span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-500 uppercase tracking-widest">
300
306
  {displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
301
307
  </span>
302
308
  </div>
303
- <div className="text-xs text-gray-500 font-mono break-all line-clamp-1" title={selectedKey}>
309
+ <div className="text-xs text-gray-500 font-mono break-all line-clamp-1 opacity-70" title={selectedKey}>
304
310
  {selectedKey}
305
311
  </div>
306
312
  </div>
307
313
  </div>
308
314
  <div className="flex items-center gap-2 shrink-0">
309
- <Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8 shadow-none hover:bg-gray-100/50 hover:text-gray-900 border-gray-200">
315
+ <Button variant="outline" size="sm" onClick={() => setIsEditingMeta(!isEditingMeta)} className={cn("h-8.5 rounded-lg shadow-none border-gray-200 transition-all text-xs font-semibold", isEditingMeta ? "bg-gray-100 text-gray-900" : "hover:bg-gray-50 hover:text-gray-900")}>
316
+ <SettingsIcon className="w-3.5 h-3.5 mr-1.5" />
317
+ Metadata
318
+ </Button>
319
+ <Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8.5 rounded-lg shadow-none hover:bg-gray-50 hover:text-gray-900 border-gray-200 text-xs font-semibold text-gray-500">
310
320
  {t('sessionsClearHistory')}
311
321
  </Button>
312
- <Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8 shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200">
322
+ <Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8.5 rounded-lg shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200 text-xs font-semibold text-red-500">
313
323
  {t('delete')}
314
324
  </Button>
315
325
  </div>
316
326
  </div>
317
327
 
318
- <div className="flex items-center gap-3 bg-gray-50/50 p-3 rounded-lg border border-gray-100">
319
- <Input
320
- placeholder={t('sessionsLabelPlaceholder')}
321
- value={draftLabel}
322
- onChange={e => setDraftLabel(e.target.value)}
323
- className="h-8 text-sm bg-white"
324
- />
325
- <Input
326
- placeholder={t('sessionsModelPlaceholder')}
327
- value={draftModel}
328
- onChange={e => setDraftModel(e.target.value)}
329
- className="h-8 text-sm bg-white"
330
- />
331
- <Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
332
- {t('sessionsSaveMeta')}
333
- </Button>
334
- </div>
328
+ {isEditingMeta && (
329
+ <div className="flex items-center gap-3 bg-gray-50/50 p-3 rounded-lg border border-gray-100 animate-slide-in">
330
+ <Input
331
+ placeholder={t('sessionsLabelPlaceholder')}
332
+ value={draftLabel}
333
+ onChange={e => setDraftLabel(e.target.value)}
334
+ className="h-8 text-sm bg-white"
335
+ />
336
+ <Input
337
+ placeholder={t('sessionsModelPlaceholder')}
338
+ value={draftModel}
339
+ onChange={e => setDraftModel(e.target.value)}
340
+ className="h-8 text-sm bg-white"
341
+ />
342
+ <Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
343
+ {t('sessionsSaveMeta')}
344
+ </Button>
345
+ </div>
346
+ )}
335
347
  </div>
336
348
 
337
349
  {/* Chat History Area */}
@@ -370,7 +382,7 @@ export function SessionsConfig() {
370
382
  ) : (
371
383
  /* Empty State */
372
384
  <div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
373
- <div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-sm rotate-3">
385
+ <div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-[0_2px_8px_-2px_rgba(0,0,0,0.02)] rotate-3">
374
386
  <Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
375
387
  </div>
376
388
  <h3 className="text-lg font-bold text-gray-900 mb-2">No Session Selected</h3>
@@ -0,0 +1,272 @@
1
+ import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { DOCS_DEFAULT_BASE_URL, useDocBrowser } from './DocBrowserContext';
3
+ import { cn } from '@/lib/utils';
4
+ import { t } from '@/lib/i18n';
5
+ import {
6
+ ArrowLeft,
7
+ ArrowRight,
8
+ X,
9
+ ExternalLink,
10
+ PanelRightOpen,
11
+ Maximize2,
12
+ GripVertical,
13
+ Search,
14
+ BookOpen,
15
+ } from 'lucide-react';
16
+
17
+ /**
18
+ * DocBrowser — An in-app micro-browser for documentation.
19
+ *
20
+ * Supports two modes:
21
+ * - `docked`: Renders as a right sidebar panel (horizontally resizable)
22
+ * - `floating`: Renders as a draggable, resizable overlay
23
+ */
24
+ export function DocBrowser() {
25
+ const {
26
+ isOpen, mode, currentUrl,
27
+ close, toggleMode,
28
+ goBack, goForward, canGoBack, canGoForward,
29
+ navigate,
30
+ } = useDocBrowser();
31
+
32
+ const [urlInput, setUrlInput] = useState('');
33
+ const [isDragging, setIsDragging] = useState(false);
34
+ const [floatPos, setFloatPos] = useState({ x: 120, y: 80 });
35
+ const [floatSize, setFloatSize] = useState({ w: 480, h: 600 });
36
+ const [dockedWidth, setDockedWidth] = useState(420);
37
+ const dragRef = useRef<{ startX: number; startY: number; startPosX: number; startPosY: number } | null>(null);
38
+ const resizeRef = useRef<{ startX: number; startY: number; startW: number; startH: number } | null>(null);
39
+ const dockResizeRef = useRef<{ startX: number; startW: number } | null>(null);
40
+
41
+ // Sync URL input with current URL
42
+ useEffect(() => {
43
+ try {
44
+ const parsed = new URL(currentUrl);
45
+ setUrlInput(parsed.pathname);
46
+ } catch {
47
+ setUrlInput(currentUrl);
48
+ }
49
+ }, [currentUrl]);
50
+
51
+ const handleUrlSubmit = useCallback((e: React.FormEvent) => {
52
+ e.preventDefault();
53
+ const input = urlInput.trim();
54
+ if (!input) return;
55
+ if (input.startsWith('/')) {
56
+ navigate(`${DOCS_DEFAULT_BASE_URL}${input}`);
57
+ } else if (input.startsWith('http')) {
58
+ navigate(input);
59
+ } else {
60
+ navigate(`${DOCS_DEFAULT_BASE_URL}/${input}`);
61
+ }
62
+ }, [urlInput, navigate]);
63
+
64
+ // --- Dragging logic (floating mode) ---
65
+ const onDragStart = useCallback((e: React.MouseEvent) => {
66
+ if (mode !== 'floating') return;
67
+ setIsDragging(true);
68
+ dragRef.current = {
69
+ startX: e.clientX,
70
+ startY: e.clientY,
71
+ startPosX: floatPos.x,
72
+ startPosY: floatPos.y,
73
+ };
74
+ }, [mode, floatPos]);
75
+
76
+ useEffect(() => {
77
+ if (!isDragging) return;
78
+ const onMove = (e: MouseEvent) => {
79
+ if (!dragRef.current) return;
80
+ setFloatPos({
81
+ x: dragRef.current.startPosX + (e.clientX - dragRef.current.startX),
82
+ y: dragRef.current.startPosY + (e.clientY - dragRef.current.startY),
83
+ });
84
+ };
85
+ const onUp = () => {
86
+ setIsDragging(false);
87
+ dragRef.current = null;
88
+ };
89
+ window.addEventListener('mousemove', onMove);
90
+ window.addEventListener('mouseup', onUp);
91
+ return () => {
92
+ window.removeEventListener('mousemove', onMove);
93
+ window.removeEventListener('mouseup', onUp);
94
+ };
95
+ }, [isDragging]);
96
+
97
+ // --- Resize logic (floating mode — bottom-right corner) ---
98
+ const onResizeStart = useCallback((e: React.MouseEvent) => {
99
+ e.preventDefault();
100
+ e.stopPropagation();
101
+ resizeRef.current = {
102
+ startX: e.clientX,
103
+ startY: e.clientY,
104
+ startW: floatSize.w,
105
+ startH: floatSize.h,
106
+ };
107
+ const onMove = (ev: MouseEvent) => {
108
+ if (!resizeRef.current) return;
109
+ setFloatSize({
110
+ w: Math.max(360, resizeRef.current.startW + (ev.clientX - resizeRef.current.startX)),
111
+ h: Math.max(400, resizeRef.current.startH + (ev.clientY - resizeRef.current.startY)),
112
+ });
113
+ };
114
+ const onUp = () => {
115
+ resizeRef.current = null;
116
+ window.removeEventListener('mousemove', onMove);
117
+ window.removeEventListener('mouseup', onUp);
118
+ };
119
+ window.addEventListener('mousemove', onMove);
120
+ window.addEventListener('mouseup', onUp);
121
+ }, [floatSize]);
122
+
123
+ // --- Horizontal resize logic (docked mode — left edge) ---
124
+ const onDockResizeStart = useCallback((e: React.MouseEvent) => {
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ dockResizeRef.current = { startX: e.clientX, startW: dockedWidth };
128
+ const onMove = (ev: MouseEvent) => {
129
+ if (!dockResizeRef.current) return;
130
+ // Dragging left should increase width (since resize handle is on the left edge)
131
+ const delta = dockResizeRef.current.startX - ev.clientX;
132
+ setDockedWidth(Math.max(320, Math.min(800, dockResizeRef.current.startW + delta)));
133
+ };
134
+ const onUp = () => {
135
+ dockResizeRef.current = null;
136
+ window.removeEventListener('mousemove', onMove);
137
+ window.removeEventListener('mouseup', onUp);
138
+ };
139
+ window.addEventListener('mousemove', onMove);
140
+ window.addEventListener('mouseup', onUp);
141
+ }, [dockedWidth]);
142
+
143
+ if (!isOpen) return null;
144
+
145
+ const isDocked = mode === 'docked';
146
+
147
+ const panel = (
148
+ <div
149
+ className={cn(
150
+ 'flex flex-col bg-white overflow-hidden relative',
151
+ isDocked
152
+ ? 'h-full border-l border-gray-200 shrink-0'
153
+ : 'rounded-2xl shadow-2xl border border-gray-200',
154
+ )}
155
+ style={
156
+ isDocked
157
+ ? { width: dockedWidth }
158
+ : {
159
+ position: 'fixed',
160
+ left: floatPos.x,
161
+ top: floatPos.y,
162
+ width: floatSize.w,
163
+ height: floatSize.h,
164
+ zIndex: 9999,
165
+ }
166
+ }
167
+ >
168
+ {/* Docked mode: left-edge resize handle */}
169
+ {isDocked && (
170
+ <div
171
+ className="absolute top-0 left-0 w-1.5 h-full cursor-ew-resize z-20 hover:bg-primary/10 transition-colors"
172
+ onMouseDown={onDockResizeStart}
173
+ />
174
+ )}
175
+
176
+ {/* Title Bar */}
177
+ <div
178
+ className={cn(
179
+ 'flex items-center justify-between px-4 py-2.5 bg-gray-50 border-b border-gray-200 shrink-0 select-none',
180
+ !isDocked && 'cursor-grab active:cursor-grabbing',
181
+ )}
182
+ onMouseDown={!isDocked ? onDragStart : undefined}
183
+ >
184
+ <div className="flex items-center gap-2.5">
185
+ <BookOpen className="w-4 h-4 text-primary" />
186
+ <span className="text-sm font-semibold text-gray-900">{t('docBrowserTitle')}</span>
187
+ </div>
188
+ <div className="flex items-center gap-1">
189
+ <button
190
+ onClick={toggleMode}
191
+ className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
192
+ title={isDocked ? t('docBrowserFloatMode') : t('docBrowserDockMode')}
193
+ >
194
+ {isDocked ? <Maximize2 className="w-3.5 h-3.5" /> : <PanelRightOpen className="w-3.5 h-3.5" />}
195
+ </button>
196
+ <button
197
+ onClick={close}
198
+ className="hover:bg-gray-200 rounded-md p-1.5 text-gray-500 hover:text-gray-700 transition-colors"
199
+ title={t('docBrowserClose')}
200
+ >
201
+ <X className="w-3.5 h-3.5" />
202
+ </button>
203
+ </div>
204
+ </div>
205
+
206
+ {/* Navigation Bar */}
207
+ <div className="flex items-center gap-2 px-3.5 py-2 bg-white border-b border-gray-100 shrink-0">
208
+ <button
209
+ onClick={goBack}
210
+ disabled={!canGoBack}
211
+ className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
212
+ >
213
+ <ArrowLeft className="w-4 h-4" />
214
+ </button>
215
+ <button
216
+ onClick={goForward}
217
+ disabled={!canGoForward}
218
+ className="p-1.5 rounded-md hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed text-gray-600 transition-colors"
219
+ >
220
+ <ArrowRight className="w-4 h-4" />
221
+ </button>
222
+
223
+ <form onSubmit={handleUrlSubmit} className="flex-1 relative">
224
+ <Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
225
+ <input
226
+ type="text"
227
+ value={urlInput}
228
+ onChange={(e) => setUrlInput(e.target.value)}
229
+ placeholder={t('docBrowserSearchPlaceholder')}
230
+ className="w-full h-8 pl-8 pr-3 rounded-lg bg-gray-50 border border-gray-200 text-xs text-gray-700 focus:outline-none focus:ring-1 focus:ring-primary/30 focus:border-primary/40 transition-colors placeholder:text-gray-400"
231
+ />
232
+ </form>
233
+ </div>
234
+
235
+ {/* Iframe Content */}
236
+ <div className="flex-1 relative overflow-hidden">
237
+ <iframe
238
+ src={currentUrl}
239
+ className="absolute inset-0 w-full h-full border-0"
240
+ title="NextClaw Documentation"
241
+ sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
242
+ allow="clipboard-read; clipboard-write"
243
+ />
244
+ </div>
245
+
246
+ {/* Footer */}
247
+ <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-t border-gray-200 shrink-0">
248
+ <a
249
+ href={currentUrl}
250
+ target="_blank"
251
+ rel="noopener noreferrer"
252
+ className="flex items-center gap-1.5 text-xs text-primary hover:text-primary-hover font-medium transition-colors"
253
+ >
254
+ {t('docBrowserOpenExternal')}
255
+ <ExternalLink className="w-3 h-3" />
256
+ </a>
257
+ </div>
258
+
259
+ {/* Resize Handle (floating only — bottom-right corner) */}
260
+ {!isDocked && (
261
+ <div
262
+ 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"
263
+ onMouseDown={onResizeStart}
264
+ >
265
+ <GripVertical className="w-3 h-3 rotate-[-45deg]" />
266
+ </div>
267
+ )}
268
+ </div>
269
+ );
270
+
271
+ return panel;
272
+ }