@nextclaw/ui 0.15.20 → 0.15.22

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 +42 -0
  2. package/dist/assets/{api-tWUa6l9J.js → api-B1yHQYs-.js} +6 -6
  3. package/dist/assets/app-presenter-provider-uP9waOzv.js +108 -0
  4. package/dist/assets/appearance-settings-page-C9Zq3hP9.js +1 -0
  5. package/dist/assets/{book-open-BQ29ZmDE.js → book-open-oSmyVXea.js} +1 -1
  6. package/dist/assets/{card-CtKueklV.js → card-CGvJZTZp.js} +1 -1
  7. package/dist/assets/{channels-list-page-C8y3rqYt.js → channels-list-page-5BTxpQHw.js} +2 -2
  8. package/dist/assets/chat-page-jTvli8Jp.js +1 -0
  9. package/dist/assets/config-split-page-BgBsUmp1.js +1 -0
  10. package/dist/assets/confirm-dialog-h849QRWO.js +5 -0
  11. package/dist/assets/desktop-update-config-BGGHxClR.js +1 -0
  12. package/dist/assets/{dist-rYHnBo8t.js → dist-CxUhurq9.js} +1 -1
  13. package/dist/assets/doc-browser-0eL8AX42.js +1 -0
  14. package/dist/assets/{doc-browser-D8ng4EUc.js → doc-browser-DEqE9lsh.js} +1 -1
  15. package/dist/assets/{doc-browser-context-CPHNzQ8m.js → doc-browser-context-DkzFIUoK.js} +1 -1
  16. package/dist/assets/{eye-CiJz3ODY.js → eye-gOWQGt8N.js} +1 -1
  17. package/dist/assets/{form-actions-BnZVzLZf.js → form-actions-CgjYZUzM.js} +1 -1
  18. package/dist/assets/index-DSAUWCfl.js +2 -0
  19. package/dist/assets/index-G7cBryJ4.css +1 -0
  20. package/dist/assets/loader-circle-BAuycGf6.js +1 -0
  21. package/dist/assets/{logo-badge-BXI-Kdhd.js → logo-badge-BNcsDsUM.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-CxNe-Q9Q.js → mcp-marketplace-page-BX39MtPa.js} +3 -3
  23. package/dist/assets/mcp-marketplace-page-CkDtOEA4.js +1 -0
  24. package/dist/assets/{model-config-page-CR_mgEVp.js → model-config-page-7MIXg2fQ.js} +1 -1
  25. package/dist/assets/{navigation-link-C1cvB7y4.js → navigation-link-B3iFOdxx.js} +1 -1
  26. package/dist/assets/plus-yvhFgfal.js +1 -0
  27. package/dist/assets/{popover-ZoDwVwwd.js → popover-Cpep_rJe.js} +1 -1
  28. package/dist/assets/{provider-models-CXUgDxGR.js → provider-models-CUrptZ-o.js} +1 -1
  29. package/dist/assets/provider-scoped-model-input-CHmOzz7x.js +1 -0
  30. package/dist/assets/providers-config-page-A6J7Y0II.js +1 -0
  31. package/dist/assets/react-BCJGCjrP.js +8 -0
  32. package/dist/assets/{refresh-cw-DKewcdAl.js → refresh-cw-CkKwPztT.js} +1 -1
  33. package/dist/assets/remote-FD-ZfSIn.js +1 -0
  34. package/dist/assets/rotate-cw-DrJMfHhp.js +1 -0
  35. package/dist/assets/runtime-config-page-DwjO-Vs6.js +1 -0
  36. package/dist/assets/{save-BrckuAXY.js → save-BINMWate.js} +1 -1
  37. package/dist/assets/{search-BhXQB2TO.js → search-7M38azRp.js} +1 -1
  38. package/dist/assets/{search-config-page-DFOQ6SDJ.js → search-config-page-T6tMhXbP.js} +1 -1
  39. package/dist/assets/{secrets-config-page-BqqeClqE.js → secrets-config-page-2mVejXf2.js} +2 -2
  40. package/dist/assets/security-config-DD5RrjDe.js +1 -0
  41. package/dist/assets/{select-CVzGGT38.js → select-BvidP5Oa.js} +1 -1
  42. package/dist/assets/{settings-2-BP6L2enG.js → settings-2-DqZstREV.js} +1 -1
  43. package/dist/assets/skeleton-BEQW2Dva.js +1 -0
  44. package/dist/assets/{tabs-B-QWjKCL.js → tabs-BeLxVmkt.js} +1 -1
  45. package/dist/assets/{tag-chip-C88mxjwx.js → tag-chip-BoDaLnWt.js} +1 -1
  46. package/dist/assets/{trash-2-ck3t33I3.js → trash-2-CYUskMMN.js} +1 -1
  47. package/dist/assets/{use-config-BAr5bH2c.js → use-config-CxKRXXCl.js} +1 -1
  48. package/dist/assets/use-confirm-dialog-HASTvOP2.js +1 -0
  49. package/dist/assets/{use-viewport-layout-k1isXfNm.js → use-viewport-layout-CJxkjoRz.js} +1 -1
  50. package/dist/assets/x-DdUwjre5.js +1 -0
  51. package/dist/index.html +29 -29
  52. package/package.json +8 -8
  53. package/src/app/components/__tests__/app-notification-runtime.test.tsx +32 -0
  54. package/src/app/components/app-notification-runtime.tsx +15 -0
  55. package/src/app/components/layout/sidebar-items.tsx +23 -9
  56. package/src/app/components/layout/sidebar-rail.styles.ts +0 -2
  57. package/src/app/components/layout/sidebar.tsx +2 -2
  58. package/src/app/configs/app-navigation.config.ts +21 -0
  59. package/src/app/index.tsx +22 -2
  60. package/src/app/presenters/app.presenter.ts +8 -0
  61. package/src/app/styles/design-system.css +12 -0
  62. package/src/features/chat/components/chat-sidebar-list-mode-switch.tsx +2 -2
  63. package/src/features/chat/components/conversation/__tests__/chat-conversation-content.test.tsx +19 -1
  64. package/src/features/chat/components/conversation/__tests__/chat-conversation-header-section.test.tsx +1 -1
  65. package/src/features/chat/components/conversation/chat-conversation-content.tsx +5 -6
  66. package/src/features/chat/components/conversation/chat-conversation-header.tsx +1 -1
  67. package/src/features/chat/components/layout/__tests__/chat-sidebar-read-state.test.tsx +1 -1
  68. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +52 -0
  69. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +11 -17
  70. package/src/features/chat/components/layout/chat-page-shell.tsx +9 -13
  71. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +40 -38
  72. package/src/features/chat/components/layout/chat-sidebar-toolbar.tsx +13 -64
  73. package/src/features/chat/components/layout/chat-sidebar-utility-menu.tsx +1 -4
  74. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +1 -1
  75. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +15 -4
  76. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +7 -7
  77. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +4 -2
  78. package/src/features/chat/features/conversation/hooks/use-session-conversation-input-attachments.ts +1 -0
  79. package/src/features/chat/features/conversation/utils/session-conversation-input-toolbar.utils.ts +1 -0
  80. package/src/features/chat/features/conversation/utils/session-queued-input.utils.ts +1 -0
  81. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +3 -0
  82. package/src/features/chat/features/input/utils/chat-input-bar.utils.ts +0 -1
  83. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +3 -0
  84. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +29 -0
  85. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +4 -1
  86. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +10 -10
  87. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +2 -2
  88. package/src/features/chat/features/session/components/session-header/chat-session-header-menu-item.tsx +1 -1
  89. package/src/features/chat/features/session/components/session-header/chat-session-project-badge.tsx +1 -1
  90. package/src/features/chat/features/session/components/session-header/chat-session-switcher-groups.tsx +1 -1
  91. package/src/features/chat/features/session/components/session-header/chat-session-title-switcher.tsx +1 -1
  92. package/src/features/chat/features/session/utils/__tests__/chat-session-display.utils.test.ts +26 -0
  93. package/src/features/chat/features/session/utils/__tests__/ncp-session-adapter.utils.test.ts +4 -2
  94. package/src/features/chat/features/session/utils/chat-session-display.utils.ts +48 -12
  95. package/src/features/chat/features/session/utils/ncp-session-adapter.utils.ts +13 -4
  96. package/src/features/chat/features/welcome/components/__tests__/chat-conversation-welcome.test.tsx +6 -0
  97. package/src/features/chat/features/welcome/components/__tests__/chat-welcome.test.tsx +10 -7
  98. package/src/features/chat/features/welcome/components/chat-welcome-agent-picker.tsx +1 -1
  99. package/src/features/chat/features/welcome/components/chat-welcome-project-picker.tsx +3 -14
  100. package/src/features/chat/features/welcome/components/chat-welcome-prompt-suggestions.tsx +37 -0
  101. package/src/features/chat/features/welcome/components/chat-welcome-session-type-picker.tsx +1 -1
  102. package/src/features/chat/features/welcome/components/chat-welcome.tsx +33 -31
  103. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel-content.test.tsx +1 -1
  104. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-panel.test.tsx +1 -1
  105. package/src/features/chat/index.ts +1 -0
  106. package/src/features/chat/managers/__tests__/chat-completion-notification.manager.test.ts +177 -0
  107. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +15 -3
  108. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +3 -10
  109. package/src/features/chat/managers/chat-completion-notification.manager.ts +146 -0
  110. package/src/features/chat/managers/chat-session-list.manager.ts +3 -23
  111. package/src/features/chat/managers/chat-thread.manager.ts +2 -5
  112. package/src/features/chat/pages/__tests__/ncp-chat-page.test.tsx +32 -11
  113. package/src/features/chat/pages/ncp-chat-page.tsx +9 -1
  114. package/src/features/chat/types/chat-input-bar.types.ts +1 -0
  115. package/src/features/cron/components/cron-task-discovery.tsx +1 -1
  116. package/src/features/inbox/components/inbox-delivery-content.test.tsx +57 -0
  117. package/src/features/inbox/components/inbox-delivery-content.tsx +118 -0
  118. package/src/features/inbox/components/inbox-reader-dialog.test.tsx +77 -0
  119. package/src/features/inbox/components/inbox-reader-dialog.tsx +224 -0
  120. package/src/features/inbox/components/inbox-runtime.tsx +14 -0
  121. package/src/features/inbox/hooks/use-inbox-deliveries.ts +14 -0
  122. package/src/features/inbox/index.ts +7 -0
  123. package/src/features/inbox/managers/inbox.manager.test.ts +86 -0
  124. package/src/features/inbox/managers/inbox.manager.ts +178 -0
  125. package/src/features/inbox/pages/inbox-page.test.ts +28 -0
  126. package/src/features/inbox/pages/inbox-page.tsx +385 -0
  127. package/src/features/inbox/stores/inbox.store.ts +23 -0
  128. package/src/features/marketplace/components/marketplace-page-parts.tsx +1 -1
  129. package/src/features/notifications/components/__tests__/app-notification-toast.test.tsx +51 -0
  130. package/src/features/notifications/components/app-notification-toast.tsx +80 -0
  131. package/src/features/notifications/index.ts +8 -0
  132. package/src/features/notifications/managers/__tests__/app-notification.manager.test.ts +75 -0
  133. package/src/features/notifications/managers/app-notification.manager.ts +41 -0
  134. package/src/features/panel-apps/components/panel-app-list-item.tsx +1 -1
  135. package/src/features/panel-apps/components/panel-app-toolbar.tsx +2 -2
  136. package/src/features/panel-apps/components/panel-apps-list.tsx +4 -4
  137. package/src/features/service-apps/components/service-apps-panel.tsx +1 -1
  138. package/src/features/settings/components/config/provider-auth-section.tsx +90 -59
  139. package/src/features/settings/components/config/provider-form.tsx +10 -17
  140. package/src/features/settings/components/config/provider-status-badge.tsx +4 -2
  141. package/src/features/settings/pages/__tests__/providers-config-page.test.tsx +12 -0
  142. package/src/features/settings/pages/providers-config-page.tsx +2 -1
  143. package/src/platforms/mobile/components/__tests__/mobile-app-shell.test.tsx +13 -7
  144. package/src/platforms/mobile/components/__tests__/mobile-bottom-nav.test.tsx +18 -20
  145. package/src/platforms/mobile/components/mobile-bottom-nav.tsx +14 -7
  146. package/src/shared/components/doc-browser/doc-browser-panel-parts.tsx +1 -1
  147. package/src/shared/components/path-picker/server-path-picker-directory-list.tsx +1 -1
  148. package/src/shared/components/path-picker/server-path-picker-locations.tsx +1 -1
  149. package/src/shared/components/path-picker/server-path-picker-navigation-bar.tsx +3 -3
  150. package/src/shared/components/ui/actions/__tests__/icon-action-button.test.tsx +1 -1
  151. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -1
  152. package/src/shared/components/ui/button.tsx +4 -4
  153. package/src/shared/components/ui/dialog.tsx +1 -1
  154. package/src/shared/components/ui/input.tsx +1 -1
  155. package/src/shared/components/ui/scroll-area.tsx +2 -0
  156. package/src/shared/components/ui/select.tsx +1 -1
  157. package/src/shared/components/ui/switch.tsx +1 -1
  158. package/src/shared/components/ui/tabs.tsx +1 -1
  159. package/src/shared/components/ui/textarea.tsx +1 -1
  160. package/src/shared/lib/api/ncp-session.types.ts +1 -0
  161. package/src/shared/lib/api/types.ts +2 -0
  162. package/src/shared/lib/i18n/index.ts +4 -0
  163. package/src/shared/lib/i18n/locales/en-US/chat.json +16 -14
  164. package/src/shared/lib/i18n/locales/en-US/inbox.json +30 -0
  165. package/src/shared/lib/i18n/locales/zh-CN/chat.json +16 -14
  166. package/src/shared/lib/i18n/locales/zh-CN/inbox.json +30 -0
  167. package/src/shared/lib/provider-models/__tests__/anonymous-provider-configuration.test.ts +40 -0
  168. package/src/shared/lib/provider-models/index.ts +4 -3
  169. package/dist/assets/app-presenter-provider-s-FrOinZ.js +0 -106
  170. package/dist/assets/appearance-settings-page-dMRBD7ni.js +0 -1
  171. package/dist/assets/chat-page-MXBltS3G.js +0 -1
  172. package/dist/assets/config-split-page-uC6d_r9c.js +0 -1
  173. package/dist/assets/confirm-dialog-C1pbqHZX.js +0 -5
  174. package/dist/assets/desktop-update-config-DssP8LCk.js +0 -1
  175. package/dist/assets/doc-browser-AXF1zRsM.js +0 -1
  176. package/dist/assets/index-CocHWEsI.js +0 -2
  177. package/dist/assets/index-DN6KM7WD.css +0 -1
  178. package/dist/assets/loader-circle-AOpK5kkW.js +0 -1
  179. package/dist/assets/mcp-marketplace-page-5ogeBHnl.js +0 -1
  180. package/dist/assets/plus-DI37QuHc.js +0 -1
  181. package/dist/assets/provider-scoped-model-input-DIL6Ex_d.js +0 -1
  182. package/dist/assets/providers-config-page-D2NXVctW.js +0 -1
  183. package/dist/assets/react-39CDNZlM.js +0 -8
  184. package/dist/assets/remote-DYOOO1YG.js +0 -1
  185. package/dist/assets/rotate-cw-PmqA8LQT.js +0 -1
  186. package/dist/assets/runtime-config-page-0lb5NyKc.js +0 -1
  187. package/dist/assets/security-config-13AtPIlm.js +0 -1
  188. package/dist/assets/skeleton-OwCNQJ7M.js +0 -1
  189. package/dist/assets/use-confirm-dialog-C4e0nG_4.js +0 -1
  190. package/dist/assets/x-C1ijNWcE.js +0 -1
  191. package/src/features/chat/features/welcome/components/chat-welcome-capability-grid.tsx +0 -59
