@nextclaw/ui 0.6.14 → 0.7.0

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 (94) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -0
  3. package/dist/assets/ChannelsList-DF2U-LY1.js +1 -0
  4. package/dist/assets/ChatPage-BX39y0U5.js +36 -0
  5. package/dist/assets/DocBrowser-B9ws5JL7.js +1 -0
  6. package/dist/assets/{LogoBadge-BxZJ9BJT.js → LogoBadge-DvGAzkZ3.js} +1 -1
  7. package/dist/assets/MarketplacePage-DG5mHWJ8.js +49 -0
  8. package/dist/assets/ModelConfig-BL_HsOsm.js +1 -0
  9. package/dist/assets/ProvidersList-CH5z00YT.js +1 -0
  10. package/dist/assets/RuntimeConfig-BplBgkwo.js +1 -0
  11. package/dist/assets/SearchConfig-BhaI0fUf.js +1 -0
  12. package/dist/assets/{SecretsConfig-9OABNssV.js → SecretsConfig-CFoimOh9.js} +2 -2
  13. package/dist/assets/SessionsConfig-BHTAYn9T.js +2 -0
  14. package/dist/assets/index-BLeJkJ0o.css +1 -0
  15. package/dist/assets/index-DK4TS5ev.js +8 -0
  16. package/dist/assets/index-X5J6Mm--.js +1 -0
  17. package/dist/assets/{index-CkqvHQAt.js → index-uMsNsQX6.js} +1 -1
  18. package/dist/assets/{label-BIjHWZUm.js → label-D8ly4a2P.js} +1 -1
  19. package/dist/assets/page-layout-BSYfvwbp.js +1 -0
  20. package/dist/assets/security-config-DlKEYHNN.js +1 -0
  21. package/dist/assets/{session-run-status-BZEH0QZp.js → session-run-status-TkIuGbVw.js} +1 -1
  22. package/dist/assets/skeleton-CWbsNx2h.js +1 -0
  23. package/dist/assets/{switch-CnGQpdTp.js → switch-Ce_g9lpN.js} +1 -1
  24. package/dist/assets/tabs-custom-Cf5azvT5.js +1 -0
  25. package/dist/assets/useConfirmDialog-A8Ek8Wu7.js +5 -0
  26. package/dist/assets/vendor-B7ozqnFC.js +412 -0
  27. package/dist/index.html +3 -3
  28. package/package.json +9 -10
  29. package/src/App.tsx +49 -27
  30. package/src/api/client.ts +1 -0
  31. package/src/api/config.ts +60 -0
  32. package/src/api/types.ts +29 -1
  33. package/src/api/websocket.ts +2 -0
  34. package/src/components/auth/login-page.tsx +69 -0
  35. package/src/components/chat/ChatConversationPanel.tsx +12 -54
  36. package/src/components/chat/ChatSidebar.tsx +7 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +80 -0
  38. package/src/components/chat/adapters/chat-input-bar.adapter.ts +329 -0
  39. package/src/components/chat/adapters/chat-message.adapter.test.ts +137 -0
  40. package/src/components/chat/adapters/chat-message.adapter.ts +200 -0
  41. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +128 -0
  42. package/src/components/chat/chat-input/chat-input-bar.controller.ts +105 -0
  43. package/src/components/chat/containers/chat-input-bar.container.tsx +270 -0
  44. package/src/components/chat/containers/chat-message-list.container.tsx +67 -0
  45. package/src/components/chat/index.ts +1 -0
  46. package/src/components/chat/managers/chat-thread.manager.ts +3 -1
  47. package/src/components/chat/nextclaw/index.ts +23 -0
  48. package/src/components/common/BrandHeader.tsx +4 -1
  49. package/src/components/common/StatusBadge.tsx +32 -20
  50. package/src/components/config/runtime-security-card.tsx +276 -0
  51. package/src/components/config/security-config.tsx +12 -0
  52. package/src/components/layout/Sidebar.tsx +6 -1
  53. package/src/components/marketplace/MarketplacePage.test.tsx +170 -0
  54. package/src/components/marketplace/MarketplacePage.tsx +77 -28
  55. package/src/hooks/use-auth.ts +111 -0
  56. package/src/hooks/useMarketplace.ts +9 -0
  57. package/src/hooks/useWebSocket.ts +53 -1
  58. package/src/lib/i18n.ts +72 -0
  59. package/src/test/setup.ts +16 -0
  60. package/tsconfig.json +3 -2
  61. package/vite.config.ts +2 -1
  62. package/vitest.config.ts +16 -0
  63. package/.eslintrc.cjs +0 -48
  64. package/dist/assets/ChannelsList-DiSnpiW0.js +0 -1
  65. package/dist/assets/ChatPage-DsaIrNHN.js +0 -36
  66. package/dist/assets/DocBrowser-CnfcptGM.js +0 -1
  67. package/dist/assets/MarketplacePage-BI_J_DBQ.js +0 -49
  68. package/dist/assets/ModelConfig-DfL8F4tN.js +0 -1
  69. package/dist/assets/ProvidersList-DpT_oFHZ.js +0 -1
  70. package/dist/assets/RuntimeConfig-BNYR_Iag.js +0 -1
  71. package/dist/assets/SearchConfig-TDBl7Fjh.js +0 -1
  72. package/dist/assets/SessionsConfig-BRwntUDz.js +0 -2
  73. package/dist/assets/card-BYnT3Mxo.js +0 -1
  74. package/dist/assets/index-BCfS4UY1.css +0 -1
  75. package/dist/assets/index-BnUxgevr.js +0 -8
  76. package/dist/assets/input-oaepEtqu.js +0 -1
  77. package/dist/assets/page-layout-B6JXiSQB.js +0 -1
  78. package/dist/assets/popover-LJQgv5l1.js +0 -1
  79. package/dist/assets/tabs-custom-CpSv7pDl.js +0 -1
  80. package/dist/assets/useConfirmDialog-pqAlPdQZ.js +0 -5
  81. package/dist/assets/vendor-BKtTvQYU.js +0 -407
  82. package/src/components/chat/ChatThread.tsx +0 -402
  83. package/src/components/chat/SkillsPicker.tsx +0 -137
  84. package/src/components/chat/chat-input/ChatInputBarView.tsx +0 -82
  85. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +0 -83
  86. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +0 -39
  87. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +0 -31
  88. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +0 -112
  89. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +0 -24
  90. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +0 -58
  91. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +0 -56
  92. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +0 -40
  93. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputThinkingSelector.tsx +0 -74
  94. package/src/components/chat/chat-input/useChatInputBarController.ts +0 -322
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
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-BnUxgevr.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-BKtTvQYU.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-BCfS4UY1.css">
9
+ <script type="module" crossorigin src="/assets/index-DK4TS5ev.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-B7ozqnFC.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-BLeJkJ0o.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.6.14",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,39 +20,38 @@
20
20
  "react": "^18.3.1",
