@nextclaw/ui 0.10.0 → 0.10.2

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 (75) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-DSMuOmMG.js} +4 -4
  3. package/dist/assets/ChatPage-do9TwNxj.js +38 -0
  4. package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-BjoTblYl.js} +1 -1
  5. package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-2yDaYdxw.js} +1 -1
  6. package/dist/assets/MarketplacePage-DVVk4dlH.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-B4WUzuLw.js} +1 -1
  8. package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-Dr0eI9nN.js} +1 -1
  9. package/dist/assets/ProvidersList-C7A-mIbe.js +1 -0
  10. package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-CI3Am3w1.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-DvSNVSs8.js} +1 -1
  12. package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-B6TGIZow.js} +1 -1
  13. package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-CpxaKU1j.js} +1 -1
  14. package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-B-VHnv4G.js} +1 -1
  15. package/dist/assets/{chat-message-BOdA4h43.js → chat-message-BMqngrjp.js} +1 -1
  16. package/dist/assets/index-C6MeoecJ.js +8 -0
  17. package/dist/assets/index-DdXzLuNG.css +1 -0
  18. package/dist/assets/{label-BYZ62ajO.js → label-s2ILtQeP.js} +1 -1
  19. package/dist/assets/{page-layout-UC-h92sU.js → page-layout-BX5Ro4Sj.js} +1 -1
  20. package/dist/assets/{popover-DASCEr3G.js → popover-qmNpQSIy.js} +1 -1
  21. package/dist/assets/{security-config-Cvujq4fH.js → security-config--F-f-nDl.js} +1 -1
  22. package/dist/assets/skeleton-DthPOKSc.js +1 -0
  23. package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DWj7aUy8.js} +1 -1
  24. package/dist/assets/{switch-D3wVuCSh.js → switch-62r7L4Lj.js} +1 -1
  25. package/dist/assets/tabs-custom-DEmoGMsc.js +1 -0
  26. package/dist/assets/useConfirmDialog-DzT94nC_.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/App.test.tsx +41 -0
  31. package/src/App.tsx +37 -0
  32. package/src/api/client.test.ts +12 -0
  33. package/src/api/client.ts +4 -2
  34. package/src/api/config.ts +1 -1
  35. package/src/components/chat/ChatSidebar.tsx +41 -69
  36. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
  38. package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
  39. package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
  40. package/src/components/chat/chat-composer-state.ts +38 -0
  41. package/src/components/chat/chat-stream/types.ts +2 -0
  42. package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
  43. package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
  44. package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
  45. package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
  46. package/src/components/chat/managers/chat-thread.manager.ts +0 -1
  47. package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
  48. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
  49. package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
  50. package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
  51. package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
  52. package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
  53. package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
  54. package/src/components/chat/stores/chat-input.store.ts +3 -0
  55. package/src/components/config/ChannelsList.test.tsx +2 -1
  56. package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
  57. package/src/components/layout/Sidebar.tsx +62 -102
  58. package/src/components/layout/sidebar-items.tsx +172 -0
  59. package/src/components/layout/sidebar.layout.test.tsx +11 -4
  60. package/src/hooks/use-auth.ts +1 -2
  61. package/src/lib/i18n.chat.ts +117 -0
  62. package/src/lib/i18n.remote.ts +1 -1
  63. package/src/lib/i18n.ts +2 -112
  64. package/src/transport/local.transport.ts +28 -7
  65. package/src/transport/remote.transport.test.ts +135 -0
  66. package/src/transport/remote.transport.ts +14 -1
  67. package/src/transport/transport.types.ts +1 -0
  68. package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
  69. package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
  70. package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
  71. package/dist/assets/index-C63mHRbE.css +0 -1
  72. package/dist/assets/index-DS7D1-KS.js +0 -8
  73. package/dist/assets/skeleton-DlYEKkkj.js +0 -1
  74. package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
  75. package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
@@ -1,127 +1,9 @@
1
- import { API_BASE } from '@/api/api-base';
2
- import { appClient } from '@/transport';
3
-
4
1
  type FetchLike = typeof fetch;
5
2
 
