@nextclaw/ui 0.15.0 → 0.15.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 (191) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/assets/{api-DLtsiBKm.js → api-B7Qigvtm.js} +2 -2
  3. package/dist/assets/{appearance-settings-page-CtkeYXux.js → appearance-settings-page-CuE0Iiqj.js} +1 -1
  4. package/dist/assets/{book-open-BQwWBDKb.js → book-open-Ba2f7YiG.js} +1 -1
  5. package/dist/assets/channels-list-page-CTsM6TcP.js +8 -0
  6. package/dist/assets/chat-page-C_5dcuE3.js +1 -0
  7. package/dist/assets/config-split-page-Fbb4PtTf.js +1 -0
  8. package/dist/assets/{confirm-dialog-iIJFFp2T.js → confirm-dialog-D2EG3nqE.js} +1 -1
  9. package/dist/assets/desktop-update-config-BGR3HUi1.js +1 -0
  10. package/dist/assets/{dist-D2eZC3OR.js → dist-7JTjycxE.js} +1 -1
  11. package/dist/assets/{dist-CBxWOazz.js → dist-wXLhk4hl.js} +1 -1
  12. package/dist/assets/doc-browser-bHyXnxlj.js +1 -0
  13. package/dist/assets/doc-browser-context-CYAGm5d5.js +1 -0
  14. package/dist/assets/doc-browser-gTrQ3jCF.js +1 -0
  15. package/dist/assets/{es2015-DhQo_oS6.js → es2015-j1SRCIX5.js} +1 -1
  16. package/dist/assets/{external-link-CM5dnUI6.js → external-link-DxuPaDoK.js} +1 -1
  17. package/dist/assets/form-actions-BztL7P_T.js +1 -0
  18. package/dist/assets/{i18n-provider-di1waTGX.js → i18n-provider-B4oMxOFx.js} +1 -1
  19. package/dist/assets/index-CvrvblgX.css +1 -0
  20. package/dist/assets/index-Obd4JYU2.js +104 -0
  21. package/dist/assets/loader-circle-CdzgK4eH.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-C_MVQUbT.js → mcp-marketplace-page-Bt7CuOoq.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-Ck3t78_n.js +1 -0
  24. package/dist/assets/model-config-page-DeKk9gvE.js +1 -0
  25. package/dist/assets/plus-Bx7wQbhG.js +1 -0
  26. package/dist/assets/{provider-scoped-model-input-g1WkGiJ8.js → provider-scoped-model-input-DsH2LF1q.js} +1 -1
  27. package/dist/assets/providers-config-page-BFnGp68F.js +1 -0
  28. package/dist/assets/{react-DjpzoZXB.js → react-UirzUuST.js} +1 -1
  29. package/dist/assets/{refresh-cw-DaTv3zxE.js → refresh-cw-eT8BF-8B.js} +1 -1
  30. package/dist/assets/remote-CDkHZtA8.js +1 -0
  31. package/dist/assets/rotate-cw-1JqxHTuw.js +1 -0
  32. package/dist/assets/runtime-config-page-BelAqiNG.js +1 -0
  33. package/dist/assets/{save-BZof1uie.js → save-Cb2g0BOi.js} +1 -1
  34. package/dist/assets/{search-MxAarS_g.js → search-BcHjwdsb.js} +1 -1
  35. package/dist/assets/search-config-page-zPm6--Ig.js +1 -0
  36. package/dist/assets/secrets-config-page-Cxyp-KuQ.js +3 -0
  37. package/dist/assets/security-config-B_gCq9fn.js +1 -0
  38. package/dist/assets/{select-D7GcNP0z.js → select-CdSEOwVM.js} +1 -1
  39. package/dist/assets/{setting-row-CuozlnJd.js → setting-row-CTAtGexr.js} +1 -1
  40. package/dist/assets/settings-2-2DWauOjY.js +1 -0
  41. package/dist/assets/{side-dock-BcLau5S_.js → side-dock-DPvakJ0a.js} +2 -2
  42. package/dist/assets/{skeleton-shfmXuYp.js → skeleton-P5oM14E1.js} +1 -1
  43. package/dist/assets/{switch-C1BWMU5c.js → switch-BU_UEULb.js} +1 -1
  44. package/dist/assets/{tag-chip-DCUrQUCP.js → tag-chip-D-WsKoT1.js} +1 -1
  45. package/dist/assets/{tooltip-DfAHHA76.js → tooltip-CjkOaHW7.js} +1 -1
  46. package/dist/assets/{use-config-DAdbT5QJ.js → use-config--hx5oxQn.js} +1 -1
  47. package/dist/assets/{use-confirm-dialog-eWcJ6V-s.js → use-confirm-dialog-CD9a_SK1.js} +1 -1
  48. package/dist/assets/x-DT5x45JU.js +1 -0
  49. package/dist/index.html +38 -30
  50. package/index.html +12 -4
  51. package/package.json +9 -9
  52. package/src/app/components/layout/__tests__/sidebar.layout.test.tsx +29 -1
  53. package/src/app/components/layout/sidebar-items.tsx +43 -4
  54. package/src/app/components/layout/sidebar.tsx +83 -61
  55. package/src/app/configs/app-navigation.config.ts +49 -0
  56. package/src/app/styles/design-system.css +82 -0
  57. package/src/features/agents/components/__tests__/agents-page.test.tsx +135 -2
  58. package/src/features/agents/components/agent-advanced-config-fields.tsx +70 -0
  59. package/src/features/agents/components/agent-details-dialog.tsx +398 -0
  60. package/src/features/agents/components/agent-dialogs.tsx +20 -47
  61. package/src/features/agents/components/agents-page.tsx +76 -10
  62. package/src/features/agents/types/agent-form.types.ts +21 -0
  63. package/src/features/agents/utils/agent-form.utils.ts +39 -0
  64. package/src/features/channels/components/config/channel-form.tsx +14 -11
  65. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +172 -1
  66. package/src/features/chat/components/conversation/chat-conversation-header-section.tsx +11 -1
  67. package/src/features/chat/components/conversation/chat-conversation-header.tsx +7 -3
  68. package/src/features/chat/components/conversation/chat-conversation-panel.tsx +1 -1
  69. package/src/features/chat/components/layout/__tests__/chat-sidebar-toolbar.test.tsx +48 -0
  70. package/src/features/chat/components/layout/__tests__/chat-sidebar-utility-menu.test.tsx +55 -0
  71. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +0 -3
  72. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +3 -4
  73. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +29 -30
  74. package/src/features/chat/components/layout/chat-sidebar.tsx +21 -7
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +18 -3
  76. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +2 -1
  77. package/src/features/chat/features/message/components/__tests__/chat-inline-panel-app-card.test.tsx +22 -1
  78. package/src/features/chat/features/message/components/__tests__/chat-message-list.container.test.tsx +1 -1
  79. package/src/features/chat/features/message/components/chat-inline-panel-app-card.tsx +10 -5
  80. package/src/features/chat/features/message/utils/chat-inline-panel-app-card.utils.ts +0 -11
  81. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +23 -1
  82. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +74 -0
  83. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +3 -2
  84. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +116 -0
  85. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +108 -46
  86. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +6 -0
  87. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +48 -24
  88. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +0 -3
  89. package/src/features/chat/features/session/components/session-header/chat-session-header-actions.tsx +0 -4
  90. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +294 -0
  91. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +15 -1
  92. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +24 -4
  93. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +3 -0
  94. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +25 -3
  95. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +1 -0
  96. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +9 -4
  97. package/src/features/chat/features/welcome/components/chat-welcome.tsx +0 -5
  98. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +193 -56
  99. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -0
  100. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +144 -0
  101. package/src/features/chat/features/workspace/components/chat-session-workspace-directory-browser.tsx +99 -0
  102. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -1
  103. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +67 -14
  104. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +3 -0
  105. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-nav.tsx +44 -1
  106. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +45 -3
  107. package/src/features/chat/features/workspace/utils/__tests__/chat-workspace-panel-view-model.utils.test.ts +46 -0
  108. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +33 -2
  109. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +47 -0
  110. package/src/features/chat/managers/chat-session-list.manager.ts +32 -0
  111. package/src/features/chat/managers/chat-thread.manager.ts +8 -2
  112. package/src/features/chat/stores/chat-session-list.store.ts +28 -5
  113. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +5 -12
  114. package/src/features/panel-apps/components/panel-app-list-item.tsx +6 -6
  115. package/src/features/panel-apps/index.ts +1 -0
  116. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -10
  117. package/src/features/panel-apps/utils/panel-app-iframe.utils.ts +6 -0
  118. package/src/features/pwa/managers/__tests__/pwa-shell-theme.manager.test.ts +11 -3
  119. package/src/features/pwa/managers/pwa-shell-theme.manager.ts +3 -1
  120. package/src/features/service-apps/components/service-apps-panel.tsx +145 -35
  121. package/src/features/settings/components/config/provider-auth-section.tsx +1 -1
  122. package/src/features/settings/components/config/provider-form.tsx +11 -8
  123. package/src/features/settings/components/config/provider-models-section.tsx +7 -7
  124. package/src/features/settings/components/config/provider-pill-selector.tsx +1 -1
  125. package/src/features/settings/components/config/secrets-config-form.tsx +5 -4
  126. package/src/features/settings/pages/model-config-page.tsx +26 -25
  127. package/src/features/settings/pages/providers-config-page.tsx +10 -13
  128. package/src/features/settings/pages/search-config-page.tsx +8 -5
  129. package/src/features/system-status/components/__tests__/desktop-update-config.test.tsx +97 -5
  130. package/src/features/system-status/components/config/runtime-config-editor.tsx +5 -4
  131. package/src/features/system-status/components/desktop-update-config.tsx +91 -2
  132. package/src/features/system-status/utils/update-release-notes.utils.ts +94 -0
  133. package/src/index.css +18 -0
  134. package/src/shared/components/config-split-page.tsx +6 -6
  135. package/src/shared/components/doc-browser/__tests__/doc-browser-url.utils.test.ts +73 -0
  136. package/src/shared/components/doc-browser/__tests__/doc-browser.test.tsx +48 -0
  137. package/src/shared/components/doc-browser/doc-browser-context.tsx +4 -0
  138. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +5 -1
  139. package/src/shared/components/doc-browser/doc-browser-renderer.types.ts +2 -1
  140. package/src/shared/components/doc-browser/doc-browser-tab-strip.tsx +10 -10
  141. package/src/shared/components/doc-browser/doc-browser.tsx +6 -4
  142. package/src/shared/components/doc-browser/utils/doc-browser-url.utils.ts +62 -4
  143. package/src/shared/components/resizable-right-panel/__tests__/resizable-right-panel.test.tsx +18 -0
  144. package/src/shared/components/resizable-right-panel/resizable-right-panel.tsx +28 -3
  145. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +36 -0
  146. package/src/shared/components/ui/actions/form-actions.tsx +25 -0
  147. package/src/shared/components/ui/actions/icon-action-button.tsx +25 -2
  148. package/src/shared/components/ui/card.tsx +12 -4
  149. package/src/shared/components/ui/tab-strip/compact-tab-strip.tsx +2 -1
  150. package/src/shared/hooks/use-server-path-browse.ts +26 -4
  151. package/src/shared/hooks/use-server-path-read.ts +11 -1
  152. package/src/shared/lib/api/ncp-session.types.ts +1 -1
  153. package/src/shared/lib/api/types.ts +12 -3
  154. package/src/shared/lib/api/utils/server-path.utils.ts +1 -0
  155. package/src/shared/lib/i18n/locales/en-US/agents.json +41 -0
  156. package/src/shared/lib/i18n/locales/en-US/chat.json +19 -0
  157. package/src/shared/lib/i18n/locales/en-US/core.json +3 -0
  158. package/src/shared/lib/i18n/locales/en-US/desktop-update.json +8 -0
  159. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +5 -0
  160. package/src/shared/lib/i18n/locales/zh-CN/agents.json +41 -0
  161. package/src/shared/lib/i18n/locales/zh-CN/chat.json +19 -0
  162. package/src/shared/lib/i18n/locales/zh-CN/core.json +3 -0
  163. package/src/shared/lib/i18n/locales/zh-CN/desktop-update.json +8 -0
  164. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +5 -0
  165. package/src/shared/lib/session-project/workspace-file-breadcrumb.utils.ts +9 -5
  166. package/src/shared/lib/theme/index.test.ts +21 -0
  167. package/src/shared/lib/theme/index.ts +6 -4
  168. package/src/vite-env.d.ts +2 -0
  169. package/dist/assets/channels-list-page-Cv8o9Hxg.js +0 -8
  170. package/dist/assets/chat-page-BtA7H1Ea.js +0 -1
  171. package/dist/assets/config-split-page-EidRBpP2.js +0 -1
  172. package/dist/assets/desktop-update-config-C6n_TB1r.js +0 -1
  173. package/dist/assets/doc-browser-B76XOXmW.js +0 -1
  174. package/dist/assets/doc-browser-DBnj9UMB.js +0 -1
  175. package/dist/assets/doc-browser-context-Sv-044Cl.js +0 -1
  176. package/dist/assets/ellipsis-2uzhypw5.js +0 -1
  177. package/dist/assets/index-CBRP-7-a.css +0 -1
  178. package/dist/assets/index-Z1fYpbNd.js +0 -104
  179. package/dist/assets/loader-circle-BjmN5DeJ.js +0 -1
  180. package/dist/assets/mcp-marketplace-page-CoZIiD6G.js +0 -1
  181. package/dist/assets/model-config-page-Cdr9P6TQ.js +0 -1
  182. package/dist/assets/plus-3ztRZD_l.js +0 -1
  183. package/dist/assets/providers-config-page-CWlO7J4A.js +0 -1
  184. package/dist/assets/remote-CKWZfVVB.js +0 -1
  185. package/dist/assets/rotate-cw-CHcaRUDz.js +0 -1
  186. package/dist/assets/runtime-config-page-CfTqh1nv.js +0 -1
  187. package/dist/assets/search-config-page-Dm8daYny.js +0 -1
  188. package/dist/assets/secrets-config-page-D8AbcR05.js +0 -3
  189. package/dist/assets/security-config-Ds0LsJmo.js +0 -1
  190. package/dist/assets/x-DqL1mYLC.js +0 -1
  191. /package/dist/assets/{config-hints-fGnUjDe9.js → config-hints-C_iP1zl_.js} +0 -0
