@djangocfg/layouts 2.0.8 → 2.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/layouts",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "description": "Simple, straightforward layout components for Next.js - import and use with props",
5
5
  "keywords": [
6
6
  "layouts",
@@ -92,9 +92,9 @@
92
92
  "check": "tsc --noEmit"
93
93
  },
94
94
  "peerDependencies": {
95
- "@djangocfg/api": "^1.4.38",
96
- "@djangocfg/centrifugo": "^1.4.38",
97
- "@djangocfg/ui": "^1.4.38",
95
+ "@djangocfg/api": "^1.4.39",
96
+ "@djangocfg/centrifugo": "^1.4.39",
97
+ "@djangocfg/ui": "^1.4.39",
98
98
  "@hookform/resolvers": "^5.2.0",
99
99
  "consola": "^3.4.2",
100
100
  "lucide-react": "^0.545.0",
@@ -114,7 +114,7 @@
114
114
  "uuid": "^11.1.0"
115
115
  },
116
116
  "devDependencies": {
117
- "@djangocfg/typescript-config": "^1.4.38",
117
+ "@djangocfg/typescript-config": "^1.4.39",
118
118
  "@types/node": "^24.7.2",
119
119
  "@types/react": "19.2.2",
120
120
  "@types/react-dom": "19.2.1",
@@ -8,7 +8,7 @@ import React, {
8
8
 
9
9
  import { api, Enums } from '@djangocfg/api';
10
10
  import { useAccountsContext, AccountsProvider } from './AccountsContext';
11
- import { useLocalStorage, useQueryParams, useRouter } from '@djangocfg/ui/hooks';
11
+ import { useLocalStorage, useQueryParams, useCfgRouter } from '@djangocfg/ui/hooks';
12
12
  import { getCachedProfile, clearProfileCache } from '../hooks/useProfileCache';
13
13
 
14
14
  import { authLogger } from '../../utils/logger';
@@ -50,7 +50,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
50
50
  });
51
51
 
52
52
  const [initialized, setInitialized] = useState(false);
53
- const router = useRouter();
53
+ const router = useCfgRouter();
54
54
  const pathname = usePathname();
55
55
  const queryParams = useQueryParams();
56
56
 
@@ -362,13 +362,14 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
362
362
  }
363
363
 
364
364
  // Handle redirect logic here
365
+ // Use hardPush for full page reload - ensures all React contexts reinitialize
365
366
  const defaultCallback = config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
366
367
 
367
368
  if (redirectUrl && redirectUrl !== defaultCallback) {
368
369
  clearRedirectUrl();
369
- router.push(redirectUrl);
370
+ router.hardPush(redirectUrl);
370
371
  } else {
371
- router.push(defaultCallback);
372
+ router.hardPush(defaultCallback);
372
373
  }
373
374
 
374
375
  return {
@@ -460,7 +461,11 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
460
461
  accounts.logout(); // Clear tokens and profile
461
462
  setInitialized(true);
462
463
  setIsLoading(false);
463
- pushToDefaultAuthCallbackUrl();
464
+
465
+ // Use hardReplace for full page reload + replace history
466
+ // This ensures contexts reinitialize AND back button won't return to protected page
467
+ const authCallbackUrl = config?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
468
+ router.hardReplace(authCallbackUrl);
464
469
  };
465
470
 
466
471
  // Use config.onConfirm if provided, otherwise use a simple confirm
@@ -482,7 +487,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
482
487
  performLogout();
483
488
  }
484
489
  }
485
- }, [accounts, pushToDefaultAuthCallbackUrl]);
490
+ }, [accounts, config?.routes?.defaultAuthCallback, router]);
486
491
 
487
492
  // Redirect URL methods