21
21
  "react-dom": "^18.3.1",
22
22
  "react-hook-form": "^7.53.2",
23
- "react-markdown": "^10.1.0",
24
23
  "react-router-dom": "^7.13.0",
25
24
  "rehype-sanitize": "^6.0.0",
26
- "remark-gfm": "^4.0.1",
27
25
  "rxjs": "^7.8.2",
28
26
  "sonner": "^1.7.1",
29
27
  "tailwind-merge": "^2.5.4",
30
28
  "zod": "^3.23.8",
31
29
  "zustand": "^5.0.2",
30
+ "@nextclaw/agent-chat-ui": "0.1.1",
32
31
  "@nextclaw/agent-chat": "0.1.1"
33
32
  },
34
33
  "devDependencies": {
34
+ "@testing-library/react": "^16.3.0",
35
+ "@testing-library/user-event": "^14.6.1",
35
36
  "@types/react": "^18.3.12",
36
37
  "@types/react-dom": "^18.3.1",
37
- "@typescript-eslint/eslint-plugin": "^7.18.0",
38
- "@typescript-eslint/parser": "^7.18.0",
39
38
  "@vitejs/plugin-react": "^4.3.4",
40
39
  "autoprefixer": "^10.4.20",
41
- "eslint": "^8.57.1",
42
- "eslint-config-prettier": "^9.1.0",
43
- "eslint-plugin-react-hooks": "^7.0.1",
40
+ "jsdom": "^25.0.1",
44
41
  "postcss": "^8.4.49",
45
42
  "prettier": "^3.3.3",
46
43
  "tailwindcss": "^3.4.15",
47
44
  "tailwindcss-animate": "^1.0.7",
48
45
  "typescript": "^5.6.3",
49
- "vite": "^6.0.1"
46
+ "vite": "^6.0.1",
47
+ "vitest": "^2.1.2"
50
48
  },
