@nextclaw/ui 0.14.1-beta.0 → 0.14.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 (144) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-C6EDHBDR.js → api-DPcvCLfa.js} +3 -3
  3. package/dist/assets/{channels-list-page-DKj6m1Hc.js → channels-list-page-D6jtziu4.js} +2 -2
  4. package/dist/assets/chat-page-BBnNJoKG.js +105 -0
  5. package/dist/assets/{config-split-page-CjlmJy8X.js → config-split-page-BCJhqdK9.js} +1 -1
  6. package/dist/assets/{confirm-dialog-B39X0pYp.js → confirm-dialog-CWh5FfK2.js} +1 -1
  7. package/dist/assets/{desktop-update-config-DfUQ8nly.js → desktop-update-config-DsRhBizi.js} +1 -1
  8. package/dist/assets/{dist-CmMro3nV.js → dist-DPrgcEC0.js} +1 -1
  9. package/dist/assets/{dist-BIYV2-Ia.js → dist-DsE4OsSS.js} +1 -1
  10. package/dist/assets/doc-browser-C8xOF_9F.js +1 -0
  11. package/dist/assets/{doc-browser-CDtUtcim.js → doc-browser-DgQedeAn.js} +1 -1
  12. package/dist/assets/{doc-browser-context-DZbC9zVB.js → doc-browser-context-B46tQGmO.js} +1 -1
  13. package/dist/assets/{ellipsis-DDpgTzgx.js → ellipsis-B81FStLm.js} +1 -1
  14. package/dist/assets/{external-link-D2cBOCqn.js → external-link-C_dmG-WJ.js} +1 -1
  15. package/dist/assets/index-7o1WdUbU.js +103 -0
  16. package/dist/assets/index-DQWmEWzk.css +1 -0
  17. package/dist/assets/mcp-marketplace-page-CWZN7ohK.js +1 -0
  18. package/dist/assets/mcp-marketplace-page-Dzull_4-.js +40 -0
  19. package/dist/assets/model-config-page-Cv0O3BIj.js +1 -0
  20. package/dist/assets/plus-Bnt56oqG.js +1 -0
  21. package/dist/assets/provider-scoped-model-input-Duuh7i3h.js +1 -0
  22. package/dist/assets/providers-config-page-CPDz0Lxc.js +1 -0
  23. package/dist/assets/{react-DYQXXGbH.js → react-sZz5HSCm.js} +1 -1
  24. package/dist/assets/remote-B0LMs8u7.js +1 -0
  25. package/dist/assets/rotate-cw-BfyG_KEF.js +1 -0
  26. package/dist/assets/runtime-config-page-q0fvv8ME.js +1 -0
  27. package/dist/assets/{save-BNVTgBVn.js → save-MzjoiHri.js} +1 -1
  28. package/dist/assets/{search-CKCzwLId.js → search-B29dE4GN.js} +1 -1
  29. package/dist/assets/{search-config-page-DG5Q2hF1.js → search-config-page-Dl5sZzob.js} +1 -1
  30. package/dist/assets/secrets-config-page-C_l-IFCK.js +3 -0
  31. package/dist/assets/{select-CIRwTNP4.js → select-BKmh05hd.js} +2 -2
  32. package/dist/assets/{tag-chip-DKhVFtTE.js → tag-chip-BS_7Ueom.js} +1 -1
  33. package/dist/assets/use-config-DZkBWIdL.js +1 -0
  34. package/dist/assets/x-CW2KO-Hz.js +1 -0
  35. package/dist/index.html +14 -14
  36. package/package.json +9 -9
  37. package/src/features/chat/components/conversation/__tests__/chat-conversation-panel.test.tsx +12 -63
  38. package/src/features/chat/components/conversation/chat-conversation-content.tsx +11 -50
  39. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +15 -2
  40. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +70 -7
  41. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +104 -16
  42. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -2
  43. package/src/features/chat/features/input/components/chat-input-bar.container.tsx +84 -6
  44. package/src/features/chat/features/input/hooks/use-chat-input-bar-query-state.ts +2 -0
  45. package/src/features/chat/features/input/hooks/use-chat-model-favorites.ts +88 -0
  46. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +64 -24
  47. package/src/features/chat/features/input/utils/__tests__/ncp-chat-input-availability.utils.test.ts +1 -0
  48. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +21 -12
  49. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +80 -25
  50. package/src/features/chat/features/message/utils/__tests__/chat-message-file-operation.utils.test.ts +310 -0
  51. package/src/features/chat/features/message/utils/__tests__/chat-message-test.utils.ts +43 -0
  52. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +1 -346
  53. package/src/features/chat/features/message/utils/chat-message-tool-card.utils.ts +10 -9
  54. package/src/features/chat/features/message/utils/file-operation/diff.utils.ts +39 -36
  55. package/src/features/chat/features/message/utils/file-operation/line-builder.utils.ts +46 -38
  56. package/src/features/chat/features/message/utils/file-operation/parsed-block.utils.ts +18 -15
  57. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +3 -2
  58. package/src/features/chat/features/session/components/session-header/__tests__/chat-session-project-badge.test.tsx +25 -25
  59. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +15 -2
  60. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-menu.test.tsx +24 -0
  61. package/src/features/chat/features/session-type/components/__tests__/chat-session-type-option-item.test.tsx +27 -1
  62. package/src/features/chat/features/session-type/components/{chat-sidebar-create-menu.tsx → chat-session-type-menu.tsx} +17 -4
  63. package/src/features/chat/features/session-type/components/chat-session-type-option-item.tsx +15 -3
  64. package/src/features/chat/features/session-type/hooks/__tests__/use-chat-new-session-type-preference.test.tsx +152 -0
  65. package/src/features/chat/features/session-type/hooks/use-chat-new-session-type-preference.ts +159 -0
  66. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +193 -0
  67. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +123 -0
  68. package/src/features/chat/features/welcome/components/chat-conversation-welcome.tsx +104 -0
  69. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +72 -0
  70. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +54 -0
  71. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +132 -0
  72. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +82 -0
  73. package/src/features/chat/features/welcome/components/chat-welcome.tsx +121 -0
  74. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +53 -0
  75. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-selection.utils.test.ts +63 -0
  76. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-visibility.utils.test.ts +34 -0
  77. package/src/features/chat/features/welcome/utils/chat-welcome-project-options.utils.ts +71 -0
  78. package/src/features/chat/features/welcome/utils/chat-welcome-selection.utils.ts +79 -0
  79. package/src/features/chat/features/welcome/utils/chat-welcome-visibility.utils.ts +17 -0
  80. package/src/features/chat/managers/__tests__/chat-input.manager.test.ts +65 -0
  81. package/src/features/chat/managers/chat-input.manager.ts +24 -2
  82. package/src/features/chat/stores/chat-input.store.ts +2 -0
  83. package/src/features/chat/types/chat-input-bar.types.ts +5 -0
  84. package/src/features/marketplace/components/__tests__/marketplace-page.test.tsx +24 -0
  85. package/src/features/marketplace/components/curated-shelves/marketplace-shelf-card.tsx +8 -3
  86. package/src/features/marketplace/components/marketplace-item-list-view.tsx +2 -2
  87. package/src/features/marketplace/components/marketplace-list-card.tsx +124 -98
  88. package/src/features/marketplace/components/marketplace-page-parts.tsx +11 -6
  89. package/src/features/marketplace/utils/marketplace-installed-cache.utils.ts +27 -23
  90. package/src/features/settings/components/config/provider-form.tsx +284 -387
  91. package/src/features/settings/components/config/provider-status-badge.tsx +4 -4
  92. package/src/features/settings/components/config/secrets-config-form.tsx +11 -135
  93. package/src/features/settings/hooks/use-provider-auth-flow.ts +140 -0
  94. package/src/features/settings/pages/__tests__/model-config-page.test.tsx +16 -0
  95. package/src/features/settings/pages/secrets-config-page.tsx +2 -1
  96. package/src/features/settings/utils/__tests__/provider-form-support.utils.test.ts +60 -1
  97. package/src/features/settings/utils/__tests__/secrets-config-form.utils.test.ts +66 -0
  98. package/src/features/settings/utils/provider-form-context.utils.ts +131 -0
  99. package/src/features/settings/utils/provider-form-model.utils.ts +101 -0
  100. package/src/features/settings/utils/provider-form-support.utils.ts +102 -1
  101. package/src/features/settings/utils/secrets-config-form.utils.ts +139 -0
  102. package/src/features/system-status/components/config/runtime-agent-list-card.tsx +17 -11
  103. package/src/features/system-status/components/config/runtime-binding-list-card.tsx +14 -9
  104. package/src/features/system-status/components/config/runtime-config-editor.tsx +8 -5
  105. package/src/features/system-status/components/config/runtime-entry-list-card.tsx +14 -9
  106. package/src/features/system-status/components/config/runtime-settings-card.tsx +16 -7
  107. package/src/features/system-status/components/runtime-security-card.tsx +79 -25
  108. package/src/shared/components/common/__tests__/agent-avatar.test.tsx +3 -1
  109. package/src/shared/components/common/__tests__/searchable-model-input.test.tsx +34 -0
  110. package/src/shared/components/common/agent-avatar.tsx +5 -2
  111. package/src/shared/components/common/provider-scoped-model-input.tsx +1 -0
  112. package/src/shared/components/common/searchable-model-input.tsx +102 -87
  113. package/src/shared/components/ui/README.md +7 -0
  114. package/src/shared/components/ui/popover.tsx +19 -3
  115. package/src/shared/components/ui/select.tsx +14 -4
  116. package/src/shared/hooks/use-infinite-scroll-loader.ts +14 -10
  117. package/src/shared/lib/api/index.ts +3 -0
  118. package/src/shared/lib/api/preferences/preference-keys.config.ts +9 -0
  119. package/src/shared/lib/api/preferences/preference.types.ts +22 -0
  120. package/src/shared/lib/api/preferences/preference.utils.ts +40 -0
  121. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -1
  122. package/src/shared/lib/i18n/locales/en-US/core.json +1 -0
  123. package/src/shared/lib/i18n/locales/en-US/marketplace.json +2 -0
  124. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -1
  125. package/src/shared/lib/i18n/locales/zh-CN/core.json +1 -0
  126. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +2 -0
  127. package/dist/assets/chat-page-BXngLJY6.js +0 -105
  128. package/dist/assets/doc-browser-B6Wc_TIY.js +0 -1
  129. package/dist/assets/index-DTb_XVIH.js +0 -103
  130. package/dist/assets/index-ayiPYhf1.css +0 -1
  131. package/dist/assets/mcp-marketplace-page-DZ9H0v05.js +0 -1
  132. package/dist/assets/mcp-marketplace-page-cnVWf7QQ.js +0 -40
  133. package/dist/assets/model-config-page-Bcb35AVv.js +0 -1
  134. package/dist/assets/plus-CYE1onU2.js +0 -1
  135. package/dist/assets/provider-scoped-model-input-kdoNp4HG.js +0 -1
  136. package/dist/assets/providers-config-page-CQSibSwO.js +0 -1
  137. package/dist/assets/remote-BlJDbXOI.js +0 -1
  138. package/dist/assets/rotate-cw-BWbvFDAX.js +0 -1
  139. package/dist/assets/runtime-config-page-Tn2yLQ0c.js +0 -1
  140. package/dist/assets/secrets-config-page-BXn5Uuwp.js +0 -3
  141. package/dist/assets/use-config-DgKM9VIU.js +0 -1
  142. package/dist/assets/x-Cm3JjYut.js +0 -1
  143. package/src/features/chat/components/__tests__/chat-welcome.test.tsx +0 -45
  144. package/src/features/chat/components/chat-welcome.tsx +0 -102