@@ -0,0 +1,94 @@
1
+ import type { UpdateSnapshot } from '@nextclaw/shared';
2
+
3
+ export type ReleaseNotesLocale = 'zh-CN' | 'en-US';
4
+
5
+ export type ReleaseNotesText = Partial<Record<ReleaseNotesLocale, string>>;
6
+
7
+ export type ReleaseNotesItem = {
8
+ title: ReleaseNotesText;
9
+ body?: ReleaseNotesText;
10
+ };
11
+
12
+ export type ReleaseNotesSection = {
13
+ kind: string;
14
+ title?: ReleaseNotesText;
15
+ items: ReleaseNotesItem[];
16
+ };
17
+
18
+ export type ReleaseNotesPayload = {
19
+ title?: ReleaseNotesText;
20
+ summary?: ReleaseNotesText;
21
+ sections: ReleaseNotesSection[];
22
+ };
23
+
24
+ export function readReleaseNotesText(
25
+ value: ReleaseNotesText | undefined,
26
+ locale: ReleaseNotesLocale
27
+ ): string | null {
28
+ const fallbackLocale = locale === 'zh-CN' ? 'en-US' : 'zh-CN';
29
+ const text = value?.[locale]?.trim() || value?.[fallbackLocale]?.trim();
30
+ return text || null;
31
+ }
32
+
33
+ export function resolveReleaseNotesDataUrl(snapshot: UpdateSnapshot): string | null {
34
+ const releaseNotesUrl = snapshot.releaseNotesUrl?.trim();
35
+ const version = (snapshot.availableVersion ?? snapshot.downloadedVersion)?.trim();
36
+ if (!releaseNotesUrl || !version || !/^[0-9A-Za-z.-]+$/.test(version)) {
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ return new URL(`/release-notes/nextclaw-v${version}.json`, releaseNotesUrl).toString();
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
49
+ }
50
+
51
+ function isReleaseNotesText(value: unknown): value is ReleaseNotesText {
52
+ if (!isRecord(value)) {
53
+ return false;
54
+ }
55
+ return typeof value['zh-CN'] === 'string' || typeof value['en-US'] === 'string';
56
+ }
57
+
58
+ function isReleaseNotesItem(value: unknown): value is ReleaseNotesItem {
59
+ if (!isRecord(value) || !isReleaseNotesText(value.title)) {
60
+ return false;
61
+ }
62
+ return value.body === undefined || isReleaseNotesText(value.body);
63
+ }
64
+
65
+ function isReleaseNotesSection(value: unknown): value is ReleaseNotesSection {
66
+ return isRecord(value)
67
+ && typeof value.kind === 'string'
68
+ && Array.isArray(value.items)
69
+ && value.items.every(isReleaseNotesItem)
70
+ && (value.title === undefined || isReleaseNotesText(value.title));
71
+ }
72
+
73
+ function isReleaseNotesPayload(value: unknown): value is ReleaseNotesPayload {
74
+ return isRecord(value)
75
+ && Array.isArray(value.sections)
76
+ && value.sections.every(isReleaseNotesSection)
77
+ && (value.title === undefined || isReleaseNotesText(value.title))
78
+ && (value.summary === undefined || isReleaseNotesText(value.summary));
79
+ }
80
+
81
+ export async function fetchReleaseNotesData(url: string): Promise<ReleaseNotesPayload> {
82
+ const response = await fetch(url, {
83
+ cache: 'no-store',
84
+ headers: { accept: 'application/json' }
85
+ });
86
+ if (!response.ok) {
87
+ throw new Error(`Release notes request failed with HTTP ${response.status}.`);
88
+ }
89
+ const payload: unknown = await response.json();
90
+ if (!isReleaseNotesPayload(payload)) {
91
+ throw new Error('Release notes payload has an unsupported shape.');
92
+ }
93
+ return payload;
94
+ }
package/src/index.css CHANGED
@@ -675,6 +675,24 @@
675
675
  --md-table-head-bg: rgba(191, 219, 254, 0.16);
676
676
  }