@@ -0,0 +1,80 @@
1
+ import { Link } from "react-router-dom";
2
+
3
+ export type AppNotificationToastProps = {
4
+ title: string;
5
+ description?: string;
6
+ href?: string;
7
+ iconSrc?: string;
8
+ ariaLabel?: string;
9
+ onDismiss: () => void;
10
+ };
11
+
12
+ const NOTIFICATION_CARD_CLASS =
13
+ "group ml-auto flex min-h-[74px] w-[320px] max-w-[calc(100vw-2rem)] items-center gap-3 rounded-[20px] border border-border/80 bg-background px-[18px] py-3 text-left text-foreground shadow-[0_8px_18px_rgba(0,0,0,0.12),0_2px_5px_rgba(0,0,0,0.06)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background";
14
+
15
+ function AppNotificationContent({
16
+ title,
17
+ description,
18
+ iconSrc,
19
+ }: Pick<AppNotificationToastProps, "title" | "description" | "iconSrc">) {
20
+ return (
21
+ <>
22
+ <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-border/80 bg-background p-[3px]">
23
+ <img
24
+ aria-hidden="true"
25
+ alt=""
26
+ src={iconSrc ?? "/logo.svg"}
27
+ className="h-full w-full object-contain"
28
+ />
29
+ </span>
30
+ <span className="min-w-0 flex-1">
31
+ <span className="block truncate text-[15px] font-semibold leading-5 tracking-[-0.01em]">
32
+ {title}
33
+ </span>
34
+ {description ? (
35
+ <span className="mt-0.5 block truncate text-[14px] leading-5 text-muted-foreground">
36
+ {description}
37
+ </span>
38
+ ) : null}
39
+ </span>
40
+ </>
41
+ );
42
+ }
43
+
44
+ export function AppNotificationToast({
45
+ title,
46
+ description,
47
+ href,
48
+ iconSrc,
49
+ ariaLabel,
50
+ onDismiss,
51
+ }: AppNotificationToastProps) {
52
+ const accessibleLabel = ariaLabel ?? [title, description].filter(Boolean).join(": ");
53
+
54
+ if (href) {
55
+ return (
56
+ <Link
57
+ to={href}
58
+ aria-label={accessibleLabel}
59
+ className={NOTIFICATION_CARD_CLASS}
60
+ onClick={onDismiss}
61
+ >
62
+ <AppNotificationContent
63
+ title={title}
64
+ description={description}
65
+ iconSrc={iconSrc}
66
+ />
67
+ </Link>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div role="status" aria-label={accessibleLabel} className={NOTIFICATION_CARD_CLASS}>
73
+ <AppNotificationContent
74
+ title={title}
75
+ description={description}
76
+ iconSrc={iconSrc}
77
+ />
78
+ </div>
79
+ );
80
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ AppNotificationToast,
3
+ type AppNotificationToastProps,
4
+ } from "./components/app-notification-toast";
5
+ export {
6
+ AppNotificationManager,
7
+ type AppNotification,
8
+ } from "./managers/app-notification.manager";
@@ -0,0 +1,75 @@
1
+ import { isValidElement } from "react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ AppNotificationManager,
5
+ AppNotificationToast,
6
+ type AppNotificationToastProps,
7
+ } from "@/features/notifications";
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ custom: vi.fn(),
11
+ dismiss: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("sonner", () => ({
15
+ toast: {
16
+ custom: mocks.custom,
17
+ dismiss: mocks.dismiss,
18
+ },
19
+ }));
20
+
21
+ describe("AppNotificationManager", () => {
22
+ beforeEach(() => {
23
+ mocks.custom.mockReset();
24
+ mocks.dismiss.mockReset();
25
+ mocks.custom.mockReturnValue("toast-result");
26
+ });
27
+
28
+ it("uses the shared custom notification surface and stable lifecycle defaults", () => {
29
+ const manager = new AppNotificationManager();
30
+ const result = manager.show({
31
+ id: "reply:message-1",
32
+ title: "Research complete",
33
+ description: "The findings are ready.",
34
+ href: "/chat/session-1",
35
+ });
36
+
37
+ expect(result).toBe("toast-result");
38
+ expect(mocks.custom).toHaveBeenCalledWith(expect.any(Function), {
39
+ id: "reply:message-1",
40
+ duration: 8_000,
41
+ unstyled: true,
42
+ });
43
+
44
+ const renderer = mocks.custom.mock.calls[0]?.[0] as (
45
+ id: string | number,
46
+ ) => unknown;
47
+ const element = renderer("toast-rendered");
48
+ expect(isValidElement<AppNotificationToastProps>(element)).toBe(true);
49
+ if (!isValidElement<AppNotificationToastProps>(element)) {
50
+ throw new Error("Expected AppNotificationToast element");
51
+ }
52
+ expect(element.type).toBe(AppNotificationToast);
53
+ expect(element.props).toEqual(expect.objectContaining({
54
+ title: "Research complete",
55
+ description: "The findings are ready.",
56
+ href: "/chat/session-1",
57
+ }));
58
+
59
+ element.props.onDismiss();
60
+ expect(mocks.dismiss).toHaveBeenCalledWith("toast-rendered");
61
+ });
62
+
63
+ it("supports a caller-owned duration and explicit dismiss", () => {
64
+ const manager = new AppNotificationManager();
65
+ manager.show({ title: "Saved", durationMs: 2_500 });
66
+ manager.dismiss("saved-toast");
67
+
68
+ expect(mocks.custom).toHaveBeenCalledWith(expect.any(Function), {
69
+ id: undefined,
70
+ duration: 2_500,
71
+ unstyled: true,
72
+ });
73
+ expect(mocks.dismiss).toHaveBeenCalledWith("saved-toast");
74
+ });
75
+ });
@@ -0,0 +1,41 @@
1
+ import { createElement } from "react";
2
+ import { toast } from "sonner";
3
+ import { AppNotificationToast } from "@/features/notifications/components/app-notification-toast";
4
+
5
+ export type AppNotification = {
6
+ id?: string | number;
7
+ title: string;
8
+ description?: string;
9
+ href?: string;
10
+ iconSrc?: string;
11
+ ariaLabel?: string;
12
+ durationMs?: number;
13
+ };
14
+
15
+ const DEFAULT_NOTIFICATION_DURATION_MS = 8_000;
16
+
17
+ export class AppNotificationManager {
18
+ show = (notification: AppNotification): string | number =>
19
+ toast.custom(
20
+ (toastId) =>
21
+ createElement(AppNotificationToast, {
22
+ title: notification.title,
23
+ description: notification.description,
24
+ href: notification.href,
25
+ iconSrc: notification.iconSrc,
26
+ ariaLabel: notification.ariaLabel,
27
+ onDismiss: () => {
28
+ toast.dismiss(toastId);
29
+ },
30
+ }),
31
+ {
32
+ id: notification.id,
33
+ duration: notification.durationMs ?? DEFAULT_NOTIFICATION_DURATION_MS,
34
+ unstyled: true,
35
+ },
36
+ );
37
+
38
+ dismiss = (id: string | number): void => {
39
+ toast.dismiss(id);
40
+ };
41
+ }
@@ -118,7 +118,7 @@ function PanelAppMenuItem({
118
118
  type="button"
119
119
  className={cn(
120
120
  'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
121
- destructive ? 'text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
121
+ destructive ? 'text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:bg-[var(--interaction-hover)] hover:text-accent-foreground',
122
122
  )}
123
123
  disabled={disabled}
124
124
  onClick={onClick}
@@ -17,7 +17,7 @@ export function PanelAppToolbar({
17
17
  <button
18
18
  type="button"
19
19
  onClick={onOpenApps}
20
- className="inline-flex shrink-0 items-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
20
+ className="inline-flex shrink-0 items-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
21
21
  title={t('appsTitle')}
22
22
  aria-label={t('appsTitle')}
23
23
  >
@@ -32,7 +32,7 @@ export function PanelAppToolbar({
32
32
  <button
33
33
  type="button"
34
34
  onClick={onRefresh}
35
- className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
35
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
36
36
  title={t('panelAppsRefreshCurrent')}
37
37
  aria-label={t('panelAppsRefreshCurrent')}
38
38
  >
@@ -104,7 +104,7 @@ export function PanelAppsList({
104
104
  <TooltipProvider delayDuration={250}>
105
105
  <Tooltip>
106
106
  <TooltipTrigger asChild>
107
- <button type="button" className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:bg-accent hover:text-accent-foreground" aria-label={t('panelAppsTitle')}><HelpCircle className="h-3.5 w-3.5" /></button>
107
+ <button type="button" className="rounded p-0.5 text-muted-foreground/70 transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground" aria-label={t('panelAppsTitle')}><HelpCircle className="h-3.5 w-3.5" /></button>
108
108
  </TooltipTrigger>
109
109
  <TooltipContent side="bottom" className="max-w-[320px] break-all font-mono text-xs">{panelApps.data.panelsPath}</TooltipContent>
110
110
  </Tooltip>
@@ -114,7 +114,7 @@ export function PanelAppsList({
114
114
  <button
115
115
  type="button"
116
116
  onClick={() => void panelApps.refetch()}
117
- className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
117
+ className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
118
118
  title={t('panelAppsRefresh')}
119
119
  aria-label={t('panelAppsRefresh')}
120
120
  >
@@ -204,7 +204,7 @@ function PanelAppsEmptyGuide({
204
204
  <button
205
205
  type="button"
206
206
  onClick={onRefresh}
207
- className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
207
+ className="mt-4 inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
208
208
  >
209
209
  <RefreshCw className="h-3.5 w-3.5" />
210
210
  {t('panelAppsRefresh')}
@@ -245,7 +245,7 @@ function PanelAppsEmptyGuideStep({
245
245
  <button
246
246
  type="button"
247
247
  onClick={onAction}
248
- className="flex w-full gap-2 rounded-md bg-muted/60 px-3 py-2.5 text-left transition-colors hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
248
+ className="flex w-full gap-2 rounded-md bg-muted/60 px-3 py-2.5 text-left transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
249
249
  >
250
250
  {content}
251
251
  </button>
@@ -463,7 +463,7 @@ function ServiceAppMenuItem({
463
463
  type="button"
464
464
  className={cn(
465
465
  'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
466
- destructive ? 'text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
466
+ destructive ? 'text-destructive hover:bg-destructive/10' : 'text-muted-foreground hover:bg-[var(--interaction-hover)] hover:text-accent-foreground',
467
467
  )}
468
468
  disabled={disabled}
469
469
  onClick={onClick}
@@ -1,4 +1,5 @@
1
1
  import { Button } from '@/shared/components/ui/button';
2
+ import { MaskedInput } from '@/shared/components/common/masked-input';
2
3
  import { Label } from '@/shared/components/ui/label';
3
4
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
4
5
  import { t } from '@/shared/lib/i18n';
@@ -6,6 +7,10 @@ import type { ProviderTemplateView } from '@/shared/lib/api';
6
7
  import { ProviderPillSelector } from './provider-pill-selector';
7
8
 
8
9
  type ProviderAuthSectionProps = {
10
+ apiKey: string;
11
+ apiKeyRequired: boolean;
12
+ apiKeySet: boolean;
13
+ apiKeyPlaceholder: string;
9
14
  providerAuth: ProviderTemplateView['auth'];
10
15
  providerAuthNote: string;
11
16
  providerAuthMethodOptions: Array<{ value: string; label: string }>;
@@ -20,10 +25,15 @@ type ProviderAuthSectionProps = {
20
25
  importPending: boolean;
21
26
  authSessionId: string | null;
22
27
  authStatusMessage: string;
28
+ onApiKeyChange: (value: string) => void;
23
29
  };
24
30
 
25
31
  export function ProviderAuthSection(props: ProviderAuthSectionProps) {
26
32
  const {
33
+ apiKey,
34
+ apiKeyRequired,
35
+ apiKeySet,
36
+ apiKeyPlaceholder,
27
37
  providerAuth,
28
38
  providerAuthNote,
29
39
  providerAuthMethodOptions,
@@ -37,75 +47,96 @@ export function ProviderAuthSection(props: ProviderAuthSectionProps) {
37
47
  startPending,
38
48
  importPending,
39
49
  authSessionId,
40
- authStatusMessage
50
+ authStatusMessage,
51
+ onApiKeyChange
41
52
  } = props;
42
53
 
43
- if (providerAuth?.kind !== 'device_code') return null;
54
+ const apiKeyField = apiKeyRequired ? (
55
+ <div className='space-y-2'>
56
+ <Label htmlFor='apiKey' className='text-sm font-medium text-foreground'>
57
+ {t('apiKey')}
58
+ </Label>
59
+ <MaskedInput
60
+ id='apiKey'
61
+ value={apiKey}
62
+ isSet={apiKeySet}
63
+ onChange={(event) => onApiKeyChange(event.target.value)}
64
+ placeholder={apiKeyPlaceholder}
65
+ className='rounded-xl'
66
+ />
67
+ <p className='text-xs text-muted-foreground'>{t('leaveBlankToKeepUnchanged')}</p>
68
+ </div>
69
+ ) : null;
70
+
71
+ if (providerAuth?.kind !== 'device_code') return apiKeyField;
44
72
 
45
73
  return (
46
- <div className='space-y-2 rounded-xl bg-muted/45 p-3'>
47
- <Label className='text-sm font-medium text-foreground'>
48
- {providerAuth.displayName || t('providerAuthSectionTitle')}
49
- </Label>
50
- {providerAuthNote ? <p className='text-xs text-muted-foreground'>{providerAuthNote}</p> : null}
51
- {providerAuthMethodsCount > 1 ? (
52
- <div className='space-y-2'>
53
- <Label className='text-xs font-medium text-foreground'>{t('providerAuthMethodLabel')}</Label>
54
- {shouldUseAuthMethodPills ? (
55
- <ProviderPillSelector
56
- value={resolvedAuthMethodId}
57
- onChange={onAuthMethodChange}
58
- options={providerAuthMethodOptions}
59
- />
60
- ) : (
61
- <Select value={resolvedAuthMethodId} onValueChange={onAuthMethodChange}>
62
- <SelectTrigger className='h-8 rounded-lg'>
63
- <SelectValue placeholder={t('providerAuthMethodPlaceholder')} />
64
- </SelectTrigger>
65
- <SelectContent>
66
- {providerAuthMethodOptions.map((method) => (
67
- <SelectItem key={method.value} value={method.value}>
68
- {method.label}
69
- </SelectItem>
70
- ))}
71
- </SelectContent>
72
- </Select>
73
- )}
74
- {selectedAuthMethodHint ? <p className='text-xs text-muted-foreground'>{selectedAuthMethodHint}</p> : null}
75
- </div>
76
- ) : null}
77
- <div className='flex flex-wrap items-center gap-2'>
78
- <Button
79
- type='button'
80
- variant='outline'
81
- size='sm'
82
- onClick={onStartProviderAuth}
83
- disabled={startPending || Boolean(authSessionId)}
84
- >
85
- {startPending
86
- ? t('providerAuthStarting')
87
- : authSessionId
88
- ? t('providerAuthAuthorizing')
89
- : t('providerAuthAuthorizeInBrowser')}
90
- </Button>
91
- {providerAuth.supportsCliImport ? (
74
+ <>
75
+ {apiKeyField}
76
+ <div className='space-y-2 rounded-xl bg-muted/45 p-3'>
77
+ <Label className='text-sm font-medium text-foreground'>
78
+ {providerAuth.displayName || t('providerAuthSectionTitle')}
79
+ </Label>
80
+ {providerAuthNote ? <p className='text-xs text-muted-foreground'>{providerAuthNote}</p> : null}
81
+ {providerAuthMethodsCount > 1 ? (
82
+ <div className='space-y-2'>
83
+ <Label className='text-xs font-medium text-foreground'>{t('providerAuthMethodLabel')}</Label>
84
+ {shouldUseAuthMethodPills ? (
85
+ <ProviderPillSelector
86
+ value={resolvedAuthMethodId}
87
+ onChange={onAuthMethodChange}
88
+ options={providerAuthMethodOptions}
89
+ />
90
+ ) : (
91
+ <Select value={resolvedAuthMethodId} onValueChange={onAuthMethodChange}>
92
+ <SelectTrigger className='h-8 rounded-lg'>
93
+ <SelectValue placeholder={t('providerAuthMethodPlaceholder')} />
94
+ </SelectTrigger>
95
+ <SelectContent>
96
+ {providerAuthMethodOptions.map((method) => (
97
+ <SelectItem key={method.value} value={method.value}>
98
+ {method.label}
99
+ </SelectItem>
100
+ ))}
101
+ </SelectContent>
102
+ </Select>
103
+ )}
104
+ {selectedAuthMethodHint ? <p className='text-xs text-muted-foreground'>{selectedAuthMethodHint}</p> : null}
105
+ </div>
106
+ ) : null}
107
+ <div className='flex flex-wrap items-center gap-2'>
92
108
  <Button
93
109
  type='button'
94
110
  variant='outline'
95
111
  size='sm'
96
- onClick={onImportProviderAuthFromCli}
97
- disabled={importPending}
112
+ onClick={onStartProviderAuth}
113
+ disabled={startPending || Boolean(authSessionId)}
98
114
  >
99
- {importPending ? t('providerAuthImporting') : t('providerAuthImportFromCli')}
115
+ {startPending
116
+ ? t('providerAuthStarting')
117
+ : authSessionId
118
+ ? t('providerAuthAuthorizing')
119
+ : t('providerAuthAuthorizeInBrowser')}
100
120
  </Button>
101
- ) : null}
102
- {authSessionId ? (
103
- <span className='text-xs text-muted-foreground'>
104
- {t('providerAuthSessionLabel')}: {authSessionId.slice(0, 8)}…
105
- </span>
106
- ) : null}
121
+ {providerAuth.supportsCliImport ? (
122
+ <Button
123
+ type='button'
124
+ variant='outline'
125
+ size='sm'
126
+ onClick={onImportProviderAuthFromCli}
127
+ disabled={importPending}
128
+ >
129
+ {importPending ? t('providerAuthImporting') : t('providerAuthImportFromCli')}
130
+ </Button>
131
+ ) : null}
132
+ {authSessionId ? (
133
+ <span className='text-xs text-muted-foreground'>
134
+ {t('providerAuthSessionLabel')}: {authSessionId.slice(0, 8)}…
135
+ </span>
136
+ ) : null}
137
+ </div>
138
+ {authStatusMessage ? <p className='text-xs text-muted-foreground'>{authStatusMessage}</p> : null}
107
139
  </div>
108
- {authStatusMessage ? <p className='text-xs text-muted-foreground'>{authStatusMessage}</p> : null}
109
- </div>
140
+ </>
110
141
  );
111
142
  }
@@ -9,7 +9,6 @@ import {
9
9
  useTestProviderConnection,
10
10
  useUpdateProvider
11
11
  } from '@/shared/hooks/use-config';
12
- import { MaskedInput } from '@/shared/components/common/masked-input';
13
12
  import { Button } from '@/shared/components/ui/button';
14
13
  import { Input } from '@/shared/components/ui/input';
15
14
  import { Label } from '@/shared/components/ui/label';
@@ -445,7 +444,11 @@ function ProviderFormDetailPane(props: ProviderFormDetailPaneProps) {
445
444
  >
446
445
  <Trash2 className='h-4 w-4' />
447
446
  </button>
448
- <ProviderStatusBadge enabled={context.currentEnabled} apiKeySet={context.resolvedProviderConfig.apiKeySet} />
447
+ <ProviderStatusBadge
448
+ enabled={context.currentEnabled}
449
+ apiKeyRequired={context.resolvedProviderConfig.apiKeyRequired}
450
+ apiKeySet={context.resolvedProviderConfig.apiKeySet}
451
+ />
449
452
  </div>
450
453
  </div>
451
454
  </ConfigSplitPaneHeader>
@@ -467,22 +470,11 @@ function ProviderFormDetailPane(props: ProviderFormDetailPaneProps) {
467
470
  <p className='text-xs text-muted-foreground'>{t('providerDisplayNameHelpShort')}</p>
468
471
  </div>
469
472
 
470
- <div className='space-y-2'>
471
- <Label htmlFor='apiKey' className='text-sm font-medium text-foreground'>
472
- {t('apiKey')}
473
- </Label>
474
- <MaskedInput
475
- id='apiKey'
476
- value={apiKey}
477
- isSet={context.resolvedProviderConfig.apiKeySet}
478
- onChange={(e) => onApiKeyChange(e.target.value)}
479
- placeholder={context.apiKeyHint?.placeholder ?? t('enterApiKey')}
480
- className='rounded-xl'
481
- />
482
- <p className='text-xs text-muted-foreground'>{t('leaveBlankToKeepUnchanged')}</p>
483
- </div>
484
-
485
473
  <ProviderAuthSection
474
+ apiKey={apiKey}
475
+ apiKeyRequired={context.resolvedProviderConfig.apiKeyRequired !== false}
476
+ apiKeySet={context.resolvedProviderConfig.apiKeySet}
477
+ apiKeyPlaceholder={context.apiKeyHint?.placeholder ?? t('enterApiKey')}
486
478
  providerAuth={context.providerAuth}
487
479
  providerAuthNote={context.providerAuth?.note?.[language] || context.providerAuth?.note?.en || context.providerAuth?.displayName || ''}
488
480
  providerAuthMethodOptions={context.providerAuthMethodOptions}
@@ -497,6 +489,7 @@ function ProviderFormDetailPane(props: ProviderFormDetailPaneProps) {
497
489
  importPending={importPending}
498
490
  authSessionId={authSessionId}
499
491
  authStatusMessage={authStatusMessage}
492
+ onApiKeyChange={onApiKeyChange}
500
493
  />
501
494
 
502
495
  <div className='space-y-2'>
@@ -3,13 +3,15 @@ import { t } from '@/shared/lib/i18n';
3
3
 
4
4
  type ProviderStatusBadgeProps = {
5
5
  enabled: boolean;
6
+ apiKeyRequired?: boolean;
6
7
  apiKeySet: boolean;
7
8
  className?: string;
8
9
  };
9
10
 
10
- export function ProviderStatusBadge({ apiKeySet, className, enabled }: ProviderStatusBadgeProps) {
11
+ export function ProviderStatusBadge({ apiKeyRequired = true, apiKeySet, className, enabled }: ProviderStatusBadgeProps) {
11
12
  if (!enabled) {
12
13
  return <StatusDot status="inactive" label={t('disabled')} className={className} />;
13
14
  }
14
- return <StatusDot status={apiKeySet ? 'ready' : 'setup'} label={apiKeySet ? t('statusReady') : t('statusSetup')} className={className} />;
15
+ const isReady = !apiKeyRequired || apiKeySet;
16
+ return <StatusDot status={isReady ? 'ready' : 'setup'} label={isReady ? t('statusReady') : t('statusSetup')} className={className} />;
15
17
  }
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
11
11
  providers: {
12
12
  openai: { enabled: true, apiKeySet: true },
13
13
  anthropic: { enabled: true, apiKeySet: true },
14
+ opencode: { enabled: true, apiKeyRequired: false, apiKeySet: false },
14
15
  nextclaw: { enabled: true, apiKeySet: true },
15
16
  },
16
17
  },
@@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({
22
23
  { name: 'nextclaw', displayName: 'NextClaw Builtin', defaultApiBase: 'https://ai-gateway-api.nextclaw.io/v1' },
23
24
  { name: 'openai', displayName: 'OpenAI', defaultApiBase: 'https://api.openai.com/v1' },
24
25
  { name: 'anthropic', displayName: 'Anthropic', defaultApiBase: 'https://api.anthropic.com/v1' },
26
+ { name: 'opencode', displayName: 'OpenCode Zen Free Trial', defaultApiBase: 'https://opencode.ai/zen/v1' },
25
27
  ],
26
28
  },
27
29
  },
@@ -46,6 +48,7 @@ vi.mock('@/shared/hooks/use-config', () => ({
46
48
  isBuiltInType: providerId === 'nextclaw',
47
49
  isCustom: false,
48
50
  enabled: provider.enabled,
51
+ apiKeyRequired: 'apiKeyRequired' in provider ? provider.apiKeyRequired : undefined,
49
52
  apiKeySet: provider.apiKeySet,
50
53
  },
51
54
  ]),
@@ -108,4 +111,13 @@ describe('ProvidersConfigPage', () => {
108
111
  expect.stringContaining('NextClaw Builtin'),
109
112
  ]);
110
113
  });
114
+
115
+ it('shows an enabled provider without a required API key as ready', () => {
116
+ const { container } = render(<ProvidersConfigPage />);
117
+ const opencodeCard = Array.from(container.querySelectorAll('[role="button"]'))
118
+ .find((element) => element.textContent?.includes('OpenCode Zen Free Trial'));
119
+
120
+ expect(opencodeCard?.textContent).toContain('Ready');
121
+ expect(opencodeCard?.textContent).not.toContain('Setup');
122
+ });
111
123
  });
@@ -17,6 +17,7 @@ import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog';
17
17
  import { StatusDot } from '@/shared/components/status/status-dot';
18
18
  import { t } from '@/shared/lib/i18n';
19
19
  import { cn } from '@/shared/lib/utils';
20
+ import { isProviderConfigured } from '@/shared/lib/provider-models';
20
21
  import { ProviderForm } from '@/features/settings/components/config/provider-form';
21
22
  import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
22
23
  import {
@@ -232,7 +233,7 @@ export function ProvidersConfigPage() {
232
233
  {filteredProviders.map((provider) => {
233
234
  const template = provider.providerType ? templateByType.get(provider.providerType) : null;
234
235
  const isEnabled = provider.enabled !== false;
235
- const isReady = Boolean(isEnabled && provider.apiKeySet);
236
+ const isReady = isProviderConfigured(provider);
236
237
  const providerLabel = provider.displayName?.trim() || template?.displayName || provider.providerId;
237
238
  const description =
238
239
  formatBasePreview(provider.apiBase || template?.defaultApiBase || '') ||
@@ -1,4 +1,5 @@
1
1
  import { render, screen } from "@testing-library/react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
3
  import { MemoryRouter } from "react-router-dom";
3
4
  import { describe, expect, it, vi } from "vitest";
4
5
  import { I18nProvider } from "@/app/components/i18n-provider";
@@ -9,14 +10,19 @@ vi.mock("@/shared/components/doc-browser", () => ({
9
10
  }));
10
11
 
11
12
  function renderShell(pathname: string) {
13
+ const queryClient = new QueryClient({
14
+ defaultOptions: { queries: { retry: false } },
15
+ });
12
16
  const view = render(
13
- <I18nProvider>
14
- <MemoryRouter initialEntries={[pathname]}>
15
- <MobileAppShell pathname={pathname} isDocBrowserOpen={false}>
16
- <div>Content</div>
17
- </MobileAppShell>
18
- </MemoryRouter>
19
- </I18nProvider>,
17
+ <QueryClientProvider client={queryClient}>
18
+ <I18nProvider>
19
+ <MemoryRouter initialEntries={[pathname]}>
20
+ <MobileAppShell pathname={pathname} isDocBrowserOpen={false}>
21
+ <div>Content</div>
22
+ </MobileAppShell>
23
+ </MemoryRouter>
24
+ </I18nProvider>
25
+ </QueryClientProvider>,
20
26
  );
21
27
  return {
22
28
  ...view,