6
3
  export function createNcpAppClientFetch(): FetchLike {
7
- return async (input, init) => {
8
- const request = toRequestSnapshot(input, init);
9
- if (isSseRequest(request)) {
10
- return createSseResponse(request);
11
- }
12
-
13
- try {
14
- const data = await appClient.request<unknown>({
15
- method: request.method,
16
- path: request.path,
17
- ...(request.body !== undefined ? { body: request.body } : {})
18
- });
19
- return new Response(JSON.stringify(data ?? {}), {
20
- status: 200,
21
- headers: {
22
- 'content-type': 'application/json'
23
- }
24
- });
25
- } catch (error) {
26
- return new Response(error instanceof Error ? error.message : String(error), {
27
- status: 500,
28
- headers: {
29
- 'content-type': 'text/plain; charset=utf-8'
30
- }
31
- });
32
- }
33
- };
34
- }
35
-
36
- type RequestSnapshot = {
37
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
38
- path: string;
39
- body?: unknown;
40
- signal?: AbortSignal;
41
- headers: Headers;
42
- };
43
-
44
- function toRequestSnapshot(input: URL | string | Request, init?: RequestInit): RequestSnapshot {
45
- const request = input instanceof Request ? input : null;
46
- const url = new URL(
47
- typeof input === 'string'
48
- ? input
49
- : input instanceof URL
50
- ? input.toString()
51
- : input.url,
52
- API_BASE
53
- );
54
- const headers = new Headers(init?.headers ?? request?.headers);
55
- const method = ((init?.method ?? request?.method ?? 'GET').toUpperCase()) as RequestSnapshot['method'];
56
- return {
57
- method,
58
- path: `${url.pathname}${url.search}`,
59
- body: parseRequestBody(init?.body),
60
- signal: init?.signal ?? request?.signal ?? undefined,
61
- headers
62
- };
63
- }
64
-
65
- function parseRequestBody(body: BodyInit | null | undefined): unknown {
66
- if (body === undefined || body === null) {
67
- return undefined;
68
- }
69
- if (typeof body === 'string') {
70
- try {
71
- return JSON.parse(body);
72
- } catch {
73
- return body;
74
- }
75
- }
76
- return body;
77
- }
78
-
79
- function isSseRequest(request: RequestSnapshot): boolean {
80
- const accept = request.headers.get('accept')?.toLowerCase() ?? '';
81
- return accept.includes('text/event-stream');
82
- }
83
-
84
- function createSseResponse(request: RequestSnapshot): Response {
85
- const encoder = new TextEncoder();
86
- let session: ReturnType<typeof appClient.openStream<unknown>> | null = null;
87
-
88
- const stream = new ReadableStream<Uint8Array>({
89
- start(controller) {
90
- session = appClient.openStream<unknown>({
91
- method: request.method === 'GET' ? 'GET' : 'POST',
92
- path: request.path,
93
- ...(request.body !== undefined ? { body: request.body } : {}),
94
- signal: request.signal,
95
- onEvent: (event) => {
96
- controller.enqueue(encoder.encode(encodeSseFrame(event.name, event.payload)));
97
- }
98
- });
99
-
100
- void session.finished
101
- .then(() => {
102
- controller.close();
103
- })
104
- .catch((error) => {
105
- controller.enqueue(encoder.encode(encodeSseFrame('error', {
106
- message: error instanceof Error ? error.message : String(error)
107
- })));
108
- controller.close();
109
- });
110
- },
111
- cancel() {
112
- session?.cancel();
113
- }
114
- });
115
-
116
- return new Response(stream, {
117
- status: 200,
118
- headers: {
119
- 'content-type': 'text/event-stream'
120
- }
121
- });
122
- }
123
-
124
- function encodeSseFrame(event: string, payload: unknown): string {
125
- const data = payload === undefined ? '' : JSON.stringify(payload);
126
- return `event: ${event}\ndata: ${data}\n\n`;
4
+ return (input, init) =>
5
+ fetch(input, {
6
+ credentials: 'include',
7
+ ...init
8
+ });
127
9
  }
@@ -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 type { SetStateAction } from 'react';
3
4
  import type { ThinkingLevel } from '@/api/types';
4
5
  import { updateNcpSession } from '@/api/ncp-session';
@@ -7,6 +8,8 @@ import {
7
8
  createInitialChatComposerNodes,
8
9
  deriveChatComposerDraft,
9
10
  deriveSelectedSkillsFromComposer,
11
+ pruneComposerAttachments,
12
+ syncComposerAttachments,
10
13
  syncComposerSkills
11
14
  } from '@/components/chat/chat-composer-state';
