@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-BX7KqEk7.js} +4 -4
  3. package/dist/assets/ChatPage-zXLBKIAY.js +38 -0
  4. package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-Cdbh4cVD.js} +1 -1
  5. package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-4801esOJ.js} +1 -1
  6. package/dist/assets/MarketplacePage-GZgus0Or.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-CAGGvoMo.js} +1 -1
  8. package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-CfLYjQM3.js} +1 -1
  9. package/dist/assets/ProvidersList-CEo1kdf-.js +1 -0
  10. package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-6GYzD7cc.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-BZdbp8mH.js} +1 -1
  12. package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-ifvYKix-.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-tDPbhTeR.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-DhkAIzGm.js} +1 -1
  15. package/dist/assets/{chat-message-BOdA4h43.js → chat-message-C5Gl-dCH.js} +1 -1
  16. package/dist/assets/index-BTt_JlNV.css +1 -0
  17. package/dist/assets/index-JN3V84h_.js +8 -0
  18. package/dist/assets/{label-BYZ62ajO.js → label-D8zWKdqp.js} +1 -1
  19. package/dist/assets/{page-layout-UC-h92sU.js → page-layout-qAJ47LNQ.js} +1 -1
  20. package/dist/assets/{popover-DASCEr3G.js → popover-hyBGxpxS.js} +1 -1
  21. package/dist/assets/{security-config-Cvujq4fH.js → security-config-BJYZSnCA.js} +1 -1
  22. package/dist/assets/skeleton-CUQLsNsM.js +1 -0
  23. package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DKcoD-iY.js} +1 -1
  24. package/dist/assets/{switch-D3wVuCSh.js → switch-DtUdQxr_.js} +1 -1
  25. package/dist/assets/tabs-custom-Dj1BWHGK.js +1 -0
  26. package/dist/assets/useConfirmDialog-nZdrtETU.js +1 -0
  27. package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
  28. package/dist/index.html +3 -3
  29. package/package.json +5 -5
  30. package/src/components/chat/ChatSidebar.tsx +41 -69
  31. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
  32. package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
  33. package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
  34. package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
  35. package/src/components/chat/chat-composer-state.ts +38 -0
  36. package/src/components/chat/chat-stream/types.ts +2 -0
  37. package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
  38. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  39. package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
  40. package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
  41. package/src/components/chat/managers/chat-thread.manager.ts +0 -1
  42. package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
  43. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
  44. package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
  45. package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
  46. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
  47. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
  48. package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
  49. package/src/components/chat/stores/chat-input.store.ts +3 -0
  50. package/src/components/config/ChannelsList.test.tsx +2 -1
  51. package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
  52. package/src/components/layout/Sidebar.tsx +62 -102
  53. package/src/components/layout/sidebar-items.tsx +172 -0
  54. package/src/components/layout/sidebar.layout.test.tsx +11 -4
  55. package/src/lib/i18n.chat.ts +117 -0
  56. package/src/lib/i18n.remote.ts +1 -1
  57. package/src/lib/i18n.ts +2 -112
  58. package/src/transport/remote.transport.test.ts +135 -0
  59. package/src/transport/remote.transport.ts +11 -1
  60. package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
  61. package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
  62. package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
  63. package/dist/assets/index-C63mHRbE.css +0 -1
  64. package/dist/assets/index-DS7D1-KS.js +0 -8
  65. package/dist/assets/skeleton-DlYEKkkj.js +0 -1
  66. package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
  67. 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 import('@tanstack/react-query')>('@tanstack/react-query');
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 import('@tanstack/react-query')>('@tanstack/react-query');
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 { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
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 = mode === 'main' ? mainNavItems : settingsNavItems;
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
- {mode === 'settings' ? (
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="space-y-1 pb-4">
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
- <NavLink
159
+ <SidebarNavLinkItem
159
160
  to={item.target}
160
- className={({ isActive }) =>
161
- cn(
162
- 'group w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium transition-all duration-base',
163
- isActive
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="mt-3 shrink-0 border-t border-[#dde0ea] bg-secondary pt-3">
189
- {mode === 'settings' ? (
190
- <button
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
- className="mb-2 w-full rounded-xl px-3 py-2.5 text-left text-gray-600 transition-all duration-base hover:bg-[#e4e7ef] hover:text-gray-900"
193
- data-testid="settings-sidebar-account-entry"
194
- >
195
- <div className="flex items-start gap-3">
196
- <KeyRound className="mt-0.5 h-[17px] w-[17px] shrink-0 text-gray-400" />
197
- <div className="min-w-0 flex-1">
198
- <p className="truncate text-[14px] font-medium text-gray-600">
199
- {t('remoteAccountEntryManage')}
200
- </p>
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
- <NavLink
188
+ <SidebarNavLinkItem
211
189
  to="/settings"
212
- className={({ isActive }) =>
213
- cn(
214
- 'group w-full flex items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium transition-all duration-base',
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
- <Select value={theme} onValueChange={(value) => handleThemeSwitch(value as UiTheme)}>
232
- <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent px-3 py-2.5 text-[14px] font-medium text-gray-600 shadow-none hover:bg-[#e4e7ef] focus:ring-0">
233
- <div className="flex min-w-0 items-center gap-3">
234
- <Palette className="h-[17px] w-[17px] text-gray-400" />
235
- <span className="text-left">{t('theme')}</span>
236
- </div>
237
- <span className="ml-auto text-xs text-gray-500">{currentThemeLabel}</span>
238
- </SelectTrigger>
239
- <SelectContent>
240
- {THEME_OPTIONS.map((option) => (
241
- <SelectItem key={option.value} value={option.value} className="text-xs">
242
- {t(option.labelKey)}
243
- </SelectItem>
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
- <Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
250
- <SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent px-3 py-2.5 text-[14px] font-medium text-gray-600 shadow-none hover:bg-[#e4e7ef] focus:ring-0">
251
- <div className="flex min-w-0 items-center gap-3">
252
- <Languages className="h-[17px] w-[17px] text-gray-400" />
253
- <span className="text-left">{t('language')}</span>
254
- </div>
255
- <span className="ml-auto text-xs text-gray-500">{currentLanguageLabel}</span>
256
- </SelectTrigger>
257
- <SelectContent>
258
- {LANGUAGE_OPTIONS.map((option) => (
259
- <SelectItem key={option.value} value={option.value} className="text-xs">
260
- {option.label}
261
- </SelectItem>
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
- <button
227
+ <SidebarActionItem
267
228
  onClick={() => docBrowser.open(undefined, { kind: 'docs', newTab: true, title: 'Docs' })}
268
- className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-[14px] font-medium text-gray-600 transition-all duration-base hover:bg-[#e4e7ef] hover:text-gray-800"
269
- >
270
- <BookOpen className="h-[17px] w-[17px] text-gray-400" />
271
- <span className="flex-1 text-left">{t('docBrowserHelp')}</span>
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('uses a compact single-row header for settings mode', () => {
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(screen.getByRole('link', { name: 'Back to Main' })).toBeTruthy();
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('renders the account entry with the same neutral visual tone as other footer items', () => {
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(screen.getByText('Account and Device Entry')).toBeTruthy();
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
  });