51
49
  "scripts": {
52
50
  "dev": "vite",
53
51
  "build": "tsc && vite build",
54
52
  "preview": "vite preview",
55
53
  "lint": "eslint .",
56
- "tsc": "tsc --noEmit"
54
+ "tsc": "tsc --noEmit",
55
+ "test": "vitest run"
57
56
  }
58
57
  }
package/src/App.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import { lazy, Suspense } from 'react';
2
2
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { LoginPage } from '@/components/auth/login-page';
3
4
  import { AppLayout } from '@/components/layout/AppLayout';
5
+ import { useAuthStatus } from '@/hooks/use-auth';
4
6
  import { useWebSocket } from '@/hooks/useWebSocket';
5
7
  import { Toaster } from 'sonner';
6
8
  import { Routes, Route, Navigate } from 'react-router-dom';
@@ -20,6 +22,7 @@ const SearchConfigPage = lazy(async () => ({ default: (await import('@/component
20
22
  const ProvidersListPage = lazy(async () => ({ default: (await import('@/components/config/ProvidersList')).ProvidersList }));
21
23
  const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
22
24
  const RuntimeConfigPage = lazy(async () => ({ default: (await import('@/components/config/RuntimeConfig')).RuntimeConfig }));
25
+ const SecurityConfigPage = lazy(async () => ({ default: (await import('@/components/config/security-config')).SecurityConfig }));
23
26
  const SessionsConfigPage = lazy(async () => ({ default: (await import('@/components/config/SessionsConfig')).SessionsConfig }));
24
27
  const SecretsConfigPage = lazy(async () => ({ default: (await import('@/components/config/SecretsConfig')).SecretsConfig }));
25
28
  const MarketplacePage = lazy(async () => ({ default: (await import('@/components/marketplace/MarketplacePage')).MarketplacePage }));
@@ -32,38 +35,57 @@ function LazyRoute({ children }: { children: JSX.Element }) {
32
35
  return <Suspense fallback={<RouteFallback />}>{children}</Suspense>;
33
36
  }
34
37
 
35
- function AppContent() {
38
+ function ProtectedApp() {
36
39
  useWebSocket(queryClient); // Initialize WebSocket connection
37
40
 
41
+ return (
42
+ <AppLayout>
43
+ <div className="w-full h-full">
44
+ <Routes>
45
+ <Route path="/chat/skills" element={<Navigate to="/skills" replace />} />
46
+ <Route path="/chat/cron" element={<Navigate to="/cron" replace />} />
47
+ <Route path="/chat/:sessionId?" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
48
+ <Route path="/skills" element={<LazyRoute><ChatPage view="skills" /></LazyRoute>} />
49
+ <Route path="/cron" element={<LazyRoute><ChatPage view="cron" /></LazyRoute>} />
50
+ <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
51
+ <Route path="/search" element={<LazyRoute><SearchConfigPage /></LazyRoute>} />
52
+ <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
53
+ <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
54
+ <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
55
+ <Route path="/security" element={<LazyRoute><SecurityConfigPage /></LazyRoute>} />
56
+ <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
57
+ <Route path="/secrets" element={<LazyRoute><SecretsConfigPage /></LazyRoute>} />
58
+ <Route path="/settings" element={<Navigate to="/model" replace />} />
59
+ <Route path="/marketplace/skills" element={<Navigate to="/skills" replace />} />
60
+ <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
61
+ <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
62
+ <Route path="/" element={<Navigate to="/chat" replace />} />
63
+ <Route path="*" element={<Navigate to="/chat" replace />} />
64
+ </Routes>
65
+ </div>
66
+ </AppLayout>
67
+ );
68
+ }
69
+
70
+ function AuthGate() {
71
+ const authStatus = useAuthStatus();
72
+
73
+ if (authStatus.isLoading && !authStatus.isError) {
74
+ return <RouteFallback />;
75
+ }
76
+
77
+ if (authStatus.data?.enabled && !authStatus.data.authenticated) {
78
+ return <LoginPage username={authStatus.data.username} />;
79
+ }
80
+
81
+ return <ProtectedApp />;
82
+ }
83
+
84
+ export default function AppContent() {
38
85
  return (
39
86
  <QueryClientProvider client={queryClient}>
40
- <AppLayout>
41
- <div className="w-full h-full">
42
- <Routes>
43
- <Route path="/chat/skills" element={<Navigate to="/skills" replace />} />
44
- <Route path="/chat/cron" element={<Navigate to="/cron" replace />} />
45
- <Route path="/chat/:sessionId?" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
46
- <Route path="/skills" element={<LazyRoute><ChatPage view="skills" /></LazyRoute>} />
47
- <Route path="/cron" element={<LazyRoute><ChatPage view="cron" /></LazyRoute>} />
48
- <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
49
- <Route path="/search" element={<LazyRoute><SearchConfigPage /></LazyRoute>} />
50
- <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
51
- <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
52
- <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
53
- <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
54
- <Route path="/secrets" element={<LazyRoute><SecretsConfigPage /></LazyRoute>} />
55
- <Route path="/settings" element={<Navigate to="/model" replace />} />
56
- <Route path="/marketplace/skills" element={<Navigate to="/skills" replace />} />
57
- <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
58
- <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
59
- <Route path="/" element={<Navigate to="/chat" replace />} />
60
- <Route path="*" element={<Navigate to="/chat" replace />} />
61
- </Routes>
62
- </div>
63
- </AppLayout>
87
+ <AuthGate />
64
88
  <Toaster position="top-right" richColors />
65
89
  </QueryClientProvider>
66
90
  );
67
91
  }
68
-
69
- export default AppContent;
package/src/api/client.ts CHANGED
@@ -50,6 +50,7 @@ async function apiRequest<T>(
50
50
  const method = (options.method || 'GET').toUpperCase();
51
51
 
52
52
  const response = await fetch(url, {
53
+ credentials: 'include',
53
54
  headers: {
54
55
  'Content-Type': 'application/json',
55
56
  ...options.headers
package/src/api/config.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { api, API_BASE } from './client';
2
2
  import type {
3
+ AuthEnabledUpdateRequest,
4
+ AuthLoginRequest,
5
+ AuthPasswordUpdateRequest,
6
+ AuthSetupRequest,
7
+ AuthStatusView,
3
8
  AppMetaView,
4
9
  ConfigView,
5
10
  ConfigMetaView,
@@ -46,6 +51,60 @@ import type {
46
51
  CronActionResult
47
52
  } from './types';
48
53
 
54
+ // GET /api/auth/status
55
+ export async function fetchAuthStatus(): Promise<AuthStatusView> {
56
+ const response = await api.get<AuthStatusView>('/api/auth/status');
57
+ if (!response.ok) {
58
+ throw new Error(response.error.message);
59
+ }
60
+ return response.data;
61
+ }
62
+
63
+ // POST /api/auth/setup
64
+ export async function setupAuth(data: AuthSetupRequest): Promise<AuthStatusView> {
65
+ const response = await api.post<AuthStatusView>('/api/auth/setup', data);
66
+ if (!response.ok) {
67
+ throw new Error(response.error.message);
68
+ }
69
+ return response.data;
70
+ }
71
+
72
+ // POST /api/auth/login
73
+ export async function loginAuth(data: AuthLoginRequest): Promise<AuthStatusView> {
74
+ const response = await api.post<AuthStatusView>('/api/auth/login', data);
75
+ if (!response.ok) {
76
+ throw new Error(response.error.message);
77
+ }
78
+ return response.data;
79
+ }
80
+
81
+ // POST /api/auth/logout
82
+ export async function logoutAuth(): Promise<{ success: boolean }> {
83
+ const response = await api.post<{ success: boolean }>('/api/auth/logout', {});
84
+ if (!response.ok) {
85
+ throw new Error(response.error.message);
86
+ }
87
+ return response.data;
88
+ }
89
+
90
+ // PUT /api/auth/password
91
+ export async function updateAuthPassword(data: AuthPasswordUpdateRequest): Promise<AuthStatusView> {
92
+ const response = await api.put<AuthStatusView>('/api/auth/password', data);
93
+ if (!response.ok) {
94
+ throw new Error(response.error.message);
95
+ }
96
+ return response.data;
97
+ }
98
+
99
+ // PUT /api/auth/enabled
100
+ export async function updateAuthEnabled(data: AuthEnabledUpdateRequest): Promise<AuthStatusView> {
101
+ const response = await api.put<AuthStatusView>('/api/auth/enabled', data);
102
+ if (!response.ok) {
103
+ throw new Error(response.error.message);
104
+ }
105
+ return response.data;
106
+ }
107
+
49
108
  // GET /api/app/meta
50
109
  export async function fetchAppMeta(): Promise<AppMetaView> {
51
110
  const response = await api.get<AppMetaView>('/api/app/meta');
@@ -356,6 +415,7 @@ async function readSseStream(params: {
356
415
  }): Promise<{ sessionKey: string; reply: string }> {
357
416
  const response = await fetch(`${API_BASE}${params.path}`, {
358
417
  method: params.method,
418
+ credentials: 'include',
359
419
  headers: {
360
420
  'Content-Type': 'application/json',
361
421
  Accept: 'text/event-stream'
package/src/api/types.ts CHANGED
@@ -158,6 +158,31 @@ export type ProviderAuthImportResult = {
158
158
  expiresAt?: string;
159
159
  };
160
160
 
161
+ export type AuthStatusView = {
162
+ enabled: boolean;
163
+ configured: boolean;
164
+ authenticated: boolean;
165
+ username?: string;
166
+ };
167
+
168
+ export type AuthSetupRequest = {
169
+ username: string;
170
+ password: string;
171
+ };
172
+
173
+ export type AuthLoginRequest = {
174
+ username: string;
175
+ password: string;
176
+ };
177
+
178
+ export type AuthPasswordUpdateRequest = {
179
+ password: string;
180
+ };
181
+
182
+ export type AuthEnabledUpdateRequest = {
183
+ enabled: boolean;
184
+ };
185
+
161
186
  export type AgentProfileView = {
162
187
  id: string;
163
188
  default?: boolean;
@@ -501,6 +526,7 @@ export type ConfigView = {
501
526
  session?: SessionConfigView;
502
527
  tools?: Record<string, unknown>;
503
528
  gateway?: Record<string, unknown>;
529
+ ui?: Record<string, unknown>;
504
530
  secrets?: SecretsView;
505
531
  };
506
532
 
@@ -653,7 +679,9 @@ export type WsEvent =
653
679
  | { type: 'config.reload.started'; payload?: Record<string, unknown> }
654
680
  | { type: 'config.reload.finished'; payload?: Record<string, unknown> }
655
681
  | { type: 'error'; payload: { message: string; code?: string } }
656
- | { type: 'connection.open'; payload?: Record<string, unknown> };
682
+ | { type: 'connection.open'; payload?: Record<string, unknown> }
683
+ | { type: 'connection.close'; payload?: Record<string, unknown> }
684
+ | { type: 'connection.error'; payload?: { message?: string } };
657
685
 
658
686
  export type MarketplaceItemType = 'plugin' | 'skill';
659
687
 
@@ -30,6 +30,7 @@ export class ConfigWebSocket {
30
30
 
31
31
  this.ws.onclose = () => {
32
32
  console.log('WebSocket disconnected');
33
+ this.emit({ type: 'connection.close', payload: {} });
33
34
  if (!this.manualClose) {
34
35
  this.scheduleReconnect();
35
36
  }
@@ -37,6 +38,7 @@ export class ConfigWebSocket {
37
38
 
38
39
  this.ws.onerror = (error) => {
39
40
  console.error('WebSocket error:', error);
41
+ this.emit({ type: 'connection.error', payload: { message: 'websocket error' } });
40
42
  };
41
43
  }
42
44
 
@@ -0,0 +1,69 @@
1
+ import { useState, type FormEvent } from 'react';
2
+ import { Button } from '@/components/ui/button';
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
4
+ import { Input } from '@/components/ui/input';
5
+ import { useLoginAuth } from '@/hooks/use-auth';
6
+ import { t } from '@/lib/i18n';
7
+
8
+ type LoginPageProps = {
9
+ username?: string;
10
+ };
11
+
12
+ export function LoginPage({ username }: LoginPageProps) {
13
+ const loginMutation = useLoginAuth();
14
+ const [formUsername, setFormUsername] = useState(username ?? '');
15
+ const [password, setPassword] = useState('');
16
+ const canSubmit = formUsername.trim().length > 0 && password.length > 0 && !loginMutation.isPending;
17
+
18
+ const handleSubmit = (event?: FormEvent<HTMLFormElement>) => {
19
+ event?.preventDefault();
20
+ if (!canSubmit) {
21
+ return;
22
+ }
23
+ loginMutation.mutate({
24
+ username: formUsername.trim(),
25
+ password
26
+ });
27
+ };
28
+
29
+ return (
30
+ <main className="flex min-h-screen items-center justify-center bg-secondary px-6 py-10">
31
+ <Card hover={false} className="w-full max-w-md shadow-card-hover">
32
+ <CardHeader>
33
+ <p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">{t('authBrand')}</p>
34
+ <CardTitle className="text-2xl">{t('authLoginTitle')}</CardTitle>
35
+ <CardDescription>{t('authLoginDescription')}</CardDescription>
36
+ </CardHeader>
37
+ <CardContent>
38
+ <form className="space-y-4" onSubmit={handleSubmit}>
39
+ <div className="space-y-2">
40
+ <label className="text-sm font-medium text-gray-800">{t('authUsername')}</label>
41
+ <Input
42
+ value={formUsername}
43
+ onChange={(event) => setFormUsername(event.target.value)}
44
+ placeholder={t('authUsernamePlaceholder')}
45
+ autoFocus
46
+ />
47
+ </div>
48
+ <div className="space-y-2">
49
+ <label className="text-sm font-medium text-gray-800">{t('authPassword')}</label>
50
+ <Input
51
+ type="password"
52
+ value={password}
53
+ onChange={(event) => setPassword(event.target.value)}
54
+ placeholder={t('authPasswordPlaceholder')}
55
+ />
56
+ </div>
57
+ <Button
58
+ type="submit"
59
+ className="w-full"
60
+ disabled={!canSubmit}
61
+ >
62
+ {loginMutation.isPending ? t('authLoggingIn') : t('authLoginAction')}
63
+ </Button>
64
+ </form>
65
+ </CardContent>
66
+ </Card>
67
+ </main>
68
+ );
69
+ }
@@ -1,16 +1,14 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
1
+ import { useRef } from 'react';
2
+ import { useStickyBottomScroll } from '@nextclaw/agent-chat-ui';
2
3
  import { Button } from '@/components/ui/button';
3
- import { ChatThread } from '@/components/chat/ChatThread';
4
+ import { ChatInputBarContainer, ChatMessageListContainer } from '@/components/chat/nextclaw';
4
5
  import { ChatWelcome } from '@/components/chat/ChatWelcome';
5
- import { ChatInputBarView } from '@/components/chat/chat-input/ChatInputBarView';
6
6
  import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
7
7
  import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
8
8
  import { t } from '@/lib/i18n';
9
9
  import { cn } from '@/lib/utils';
10
10
  import { Trash2 } from 'lucide-react';
11
11
 
12
- const STICKY_BOTTOM_THRESHOLD_PX = 10;
13
-
14
12
  function ChatConversationSkeleton() {
15
13
  return (
16
14
  <section className="flex-1 min-h-0 flex flex-col overflow-hidden bg-gradient-to-b from-gray-50/60 to-white">
@@ -45,12 +43,6 @@ export function ChatConversationPanel() {
45
43
  const fallbackThreadRef = useRef<HTMLDivElement | null>(null);
46
44
  const threadRef = snapshot.threadRef ?? fallbackThreadRef;
47
45
 
48
- // --- Sticky-to-bottom scroll state ---
49
- const isStickyRef = useRef(true);
50
- const isProgrammaticScrollRef = useRef(false);
51
- const previousSessionKeyRef = useRef<string | null>(null);
52
- const pendingInitialScrollRef = useRef(false);
53
-
54
46
  const showWelcome = !snapshot.selectedSessionKey && snapshot.uiMessages.length === 0 && !snapshot.isSending;
55
47
  const hasConfiguredModel = snapshot.modelOptions.length > 0;
56
48
  const shouldShowProviderHint = snapshot.isProviderStateResolved && !hasConfiguredModel;
@@ -60,47 +52,13 @@ export function ChatConversationPanel() {
60
52
  !snapshot.isSending &&
61
53
  !snapshot.isAwaitingAssistantOutput;
62
54
 
63
- const scrollToBottom = useCallback(() => {
64
- const el = threadRef.current;
65
- if (!el) return;
66
- isProgrammaticScrollRef.current = true;
67
- el.scrollTop = el.scrollHeight;
68
- }, [threadRef]);
69
-
70
- const handleScroll = useCallback(() => {
71
- // Skip sticky check for programmatic scrolls
72
- if (isProgrammaticScrollRef.current) {
73
- isProgrammaticScrollRef.current = false;
74
- return;
75
- }
76
- const el = threadRef.current;
77
- if (!el) return;
78
- const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
79
- isStickyRef.current = distanceFromBottom <= STICKY_BOTTOM_THRESHOLD_PX;
80
- }, [threadRef]);
81
-
82
- // Session change → force sticky + schedule initial scroll
83
- useEffect(() => {
84
- if (previousSessionKeyRef.current === snapshot.selectedSessionKey) return;
85
- previousSessionKeyRef.current = snapshot.selectedSessionKey;
86
- isStickyRef.current = true;
87
- pendingInitialScrollRef.current = true;
88
- }, [snapshot.selectedSessionKey]);
89
-
90
- // Initial scroll after history loads for a new session
91
- useLayoutEffect(() => {
92
- if (!pendingInitialScrollRef.current) return;
93
- if (snapshot.isHistoryLoading || snapshot.uiMessages.length === 0) return;
94
- pendingInitialScrollRef.current = false;
95
- scrollToBottom();
96
- }, [scrollToBottom, snapshot.isHistoryLoading, snapshot.uiMessages]);
97
-
98
- // Streaming updates: keep bottom visible while still sticky.
99
- useLayoutEffect(() => {
100
- if (!isStickyRef.current) return;
101
- if (snapshot.uiMessages.length === 0) return;
102
- scrollToBottom();
103
- }, [scrollToBottom, snapshot.uiMessages]);
55
+ const { onScroll: handleScroll } = useStickyBottomScroll({
56
+ scrollRef: threadRef,
57
+ resetKey: snapshot.selectedSessionKey,
58
+ isLoading: snapshot.isHistoryLoading,
59
+ hasContent: snapshot.uiMessages.length > 0,
60
+ contentVersion: snapshot.uiMessages
61
+ });
104
62
 
105
63
  if (!snapshot.isProviderStateResolved) {
106
64
  return <ChatConversationSkeleton />;
@@ -160,12 +118,12 @@ export function ChatConversationPanel() {
160
118
  <div className="px-5 py-5 text-sm text-gray-500">{t('chatNoMessages')}</div>
161
119
  ) : (
162
120
  <div className="mx-auto w-full max-w-[min(1120px,100%)] px-6 py-5">
163
- <ChatThread uiMessages={snapshot.uiMessages} isSending={snapshot.isSending && snapshot.isAwaitingAssistantOutput} />
121
+ <ChatMessageListContainer uiMessages={snapshot.uiMessages} isSending={snapshot.isSending && snapshot.isAwaitingAssistantOutput} />
164
122
  </div>
165
123
  )}
166
124
  </div>
167
125
 
168
- <ChatInputBarView />
126
+ <ChatInputBarContainer />
169
127
  </section>
170
128
  );
171
129
  }
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
2
2
  import type { SessionEntryView } from '@/api/types';
3
3
  import { Button } from '@/components/ui/button';
4
4
  import { BrandHeader } from '@/components/common/BrandHeader';
5
+ import { StatusBadge } from '@/components/common/StatusBadge';
5
6
  import { Input } from '@/components/ui/input';
6
7
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
7
8
  import { SessionRunBadge } from '@/components/common/SessionRunBadge';
@@ -14,6 +15,7 @@ import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
14
15
  import { useI18n } from '@/components/providers/I18nProvider';
15
16
  import { useTheme } from '@/components/providers/ThemeProvider';
16
17
  import { useDocBrowser } from '@/components/doc-browser';
18
+ import { useUiStore } from '@/stores/ui.store';
17
19
  import { NavLink } from 'react-router-dom';
18
20
  import { AlarmClock, BookOpen, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
19
21
 
@@ -72,6 +74,7 @@ export function ChatSidebar() {
72
74
  const docBrowser = useDocBrowser();
73
75
  const listSnapshot = useChatSessionListStore((state) => state.snapshot);
74
76
  const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
77
+ const connectionStatus = useUiStore((state) => state.connectionStatus);
75
78
  const { language, setLanguage } = useI18n();
76
79
  const { theme, setTheme } = useTheme();
77
80
  const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
@@ -88,7 +91,10 @@ export function ChatSidebar() {
88
91
  return (
89
92
  <aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
90
93
  <div className="px-5 pt-5 pb-3">
91
- <BrandHeader />
94
+ <BrandHeader
95
+ className="flex items-center gap-2.5 min-w-0"
96
+ suffix={<StatusBadge status={connectionStatus} />}
97
+ />
92
98
  </div>
93
99
 
94
100
  <div className="px-4 pb-3">
@@ -0,0 +1,80 @@
1
+ import { buildChatSlashItems, buildSelectedSkillItems, buildSkillPickerModel } from '@/components/chat/adapters/chat-input-bar.adapter';
2
+ import type { ChatSkillRecord } from '@/components/chat/adapters/chat-input-bar.adapter';
3
+
4
+ function createSkillRecord(partial: Partial<ChatSkillRecord>): ChatSkillRecord {
5
+ return {
6
+ key: 'demo.skill',
7
+ label: 'Demo Skill',
8
+ ...partial
9
+ };
10
+ }
11
+
12
+ describe('buildChatSlashItems', () => {
13
+ const texts = {
14
+ slashSkillSubtitle: 'Skill',
15
+ slashSkillSpecLabel: 'Spec',
16
+ noSkillDescription: 'No description'
17
+ };
18
+
19
+ it('sorts exact spec matches ahead of weaker matches', () => {
20
+ const items = buildChatSlashItems(
21
+ [
22
+ createSkillRecord({ key: 'web-search', label: 'Web Search' }),
23
+ createSkillRecord({ key: 'weather', label: 'Web Weather' })
24
+ ],
25
+ 'web',
26
+ texts
27
+ );
28
+
29
+ expect(items.map((item) => item.value)).toEqual(['web-search', 'weather']);
30
+ expect(items[0]?.detailLines).toContain('Spec: web-search');
31
+ });
32
+
33
+ it('returns an empty list when nothing matches', () => {
34
+ const items = buildChatSlashItems([createSkillRecord({ key: 'weather' })], 'terminal', texts);
35
+ expect(items).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe('buildSelectedSkillItems', () => {
40
+ it('keeps selected specs and resolves labels when available', () => {
41
+ const chips = buildSelectedSkillItems(
42
+ ['web-search', 'missing-skill'],
43
+ [createSkillRecord({ key: 'web-search', label: 'Web Search' })]
44
+ );
45
+
46
+ expect(chips).toEqual([
47
+ { key: 'web-search', label: 'Web Search' },
48
+ { key: 'missing-skill', label: 'missing-skill' }
49
+ ]);
50
+ });
51
+ });
52
+
53
+ describe('buildSkillPickerModel', () => {
54
+ it('builds a stable semantic model for toolbar skill picker', () => {
55
+ const onSelectedKeysChange = vi.fn();
56
+ const model = buildSkillPickerModel({
57
+ skillRecords: [createSkillRecord({ key: 'web-search', label: 'Web Search', description: 'Search web' })],
58
+ selectedSkills: ['web-search'],
59
+ isLoading: false,
60
+ onSelectedKeysChange,
61
+ texts: {
62
+ title: 'Skills',
63
+ searchPlaceholder: 'Search skills',
64
+ emptyLabel: 'No skills',
65
+ loadingLabel: 'Loading',
66
+ manageLabel: 'Manage'
67
+ }
68
+ });
69
+
70
+ expect(model).toMatchObject({
71
+ title: 'Skills',
72
+ selectedKeys: ['web-search'],
73
+ manageHref: '/marketplace/skills'
74
+ });
75
+ expect(model.options[0]).toMatchObject({
76
+ key: 'web-search',
77
+ label: 'Web Search'
78
+ });
79
+ });
80
+ });