12
15
  import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
@@ -48,13 +51,47 @@ export class NcpChatInputManager {
48
51
  left.length === right.length && left.every((value, index) => value === right[index]);
49
52
 
50
53
  private syncComposerSnapshot = (nodes: ChatComposerNode[]) => {
54
+ const currentAttachments = useChatInputStore.getState().snapshot.attachments;
55
+ const attachments = pruneComposerAttachments(nodes, currentAttachments);
51
56
  useChatInputStore.getState().setSnapshot({
52
57
  composerNodes: nodes,
58
+ attachments,
53
59
  draft: deriveChatComposerDraft(nodes),
54
60
  selectedSkills: deriveSelectedSkillsFromComposer(nodes)
55
61
  });
56
62
  };
57
63
 
64
+ private syncComposerSnapshotWithAttachments = (
65
+ nodes: ChatComposerNode[],
66
+ attachments: NcpDraftAttachment[]
67
+ ) => {
68
+ useChatInputStore.getState().setSnapshot({
69
+ composerNodes: nodes,
70
+ attachments,
71
+ draft: deriveChatComposerDraft(nodes),
72
+ selectedSkills: deriveSelectedSkillsFromComposer(nodes)
73
+ });
74
+ };
75
+
76
+ private dedupeAttachments = (attachments: NcpDraftAttachment[]): NcpDraftAttachment[] => {
77
+ const seen = new Set<string>();
78
+ const output: NcpDraftAttachment[] = [];
79
+ for (const attachment of attachments) {
80
+ const signature = [
81
+ attachment.name,
82
+ attachment.mimeType,
83
+ String(attachment.sizeBytes),
84
+ attachment.contentBase64,
85
+ ].join(':');
86
+ if (seen.has(signature)) {
87
+ continue;
88
+ }
89
+ seen.add(signature);
90
+ output.push(attachment);
91
+ }
92
+ return output;
93
+ };
94
+
58
95
  syncSnapshot = (patch: Partial<ChatInputSnapshot>) => {
59
96
  if (!this.hasSnapshotChanges(patch)) {
60
97
  return;
@@ -88,6 +125,22 @@ export class NcpChatInputManager {
88
125
  this.syncComposerSnapshot(value);
89
126
  };
90
127
 
128
+ addAttachments = (attachments: NcpDraftAttachment[]) => {
129
+ if (attachments.length === 0) {
130
+ return;
131
+ }
132
+ const snapshot = useChatInputStore.getState().snapshot;
133
+ const nextAttachments = this.dedupeAttachments([...snapshot.attachments, ...attachments]);
134
+ const nextNodes = syncComposerAttachments(snapshot.composerNodes, nextAttachments);
135
+ this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
136
+ };
137
+
138
+ restoreComposerState = (nodes: ChatComposerNode[], attachments: NcpDraftAttachment[]) => {
139
+ const nextAttachments = this.dedupeAttachments(attachments);
140
+ const nextNodes = syncComposerAttachments(nodes, nextAttachments);
141
+ this.syncComposerSnapshotWithAttachments(nextNodes, nextAttachments);
142
+ };
143
+
91
144
  setPendingSessionType = (next: SetStateAction<string>) => {
92
145
  const prev = useChatInputStore.getState().snapshot.pendingSessionType;
93
146
  const value = this.resolveUpdateValue(prev, next);
@@ -101,7 +154,8 @@ export class NcpChatInputManager {
101
154
  const inputSnapshot = useChatInputStore.getState().snapshot;
102
155
  const sessionSnapshot = useChatSessionListStore.getState().snapshot;
103
156
  const message = inputSnapshot.draft.trim();
104
- if (!message) {
157
+ const attachments = inputSnapshot.attachments;
158
+ if (!message && attachments.length === 0) {
105
159
  return;
106
160
  }
107
161
  const { selectedSkills: requestedSkills, composerNodes } = inputSnapshot;
@@ -119,6 +173,7 @@ export class NcpChatInputManager {
119
173
  thinkingLevel: inputSnapshot.selectedThinkingLevel ?? undefined,
120
174
  stopSupported: true,
121
175
  requestedSkills,
176
+ attachments,
122
177
  restoreDraftOnError: true,
123
178
  composerNodes
124
179
  });
@@ -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>