@nextclaw/ui 0.10.0 → 0.10.1
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/CHANGELOG.md +18 -1
- package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-BX7KqEk7.js} +4 -4
- package/dist/assets/ChatPage-zXLBKIAY.js +38 -0
- package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-Cdbh4cVD.js} +1 -1
- package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-4801esOJ.js} +1 -1
- package/dist/assets/MarketplacePage-GZgus0Or.js +49 -0
- package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-CAGGvoMo.js} +1 -1
- package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-CfLYjQM3.js} +1 -1
- package/dist/assets/ProvidersList-CEo1kdf-.js +1 -0
- package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-6GYzD7cc.js} +1 -1
- package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-BZdbp8mH.js} +1 -1
- package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-ifvYKix-.js} +1 -1
- package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-tDPbhTeR.js} +1 -1
- package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-DhkAIzGm.js} +1 -1
- package/dist/assets/{chat-message-BOdA4h43.js → chat-message-C5Gl-dCH.js} +1 -1
- package/dist/assets/index-BTt_JlNV.css +1 -0
- package/dist/assets/index-JN3V84h_.js +8 -0
- package/dist/assets/{label-BYZ62ajO.js → label-D8zWKdqp.js} +1 -1
- package/dist/assets/{page-layout-UC-h92sU.js → page-layout-qAJ47LNQ.js} +1 -1
- package/dist/assets/{popover-DASCEr3G.js → popover-hyBGxpxS.js} +1 -1
- package/dist/assets/{security-config-Cvujq4fH.js → security-config-BJYZSnCA.js} +1 -1
- package/dist/assets/skeleton-CUQLsNsM.js +1 -0
- package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DKcoD-iY.js} +1 -1
- package/dist/assets/{switch-D3wVuCSh.js → switch-DtUdQxr_.js} +1 -1
- package/dist/assets/tabs-custom-Dj1BWHGK.js +1 -0
- package/dist/assets/useConfirmDialog-nZdrtETU.js +1 -0
- package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +5 -5
- package/src/components/chat/ChatSidebar.tsx +41 -69
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
- package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
- package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
- package/src/components/chat/chat-composer-state.ts +38 -0
- package/src/components/chat/chat-stream/types.ts +2 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
- package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
- package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
- package/src/components/chat/managers/chat-thread.manager.ts +0 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
- package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
- package/src/components/chat/stores/chat-input.store.ts +3 -0
- package/src/components/config/ChannelsList.test.tsx +2 -1
- package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
- package/src/components/layout/Sidebar.tsx +62 -102
- package/src/components/layout/sidebar-items.tsx +172 -0
- package/src/components/layout/sidebar.layout.test.tsx +11 -4
- package/src/lib/i18n.chat.ts +117 -0
- package/src/lib/i18n.remote.ts +1 -1
- package/src/lib/i18n.ts +2 -112
- package/src/transport/remote.transport.test.ts +135 -0
- package/src/transport/remote.transport.ts +11 -1
- package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
- package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
- package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
- package/dist/assets/index-C63mHRbE.css +0 -1
- package/dist/assets/index-DS7D1-KS.js +0 -8
- package/dist/assets/skeleton-DlYEKkkj.js +0 -1
- package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
- package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
|
@@ -16,6 +16,14 @@ const modelOptions: ChatModelOption[] = [
|
|
|
16
16
|
];
|
|
17
17
|
|
|
18
18
|
describe('filterModelOptionsBySessionType', () => {
|
|
19
|
+
it('keeps the full model catalog when the session type does not publish a supportedModels whitelist', () => {
|
|
20
|
+
expect(
|
|
21
|
+
filterModelOptionsBySessionType({
|
|
22
|
+
modelOptions
|
|
23
|
+
})
|
|
24
|
+
).toEqual(modelOptions);
|
|
25
|
+
});
|
|
26
|
+
|
|
19
27
|
it('keeps only session-type-supported models when the runtime publishes a filtered list', () => {
|
|
20
28
|
expect(
|
|
21
29
|
filterModelOptionsBySessionType({
|
|
@@ -79,7 +79,6 @@ export class NcpChatThreadManager {
|
|
|
79
79
|
try {
|
|
80
80
|
await deleteNcpSessionApi(selectedSessionKey);
|
|
81
81
|
this.streamActionsManager.resetStreamState();
|
|
82
|
-
useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
|
|
83
82
|
this.uiManager.goToChatRoot({ replace: true });
|
|
84
83
|
await this.actions.refetchSessions();
|
|
85
84
|
} finally {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
2
3
|
import { createContext, useContext } from 'react';
|
|
3
4
|
import type { ReactNode } from 'react';
|
|
4
5
|
import type { SetStateAction } from 'react';
|
|
@@ -13,6 +14,11 @@ export type ChatInputManagerLike = {
|
|
|
13
14
|
syncSnapshot: (patch: Record<string, unknown>) => void;
|
|
14
15
|
setDraft: (next: SetStateAction<string>) => void;
|
|
15
16
|
setComposerNodes: (next: SetStateAction<ChatComposerNode[]>) => void;
|
|
17
|
+
addAttachments?: (attachments: NcpDraftAttachment[]) => void;
|
|
18
|
+
restoreComposerState?: (
|
|
19
|
+
nodes: ChatComposerNode[],
|
|
20
|
+
attachments: NcpDraftAttachment[]
|
|
21
|
+
) => void;
|
|
16
22
|
setPendingSessionType: (next: SetStateAction<string>) => void;
|
|
17
23
|
send: () => Promise<void>;
|
|
18
24
|
stop: () => Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { create } from 'zustand';
|
|
2
2
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
3
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
3
4
|
import type { MarketplaceInstalledRecord } from '@/api/types';
|
|
4
5
|
import type { ThinkingLevel } from '@/api/types';
|
|
5
6
|
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
@@ -8,6 +9,7 @@ import { createInitialChatComposerNodes } from '@/components/chat/chat-composer-
|
|
|
8
9
|
export type ChatInputSnapshot = {
|
|
9
10
|
isProviderStateResolved: boolean;
|
|
10
11
|
composerNodes: ChatComposerNode[];
|
|
12
|
+
attachments: NcpDraftAttachment[];
|
|
11
13
|
draft: string;
|
|
12
14
|
pendingSessionType: string;
|
|
13
15
|
defaultSessionType: string;
|
|
@@ -50,6 +52,7 @@ type ChatInputStore = {
|
|
|
50
52
|
const initialSnapshot: ChatInputSnapshot = {
|
|
51
53
|
isProviderStateResolved: false,
|
|
52
54
|
composerNodes: createInitialChatComposerNodes(),
|
|
55
|
+
attachments: [],
|
|
53
56
|
draft: '',
|
|
54
57
|
pendingSessionType: 'native',
|
|
55
58
|
defaultSessionType: 'native',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import type * as ReactQueryModule from '@tanstack/react-query';
|
|
3
4
|
import { ChannelsList } from '@/components/config/ChannelsList';
|
|
4
5
|
|
|
5
6
|
const mocks = vi.hoisted(() => ({
|
|
@@ -58,7 +59,7 @@ vi.mock('qrcode', () => ({
|
|
|
58
59
|
}));
|
|
59
60
|
|
|
60
61
|
vi.mock('@tanstack/react-query', async () => {
|
|
61
|
-
const actual = await vi.importActual<typeof
|
|
62
|
+
const actual = await vi.importActual<typeof ReactQueryModule>('@tanstack/react-query');
|
|
62
63
|
return {
|
|
63
64
|
...actual,
|
|
64
65
|
useQueryClient: () => ({
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import type * as ReactQueryModule from '@tanstack/react-query';
|
|
3
4
|
import { WeixinChannelAuthSection } from './weixin-channel-auth-section';
|
|
4
5
|
|
|
5
6
|
const mocks = vi.hoisted(() => ({
|
|
@@ -9,7 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
9
10
|
}));
|
|
10
11
|
|
|
11
12
|
vi.mock('@tanstack/react-query', async () => {
|
|
12
|
-
const actual = await vi.importActual<typeof
|
|
13
|
+
const actual = await vi.importActual<typeof ReactQueryModule>('@tanstack/react-query');
|
|
13
14
|
return {
|
|
14
15
|
...actual,
|
|
15
16
|
useQueryClient: () => ({
|
|
@@ -5,9 +5,10 @@ import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOp
|
|
|
5
5
|
import { NavLink } from 'react-router-dom';
|
|
6
6
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
7
7
|
import { BrandHeader } from '@/components/common/BrandHeader';
|
|
8
|
+
import { SidebarActionItem, SidebarNavLinkItem, SidebarSelectItem } from '@/components/layout/sidebar-items';
|
|
8
9
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
9
10
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
10
|
-
import {
|
|
11
|
+
import { SelectItem } from '@/components/ui/select';
|
|
11
12
|
import { useRemoteStatus } from '@/hooks/useRemoteAccess';
|
|
12
13
|
import { useAppPresenter } from '@/presenter/app-presenter-context';
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
23
24
|
const remoteStatus = useRemoteStatus();
|
|
24
25
|
const { language, setLanguage } = useI18n();
|
|
25
26
|
const { theme, setTheme } = useTheme();
|
|
27
|
+
const isSettingsMode = mode === 'settings';
|
|
26
28
|
const currentLanguageLabel = LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language;
|
|
27
29
|
const currentThemeLabel = t(THEME_OPTIONS.find((option) => option.value === theme)?.labelKey ?? 'themeWarm');
|
|
28
30
|
const accountEmail = remoteStatus.data?.account.email?.trim();
|
|
@@ -119,11 +121,12 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
119
121
|
icon: Wrench,
|
|
120
122
|
}
|
|
121
123
|
];
|
|
122
|
-
const navItems =
|
|
124
|
+
const navItems = isSettingsMode ? settingsNavItems : mainNavItems;
|
|
125
|
+
const sidebarDensity = isSettingsMode ? 'compact' : 'default';
|
|
123
126
|
|
|
124
127
|
return (
|
|
125
128
|
<aside className="w-[240px] shrink-0 flex h-full min-h-0 flex-col overflow-hidden bg-secondary px-4 py-6">
|
|
126
|
-
{
|
|
129
|
+
{isSettingsMode ? (
|
|
127
130
|
<div className="shrink-0 px-2 pb-3">
|
|
128
131
|
<div
|
|
129
132
|
className="flex items-center gap-2 px-1 py-1"
|
|
@@ -131,7 +134,7 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
131
134
|
>
|
|
132
135
|
<NavLink
|
|
133
136
|
to="/chat"
|
|
134
|
-
className="group inline-flex min-w-0 items-center gap-1.5 rounded-lg px-1 py-1 text-[12px] font-medium text-gray-500 transition-colors hover:text-gray-900"
|
|
137
|
+
className="group inline-flex min-w-0 items-center gap-1.5 rounded-lg px-1 py-1 text-[12px] font-medium text-gray-500 transition-colors hover:bg-gray-200/60 hover:text-gray-900"
|
|
135
138
|
>
|
|
136
139
|
<ArrowLeft className="h-3.5 w-3.5 shrink-0 text-gray-400 group-hover:text-gray-700" />
|
|
137
140
|
<span className="truncate">{t('backToMain')}</span>
|
|
@@ -149,35 +152,16 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
149
152
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
150
153
|
{/* Navigation */}
|
|
151
154
|
<nav className="custom-scrollbar min-h-0 flex-1 overflow-y-auto pr-1">
|
|
152
|
-
<ul className=
|
|
155
|
+
<ul className={cn(isSettingsMode ? 'space-y-0.5 pb-3' : 'space-y-1 pb-4')}>
|
|
153
156
|
{navItems.map((item) => {
|
|
154
|
-
const Icon = item.icon;
|
|
155
|
-
|
|
156
157
|
return (
|
|
157
158
|
<li key={item.target}>
|
|
158
|
-
<
|
|
159
|
+
<SidebarNavLinkItem
|
|
159
160
|
to={item.target}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
165
|
-
: 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
>
|
|
169
|
-
{({ isActive }) => (
|
|
170
|
-
<>
|
|
171
|
-
<Icon
|
|
172
|
-
className={cn(
|
|
173
|
-
'h-[17px] w-[17px] transition-colors',
|
|
174
|
-
isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
|
|
175
|
-
)}
|
|
176
|
-
/>
|
|
177
|
-
<span className="flex-1 text-left">{item.label}</span>
|
|
178
|
-
</>
|
|
179
|
-
)}
|
|
180
|
-
</NavLink>
|
|
161
|
+
label={item.label}
|
|
162
|
+
icon={item.icon}
|
|
163
|
+
density={sidebarDensity}
|
|
164
|
+
/>
|
|
181
165
|
</li>
|
|
182
166
|
);
|
|
183
167
|
})}
|
|
@@ -185,91 +169,67 @@ export function Sidebar({ mode }: SidebarProps) {
|
|
|
185
169
|
</nav>
|
|
186
170
|
|
|
187
171
|
{/* Footer actions stay reachable while the nav scrolls independently. */}
|
|
188
|
-
<div className=
|
|
189
|
-
{
|
|
190
|
-
<
|
|
172
|
+
<div className={cn('shrink-0 border-t border-[#dde0ea] bg-secondary', isSettingsMode ? 'mt-2 pt-3' : 'mt-3 pt-3')}>
|
|
173
|
+
{isSettingsMode ? (
|
|
174
|
+
<SidebarActionItem
|
|
191
175
|
onClick={() => presenter.accountManager.openAccountPanel()}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
<p className="mt-1 truncate text-xs text-gray-500">
|
|
202
|
-
{accountConnected ? accountEmail || t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
|
|
203
|
-
</p>
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</button>
|
|
176
|
+
icon={KeyRound}
|
|
177
|
+
label={t('remoteAccountEntryManage')}
|
|
178
|
+
density="compact"
|
|
179
|
+
className="mb-1.5"
|
|
180
|
+
trailing={accountConnected ? accountEmail || t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
|
|
181
|
+
trailingClassName="max-w-[92px] truncate text-right"
|
|
182
|
+
testId="settings-sidebar-account-entry"
|
|
183
|
+
trailingTestId="settings-sidebar-account-status"
|
|
184
|
+
/>
|
|
207
185
|
) : null}
|
|
208
186
|
{mode === 'main' && (
|
|
209
187
|
<div className="mb-2">
|
|
210
|
-
<
|
|
188
|
+
<SidebarNavLinkItem
|
|
211
189
|
to="/settings"
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
isActive
|
|
216
|
-
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
217
|
-
: 'text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900'
|
|
218
|
-
)
|
|
219
|
-
}
|
|
220
|
-
>
|
|
221
|
-
{({ isActive }) => (
|
|
222
|
-
<>
|
|
223
|
-
<Settings className={cn('h-[17px] w-[17px] transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
|
|
224
|
-
<span className="flex-1 text-left">{t('settings')}</span>
|
|
225
|
-
</>
|
|
226
|
-
)}
|
|
227
|
-
</NavLink>
|
|
190
|
+
label={t('settings')}
|
|
191
|
+
icon={Settings}
|
|
192
|
+
/>
|
|
228
193
|
</div>
|
|
229
194
|
)}
|
|
230
195
|
<div className="mb-2">
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
{
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
</SelectContent>
|
|
246
|
-
</Select>
|
|
196
|
+
<SidebarSelectItem
|
|
197
|
+
value={theme}
|
|
198
|
+
onValueChange={(value) => handleThemeSwitch(value as UiTheme)}
|
|
199
|
+
icon={Palette}
|
|
200
|
+
label={t('theme')}
|
|
201
|
+
valueLabel={currentThemeLabel}
|
|
202
|
+
density={sidebarDensity}
|
|
203
|
+
>
|
|
204
|
+
{THEME_OPTIONS.map((option) => (
|
|
205
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
206
|
+
{t(option.labelKey)}
|
|
207
|
+
</SelectItem>
|
|
208
|
+
))}
|
|
209
|
+
</SidebarSelectItem>
|
|
247
210
|
</div>
|
|
248
211
|
<div className="mb-2">
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
{
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
</SelectContent>
|
|
264
|
-
</Select>
|
|
212
|
+
<SidebarSelectItem
|
|
213
|
+
value={language}
|
|
214
|
+
onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}
|
|
215
|
+
icon={Languages}
|
|
216
|
+
label={t('language')}
|
|
217
|
+
valueLabel={currentLanguageLabel}
|
|
218
|
+
density={sidebarDensity}
|
|
219
|
+
>
|
|
220
|
+
{LANGUAGE_OPTIONS.map((option) => (
|
|
221
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">
|
|
222
|
+
{option.label}
|
|
223
|
+
</SelectItem>
|
|
224
|
+
))}
|
|
225
|
+
</SidebarSelectItem>
|
|
265
226
|
</div>
|
|
266
|
-
<
|
|
227
|
+
<SidebarActionItem
|
|
267
228
|
onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
</button>
|
|
229
|
+
icon={BookOpen}
|
|
230
|
+
label={t('docBrowserHelp')}
|
|
231
|
+
density={sidebarDensity}
|
|
232
|
+
/>
|
|
273
233
|
</div>
|
|
274
234
|
</div>
|
|
275
235
|
</aside>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import { NavLink } from 'react-router-dom';
|
|
3
|
+
import { Select, SelectContent, SelectTrigger } from '@/components/ui/select';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
type SidebarIcon = ComponentType<{ className?: string }>;
|
|
7
|
+
type SidebarItemDensity = 'default' | 'compact';
|
|
8
|
+
|
|
9
|
+
type SidebarItemTone = {
|
|
10
|
+
row: string;
|
|
11
|
+
icon: string;
|
|
12
|
+
value: string;
|
|
13
|
+
gap: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SIDEBAR_ITEM_TONES: Record<SidebarItemDensity, SidebarItemTone> = {
|
|
17
|
+
default: {
|
|
18
|
+
row: 'gap-3 px-3 py-2.5 text-[14px]',
|
|
19
|
+
icon: 'h-[17px] w-[17px]',
|
|
20
|
+
value: 'text-xs',
|
|
21
|
+
gap: 'gap-3'
|
|
22
|
+
},
|
|
23
|
+
compact: {
|
|
24
|
+
row: 'gap-2.5 px-3 py-2 text-[13px]',
|
|
25
|
+
icon: 'h-4 w-4',
|
|
26
|
+
value: 'text-[11px]',
|
|
27
|
+
gap: 'gap-2.5'
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function getSidebarItemTone(density: SidebarItemDensity): SidebarItemTone {
|
|
32
|
+
return SIDEBAR_ITEM_TONES[density];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SidebarNavLinkItemProps = {
|
|
36
|
+
to: string;
|
|
37
|
+
label: ReactNode;
|
|
38
|
+
icon: SidebarIcon;
|
|
39
|
+
density?: SidebarItemDensity;
|
|
40
|
+
className?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function SidebarNavLinkItem({
|
|
44
|
+
to,
|
|
45
|
+
label,
|
|
46
|
+
icon: Icon,
|
|
47
|
+
density = 'default',
|
|
48
|
+
className
|
|
49
|
+
}: SidebarNavLinkItemProps) {
|
|
50
|
+
const tone = getSidebarItemTone(density);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<NavLink
|
|
54
|
+
to={to}
|
|
55
|
+
className={({ isActive }) =>
|
|
56
|
+
cn(
|
|
57
|
+
'group flex w-full items-center rounded-xl font-medium transition-colors duration-base',
|
|
58
|
+
tone.row,
|
|
59
|
+
isActive
|
|
60
|
+
? 'bg-gray-200 text-gray-900 shadow-sm'
|
|
61
|
+
: 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900',
|
|
62
|
+
className
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
>
|
|
66
|
+
{({ isActive }) => (
|
|
67
|
+
<>
|
|
68
|
+
<Icon
|
|
69
|
+
className={cn(
|
|
70
|
+
tone.icon,
|
|
71
|
+
'transition-colors',
|
|
72
|
+
isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
<span className="min-w-0 flex-1 text-left">{label}</span>
|
|
76
|
+
</>
|
|
77
|
+
)}
|
|
78
|
+
</NavLink>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type SidebarActionItemProps = {
|
|
83
|
+
label: ReactNode;
|
|
84
|
+
icon: SidebarIcon;
|
|
85
|
+
onClick: () => void;
|
|
86
|
+
density?: SidebarItemDensity;
|
|
87
|
+
className?: string;
|
|
88
|
+
labelClassName?: string;
|
|
89
|
+
trailing?: ReactNode;
|
|
90
|
+
trailingClassName?: string;
|
|
91
|
+
testId?: string;
|
|
92
|
+
trailingTestId?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function SidebarActionItem({
|
|
96
|
+
label,
|
|
97
|
+
icon: Icon,
|
|
98
|
+
onClick,
|
|
99
|
+
density = 'default',
|
|
100
|
+
className,
|
|
101
|
+
labelClassName,
|
|
102
|
+
trailing,
|
|
103
|
+
trailingClassName,
|
|
104
|
+
testId,
|
|
105
|
+
trailingTestId
|
|
106
|
+
}: SidebarActionItemProps) {
|
|
107
|
+
const tone = getSidebarItemTone(density);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
onClick={onClick}
|
|
113
|
+
className={cn(
|
|
114
|
+
'flex w-full items-center rounded-xl font-medium text-gray-600 transition-all duration-base hover:bg-gray-200/60 hover:text-gray-800',
|
|
115
|
+
tone.row,
|
|
116
|
+
className
|
|
117
|
+
)}
|
|
118
|
+
data-testid={testId}
|
|
119
|
+
>
|
|
120
|
+
<Icon className={cn(tone.icon, 'shrink-0 text-gray-400')} />
|
|
121
|
+
<span className={cn('min-w-0 flex-1 text-left', labelClassName)}>{label}</span>
|
|
122
|
+
{trailing ? (
|
|
123
|
+
<span
|
|
124
|
+
className={cn('shrink-0 text-gray-500', tone.value, trailingClassName)}
|
|
125
|
+
data-testid={trailingTestId}
|
|
126
|
+
>
|
|
127
|
+
{trailing}
|
|
128
|
+
</span>
|
|
129
|
+
) : null}
|
|
130
|
+
</button>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type SidebarSelectItemProps = {
|
|
135
|
+
label: ReactNode;
|
|
136
|
+
icon: SidebarIcon;
|
|
137
|
+
value: string;
|
|
138
|
+
valueLabel: ReactNode;
|
|
139
|
+
onValueChange: (value: string) => void;
|
|
140
|
+
density?: SidebarItemDensity;
|
|
141
|
+
children: ReactNode;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export function SidebarSelectItem({
|
|
145
|
+
label,
|
|
146
|
+
icon: Icon,
|
|
147
|
+
value,
|
|
148
|
+
valueLabel,
|
|
149
|
+
onValueChange,
|
|
150
|
+
density = 'default',
|
|
151
|
+
children
|
|
152
|
+
}: SidebarSelectItemProps) {
|
|
153
|
+
const tone = getSidebarItemTone(density);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Select value={value} onValueChange={onValueChange}>
|
|
157
|
+
<SelectTrigger
|
|
158
|
+
className={cn(
|
|
159
|
+
'h-auto w-full rounded-xl border-0 bg-transparent font-medium text-gray-600 shadow-none hover:bg-gray-200/60 focus:ring-0',
|
|
160
|
+
tone.row
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
<div className={cn('flex min-w-0 items-center', tone.gap)}>
|
|
164
|
+
<Icon className={cn(tone.icon, 'text-gray-400')} />
|
|
165
|
+
<span className="text-left">{label}</span>
|
|
166
|
+
</div>
|
|
167
|
+
<span className={cn('ml-auto text-gray-500', tone.value)}>{valueLabel}</span>
|
|
168
|
+
</SelectTrigger>
|
|
169
|
+
<SelectContent>{children}</SelectContent>
|
|
170
|
+
</Select>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -64,9 +64,10 @@ describe('Sidebar', () => {
|
|
|
64
64
|
expect(nav?.className).toContain('flex-1');
|
|
65
65
|
expect(nav?.className).toContain('min-h-0');
|
|
66
66
|
expect(nav?.className).toContain('overflow-y-auto');
|
|
67
|
+
expect(screen.getByRole('link', { current: 'page' }).className).not.toContain('font-semibold');
|
|
67
68
|
});
|
|
68
69
|
|
|
69
|
-
it('
|
|
70
|
+
it('keeps the original compact single-row header in settings mode', () => {
|
|
70
71
|
render(
|
|
71
72
|
<MemoryRouter initialEntries={['/model']}>
|
|
72
73
|
<Sidebar mode="settings" />
|
|
@@ -74,15 +75,17 @@ describe('Sidebar', () => {
|
|
|
74
75
|
);
|
|
75
76
|
|
|
76
77
|
const header = screen.getByTestId('settings-sidebar-header');
|
|
78
|
+
const backLink = screen.getByRole('link', { name: 'Back to Main' });
|
|
77
79
|
|
|
78
80
|
expect(header).toBeTruthy();
|
|
79
81
|
expect(screen.getByRole('heading', { name: 'Settings' })).toBeTruthy();
|
|
80
|
-
expect(
|
|
82
|
+
expect(backLink).toBeTruthy();
|
|
81
83
|
expect(header.className).not.toContain('bg-white');
|
|
82
84
|
expect(header.className).not.toContain('rounded-2xl');
|
|
85
|
+
expect(backLink.className).toContain('hover:bg-gray-200/60');
|
|
83
86
|
});
|
|
84
87
|
|
|
85
|
-
it('
|
|
88
|
+
it('keeps the footer utilities compact without changing the top header structure', () => {
|
|
86
89
|
render(
|
|
87
90
|
<MemoryRouter initialEntries={['/model']}>
|
|
88
91
|
<Sidebar mode="settings" />
|
|
@@ -90,10 +93,14 @@ describe('Sidebar', () => {
|
|
|
90
93
|
);
|
|
91
94
|
|
|
92
95
|
const accountEntry = screen.getByTestId('settings-sidebar-account-entry');
|
|
96
|
+
const accountStatus = screen.getByTestId('settings-sidebar-account-status');
|
|
93
97
|
|
|
94
98
|
expect(accountEntry).toBeTruthy();
|
|
95
|
-
expect(
|
|
99
|
+
expect(accountEntry.textContent).toContain('Account');
|
|
96
100
|
expect(screen.getByText('user@example.com')).toBeTruthy();
|
|
101
|
+
expect(accountEntry.className).toContain('py-2');
|
|
97
102
|
expect(accountEntry.className).toContain('text-gray-600');
|
|
103
|
+
expect(accountEntry.className).toContain('hover:bg-gray-200/60');
|
|
104
|
+
expect(accountStatus.className).toContain('text-[11px]');
|
|
98
105
|
});
|
|
99
106
|
});
|