488
493
  const getSavedRedirectUrl = useCallback((): string | null => {
@@ -3,7 +3,7 @@
3
3
  import { useEffect } from 'react';
4
4
 
5
5
  import { useAuth } from '../context';
6
- import { useRouter } from '@djangocfg/ui/hooks';
6
+ import { useCfgRouter } from '@djangocfg/ui/hooks';
7
7
 
8
8
  interface UseAuthGuardOptions {
9
9
  redirectTo?: string;
@@ -13,7 +13,7 @@ interface UseAuthGuardOptions {
13
13
  export const useAuthGuard = (options: UseAuthGuardOptions = {}) => {
14
14
  const { redirectTo = '/auth', requireAuth = true } = options;
15
15
  const { isAuthenticated, isLoading } = useAuth();
16
- const router = useRouter();
16
+ const router = useCfgRouter();
17
17
 
18
18
  useEffect(() => {
19
19
  if (!isLoading && requireAuth && !isAuthenticated) {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
  import { usePathname } from 'next/navigation';
5
- import { useQueryParams, useRouter } from '@djangocfg/ui/hooks';
5
+ import { useQueryParams, useCfgRouter } from '@djangocfg/ui/hooks';
6
6
  import { authLogger } from '../../utils/logger';
7
7
 
8
8
  export interface UseAutoAuthOptions {
@@ -18,7 +18,7 @@ export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
18
18
  const { onOTPDetected, cleanupUrl = true } = options;
19
19
  const queryParams = useQueryParams();
20
20
  const pathname = usePathname();
21
- const router = useRouter();
21
+ const router = useCfgRouter();
22
22
 
23
23
  const isReady = !!pathname && !!queryParams.get('otp');
24
24
  const hasOTP = !!(queryParams.get('otp'));
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useState } from 'react';
4
- import { useRouter } from 'next/navigation';
5
4
 
6
5
  import { api } from '@djangocfg/api';
6
+ import { useCfgRouter } from '@djangocfg/ui/hooks';
7
7
  import { authLogger } from '../../utils/logger';
8
8
  import { Analytics, AnalyticsEvent, AnalyticsCategory } from '../../snippets/Analytics';
9
9
 
@@ -42,7 +42,7 @@ export interface UseGithubAuthReturn {
42
42
  */
43
43
  export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuthReturn => {
44
44
  const { sourceUrl, onSuccess, onError, redirectUrl } = options;
45
- const router = useRouter();
45
+ const router = useCfgRouter();
46
46
 
47
47
  const [isLoading, setIsLoading] = useState(false);
48
48
  const [error, setError] = useState<string | null>(null);
@@ -155,8 +155,9 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
155
155
  onSuccess?.(response.user, response.is_new_user || false);
156
156
 
157
157
  // Redirect to dashboard or specified URL
158
+ // Use hardPush for full page reload - ensures all React contexts reinitialize
158
159
  const finalRedirectUrl = redirectUrl || '/dashboard';
159
- router.push(finalRedirectUrl);
160
+ router.hardPush(finalRedirectUrl);
160
161
 
161
162
  } catch (err) {
162
163
  const errorMessage = err instanceof Error ? err.message : 'GitHub authentication failed';
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
  import { useAuth } from '../../auth';
5
- import { useRouter } from '@djangocfg/ui/hooks';
5
+ import { useCfgRouter } from '@djangocfg/ui/hooks';
6
6
  import { Preloader } from '@djangocfg/ui/components';
7
7
 
8
8
  export interface RedirectPageProps {
@@ -49,7 +49,7 @@ export function RedirectPage({
49
49
  loadingText = 'Loading...',
50
50
  }: RedirectPageProps) {
51
51
  const { isAuthenticated } = useAuth();
52
- const router = useRouter();
52
+ const router = useCfgRouter();
53
53
 
54
54
  useEffect(() => {
55
55
  if (!isAuthenticated) {
@@ -10,7 +10,7 @@ import {
10
10
  DialogHeader,
11
11
  DialogTitle,
12
12
  } from '@djangocfg/ui/components';
13
- import { useEventListener, useRouter } from '@djangocfg/ui/hooks';
13
+ import { useEventListener, useCfgRouter } from '@djangocfg/ui/hooks';
14
14
 
15
15
  // Re-export events for backwards compatibility
16
16
  export const DIALOG_EVENTS = {
@@ -31,7 +31,7 @@ export const AuthDialog: React.FC<AuthDialogProps> = ({
31
31
  }) => {
32
32
  const [open, setOpen] = useState(false);
33
33
  const [message, setMessage] = useState<string>('Please sign in to continue');
34
- const router = useRouter();
34
+ const router = useCfgRouter();
35
35
 
36
36
  // Listen for open auth dialog event
37
37
  useEventListener(DIALOG_EVENTS.OPEN_AUTH_DIALOG, (payload: any) => {
@@ -66,20 +66,7 @@ export interface AIChatWidgetProps extends ChatWidgetConfig {
66
66
  * Internal AI chat widget that uses context
67
67
  */
68
68
  const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className }) => {
69
- const {
70
- messages,
71
- isLoading,
72
- isMinimized,
73
- config,
74
- displayMode,
75
- isMobile,
76
- sendMessage,
77
- openChat,
78
- closeChat,
79
- toggleMinimize,
80
- setDisplayMode,
81
- stopStreaming,
82
- } = useAIChatContext();
69
+ const { config, displayMode, openChat } = useAIChatContext();
83
70
 
84
71
  // Use layout hook for consistent positioning
85
72
  const { getFabStyles, getFloatingStyles } = useChatLayout();
@@ -173,17 +160,7 @@ const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className })
173
160
  if (displayMode === 'sidebar') {
174
161
  return (
175
162
  <Portal>
176
- <ChatSidebar
177
- messages={messages}
178
- isLoading={isLoading}
179
- onSendMessage={sendMessage}
180
- onClose={closeChat}
181
- onModeChange={setDisplayMode}
182
- onStopStreaming={stopStreaming}
183
- title={config.title}
184
- placeholder={config.placeholder}
185
- greeting={config.greeting}
186
- />
163
+ <ChatSidebar />
187
164
  </Portal>
188
165
  );
189
166
  }
@@ -192,20 +169,7 @@ const AIChatWidgetInternal = React.memo<{ className?: string }>(({ className })
192
169
  return (
193
170
  <Portal>
194
171
  <div style={floatingStyles} className={className || ''}>
195
- <ChatPanel
196
- messages={messages}
197
- isLoading={isLoading}
198
- onSendMessage={sendMessage}
199
- onClose={closeChat}
200
- onMinimize={toggleMinimize}
201
- onModeChange={setDisplayMode}
202
- onStopStreaming={stopStreaming}
203
- isMinimized={isMinimized}
204
- isMobile={isMobile}
205
- title={config.title}
206
- placeholder={config.placeholder}
207
- greeting={config.greeting}
208
- />
172
+ <ChatPanel />
209
173
  </div>
210
174
  </Portal>
211
175
  );
@@ -93,8 +93,8 @@ export const ChatMessages = forwardRef<ChatMessagesHandle, ChatMessagesProps>(
93
93
  const padding = largeGreetingIcon ? 'py-12' : 'py-8';
94
94
 
95
95
  return (
96
- <ScrollArea ref={scrollAreaRef} className="h-full">
97
- <div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4`}>
96
+ <ScrollArea ref={scrollAreaRef} className="h-full w-full">
97
+ <div className={`${isCompact ? 'p-3' : 'p-4'} space-y-4 max-w-full overflow-x-hidden`}>
98
98
  {/* Greeting */}
99
99
  {messages.length === 0 && greeting && (
100
100
  <div className={`text-center ${padding}`}>
@@ -2,125 +2,99 @@
2
2
 
3
3
  import React from 'react';
4
4
  import { Card, CardHeader, CardContent, CardFooter, Button } from '@djangocfg/ui';
5
- import { X, Minimize2, PanelRight, Bot } from 'lucide-react';
6
- import type { ChatDisplayMode, AIChatMessage } from '../types';
5
+ import { X, PanelRight, Bot, RotateCcw } from 'lucide-react';
7
6
  import { ChatMessages } from './ChatMessages';
8
7
  import { AIMessageInput } from './MessageInput';
8
+ import { useAIChatContext } from '../context/AIChatContext';
9
9
 
10
- export interface ChatPanelProps {
11
- messages: AIChatMessage[];
12
- isLoading: boolean;
13
- onSendMessage: (content: string) => void;
14
- onClose?: () => void;
15
- onMinimize?: () => void;
16
- onModeChange?: (mode: ChatDisplayMode) => void;
17
- onStopStreaming?: () => void;
18
- isMinimized?: boolean;
19
- isMobile?: boolean;
20
- title?: string;
21
- placeholder?: string;
22
- greeting?: string;
23
- }
24
-
25
- export const ChatPanel = React.memo<ChatPanelProps>(
26
- ({
10
+ export const ChatPanel = React.memo(() => {
11
+ const {
27
12
  messages,
28
13
  isLoading,
29
- onSendMessage,
30
- onClose,
31
- onMinimize,
32
- onModeChange,
33
- onStopStreaming,
34
- isMinimized = false,
35
- isMobile = false,
36
- title = 'DjangoCFG Assistant',
37
- placeholder,
38
- greeting,
39
- }) => {
40
- if (isMinimized) {
41
- return (
42
- <Button
43
- onClick={onMinimize}
44
- className="rounded-full shadow-lg"
45
- style={{ width: '56px', height: '56px' }}
46
- >
47
- <Bot className="h-6 w-6" />
48
- </Button>
49
- );
50
- }
14
+ config,
15
+ isMobile,
16
+ sendMessage,
17
+ closeChat,
18
+ setDisplayMode,
19
+ stopStreaming,
20
+ clearMessages,
21
+ } = useAIChatContext();
51
22
 
52
- return (
53
- <Card
54
- className="flex flex-col shadow-2xl border-border/50"
55
- style={{
56
- width: '380px',
57
- height: '520px',
58
- maxHeight: 'calc(100vh - 100px)',
59
- }}
60
- >
61
- {/* Header */}
62
- <CardHeader className="flex flex-row items-center justify-between p-3 border-b">
63
- <div className="flex items-center gap-2">
64
- <div
65
- className="rounded-full bg-primary/10 flex items-center justify-center"
66
- style={{ width: '32px', height: '32px' }}
67
- >
68
- <Bot className="h-4 w-4 text-primary" />
69
- </div>
70
- <div>
71
- <h3 className="font-semibold text-sm">{title}</h3>
72
- <p className="text-xs text-muted-foreground">AI-powered documentation assistant</p>
73
- </div>
23
+ return (
24
+ <Card
25
+ className="flex flex-col shadow-2xl border-border/50"
26
+ style={{
27
+ width: '380px',
28
+ height: '520px',
29
+ maxHeight: 'calc(100vh - 100px)',
30
+ }}
31
+ >
32
+ {/* Header */}
33
+ <CardHeader className="flex flex-row items-center justify-between p-3 border-b">
34
+ <div className="flex items-center gap-2">
35
+ <div
36
+ className="rounded-full bg-primary/10 flex items-center justify-center"
37
+ style={{ width: '32px', height: '32px' }}
38
+ >
39
+ <Bot className="h-4 w-4 text-primary" />
74
40
  </div>
75
- <div className="flex gap-1">
76
- {/* Sidebar mode button - only on desktop */}
77
- {onModeChange && !isMobile && (
78
- <Button
79
- variant="ghost"
80
- size="icon"
81
- className="h-8 w-8"
82
- onClick={() => onModeChange('sidebar')}
83
- title="Switch to sidebar mode"
84
- >
85
- <PanelRight className="h-4 w-4" />
86
- </Button>
87
- )}
88
- {onMinimize && (
89
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onMinimize} title="Minimize">
90
- <Minimize2 className="h-4 w-4" />
91
- </Button>
92
- )}
93
- {onClose && (
94
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close">
95
- <X className="h-4 w-4" />
96
- </Button>
97
- )}
41
+ <div>
42
+ <h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
43
+ <p className="text-xs text-muted-foreground">documentation assistant</p>
98
44
  </div>
99
- </CardHeader>
45
+ </div>
46
+ <div className="flex gap-1">
47
+ {messages.length > 0 && (
48
+ <Button
49
+ variant="ghost"
50
+ size="icon"
51
+ className="h-8 w-8"
52
+ onClick={clearMessages}
53
+ title="New chat"
54
+ >
55
+ <RotateCcw className="h-4 w-4" />
56
+ </Button>
57
+ )}
58
+ {/* Sidebar mode button - only on desktop */}
59
+ {!isMobile && (
60
+ <Button
61
+ variant="ghost"
62
+ size="icon"
63
+ className="h-8 w-8"
64
+ onClick={() => setDisplayMode('sidebar')}
65
+ title="Switch to sidebar mode"
66
+ >
67
+ <PanelRight className="h-4 w-4" />
68
+ </Button>
69
+ )}
70
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={closeChat} title="Close">
71
+ <X className="h-4 w-4" />
72
+ </Button>
73
+ </div>
74
+ </CardHeader>
100
75
 
101
- {/* Messages */}
102
- <CardContent className="flex-1 p-0 overflow-hidden">
103
- <ChatMessages
104
- messages={messages}
105
- isLoading={isLoading}
106
- greeting={greeting}
107
- onStopStreaming={onStopStreaming}
108
- isCompact
109
- greetingIcon="bot"
110
- />
111
- </CardContent>
76
+ {/* Messages */}
77
+ <CardContent className="flex-1 p-0 overflow-hidden">
78
+ <ChatMessages
79
+ messages={messages}
80
+ isLoading={isLoading}
81
+ greeting={config.greeting}
82
+ onStopStreaming={stopStreaming}
83
+ isCompact
84
+ greetingIcon="bot"
85
+ />
86
+ </CardContent>
112
87
 
113
- {/* Input */}
114
- <CardFooter className="p-3 border-t">
115
- <AIMessageInput
116
- onSend={onSendMessage}
117
- isLoading={isLoading}
118
- placeholder={placeholder}
119
- />
120
- </CardFooter>
121
- </Card>
122
- );
123
- }
124
- );
88
+ {/* Input */}
89
+ <CardFooter className="p-3 border-t">
90
+ <AIMessageInput
91
+ onSend={sendMessage}
92
+ isLoading={isLoading}
93
+ placeholder={config.placeholder}
94
+ />
95
+ </CardFooter>
96
+ </Card>
97
+ );
98
+ });
125
99
 
126
100
  ChatPanel.displayName = 'ChatPanel';
@@ -2,60 +2,59 @@
2
2
 
3
3
  import React, { useEffect } from 'react';
4
4
  import { Button } from '@djangocfg/ui';
5
- import { X, PanelRightClose, Bot } from 'lucide-react';
5
+ import { X, PanelRightClose, Bot, GripVertical, RotateCcw } from 'lucide-react';
6
6
  import { ChatMessages } from './ChatMessages';
7
7
  import { AIMessageInput } from './MessageInput';
8
8
  import { useChatLayout } from '../hooks/useChatLayout';
9
- import type { AIChatMessage, ChatDisplayMode } from '../types';
9
+ import { useAIChatContext } from '../context/AIChatContext';
10
10
 
11
- const SIDEBAR_WIDTH = 400;
12
-
13
- export interface ChatSidebarProps {
14
- messages: AIChatMessage[];
15
- isLoading: boolean;
16
- onSendMessage: (content: string) => void;
17
- onClose?: () => void;
18
- onModeChange?: (mode: ChatDisplayMode) => void;
19
- onStopStreaming?: () => void;
20
- title?: string;
21
- placeholder?: string;
22
- greeting?: string;
23
- }
24
-
25
- export const ChatSidebar = React.memo<ChatSidebarProps>(
26
- ({
11
+ export const ChatSidebar = React.memo(() => {
12
+ const {
27
13
  messages,
28
14
  isLoading,
29
- onSendMessage,
30
- onClose,
31
- onModeChange,
32
- onStopStreaming,
33
- title = 'DjangoCFG Assistant',
34
- placeholder,
35
- greeting,
36
- }) => {
37
- // Use the layout hook for content pushing
38
- const { applyLayout, getSidebarStyles } = useChatLayout({
39
- sidebarWidth: SIDEBAR_WIDTH,
40
- });
15
+ config,
16
+ sendMessage,
17
+ closeChat,
18
+ setDisplayMode,
19
+ stopStreaming,
20
+ clearMessages,
21
+ } = useAIChatContext();
41
22
 
42
- // Apply sidebar layout on mount, reset on unmount
43
- // eslint-disable-next-line react-hooks/exhaustive-deps
44
- useEffect(() => {
45
- applyLayout('sidebar');
46
- return () => {
47
- applyLayout('closed');
48
- };
49
- }, []);
23
+ // Use the layout hook for content pushing and resizing
24
+ const { applyLayout, getSidebarStyles, startResize, isResizing } = useChatLayout();
50
25
 
51
- const sidebarStyles = getSidebarStyles();
26
+ // Apply sidebar layout on mount, reset on unmount
27
+ // eslint-disable-next-line react-hooks/exhaustive-deps
28
+ useEffect(() => {
29
+ applyLayout('sidebar');
30
+ return () => {
31
+ applyLayout('closed');
32
+ };
33
+ }, []);
52
34
 
53
- return (
35
+ const sidebarStyles = getSidebarStyles();
36
+
37
+ return (
38
+ <div
39
+ className="flex bg-background"
40
+ style={sidebarStyles}
41
+ data-chat-sidebar-panel
42
+ >
43
+ {/* Resize handle */}
54
44
  <div
55
- className="flex flex-col bg-background border-l border-border"
56
- style={sidebarStyles}
57
- data-chat-sidebar-panel
45
+ className={`
46
+ flex items-center justify-center w-3 cursor-ew-resize
47
+ border-l border-border transition-colors select-none flex-shrink-0
48
+ ${isResizing ? 'bg-primary/20' : 'bg-muted/30 hover:bg-muted/50'}
49
+ `}
50
+ onMouseDown={startResize}
51
+ title="Drag to resize"
58
52
  >
53
+ <GripVertical className={`h-4 w-4 ${isResizing ? 'text-primary' : 'text-muted-foreground/50'}`} />
54
+ </div>
55
+
56
+ {/* Main sidebar content */}
57
+ <div className="flex flex-col flex-1 min-w-0">
59
58
  {/* Header - uses CSS variable for navbar height consistency */}
60
59
  <div
61
60
  className="flex items-center justify-between px-4 border-b border-border bg-muted/30"
@@ -69,27 +68,34 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
69
68
  <Bot className="h-4 w-4 text-primary" />
70
69
  </div>
71
70
  <div>
72
- <h3 className="font-semibold text-sm">{title}</h3>
73
- <p className="text-xs text-muted-foreground">Documentation assistant</p>
71
+ <h3 className="font-semibold text-sm">{config.title || 'DjangoCFG AI'}</h3>
72
+ <p className="text-xs text-muted-foreground">documentation assistant</p>
74
73
  </div>
75
74
  </div>
76
75
  <div className="flex gap-1">
77
- {onModeChange && (
76
+ {messages.length > 0 && (
78
77
  <Button
79
78
  variant="ghost"
80
79
  size="icon"
81
80
  className="h-8 w-8"
82
- onClick={() => onModeChange('floating')}
83
- title="Switch to floating mode"
81
+ onClick={clearMessages}
82
+ title="New chat"
84
83
  >
85
- <PanelRightClose className="h-4 w-4" />
86
- </Button>
87
- )}
88
- {onClose && (
89
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose} title="Close chat">
90
- <X className="h-4 w-4" />
84
+ <RotateCcw className="h-4 w-4" />
91
85
  </Button>
92
86
  )}
87
+ <Button
88
+ variant="ghost"
89
+ size="icon"
90
+ className="h-8 w-8"
91
+ onClick={() => setDisplayMode('floating')}
92
+ title="Switch to floating mode"
93
+ >
94
+ <PanelRightClose className="h-4 w-4" />
95
+ </Button>
96
+ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={closeChat} title="Close chat">
97
+ <X className="h-4 w-4" />
98
+ </Button>
93
99
  </div>
94
100
  </div>
95
101
 
@@ -98,8 +104,8 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
98
104
  <ChatMessages
99
105
  messages={messages}
100
106
  isLoading={isLoading}
101
- greeting={greeting}
102
- onStopStreaming={onStopStreaming}
107
+ greeting={config.greeting}
108
+ onStopStreaming={stopStreaming}
103
109
  isCompact={false}
104
110
  largeGreetingIcon
105
111
  greetingIcon="message"
@@ -109,11 +115,11 @@ export const ChatSidebar = React.memo<ChatSidebarProps>(
109
115
 
110
116
  {/* Input */}
111
117
  <div className="p-4 border-t border-border bg-muted/30">
112
- <AIMessageInput onSend={onSendMessage} isLoading={isLoading} placeholder={placeholder} />
118
+ <AIMessageInput onSend={sendMessage} isLoading={isLoading} placeholder={config.placeholder} />
113
119
  </div>
114
120
  </div>
115
- );
116
- }
117
- );
121
+ </div>
122
+ );
123
+ });
118
124
 
119
125
  ChatSidebar.displayName = 'ChatSidebar';
@@ -18,19 +18,7 @@ export interface ChatWidgetProps extends ChatWidgetConfig {
18
18
  * Internal chat widget that uses context
19
19
  */
20
20
  const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) => {
21
- const {
22
- messages,
23
- isLoading,
24
- isMinimized,
25
- config,
26
- displayMode,
27
- isMobile,
28
- sendMessage,
29
- openChat,
30
- closeChat,
31
- toggleMinimize,
32
- setDisplayMode,
33
- } = useChatContext();
21
+ const { config, displayMode, openChat } = useChatContext();
34
22
 
35
23
  // Use layout hook for consistent positioning
36
24
  const { getFabStyles, getFloatingStyles } = useChatLayout();
@@ -60,16 +48,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
60
48
  if (displayMode === 'sidebar') {
61
49
  return (
62
50
  <Portal>
63
- <ChatSidebar
64
- messages={messages}
65
- isLoading={isLoading}
66
- onSendMessage={sendMessage}
67
- onClose={closeChat}
68
- onModeChange={setDisplayMode}
69
- title={config.title}
70
- placeholder={config.placeholder}
71
- greeting={config.greeting}
72
- />
51
+ <ChatSidebar />
73
52
  </Portal>
74
53
  );
75
54
  }
@@ -78,19 +57,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
78
57
  return (
79
58
  <Portal>
80
59
  <div style={floatingStyles} className={className || ''}>
81
- <ChatPanel
82
- messages={messages}
83
- isLoading={isLoading}
84
- onSendMessage={sendMessage}
85
- onClose={closeChat}
86
- onMinimize={toggleMinimize}
87
- onModeChange={setDisplayMode}
88
- isMinimized={isMinimized}
89
- isMobile={isMobile}
90
- title={config.title}
91
- placeholder={config.placeholder}
92
- greeting={config.greeting}
93
- />
60
+ <ChatPanel />
94
61
  </div>
95
62
  </Portal>
96
63
  );
@@ -105,7 +72,7 @@ const ChatWidgetInternal: React.FC<{ className?: string }> = ({ className }) =>
105
72
  */
106
73
  export const ChatWidget: React.FC<ChatWidgetProps> = ({
107
74
  apiEndpoint = '/api/chat',
108
- title = 'DjangoCFG Assistant',
75
+ title = 'DjangoCFG AI',
109
76
  placeholder = 'Ask about DjangoCFG...',
110
77
  greeting = "Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
111
78
  position = 'bottom-right',
@@ -24,7 +24,7 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
24
24
 
25
25
  return (
26
26
  <div
27
- className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300 ${
27
+ className={`flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-300 max-w-full overflow-hidden ${
28
28
  isUser ? 'flex-row-reverse' : ''
29
29
  }`}
30
30
  >
@@ -52,7 +52,7 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
52
52
  {/* Header */}
53
53
  <div className={`flex items-baseline gap-2 mb-1 ${isUser ? 'justify-end' : ''}`}>
54
54
  <span className={`font-medium ${isCompact ? 'text-xs' : 'text-sm'}`}>
55
- {isUser ? 'You' : 'DjangoCFG Assistant'}
55
+ {isUser ? 'You' : 'DjangoCFG AI'}
56
56
  </span>
57
57
  <span className="text-xs text-muted-foreground">
58
58
  {formatTime(message.timestamp)}
@@ -69,12 +69,12 @@ export const MessageBubble = React.memo<MessageBubbleProps>(
69
69
  >
70
70
  <CardContent className={isCompact ? 'p-2' : 'p-3'}>
71
71
  {/* Message Text */}
72
- <div className={`${isCompact ? 'text-xs' : 'text-sm'}`}>
72
+ <div className={`${isCompact ? 'text-xs' : 'text-sm'} overflow-hidden`} style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
73
73
  {isUser ? (
74
- <span className="whitespace-pre-wrap">{message.content}</span>
74
+ <span className="whitespace-pre-wrap" style={{ overflowWrap: 'anywhere' }}>{message.content}</span>
75
75
  ) : message.isStreaming ? (
76
76
  // During streaming - show plain text to avoid parsing errors
77
- <span className="whitespace-pre-wrap">
77
+ <span className="whitespace-pre-wrap" style={{ overflowWrap: 'anywhere' }}>
78
78
  {message.content}
79
79
  <Loader2 className="inline-block ml-1 h-3 w-3 animate-spin" />
80
80
  </span>
@@ -7,7 +7,6 @@ export { AIChatWidget } from './AIChatWidget';
7
7
  export type { AIChatWidgetProps } from './AIChatWidget';
8
8
 
9
9
  export { ChatPanel } from './ChatPanel';
10
- export type { ChatPanelProps } from './ChatPanel';
11
10
 
12
11
  export { ChatMessages } from './ChatMessages';
13
12
  export type { ChatMessagesProps, ChatMessagesHandle } from './ChatMessages';
@@ -19,4 +18,3 @@ export { AIMessageInput } from './MessageInput';
19
18
  export type { AIMessageInputProps } from './MessageInput';
20
19
 
21
20
  export { ChatSidebar } from './ChatSidebar';
22
- export type { ChatSidebarProps } from './ChatSidebar';
@@ -33,3 +33,45 @@ export const mcpEndpoints = {
33
33
  // Export defaults for backwards compatibility
34
34
  export const DEFAULT_CHAT_API_ENDPOINT = PROD_HOST + '/api/chat';
35
35
  export const DEFAULT_MCP_BASE_URL = PROD_HOST;
36
+
37
+ /**
38
+ * Chat sidebar layout configuration
39
+ */
40
+ export const sidebarConfig = {
41
+ /** Minimum sidebar width in pixels */
42
+ minWidth: 320,
43
+ /** Maximum sidebar width in pixels */
44
+ maxWidth: 600,
45
+ /** Default sidebar width in pixels */
46
+ defaultWidth: 400,
47
+ /** Z-index for chat elements */
48
+ zIndex: 300,
49
+ /** Animation duration in milliseconds */
50
+ animationDuration: 200,
51
+ };
52
+
53
+ /**
54
+ * Chat FAB (Floating Action Button) configuration
55
+ */
56
+ export const fabConfig = {
57
+ /** Bottom offset in pixels */
58
+ bottom: 24,
59
+ /** Right offset in pixels */
60
+ right: 24,
61
+ /** Size of FAB button in pixels */
62
+ size: 56,
63
+ };
64
+
65
+ /**
66
+ * LocalStorage keys for chat state persistence
67
+ */
68
+ export const storageKeys = {
69
+ /** Display mode (closed, floating, sidebar) */
70
+ mode: 'djangocfg-chat-mode',
71
+ /** User ID for conversation tracking */
72
+ userId: 'djangocfg-chat-user-id',
73
+ /** Chat messages history */
74
+ messages: 'djangocfg-chat-messages',
75
+ /** Sidebar width */
76
+ sidebarWidth: 'djangocfg-chat-sidebar-width',
77
+ };
@@ -4,10 +4,8 @@ import React, { createContext, useContext, useState, useCallback, useMemo, useEf
4
4
  import { useLocalStorage, useIsMobile } from '@djangocfg/ui/hooks';
5
5
  import { v4 as uuidv4 } from 'uuid';
6
6
  import type { AIChatMessage, ChatApiResponse, AIChatSource, ChatWidgetConfig, ChatDisplayMode } from '../types';
7
+ import { storageKeys } from '../config';
7
8
 
8
- const STORAGE_KEY_MODE = 'djangocfg-chat-mode';
9
- const STORAGE_KEY_USER_ID = 'djangocfg-chat-user-id';
10
- const STORAGE_KEY_MESSAGES = 'djangocfg-chat-messages';
11
9
  const MAX_STORED_MESSAGES = 50;
12
10
 
13
11
  function generateMessageId(): string {
@@ -102,13 +100,13 @@ export function ChatProvider({
102
100
  const isHydratedRef = useRef(false);
103
101
 
104
102
  // Display mode with localStorage persistence
105
- const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(STORAGE_KEY_MODE, 'closed');
103
+ const [storedMode, setStoredMode] = useLocalStorage<ChatDisplayMode>(storageKeys.mode, 'closed');
106
104
 
107
105
  // User ID with localStorage persistence
108
- const [userId, setUserId] = useLocalStorage<string>(STORAGE_KEY_USER_ID, '');
106
+ const [userId, setUserId] = useLocalStorage<string>(storageKeys.userId, '');
109
107
 
110
108
  // Messages storage (serialized)
111
- const [storedMessages, setStoredMessages] = useLocalStorage<SerializedMessage[]>(STORAGE_KEY_MESSAGES, []);
109
+ const [storedMessages, setStoredMessages] = useLocalStorage<SerializedMessage[]>(storageKeys.messages, []);
112
110
 
113
111
  const isMobile = useIsMobile();
114
112
 
@@ -163,7 +161,7 @@ export function ChatProvider({
163
161
  const config: ChatWidgetConfig = useMemo(
164
162
  () => ({
165
163
  apiEndpoint,
166
- title: 'DjangoCFG Assistant',
164
+ title: 'DjangoCFG AI',
167
165
  placeholder: 'Ask about DjangoCFG...',
168
166
  greeting:
169
167
  "Hi! I'm your DjangoCFG documentation assistant. Ask me anything about configuration, features, or how to use the library.",
@@ -1,16 +1,21 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useCallback, useRef } from 'react';
3
+ import { useEffect, useCallback, useRef, useState } from 'react';
4
+ import { useLocalStorage } from '@djangocfg/ui/hooks';
4
5
  import type { ChatDisplayMode } from '../types';
6
+ import { sidebarConfig, fabConfig, storageKeys } from '../config';
7
+
8
+ // Re-export for convenience
9
+ export const MIN_SIDEBAR_WIDTH = sidebarConfig.minWidth;
10
+ export const MAX_SIDEBAR_WIDTH = sidebarConfig.maxWidth;
11
+ export const DEFAULT_SIDEBAR_WIDTH = sidebarConfig.defaultWidth;
5
12
 
6
13
  /**
7
14
  * Configuration for chat layout management
8
15
  */
9
16
  export interface ChatLayoutConfig {
10
- /** Width of sidebar in pixels */
11
- sidebarWidth?: number;
12
- /** Z-index for chat elements */
13
- zIndex?: number;
17
+ /** Initial width of sidebar in pixels */
18
+ initialWidth?: number;
14
19
  /** Animation duration in ms */
15
20
  animationDuration?: number;
16
21
  /** Element to push (defaults to body) */
@@ -21,10 +26,18 @@ export interface ChatLayoutConfig {
21
26
  * Return type for useChatLayout hook
22
27
  */
23
28
  export interface UseChatLayoutReturn {
29
+ /** Current sidebar width */
30
+ sidebarWidth: number;
24
31
  /** Apply layout changes for mode */
25
32
  applyLayout: (mode: ChatDisplayMode) => void;
26
33
  /** Reset layout to default */
27
34
  resetLayout: () => void;
35
+ /** Update sidebar width (for resize) */
36
+ updateWidth: (width: number) => void;
37
+ /** Start resize operation */
38
+ startResize: (e: React.MouseEvent) => void;
39
+ /** Whether currently resizing */
40
+ isResizing: boolean;
28
41
  /** Get CSS for sidebar container */
29
42
  getSidebarStyles: () => React.CSSProperties;
30
43
  /** Get CSS for floating container */
@@ -34,9 +47,8 @@ export interface UseChatLayoutReturn {
34
47
  }
35
48
 
36
49
  const DEFAULT_CONFIG: Required<ChatLayoutConfig> = {
37
- sidebarWidth: 400,
38
- zIndex: 300,
39
- animationDuration: 200,
50
+ initialWidth: sidebarConfig.defaultWidth,
51
+ animationDuration: sidebarConfig.animationDuration,
40
52
  pushTarget: 'body',
41
53
  };
42
54
 
@@ -69,7 +81,22 @@ interface FixedElementOriginalStyles {
69
81
  */
70
82
  export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
71
83
  const mergedConfig = { ...DEFAULT_CONFIG, ...config };
72
- const { sidebarWidth, zIndex, animationDuration, pushTarget } = mergedConfig;
84
+ const { initialWidth, animationDuration, pushTarget } = mergedConfig;
85
+
86
+ // Sidebar width with localStorage persistence
87
+ const [storedWidth, setStoredWidth] = useLocalStorage<number>(storageKeys.sidebarWidth, initialWidth);
88
+
89
+ // Clamp stored width to valid range
90
+ const sidebarWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, storedWidth));
91
+ const sidebarWidthRef = useRef(sidebarWidth);
92
+
93
+ // Resizing state (runtime only, not persisted)
94
+ const [isResizing, setIsResizing] = useState(false);
95
+
96
+ // Keep ref in sync
97
+ useEffect(() => {
98
+ sidebarWidthRef.current = sidebarWidth;
99
+ }, [sidebarWidth]);
73
100
 
74
101
  // Store original styles for cleanup
75
102
  const originalStylesRef = useRef<{
@@ -157,6 +184,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
157
184
  */
158
185
  const adjustFixedElements = useCallback(
159
186
  (open: boolean) => {
187
+ const currentWidth = sidebarWidthRef.current;
160
188
  if (open) {
161
189
  // Save and adjust fixed elements
162
190
  const fixedElements = getFixedElements();
@@ -168,7 +196,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
168
196
 
169
197
  fixedElements.forEach((el) => {
170
198
  el.style.transition = `right ${animationDuration}ms ease`;
171
- el.style.right = `${sidebarWidth}px`;
199
+ el.style.right = `${currentWidth}px`;
172
200
  });
173
201
  } else {
174
202
  // Restore fixed elements
@@ -185,7 +213,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
185
213
  fixedElementsRef.current = [];
186
214
  }
187
215
  },
188
- [getFixedElements, sidebarWidth, animationDuration]
216
+ [getFixedElements, animationDuration]
189
217
  );
190
218
 
191
219
  /**
@@ -195,11 +223,13 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
195
223
  const target = getTargetElement();
196
224
  if (!target) return;
197
225
 
226
+ const currentWidth = sidebarWidthRef.current;
227
+
198
228
  saveOriginalStyles(target);
199
229
 
200
230
  // Add smooth transition
201
231
  target.style.transition = `margin-right ${animationDuration}ms ease`;
202
- target.style.marginRight = `${sidebarWidth}px`;
232
+ target.style.marginRight = `${currentWidth}px`;
203
233
  target.style.overflowX = 'hidden';
204
234
  target.setAttribute('data-chat-sidebar', 'open');
205
235
 
@@ -207,7 +237,7 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
207
237
  adjustFixedElements(true);
208
238
 
209
239
  currentModeRef.current = 'sidebar';
210
- }, [getTargetElement, saveOriginalStyles, sidebarWidth, animationDuration, adjustFixedElements]);
240
+ }, [getTargetElement, saveOriginalStyles, animationDuration, adjustFixedElements]);
211
241
 
212
242
  /**
213
243
  * Apply floating/closed mode layout (reset sidebar push)
@@ -268,6 +298,83 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
268
298
  currentModeRef.current = 'closed';
269
299
  }, [getTargetElement, restoreOriginalStyles]);
270
300
 
301
+ /**
302
+ * Update width during resize (no animation)
303
+ */
304
+ const updateWidthImmediate = useCallback(
305
+ (newWidth: number) => {
306
+ const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, newWidth));
307
+
308
+ // Update body margin
309
+ const target = getTargetElement();
310
+ if (target && currentModeRef.current === 'sidebar') {
311
+ target.style.transition = 'none';
312
+ target.style.marginRight = `${clampedWidth}px`;
313
+ }
314
+
315
+ // Update fixed elements
316
+ fixedElementsRef.current.forEach(({ element }) => {
317
+ element.style.transition = 'none';
318
+ element.style.right = `${clampedWidth}px`;
319
+ });
320
+
321
+ return clampedWidth;
322
+ },
323
+ [getTargetElement]
324
+ );
325
+
326
+ /**
327
+ * Update sidebar width (for resize)
328
+ */
329
+ const updateWidth = useCallback(
330
+ (newWidth: number) => {
331
+ const clampedWidth = updateWidthImmediate(newWidth);
332
+ setStoredWidth(clampedWidth);
333
+ },
334
+ [updateWidthImmediate, setStoredWidth]
335
+ );
336
+
337
+ /**
338
+ * Start resize operation
339
+ */
340
+ const startResize = useCallback(
341
+ (e: React.MouseEvent) => {
342
+ e.preventDefault();
343
+ setIsResizing(true);
344
+
345
+ const startX = e.clientX;
346
+ const startWidth = sidebarWidthRef.current;
347
+
348
+ const handleMouseMove = (moveEvent: MouseEvent) => {
349
+ // Calculate new width (dragging left increases width)
350
+ const deltaX = startX - moveEvent.clientX;
351
+ const newWidth = startWidth + deltaX;
352
+ const clampedWidth = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, newWidth));
353
+
354
+ // Update DOM immediately for smooth feel
355
+ updateWidthImmediate(clampedWidth);
356
+ sidebarWidthRef.current = clampedWidth;
357
+
358
+ // Also update React state so sidebar visually resizes
359
+ setStoredWidth(clampedWidth);
360
+ };
361
+
362
+ const handleMouseUp = () => {
363
+ setIsResizing(false);
364
+ document.removeEventListener('mousemove', handleMouseMove);
365
+ document.removeEventListener('mouseup', handleMouseUp);
366
+ document.body.style.cursor = '';
367
+ document.body.style.userSelect = '';
368
+ };
369
+
370
+ document.addEventListener('mousemove', handleMouseMove);
371
+ document.addEventListener('mouseup', handleMouseUp);
372
+ document.body.style.cursor = 'ew-resize';
373
+ document.body.style.userSelect = 'none';
374
+ },
375
+ [updateWidthImmediate, setStoredWidth]
376
+ );
377
+
271
378
  /**
272
379
  * Get CSS styles for sidebar container
273
380
  */
@@ -278,9 +385,9 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
278
385
  right: 0,
279
386
  bottom: 0,
280
387
  width: `${sidebarWidth}px`,
281
- zIndex,
388
+ zIndex: sidebarConfig.zIndex,
282
389
  };
283
- }, [sidebarWidth, zIndex]);
390
+ }, [sidebarWidth]);
284
391
 
285
392
  /**
286
393
  * Get CSS styles for floating container
@@ -289,12 +396,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
289
396
  (position: 'bottom-right' | 'bottom-left'): React.CSSProperties => {
290
397
  return {
291
398
  position: 'fixed',
292
- zIndex: zIndex - 50, // Slightly lower than sidebar
293
- bottom: 56, // Above banner
294
- ...(position === 'bottom-right' ? { right: 16 } : { left: 16 }),
399
+ zIndex: sidebarConfig.zIndex - 50,
400
+ bottom: fabConfig.bottom,
401
+ ...(position === 'bottom-right' ? { right: fabConfig.right } : { left: fabConfig.right }),
295
402
  };
296
403
  },
297
- [zIndex]
404
+ []
298
405
  );
299
406
 
300
407
  /**
@@ -304,12 +411,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
304
411
  (position: 'bottom-right' | 'bottom-left'): React.CSSProperties => {
305
412
  return {
306
413
  position: 'fixed',
307
- zIndex: zIndex - 50,
308
- bottom: 56,
309
- ...(position === 'bottom-right' ? { right: 16 } : { left: 16 }),
414
+ zIndex: sidebarConfig.zIndex - 50,
415
+ bottom: fabConfig.bottom,
416
+ ...(position === 'bottom-right' ? { right: fabConfig.right } : { left: fabConfig.right }),
310
417
  };
311
418
  },
312
- [zIndex]
419
+ []
313
420
  );
314
421
 
315
422
  // Cleanup on unmount
@@ -320,8 +427,12 @@ export function useChatLayout(config?: ChatLayoutConfig): UseChatLayoutReturn {
320
427
  }, [resetLayout]);
321
428
 
322
429
  return {
430
+ sidebarWidth,
323
431
  applyLayout,
324
432
  resetLayout,
433
+ updateWidth,
434
+ startResize,
435
+ isResizing,
325
436
  getSidebarStyles,
326
437
  getFloatingStyles,
327
438
  getFabStyles,
@@ -46,7 +46,6 @@ export { ChatWidget, AIChatWidget, ChatPanel, MessageBubble, AIMessageInput } fr
46
46
  export type {
47
47
  ChatWidgetProps,
48
48
  AIChatWidgetProps,
49
- ChatPanelProps,
50
49
  MessageBubbleProps,
51
50
  AIMessageInputProps,
52
51
  } from './components';
@@ -24,7 +24,6 @@ export {
24
24
  } from './McpChat';
25
25
  export type {
26
26
  AIChatWidgetProps,
27
- ChatPanelProps,
28
27
  MessageBubbleProps,
29
28
  AIMessageInputProps,
30
29
  AIChatContextState,