@@ -40,7 +40,29 @@ function SecurityCardFrame({ children, contentClassName = 'space-y-6' }: { child
40
40
  );
41
41
  }
42
42
 
43
- function RuntimeSecuritySetupSection(props: { setupUsername: string; setSetupUsername: (value: string) => void; setupPassword: string; setSetupPassword: (value: string) => void; setupConfirmPassword: string; setSetupConfirmPassword: (value: string) => void; canSubmitSetup: boolean; isPending: boolean; onSubmit: () => void }) {
43
+ type RuntimeSecuritySetupSectionProps = {
44
+ setupUsername: string;
45
+ setSetupUsername: (value: string) => void;
46
+ setupPassword: string;
47
+ setSetupPassword: (value: string) => void;
48
+ setupConfirmPassword: string;
49
+ setSetupConfirmPassword: (value: string) => void;
50
+ canSubmitSetup: boolean;
51
+ isPending: boolean;
52
+ onSubmit: () => void;
53
+ };
54
+
55
+ function RuntimeSecuritySetupSection({
56
+ canSubmitSetup,
57
+ isPending,
58
+ onSubmit,
59
+ setSetupConfirmPassword,
60
+ setSetupPassword,
61
+ setSetupUsername,
62
+ setupConfirmPassword,
63
+ setupPassword,
64
+ setupUsername
65
+ }: RuntimeSecuritySetupSectionProps) {
44
66
  return (
45
67
  <SecurityCardFrame contentClassName="space-y-5">
46
68
  <div className="rounded-xl border border-dashed border-gray-200 bg-gray-50/70 p-4">
@@ -52,8 +74,8 @@ function RuntimeSecuritySetupSection(props: { setupUsername: string; setSetupUse
52
74
  <Label htmlFor="auth-setup-username">{t('authUsername')}</Label>
53
75
  <Input
54
76
  id="auth-setup-username"
55
- value={props.setupUsername}
56
- onChange={(event) => props.setSetupUsername(event.target.value)}
77
+ value={setupUsername}
78
+ onChange={(event) => setSetupUsername(event.target.value)}
57
79
  placeholder={t('authUsernamePlaceholder')}
58
80
  />
59
81
  </div>
@@ -62,8 +84,8 @@ function RuntimeSecuritySetupSection(props: { setupUsername: string; setSetupUse
62
84
  <Input
63
85
  id="auth-setup-password"
64
86
  type="password"
65
- value={props.setupPassword}
66
- onChange={(event) => props.setSetupPassword(event.target.value)}
87
+ value={setupPassword}
88
+ onChange={(event) => setSetupPassword(event.target.value)}
67
89
  placeholder={t('authPasswordPlaceholder')}
68
90
  />
69
91
  </div>
@@ -73,43 +95,75 @@ function RuntimeSecuritySetupSection(props: { setupUsername: string; setSetupUse
73
95
  <Input
74
96
  id="auth-setup-confirm"
75
97
  type="password"
76
- value={props.setupConfirmPassword}
77
- onChange={(event) => props.setSetupConfirmPassword(event.target.value)}
98
+ value={setupConfirmPassword}
99
+ onChange={(event) => setSetupConfirmPassword(event.target.value)}
78
100
  placeholder={t('authConfirmPasswordPlaceholder')}
79
101
  />
80
102
  <p className="text-xs text-gray-500">{t('authPasswordMinLengthHint')}</p>
81
103
  </div>
82
- <Button type="button" disabled={!props.canSubmitSetup} onClick={() => void props.onSubmit()}>
83
- {props.isPending ? t('authSettingUp') : t('authSetupAction')}
104
+ <Button type="button" disabled={!canSubmitSetup} onClick={() => void onSubmit()}>
105
+ {isPending ? t('authSettingUp') : t('authSetupAction')}
84
106
  </Button>
85
107
  </SecurityCardFrame>
86
108
  );
87
109
  }
88
110
 
89
- function RuntimeSecurityConfiguredSection(props: { authenticated: boolean; enabled: boolean; username: string | null | undefined; nextPassword: string; setNextPassword: (value: string) => void; nextConfirmPassword: string; setNextConfirmPassword: (value: string) => void; canUpdatePassword: boolean; isUpdatingEnabled: boolean; isUpdatingPassword: boolean; isLoggingOut: boolean; onEnabledChange: (enabled: boolean) => void; onPasswordUpdate: () => void; onLogout: () => void }) {
111
+ type RuntimeSecurityConfiguredSectionProps = {
112
+ authenticated: boolean;
113
+ enabled: boolean;
114
+ username: string | null | undefined;
115
+ nextPassword: string;
116
+ setNextPassword: (value: string) => void;
117
+ nextConfirmPassword: string;
118
+ setNextConfirmPassword: (value: string) => void;
119
+ canUpdatePassword: boolean;
120
+ isUpdatingEnabled: boolean;
121
+ isUpdatingPassword: boolean;
122
+ isLoggingOut: boolean;
123
+ onEnabledChange: (enabled: boolean) => void;
124
+ onPasswordUpdate: () => void;
125
+ onLogout: () => void;
126
+ };
127
+
128
+ function RuntimeSecurityConfiguredSection({
129
+ authenticated,
130
+ canUpdatePassword,
131
+ enabled,
132
+ isLoggingOut,
133
+ isUpdatingEnabled,
134
+ isUpdatingPassword,
135
+ nextConfirmPassword,
136
+ nextPassword,
137
+ onEnabledChange,
138
+ onLogout,
139
+ onPasswordUpdate,
140
+ setNextConfirmPassword,
141
+ setNextPassword,
142
+ username
143
+ }: RuntimeSecurityConfiguredSectionProps) {
90
144
  return (
91
145
  <SecurityCardFrame>
92
146
  <div className="rounded-xl border border-gray-200 p-4">
93
147
  <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
94
148
  <div className="space-y-1">
95
149
  <p className="text-sm font-medium text-gray-900">{t('authStatusLabel')}</p>
96
- <p className="text-sm text-gray-600">{t('authStatusConfiguredUser').replace('{username}', props.username ?? '')}</p>
150
+ <p className="text-sm text-gray-600">{t('authStatusConfiguredUser').replace('{username}', username ?? '')}</p>
97
151
  <p className="text-xs text-gray-500">{t('authUsernameFixedHelp')}</p>
98
152
  </div>
99
153
  <span className="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700">
100
- {props.enabled ? t('enabled') : t('disabled')}
154
+ {enabled ? t('enabled') : t('disabled')}
101
155
  </span>
102
156
  </div>
103
157
  <div className="mt-4 flex flex-col gap-4 border-t border-gray-200 pt-4 md:flex-row md:items-center md:justify-between">
104
158
  <div className="space-y-1">
105
159
  <p className="text-sm font-medium text-gray-900">{t('authEnableLabel')}</p>
106
- <p className="text-xs text-gray-500">{props.enabled ? t('authEnableOnHelp') : t('authEnableOffHelp')}</p>
160
+ <p className="text-xs text-gray-500">{enabled ? t('authEnableOnHelp') : t('authEnableOffHelp')}</p>
107
161
  </div>
108
162
  <Switch
109
- checked={props.enabled}
110
- disabled={props.isUpdatingEnabled}
163
+ checked={enabled}
164
+ disabled={isUpdatingEnabled}
111
165
  onCheckedChange={(checked) => {
112
- void props.onEnabledChange(checked);
166
+ void onEnabledChange(checked);
113
167
  }}
114
168
  />
115
169
  </div>
@@ -125,8 +179,8 @@ function RuntimeSecurityConfiguredSection(props: { authenticated: boolean; enabl
125
179
  <Input
126
180
  id="auth-password-next"
127
181
  type="password"
128
- value={props.nextPassword}
129
- onChange={(event) => props.setNextPassword(event.target.value)}
182
+ value={nextPassword}
183
+ onChange={(event) => setNextPassword(event.target.value)}
130
184
  placeholder={t('authPasswordPlaceholder')}
131
185
  />
132
186
  </div>
@@ -135,19 +189,19 @@ function RuntimeSecurityConfiguredSection(props: { authenticated: boolean; enabl
135
189
  <Input
136
190
  id="auth-password-confirm"
137
191
  type="password"
138
- value={props.nextConfirmPassword}
139
- onChange={(event) => props.setNextConfirmPassword(event.target.value)}
192
+ value={nextConfirmPassword}
193
+ onChange={(event) => setNextConfirmPassword(event.target.value)}
140
194
  placeholder={t('authConfirmPasswordPlaceholder')}
141
195
  />
142
196
  </div>
143
197
  </div>
144
198
  <div className="flex flex-wrap items-center gap-3">
145
- <Button type="button" disabled={!props.canUpdatePassword} onClick={() => void props.onPasswordUpdate()}>
146
- {props.isUpdatingPassword ? t('authPasswordUpdating') : t('authPasswordAction')}
199
+ <Button type="button" disabled={!canUpdatePassword} onClick={() => void onPasswordUpdate()}>
200
+ {isUpdatingPassword ? t('authPasswordUpdating') : t('authPasswordAction')}
147
201
  </Button>
148
- {props.enabled && props.authenticated ? (
149
- <Button type="button" variant="outline" disabled={props.isLoggingOut} onClick={() => void props.onLogout()}>
150
- {props.isLoggingOut ? t('authLoggingOut') : t('authLogoutAction')}
202
+ {enabled && authenticated ? (
203
+ <Button type="button" variant="outline" disabled={isLoggingOut} onClick={() => void onLogout()}>
204
+ {isLoggingOut ? t('authLoggingOut') : t('authLogoutAction')}
151
205
  </Button>
152
206
  ) : null}
153
207
  </div>
@@ -1,6 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { AgentAvatar } from '../agent-avatar';
3
+ import { AgentAvatar } from '@/shared/components/common/agent-avatar';
4
4
 
5
5
  describe('AgentAvatar', () => {
6
6
  it('uses the configured image for the main agent before fallback', () => {
@@ -23,6 +23,8 @@ describe('AgentAvatar', () => {
23
23
  const avatar = screen.getByLabelText('Main');
24
24
  expect(avatar.querySelector('svg')).toBeTruthy();
25
25
  expect(avatar.textContent).not.toContain('M');
26
+ expect(avatar.className).toContain('bg-primary');
27
+ expect(avatar.className).toContain('text-primary-foreground');
26
28
  });
27
29
 
28
30
  it('keeps letter fallback avatars for specialist agents', () => {
@@ -0,0 +1,34 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { SearchableModelInput } from '@/shared/components/common/searchable-model-input';
4
+ import { createPopoverAvailableHeightLimit } from '@/shared/components/ui/popover';
5
+
6
+ describe('SearchableModelInput', () => {
7
+ it('keeps the model option panel bounded with an internal scroll region', async () => {
8
+ render(
9
+ <SearchableModelInput
10
+ value=""
11
+ onChange={vi.fn()}
12
+ options={Array.from({ length: 40 }, (_, index) => `model-${index}`)}
13
+ placeholder="Model"
14
+ emptyText="No models"
15
+ createText="Use {value}"
16
+ toggleLabel="Toggle models"
17
+ />
18
+ );
19
+
20
+ fireEvent.focus(screen.getByPlaceholderText('Model'));
21
+
22
+ const firstOption = await screen.findByRole('button', { name: 'model-0' });
23
+ const panel = firstOption.closest('[data-state="open"]') as HTMLElement | null;
24
+ const scrollRegion = firstOption.closest('.overflow-y-auto');
25
+
26
+ expect(panel?.style.maxHeight).toBe(createPopoverAvailableHeightLimit('15rem'));
27
+ expect(panel?.style.maxHeight).toContain('max(0px');
28
+ expect(panel?.style.maxHeight).toContain('100vh');
29
+ expect(panel?.style.maxHeight).toContain('2rem');
30
+ expect(panel?.className).toContain('flex-col');
31
+ expect(scrollRegion?.className).toContain('flex-1');
32
+ expect(scrollRegion?.className).toContain('overflow-y-auto');
33
+ });
34
+ });
@@ -28,7 +28,10 @@ function hashText(value: string): number {
28
28
 
29
29
  export function AgentAvatar({ agentId, displayName, avatarUrl, className }: AgentAvatarProps) {
30
30
  const seed = displayName?.trim() || agentId;
31
- const [bgClass, textClass] = PALETTE[hashText(agentId) % PALETTE.length] ?? PALETTE[0];
31
+ const isMainAgent = agentId.trim().toLowerCase() === 'main';
32
+ const [paletteBgClass, paletteTextClass] = PALETTE[hashText(agentId) % PALETTE.length] ?? PALETTE[0];
33
+ const bgClass = isMainAgent ? 'bg-primary' : paletteBgClass;
34
+ const textClass = isMainAgent ? 'text-primary-foreground' : paletteTextClass;
32
35
 
33
36
  if (avatarUrl?.trim()) {
34
37
  return (
@@ -50,7 +53,7 @@ export function AgentAvatar({ agentId, displayName, avatarUrl, className }: Agen
50
53
  )}
51
54
  aria-label={displayName?.trim() || agentId}
52
55
  >
53
- {agentId.trim().toLowerCase() === 'main' ? (
56
+ {isMainAgent ? (
54
57
  <Bot className="h-[55%] w-[55%]" strokeWidth={2.4} />
55
58
  ) : (
56
59
  (seed.trim() || 'A').slice(0, 1).toUpperCase()
@@ -151,6 +151,7 @@ export function ProviderScopedModelInput({
151
151
  inputClassName="h-10 rounded-xl"
152
152
  emptyText={t('modelPickerNoOptions')}
153
153
  createText={t('modelPickerUseCustom')}
154
+ toggleLabel={t('modelPickerToggleOptions')}
154
155
  />
155
156
  </div>
156
157
  <p className="mt-2 text-xs text-gray-500">{t('modelInputCustomHint')}</p>
@@ -1,6 +1,12 @@
1
1
  import { useEffect, useMemo, useState } from 'react';
2
2
  import { Check, ChevronsUpDown } from 'lucide-react';
3
3
  import { Input } from '@/shared/components/ui/input';
4
+ import {
5
+ Popover,
6
+ PopoverAnchor,
7
+ PopoverContent,
8
+ createPopoverAvailableHeightLimit,
9
+ } from '@/shared/components/ui/popover';
4
10
  import { cn } from '@/shared/lib/utils';
5
11
 
6
12
  type SearchableModelInputProps = {
@@ -12,12 +18,15 @@ type SearchableModelInputProps = {
12
18
  placeholder?: string;
13
19
  className?: string;
14
20
  inputClassName?: string;
15
- emptyText?: string;
16
- createText?: string;
21
+ emptyText: string;
22
+ createText: string;
23
+ toggleLabel: string;
17
24
  maxItems?: number;
18
25
  onEnter?: () => void;
19
26
  };
20
27
 
28
+ const SEARCHABLE_MODEL_MENU_MAX_HEIGHT = createPopoverAvailableHeightLimit('15rem');
29
+
21
30
  function normalizeOptions(options: string[]): string[] {
22
31
  const deduped = new Set<string>();
23
32
  for (const option of options) {
@@ -40,6 +49,7 @@ export function SearchableModelInput({
40
49
  inputClassName,
41
50
  emptyText,
42
51
  createText,
52
+ toggleLabel,
43
53
  maxItems = Number.POSITIVE_INFINITY,
44
54
  onEnter
45
55
  }: SearchableModelInputProps) {
@@ -77,97 +87,102 @@ export function SearchableModelInput({
77
87
  }, [normalizedOptions, query, maxItems]);
78
88
 
79
89
  const hasExactMatch = value.trim().length > 0 && normalizedOptions.some((option) => option === value.trim());
90
+ const closeMenu = () => setOpen(false);
80
91
 
81
92
  return (
82
- <div
83
- className={cn('relative', className)}
84
- onBlur={() => {
85
- setTimeout(() => setOpen(false), 120);
86
- }}
93
+ <Popover
94
+ open={open && !disabled}
95
+ onOpenChange={(nextOpen) => setOpen(!disabled && nextOpen)}
87
96
  >
88
- <Input
89
- id={id}
90
- value={value}
91
- disabled={disabled}
92
- onFocus={() => {
93
- if (!disabled) {
94
- setOpen(true);
95
- }
96
- }}
97
- onChange={(event) => {
98
- onChange(event.target.value);
99
- if (!open && !disabled) {
100
- setOpen(true);
101
- }
102
- }}
103
- onKeyDown={(event) => {
104
- if (event.key === 'Enter') {
105
- if (onEnter) {
106
- event.preventDefault();
107
- onEnter();
108
- }
109
- setOpen(false);
110
- }
111
- }}
112
- placeholder={placeholder}
113
- className={cn('pr-10', inputClassName)}
114
- />
115
- <button
116
- type="button"
117
- onMouseDown={(event) => event.preventDefault()}
118
- onClick={() => setOpen((prev) => !prev)}
119
- disabled={disabled}
120
- className={cn(
121
- 'absolute inset-y-0 right-0 inline-flex w-10 items-center justify-center',
122
- disabled ? 'cursor-not-allowed text-gray-300' : 'text-gray-400 hover:text-gray-600'
123
- )}
124
- aria-label="toggle model options"
125
- >
126
- <ChevronsUpDown className="h-4 w-4" />
127
- </button>
128
-
129
- {open && !disabled && (
130
- <div className="absolute z-20 mt-1 w-full overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
131
- <div className="max-h-60 overflow-y-auto py-1">
132
- {!hasExactMatch && value.trim().length > 0 && (
133
- <button
134
- type="button"
135
- onMouseDown={(event) => event.preventDefault()}
136
- onClick={() => {
137
- onChange(value.trim());
138
- setOpen(false);
139
- }}
140
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
141
- >
142
- <Check className="h-4 w-4 text-transparent" />
143
- <span className="truncate text-gray-700">
144
- {createText ? createText.replace('{value}', value.trim()) : value.trim()}
145
- </span>
146
- </button>
97
+ <PopoverAnchor asChild>
98
+ <div className={cn('relative', className)}>
99
+ <Input
100
+ id={id}
101
+ value={value}
102
+ disabled={disabled}
103
+ onFocus={() => {
104
+ if (!disabled) {
105
+ setOpen(true);
106
+ }
107
+ }}
108
+ onChange={(event) => {
109
+ onChange(event.target.value);
110
+ if (!open && !disabled) {
111
+ setOpen(true);
112
+ }
113
+ }}
114
+ onKeyDown={(event) => {
115
+ if (event.key === 'Enter') {
116
+ if (onEnter) {
117
+ event.preventDefault();
118
+ onEnter();
119
+ }
120
+ closeMenu();
121
+ }
122
+ }}
123
+ placeholder={placeholder}
124
+ className={cn('pr-10', inputClassName)}
125
+ />
126
+ <button
127
+ type="button"
128
+ onMouseDown={(event) => event.preventDefault()}
129
+ onClick={() => setOpen((prev) => !prev)}
130
+ disabled={disabled}
131
+ className={cn(
132
+ 'absolute inset-y-0 right-0 inline-flex w-10 items-center justify-center',
133
+ disabled ? 'cursor-not-allowed text-gray-300' : 'text-gray-400 hover:text-gray-600'
147
134
  )}
135
+ aria-label={toggleLabel}
136
+ >
137
+ <ChevronsUpDown className="h-4 w-4" />
138
+ </button>
139
+ </div>
140
+ </PopoverAnchor>
141
+ <PopoverContent
142
+ align="start"
143
+ className="flex w-[var(--radix-popover-trigger-width)] min-w-[12rem] flex-col overflow-hidden rounded-xl border border-gray-200 p-0 shadow-lg"
144
+ onOpenAutoFocus={(event) => event.preventDefault()}
145
+ style={{ maxHeight: SEARCHABLE_MODEL_MENU_MAX_HEIGHT }}
146
+ >
147
+ <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto py-1">
148
+ {!hasExactMatch && value.trim().length > 0 && (
149
+ <button
150
+ type="button"
151
+ onMouseDown={(event) => event.preventDefault()}
152
+ onClick={() => {
153
+ onChange(value.trim());
154
+ closeMenu();
155
+ }}
156
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
157
+ >
158
+ <Check className="h-4 w-4 text-transparent" />
159
+ <span className="truncate text-gray-700">
160
+ {createText.replace('{value}', value.trim())}
161
+ </span>
162
+ </button>
163
+ )}
148
164
 
149
- {orderedOptions.map((option) => (
150
- <button
151
- key={option}
152
- type="button"
153
- onMouseDown={(event) => event.preventDefault()}
154
- onClick={() => {
155
- onChange(option);
156
- setOpen(false);
157
- }}
158
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
159
- >
160
- <Check className={cn('h-4 w-4', option === value.trim() ? 'text-primary' : 'text-transparent')} />
161
- <span className="truncate text-gray-700">{option}</span>
162
- </button>
163
- ))}
165
+ {orderedOptions.map((option) => (
166
+ <button
167
+ key={option}
168
+ type="button"
169
+ onMouseDown={(event) => event.preventDefault()}
170
+ onClick={() => {
171
+ onChange(option);
172
+ closeMenu();
173
+ }}
174
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50"
175
+ >
176
+ <Check className={cn('h-4 w-4', option === value.trim() ? 'text-primary' : 'text-transparent')} />
177
+ <span className="truncate text-gray-700">{option}</span>
178
+ </button>
179
+ ))}
164
180
 
165
- {orderedOptions.length === 0 && value.trim().length === 0 && (
166
- <div className="px-3 py-2 text-sm text-gray-500">{emptyText ?? 'No models available'}</div>
167
- )}
168
- </div>
181
+ {orderedOptions.length === 0 && value.trim().length === 0 && (
182
+ <div className="px-3 py-2 text-sm text-gray-500">{emptyText}</div>
183
+ )}
169
184
  </div>
170
- )}
171
- </div>
185
+ </PopoverContent>
186
+ </Popover>
172
187
  );
173
188
  }
@@ -0,0 +1,7 @@
1
+ # Shared UI Components
2
+
3
+ 本目录是前端 shared UI primitive 根目录,只放业务无关的基础展示与交互组件。
4
+
5
+ ## 目录预算豁免
6
+
7
+ - 原因:该目录历史上承载 button、dialog、popover、select、tabs、tooltip 等基础 UI primitive,当前文件数已经超过目录预算。后续新增复杂组件应优先进入语义子目录,或复用现有 primitive 扩展合同;不要继续把业务组件或一次性页面组件放到本目录根部。
@@ -3,6 +3,14 @@ import * as PopoverPrimitive from '@radix-ui/react-popover';
3
3
 
4
4
  import { cn } from '@/shared/lib/utils';
5
5
 
6
+ const FLOATING_CONTENT_AVAILABLE_HEIGHT_GAP = '2rem';
7
+
8
+ function createPopoverAvailableHeightLimit(limit: string): string {
9
+ return `min(${limit}, max(0px, calc(var(--radix-popover-content-available-height, 100vh) - ${FLOATING_CONTENT_AVAILABLE_HEIGHT_GAP})))`;
10
+ }
11
+
12
+ const POPOVER_CONTENT_MAX_HEIGHT = createPopoverAvailableHeightLimit('24rem');
13
+
6
14
  const Popover = PopoverPrimitive.Root;
7
15
 
8
16
  const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -12,20 +20,28 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
12
20
  const PopoverContent = React.forwardRef<
13
21
  React.ElementRef<typeof PopoverPrimitive.Content>,
14
22
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
15
- >(({ className, sideOffset = 8, align = 'start', ...props }, ref) => (
23
+ >(({ className, sideOffset = 8, align = 'start', collisionPadding = 12, style, ...props }, ref) => (
16
24
  <PopoverPrimitive.Portal>
17
25
  <PopoverPrimitive.Content
18
26
  ref={ref}
19
27
  sideOffset={sideOffset}
20
28
  align={align}
29
+ collisionPadding={collisionPadding}
21
30
  className={cn(
22
- 'z-[var(--z-popover,50)] w-72 overflow-hidden rounded-2xl border border-gray-200/50 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
31
+ 'z-[var(--z-popover,50)] w-72 overflow-x-hidden overflow-y-auto rounded-2xl border border-gray-200/50 bg-white p-4 shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
23
32
  className
24
33
  )}
34
+ style={{ maxHeight: POPOVER_CONTENT_MAX_HEIGHT, ...style }}
25
35
  {...props}
26
36
  />
27
37
  </PopoverPrimitive.Portal>
28
38
  ));
29
39
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30
40
 
31
- export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
41
+ export {
42
+ Popover,
43
+ PopoverTrigger,
44
+ PopoverContent,
45
+ PopoverAnchor,
46
+ createPopoverAvailableHeightLimit,
47
+ };
@@ -6,6 +6,13 @@ import { cn } from "@/shared/lib/utils"
6
6
  const Select = SelectPrimitive.Root
7
7
  const SelectGroup = SelectPrimitive.Group
8
8
  const SelectValue = SelectPrimitive.Value
9
+ const SELECT_CONTENT_AVAILABLE_HEIGHT_GAP = "2rem"
10
+
11
+ function createSelectAvailableHeightLimit(limit: string): string {
12
+ return `min(${limit}, max(0px, calc(var(--radix-select-content-available-height, 100vh) - ${SELECT_CONTENT_AVAILABLE_HEIGHT_GAP})))`
13
+ }
14
+
15
+ const SELECT_CONTENT_MAX_HEIGHT = createSelectAvailableHeightLimit("24rem")
9
16
 
10
17
  type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
11
18
  indicator?: React.ReactNode
@@ -66,24 +73,26 @@ type SelectContentProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.
66
73
  const SelectContent = React.forwardRef<
67
74
  React.ElementRef<typeof SelectPrimitive.Content>,
68
75
  SelectContentProps
69
- >(({ className, children, position = "popper", viewportClassName, ...props }, ref) => (
76
+ >(({ className, children, collisionPadding = 12, position = "popper", style, viewportClassName, ...props }, ref) => (
70
77
  <SelectPrimitive.Portal>
71
78
  <SelectPrimitive.Content
72
79
  ref={ref}
73
80
  className={cn(
74
- "relative z-[var(--z-popover,50)] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-white",
81
+ "relative z-[var(--z-popover,50)] flex max-h-96 min-w-[8rem] flex-col overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-white",
75
82
  position === "popper" &&
76
83
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
77
84
  className
78
85
  )}
86
+ collisionPadding={collisionPadding}
79
87
  position={position}
88
+ style={{ maxHeight: SELECT_CONTENT_MAX_HEIGHT, ...style }}
80
89
  {...props}
81
90
  >
82
91
  <SelectScrollUpButton />
83
92
  <SelectPrimitive.Viewport
84
93
  className={cn(
85
- "p-1",
86
- position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
94
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain p-1",
95
+ position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
87
96
  viewportClassName
88
97
  )}
89
98
  >
@@ -144,4 +153,5 @@ export {
144
153
  SelectSeparator,
145
154
  SelectScrollUpButton,
146
155
  SelectScrollDownButton,
156
+ createSelectAvailableHeightLimit,
147
157
  }
@@ -9,33 +9,37 @@ type UseInfiniteScrollLoaderParams = {
9
9
  watchValue?: string | number;
10
10
  };
11
11
 
12
- export function useInfiniteScrollLoader(params: UseInfiniteScrollLoaderParams) {
12
+ export function useInfiniteScrollLoader({
13
+ disabled,
14
+ onLoadMore,
15
+ thresholdPx = DEFAULT_THRESHOLD_PX,
16
+ watchValue
17
+ }: UseInfiniteScrollLoaderParams) {
13
18
  const containerRef = useRef<HTMLDivElement | null>(null);
14
19
  const sentinelRef = useRef<HTMLDivElement | null>(null);
15
- const onLoadMoreRef = useRef(params.onLoadMore);
20
+ const onLoadMoreRef = useRef(onLoadMore);
16
21
  const loadingRef = useRef(false);
17
22
 
18
23
  useEffect(() => {
19
- onLoadMoreRef.current = params.onLoadMore;
20
- }, [params.onLoadMore]);
24
+ onLoadMoreRef.current = onLoadMore;
25
+ }, [onLoadMore]);
21
26
 
22
27
  useEffect(() => {
23
- if (params.disabled) {
28
+ if (disabled) {
24
29
  loadingRef.current = false;
25
30
  }
26
- }, [params.disabled]);
31
+ }, [disabled]);
27
32
 
28
33
  useEffect(() => {
29
34
  const container = containerRef.current;
30
35
  const sentinel = sentinelRef.current;
31
- const thresholdPx = params.thresholdPx ?? DEFAULT_THRESHOLD_PX;
32
36
 
33
- if (params.disabled || !container || !sentinel) {
37
+ if (disabled || !container || !sentinel) {
34
38
  return;
35
39
  }
36
40
 
37
41
  const triggerLoadMore = () => {
38
- if (loadingRef.current || params.disabled) {
42
+ if (loadingRef.current || disabled) {
39
43
  return;
40
44
  }
41
45
 
@@ -79,7 +83,7 @@ export function useInfiniteScrollLoader(params: UseInfiniteScrollLoaderParams) {
79
83
  return () => {
80
84
  container.removeEventListener('scroll', maybeLoadMore);
81
85
  };
82
- }, [params.disabled, params.thresholdPx, params.watchValue]);
86
+ }, [disabled, thresholdPx, watchValue]);
83
87
 
84
88
  return {
85
89
  containerRef,