677
677
 
678
+ :root[data-theme="work"] .chat-markdown-assistant {
679
+ --md-link: #1f73c9;
680
+ --md-border: rgba(0, 0, 0, 0.11);
681
+ --md-table-head-bg: rgba(0, 0, 0, 0.045);
682
+ }
683
+
684
+ :root[data-theme="work"] .chat-markdown-user {
685
+ --md-text: #212121;
686
+ --md-muted: #737373;
687
+ --md-border: rgba(0, 0, 0, 0.12);
688
+ --md-link: #1f73c9;
689
+ --md-inline-code-bg: rgba(0, 0, 0, 0.06);
690
+ --md-inline-code-color: #212121;
691
+ --md-blockquote-border: rgba(0, 0, 0, 0.2);
692
+ --md-blockquote-bg: rgba(0, 0, 0, 0.04);
693
+ --md-table-head-bg: rgba(0, 0, 0, 0.045);
694
+ }
695
+
678
696
  :root[data-theme-appearance="dark"] .chat-markdown-assistant {
679
697
  --md-text: hsl(var(--foreground));
680
698
  --md-muted: hsl(var(--foreground-tertiary));
@@ -3,7 +3,7 @@ import { ArrowLeft, type LucideIcon } from "lucide-react";
3
3
  import { t } from "@/shared/lib/i18n";
4
4
  import { cn } from "@/shared/lib/utils";
5
5
 
6
- const CARD_CLASS = "min-w-0 overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-card xl:h-[calc(100vh-180px)] xl:max-h-[860px]";
6
+ const CARD_CLASS = "min-w-0 overflow-hidden rounded-2xl border border-border/75 bg-card text-card-foreground xl:h-[calc(100vh-180px)] xl:max-h-[860px]";
7
7
  type DivProps = HTMLAttributes<HTMLDivElement>; type SectionProps = HTMLAttributes<HTMLElement>;
8
8
 
9
9
  function ConfigSplitPane({ className, ...props }: SectionProps) {
@@ -102,7 +102,7 @@ export function ConfigSplitPaneBody({
102
102
  }
103
103
 
104
104
  export function ConfigSplitPaneFooter({ className, ...props }: DivProps) {
105
- return <div className={cn("shrink-0 border-t border-border/70", className)} {...props} />;
105
+ return <div className={cn("shrink-0 border-t border-border/70 px-4 py-2.5", className)} {...props} />;
106
106
  }
107
107
 
108
108
  export function ConfigSelectionCard({
@@ -115,10 +115,10 @@ export function ConfigSelectionCard({
115
115
  <button
116
116
  type={type}
117
117
  className={cn(
118
- "w-full rounded-xl border p-2.5 text-left transition-all",
118
+ "w-full rounded-xl border p-2.5 text-left transition-colors",
119
119
  active
120
- ? "border-primary/35 bg-primary-50/45 text-foreground shadow-sm"
121
- : "border-border bg-card text-muted-foreground hover:border-primary/35 hover:bg-accent/70 hover:text-accent-foreground",
120
+ ? "border-border/70 bg-muted/65 text-foreground"
121
+ : "border-transparent bg-transparent text-muted-foreground hover:border-border/50 hover:bg-muted/40 hover:text-foreground",
122
122
  className,
123
123
  )}
124
124
  {...props}
@@ -140,7 +140,7 @@ export function ConfigSplitEmptyState({
140
140
  return (
141
141
  <div
142
142
  className={cn(
143
- "flex min-h-[220px] flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/60 px-4 py-10 text-center",
143
+ "flex min-h-[220px] flex-col items-center justify-center rounded-xl border border-dashed border-border/70 bg-muted/35 px-4 py-10 text-center",
144
144
  className,
145
145
  )}
146
146
  {...props}
@@ -0,0 +1,73 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ async function importDocsUrlUtils(language: 'en' | 'zh' = 'en') {
4
+ const i18n = await import('@/shared/lib/i18n');
5
+ i18n.setLanguage(language);
6
+ return await import('@/shared/components/doc-browser/utils/doc-browser-url.utils');
7
+ }
8
+
9
+ function stubTimeZone(timeZone: string) {
10
+ vi.spyOn(Intl, 'DateTimeFormat').mockReturnValue({
11
+ resolvedOptions: () => ({ timeZone }),
12
+ } as Intl.DateTimeFormat);
13
+ }
14
+
15
+ describe('doc browser docs URL utilities', () => {
16
+ beforeEach(() => {
17
+ vi.resetModules();
18
+ stubTimeZone('America/Los_Angeles');
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.unstubAllEnvs();
23
+ vi.unstubAllGlobals();
24
+ });
25
+
26
+ it('uses the official docs origin for English users outside mainland by default', async () => {
27
+ const { getDefaultDocsUrl } = await importDocsUrlUtils('en');
28
+
29
+ expect(getDefaultDocsUrl()).toBe('https://docs.nextclaw.io/en/guide/getting-started');
30
+ });
31
+
32
+ it('uses the mainland mirror for users in the mainland timezone', async () => {
33
+ stubTimeZone('Asia/Shanghai');
34
+
35
+ const { getDefaultDocsUrl } = await importDocsUrlUtils('en');
36
+
37
+ expect(getDefaultDocsUrl()).toBe('https://docs.nextclaw.net/en/guide/getting-started');
38
+ });
39
+
40
+ it('uses the mainland mirror for Chinese users by default', async () => {
41
+ const { getDefaultDocsUrl } = await importDocsUrlUtils('zh');
42
+
43
+ expect(getDefaultDocsUrl()).toBe('https://docs.nextclaw.net/zh/guide/getting-started');
44
+ });
45
+
46
+ it('lets an explicit docs base override the automatic origin', async () => {
47
+ vi.stubEnv('VITE_NEXTCLAW_DOCS_BASE_URL', 'https://docs.example.com/');
48
+
49
+ const { getDocsUrl } = await importDocsUrlUtils('zh');
50
+
51
+ expect(getDocsUrl('/guide/model-selection')).toBe('https://docs.example.com/zh/guide/model-selection');
52
+ });
53
+
54
+ it('does not rewrite non-docs absolute URLs', async () => {
55
+ const { getDocsUrl } = await importDocsUrlUtils('zh');
56
+
57
+ expect(getDocsUrl('https://example.com/help')).toBe('https://example.com/help');
58
+ });
59
+
60
+ it('rewrites official docs URLs to the selected docs origin', async () => {
61
+ const { getDocsUrl } = await importDocsUrlUtils('zh');
62
+
63
+ expect(getDocsUrl('https://docs.nextclaw.io/en/guide/commands?tab=cli#start')).toBe(
64
+ 'https://docs.nextclaw.net/en/guide/commands?tab=cli#start',
65
+ );
66
+ });
67
+
68
+ it('recognizes the mainland mirror as a docs URL', async () => {
69
+ const { isDocsUrl } = await importDocsUrlUtils('en');
70
+
71
+ expect(isDocsUrl('https://docs.nextclaw.net/zh/guide/getting-started')).toBe(true);
72
+ });
73
+ });
@@ -397,6 +397,54 @@ describe("DocBrowser", () => {
397
397
  expect(screen.getByText("Help Docs")).toBeTruthy();
398
398
  });
399
399
 
400
+ it("focuses panel app iframes on pointer entry without applying the behavior to generic content iframes", () => {
401
+ const panelAppTab: DocBrowserTab = {
402
+ id: "piano",
403
+ kind: "panel-app" as const,
404
+ title: "Piano",
405
+ currentUrl: "/api/panel-apps/piano/content",
406
+ history: ["/api/panel-apps/piano/content"],
407
+ historyIndex: 0,
408
+ navVersion: 0,
409
+ };
410
+ docBrowserState.tabs = [panelAppTab];
411
+ docBrowserState.activeTabId = panelAppTab.id;
412
+ docBrowserState.currentTab = panelAppTab;
413
+
414
+ const { container, unmount } = render(
415
+ <DocBrowser customTabRenderers={PANEL_APPS_DOC_BROWSER_RENDERERS} />,
416
+ );
417
+
418
+ const panelAppIframe = container.querySelector('iframe[title="Piano"]') as HTMLIFrameElement;
419
+ const panelAppIframeFocus = vi.spyOn(panelAppIframe, "focus");
420
+ const panelAppContentFocus = vi.spyOn(panelAppIframe.contentWindow!, "focus").mockImplementation(() => undefined);
421
+
422
+ fireEvent.pointerOver(panelAppIframe);
423
+
424
+ expect(panelAppIframe.getAttribute("tabindex")).toBe("0");
425
+ expect(panelAppIframeFocus).toHaveBeenCalledWith({ preventScroll: true });
426
+ expect(panelAppContentFocus).toHaveBeenCalled();
427
+
428
+ unmount();
429
+ resetDocBrowserTestState();
430
+ const contentTab: DocBrowserTab = {
431
+ id: "local-app",
432
+ kind: "content",
433
+ title: "Local App",
434
+ currentUrl: "http://127.0.0.1:5173/dashboard",
435
+ history: ["http://127.0.0.1:5173/dashboard"],
436
+ historyIndex: 0,
437
+ navVersion: 0,
438
+ };
439
+ docBrowserState.tabs = [contentTab];
440
+ docBrowserState.activeTabId = contentTab.id;
441
+ docBrowserState.currentTab = contentTab;
442
+
443
+ render(<DocBrowser />);
444
+
445
+ expect(screen.getByTitle("Local App").getAttribute("tabindex")).toBeNull();
446
+ });
447
+
400
448
  });
401
449
 
402
450
  describe("DocBrowser floating interactions", () => {
@@ -22,6 +22,10 @@ export {
22
22
  DOC_BROWSER_HOME_TAB_KIND,
23
23
  DOC_BROWSER_HOME_URL,
24
24
  DOCS_DEFAULT_BASE_URL,
25
+ DOCS_MAINLAND_BASE_URL,
26
+ getDocsBaseUrl,
27
+ getDocsUrl,
28
+ getDefaultDocsUrl,
25
29
  isDocsUrl,
26
30
  } from '@/shared/components/doc-browser/utils/doc-browser-url.utils';
27
31
 
@@ -1,4 +1,4 @@
1
- import type { ReactNode, Ref } from 'react';
1
+ import type { PointerEventHandler, ReactNode, Ref } from 'react';
2
2
  import {
3
3
  ExternalLink,
4
4
  RefreshCw,
@@ -29,6 +29,7 @@ type DocBrowserFrameContentProps = {
29
29
  iframeSandbox?: string;
30
30
  isDragging: boolean;
31
31
  isResizing: boolean;
32
+ onIframePointerOver?: PointerEventHandler<HTMLIFrameElement>;
32
33
  };
33
34
 
34
35
  export function DocBrowserAddressToolbar({
@@ -74,6 +75,7 @@ export function DocBrowserFrameContent({
74
75
  iframeSandbox,
75
76
  isDragging,
76
77
  isResizing,
78
+ onIframePointerOver,
77
79
  }: DocBrowserFrameContentProps) {
78
80
  return (
79
81
  <div className="flex-1 relative overflow-hidden">
@@ -87,6 +89,8 @@ export function DocBrowserFrameContent({
87
89
  className="absolute inset-0 w-full h-full border-0"
88
90
  title={currentTab?.title || 'NextClaw Docs'}
89
91
  sandbox={iframeSandbox}
92
+ tabIndex={onIframePointerOver ? 0 : undefined}
93
+ onPointerOver={onIframePointerOver}
90
94
  allow="clipboard-read; clipboard-write"
91
95
  allowFullScreen
92
96
  />
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import type { PointerEvent, ReactNode } from 'react';
2
2
  import type { DocBrowserContextValue, DocBrowserTab } from './doc-browser-context';
3
3
 
4
4
  export type DocBrowserCustomTabRenderParams = {
@@ -20,6 +20,7 @@ export type DocBrowserCustomTabRenderer = {
20
20
  getIframeSandbox?: (tab: DocBrowserTab) => string | undefined;
21
21
  getTitle?: (tab: DocBrowserTab) => string;
22
22
  onIframeMessage?: (params: DocBrowserIframeMessageParams) => void;
23
+ onIframePointerOver?: (event: PointerEvent<HTMLIFrameElement>) => void;
23
24
  renderContent?: (params: DocBrowserCustomTabRenderParams) => ReactNode;
24
25
  renderIcon?: (tab: DocBrowserTab) => ReactNode;
25
26
  renderToolbar?: (params: DocBrowserCustomTabRenderParams) => ReactNode;
@@ -117,20 +117,20 @@ export function DocBrowserTabStrip({
117
117
  tabs={compactTabs}
118
118
  actions={actions}
119
119
  className={cn(
120
- "h-11 gap-2 border-border/70 bg-card px-2.5 shrink-0 select-none",
121
- isFullscreen && "h-[calc(env(safe-area-inset-top,0px)+2.75rem)] pt-[env(safe-area-inset-top,0px)]",
120
+ "h-10 gap-1.5 border-border/50 bg-card/80 px-2 shrink-0 select-none",
121
+ isFullscreen && "h-[calc(env(safe-area-inset-top,0px)+2.5rem)] pt-[env(safe-area-inset-top,0px)]",
122
122
  )}
123
123
  scrollClassName={cn(
124
- "doc-browser-tab-scrollbar flex h-full items-center gap-1.5",
124
+ "doc-browser-tab-scrollbar flex h-full items-center gap-0.5",
125
125
  !isDocked && !isFullscreen && "cursor-grab active:cursor-grabbing",
126
126
  )}
127
- tabsClassName="items-center gap-1.5"
128
- actionsClassName="h-full items-center gap-1"
129
- actionButtonClassName="rounded-md p-1.5 hover:text-foreground disabled:opacity-60"
130
- tabBaseClassName="group inline-flex min-w-0 cursor-pointer items-center gap-1 h-7 px-1.5 rounded-lg text-xs border max-w-[220px] shrink-0 transition-colors"
131
- activeTabClassName="border-primary/30 bg-primary-50/70 text-foreground shadow-[0_1px_2px_rgba(30,20,10,0.04)]"
132
- inactiveTabClassName="border-border bg-muted/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
133
- labelClassName="px-1 text-xs font-normal"
127
+ tabsClassName="items-center gap-0.5"
128
+ actionsClassName="h-full items-center gap-0.5"
129
+ actionButtonClassName="rounded-md p-1.5 text-muted-foreground hover:bg-muted/60 hover:text-foreground disabled:opacity-50"
130
+ tabBaseClassName="group inline-flex min-w-0 cursor-pointer items-center gap-0.5 h-7 px-2 rounded-md text-xs max-w-[220px] shrink-0 transition-colors"
131
+ activeTabClassName="bg-muted/80 text-foreground"
132
+ inactiveTabClassName="text-muted-foreground hover:bg-muted/45 hover:text-foreground"
133
+ labelClassName="px-0.5 text-xs font-normal"
134
134
  onPointerDown={!isDocked && !isFullscreen ? onDragStart : undefined}
135
135
  onScrollPointerDown={(event) => {
136
136
  if (shouldBlockHeaderDrag(event.target)) {
@@ -1,7 +1,8 @@
1
1
  import { useState, useRef, useCallback, useEffect } from 'react';
2
2
  import {
3
- DOCS_DEFAULT_BASE_URL,
4
3
  DOC_BROWSER_HOME_TAB_KIND,
4
+ getDefaultDocsUrl,
5
+ getDocsUrl,
5
6
  type DocBrowserDockControls,
6
7
  type DocBrowserDockState,
7
8
  type DocBrowserTab,
@@ -137,11 +138,11 @@ function useDocBrowserAddressBar({
137
138
  const input = urlInput.trim();
138
139
  if (!input) return;
139
140
  if (isDocsTab && input.startsWith('/')) {
140
- navigate(`${DOCS_DEFAULT_BASE_URL}${input}`);
141
+ navigate(getDocsUrl(input));
141
142
  } else if (isDocsTab && input.startsWith('http')) {
142
143
  navigate(input);
143
144
  } else if (isDocsTab) {
144
- navigate(`${DOCS_DEFAULT_BASE_URL}/${input}`);
145
+ navigate(getDocsUrl(`/${input}`));
145
146
  } else {
146
147
  navigate(resolveContentUrlInput(input, currentUrl));
147
148
  }
@@ -180,7 +181,7 @@ export function DocBrowser({ customTabRenderers = {}, displayMode = 'desktop', d
180
181
  const [floatRect, setFloatRect] = useState<FloatingPanelRect>(createInitialFloatingPanelRect);
181
182
  const [floatInteraction, setFloatInteraction] = useState<FloatingPanelInteraction | null>(null);
182
183
  const iframeRef = useRef<HTMLIFrameElement>(null);
183
- const currentUrl = currentTab?.currentUrl ?? DOCS_DEFAULT_BASE_URL;
184
+ const currentUrl = currentTab?.currentUrl ?? getDefaultDocsUrl();
184
185
  const navVersion = currentTab?.navVersion ?? 0;
185
186
  const iframeInstanceId = `${activeTabId}:${navVersion}:${iframeReloadVersion}`;
186
187
  const pendingParentDocsUrlRef = useRef<string | null>(null);
@@ -374,6 +375,7 @@ export function DocBrowser({ customTabRenderers = {}, displayMode = 'desktop', d
374
375
  iframeSandbox={iframeSandbox}
375
376
  isDragging={floatInteraction?.kind === 'drag'}
376
377
  isResizing={floatInteraction?.kind === 'resize'}
378
+ onIframePointerOver={customRenderer?.onIframePointerOver}
377
379
  />
378
380
 
379
381
  <DocBrowserExternalLink currentUrl={currentUrl} isVisible={isDocsTab || isContentTab} />
@@ -2,15 +2,19 @@ import { getLanguage, type I18nLanguage } from '@/shared/lib/i18n';
2
2
  import type { DocBrowserTabKind } from '@/shared/components/doc-browser/types/doc-browser.types';
3
3
 
4
4
  const DOCS_PRIMARY_DOMAIN = 'docs.nextclaw.io';
5
+ const DOCS_MAINLAND_DOMAIN = 'docs.nextclaw.net';
5
6
  const DOCS_PAGES_DEV = 'nextclaw-docs.pages.dev';
6
7
  const DOCS_HOSTS = new Set([
7
8
  DOCS_PRIMARY_DOMAIN,
8
9
  `www.${DOCS_PRIMARY_DOMAIN}`,
10
+ DOCS_MAINLAND_DOMAIN,
11
+ `www.${DOCS_MAINLAND_DOMAIN}`,
9
12
  DOCS_PAGES_DEV,
10
13
  `www.${DOCS_PAGES_DEV}`,
11
14
  ]);
12
15
 
13
16
  export const DOCS_DEFAULT_BASE_URL = `https://${DOCS_PRIMARY_DOMAIN}`;
17
+ export const DOCS_MAINLAND_BASE_URL = `https://${DOCS_MAINLAND_DOMAIN}`;
14
18
  export const DOC_BROWSER_HOME_TAB_KIND = 'home';
15
19
  export const DOC_BROWSER_HOME_URL = 'nextclaw://new-tab';
16
20
  const DOCS_DEFAULT_GUIDE_PATH = '/guide/getting-started';
@@ -33,19 +37,68 @@ function ensureLocalizedDocsPath(pathname: string, locale: 'en' | 'zh'): string
33
37
  return `/${locale}${normalized}`;
34
38
  }
35
39
 
40
+ function normalizeDocsBaseUrl(value?: string): string | undefined {
41
+ const raw = value?.trim();
42
+ if (!raw) {
43
+ return undefined;
44
+ }
45
+
46
+ try {
47
+ return new URL(raw).origin;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function getBrowserTimeZone(): string {
54
+ try {
55
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? '';
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ function isAbsoluteUrl(value: string): boolean {
62
+ return /^[a-z][a-z\d+.-]*:/i.test(value.trim());
63
+ }
64
+
65
+ function isLikelyMainlandDocsAudience(): boolean {
66
+ return getLanguage() === 'zh' || getBrowserTimeZone() === 'Asia/Shanghai';
67
+ }
68
+
69
+ export function getDocsBaseUrl(): string {
70
+ const configuredBaseUrl = normalizeDocsBaseUrl(import.meta.env.VITE_NEXTCLAW_DOCS_BASE_URL);
71
+ if (configuredBaseUrl) {
72
+ return configuredBaseUrl;
73
+ }
74
+
75
+ if (isLikelyMainlandDocsAudience()) {
76
+ return normalizeDocsBaseUrl(import.meta.env.VITE_NEXTCLAW_DOCS_CN_BASE_URL) ?? DOCS_MAINLAND_BASE_URL;
77
+ }
78
+
79
+ return DOCS_DEFAULT_BASE_URL;
80
+ }
81
+
36
82
  function resolveLocalizedDocsUrl(url: string): string {
37
83
  const locale = toDocsLocale(getLanguage());
84
+ const docsBaseUrl = getDocsBaseUrl();
38
85
 
39
86
  try {
40
- const parsed = new URL(url, DOCS_DEFAULT_BASE_URL);
41
- if (!DOCS_HOSTS.has(parsed.hostname)) {
87
+ const parsed = new URL(url, docsBaseUrl);
88
+ const docsBase = new URL(docsBaseUrl);
89
+ const shouldResolveAsDocs = !isAbsoluteUrl(url)
90
+ || DOCS_HOSTS.has(parsed.hostname)
91
+ || parsed.origin === docsBase.origin;
92
+ if (!shouldResolveAsDocs) {
42
93
  return parsed.toString();
43
94
  }
44
95
 
96
+ parsed.protocol = docsBase.protocol;
97
+ parsed.host = docsBase.host;
45
98
  parsed.pathname = ensureLocalizedDocsPath(parsed.pathname, locale);
46
99
  return parsed.toString();
47
100
  } catch {
48
- return new URL(`/${locale}${DOCS_DEFAULT_GUIDE_PATH}`, DOCS_DEFAULT_BASE_URL).toString();
101
+ return new URL(`/${locale}${DOCS_DEFAULT_GUIDE_PATH}`, docsBaseUrl).toString();
49
102
  }
50
103
  }
51
104
 
@@ -53,6 +106,10 @@ export function getDefaultDocsUrl(): string {
53
106
  return resolveLocalizedDocsUrl(DOCS_DEFAULT_GUIDE_PATH);
54
107
  }
55
108
 
109
+ export function getDocsUrl(pathOrUrl: string): string {
110
+ return resolveLocalizedDocsUrl(pathOrUrl);
111
+ }
112
+
56
113
  export function normalizeDocUrl(url: string): string {
57
114
  try {
58
115
  return new URL(url).pathname.replace(/\.html$/, '').replace(/\/$/, '');
@@ -78,7 +135,8 @@ export function inferTabTitle(url: string, kind: DocBrowserTabKind, fallback = '
78
135
 
79
136
  export function isDocsUrl(url: string): boolean {
80
137
  try {
81
- const parsed = new URL(url, window.location.origin);
138
+ const fallbackOrigin = typeof window !== 'undefined' ? window.location.origin : DOCS_DEFAULT_BASE_URL;
139
+ const parsed = new URL(url, fallbackOrigin);
82
140
  return DOCS_HOSTS.has(parsed.hostname);
83
141
  } catch {
84
142
  return false;
@@ -63,4 +63,22 @@ describe("ResizableRightPanel", () => {
63
63
  ).toBeNull();
64
64
  expect(screen.getByTestId("right-panel").className).toContain("fixed");
65
65
  });
66
+
67
+ it("can scope overlay positioning to its container", () => {
68
+ render(
69
+ <ResizableRightPanel
70
+ data-testid="right-panel"
71
+ overlay
72
+ overlayScope="container"
73
+ >
74
+ Content
75
+ </ResizableRightPanel>,
76
+ );
77
+
78
+ expect(
79
+ screen.queryByTestId("resizable-right-panel-handle"),
80
+ ).toBeNull();
81
+ expect(screen.getByTestId("right-panel").className).toContain("absolute");
82
+ expect(screen.getByTestId("right-panel").className).not.toContain("fixed");
83
+ });
66
84
  });
@@ -1,12 +1,33 @@
1
1
  import { useRef, useState, type ComponentPropsWithoutRef } from "react";
2
2
  import { cn } from "@/shared/lib/utils";
3
3
 
4
- type ResizableRightPanelProps = ComponentPropsWithoutRef<"aside"> & { defaultWidth?: number; minWidth?: number; maxWidth?: number; overlay?: boolean };
4
+ type ResizableRightPanelProps = ComponentPropsWithoutRef<"aside"> & {
5
+ defaultWidth?: number;
6
+ minWidth?: number;
7
+ maxWidth?: number;
8
+ overlay?: boolean;
9
+ overlayScope?: "viewport" | "container";
10
+ };
5
11
 
6
- export function ResizableRightPanel({ children, className, style, defaultWidth = 420, minWidth = 320, maxWidth = 860, overlay = false, ...props }: ResizableRightPanelProps) {
12
+ export function ResizableRightPanel({
13
+ children,
14
+ className,
15
+ style,
16
+ defaultWidth = 420,
17
+ minWidth = 320,
18
+ maxWidth = 860,
19
+ overlay = false,
20
+ overlayScope = "viewport",
21
+ ...props
22
+ }: ResizableRightPanelProps) {
7
23
  const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null);
8
24
  const [isResizing, setIsResizing] = useState(false);
9
25
  const [width, setWidth] = useState(defaultWidth);
26
+ const overlayClassName = overlay
27
+ ? overlayScope === "container"
28
+ ? "absolute inset-0 z-30"
29
+ : "fixed inset-0 z-40"
30
+ : "border-l border-border";
10
31
 
11
32
  const onResizeStart = (event: React.PointerEvent<HTMLDivElement>) => {
12
33
  if (overlay) return;
@@ -36,7 +57,11 @@ export function ResizableRightPanel({ children, className, style, defaultWidth =
36
57
  return (
37
58
  <aside
38
59
  {...props}
39
- className={cn("relative flex h-full min-h-0 shrink-0 overflow-hidden bg-card text-card-foreground", overlay ? "fixed inset-0 z-40" : "border-l border-border", className)}
60
+ className={cn(
61
+ "relative flex h-full min-h-0 shrink-0 overflow-hidden bg-card text-card-foreground",
62
+ overlayClassName,
63
+ className,
64
+ )}
40
65
  style={overlay ? style : { ...style, width }}
41
66
  >
42
67
  {!overlay ? (
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { IconActionButton } from '@/shared/components/ui/actions/icon-action-button';
4
+
5
+ describe('IconActionButton', () => {
6
+ it('applies compact size and strong tone classes for nested hover surfaces', () => {
7
+ render(
8
+ <IconActionButton
9
+ size="sm"
10
+ tone="strong"
11
+ icon={<span data-testid="icon" />}
12
+ label="Pin"
13
+ />,
14
+ );
15
+
16
+ const button = screen.getByRole('button', { name: 'Pin' });
17
+ expect(button.className).toContain('h-6');
18
+ expect(button.className).toContain('w-6');
19
+ expect(button.className).toContain('p-1');
20
+ expect(button.className).toContain('hover:bg-black/10');
21
+ });
22
+
23
+ it('defaults to medium size and soft accent hover', () => {
24
+ render(
25
+ <IconActionButton
26
+ icon={<span data-testid="icon" />}
27
+ label="More"
28
+ />,
29
+ );
30
+
31
+ const button = screen.getByRole('button', { name: 'More' });
32
+ expect(button.className).toContain('h-7');
33
+ expect(button.className).toContain('w-7');
34
+ expect(button.className).toContain('hover:bg-accent');
35
+ });
36
+ });
@@ -0,0 +1,25 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ import { cn } from '@/shared/lib/utils';
3
+
4
+ type FormActionsProps = HTMLAttributes<HTMLDivElement> & {
5
+ align?: 'start' | 'end' | 'between';
6
+ };
7
+
8
+ export function FormActions({
9
+ align = 'end',
10
+ className,
11
+ ...props
12
+ }: FormActionsProps) {
13
+ return (
14
+ <div
15
+ className={cn(
16
+ 'flex flex-wrap items-center gap-2',
17
+ align === 'start' && 'justify-start',
18
+ align === 'end' && 'justify-end',
19
+ align === 'between' && 'justify-between',
20
+ className,
21
+ )}
22
+ {...props}
23
+ />
24
+ );
25
+ }