@nextclaw/ui 0.20.2 → 0.21.0

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 +35 -0
  2. package/dist/assets/{api-DvpQWjTS.js → api-DNLQPHfQ.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-U0m0ktu3.js → appearance-settings-page-BlAFMOGV.js} +1 -1
  4. package/dist/assets/{book-open-DRofSTWr.js → book-open-BryfPTTa.js} +1 -1
  5. package/dist/assets/{channels-list-page-BURQarD0.js → channels-list-page-Cfbgubyv.js} +2 -2
  6. package/dist/assets/{config-split-page-BrAQYTIl.js → config-split-page-Dd8xG0Jk.js} +1 -1
  7. package/dist/assets/{confirm-dialog-DDJomLnO.js → confirm-dialog-CZIWjnJ6.js} +2 -2
  8. package/dist/assets/{createLucideIcon-DzkNHr5Z.js → createLucideIcon-BxwVe8ZD.js} +1 -1
  9. package/dist/assets/desktop-capabilities-page-C6YxsHeV.js +1 -0
  10. package/dist/assets/desktop-update-config-BVN4QPJn.js +1 -0
  11. package/dist/assets/{dist-NskPB_yW.js → dist-BRloKr3S.js} +1 -1
  12. package/dist/assets/{dist-DXbLo2b1.js → dist-Cd8Nm3BW.js} +1 -1
  13. package/dist/assets/doc-browser-Bzgqmmiu.js +1 -0
  14. package/dist/assets/doc-browser-D0qI0Z_R.js +1 -0
  15. package/dist/assets/{doc-browser-context-CnKV4gJy.js → doc-browser-context-3JGufiMA.js} +1 -1
  16. package/dist/assets/{extensions-DvlFMpAs.js → extensions-Dedd2oDI.js} +1 -1
  17. package/dist/assets/{form-actions-UnPEZBkx.js → form-actions-FWCEof3V.js} +1 -1
  18. package/dist/assets/index-CXkHcESm.css +1 -0
  19. package/dist/assets/index-D25tRg1p.js +109 -0
  20. package/dist/assets/loader-circle-BoyCoFps.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-Cn-9CDh3.js +1 -0
  22. package/dist/assets/mcp-marketplace-page-e9Rmfz-d.js +10 -0
  23. package/dist/assets/model-config-page-C173Ssil.js +1 -0
  24. package/dist/assets/{navigation-link-NjUdG6Vp.js → navigation-link-DTPOvGGL.js} +1 -1
  25. package/dist/assets/plus-D1R4Vipq.js +1 -0
  26. package/dist/assets/privacy-settings-page-CRserBVF.js +1 -0
  27. package/dist/assets/providers-config-page-C8jUtnZb.js +1 -0
  28. package/dist/assets/react-BAzO6LZS.js +8 -0
  29. package/dist/assets/{refresh-cw-CkeninE0.js → refresh-cw-BCj-yjde.js} +1 -1
  30. package/dist/assets/remote-6BHWP5zi.js +1 -0
  31. package/dist/assets/{rotate-cw-DP33PuXU.js → rotate-cw-BPnW4qUl.js} +1 -1
  32. package/dist/assets/runtime-config-page-aeQ1Dryz.js +1 -0
  33. package/dist/assets/{save-Cw2Xz-pB.js → save-KT0MumfJ.js} +1 -1
  34. package/dist/assets/{search-C2CFAVvd.js → search-Cv1Hx_nx.js} +1 -1
  35. package/dist/assets/{search-config-page-dqNT0Swf.js → search-config-page-b283V-fo.js} +1 -1
  36. package/dist/assets/{secrets-config-page-De6J3wVx.js → secrets-config-page-ss7x3BOo.js} +2 -2
  37. package/dist/assets/security-config-HHz62oxH.js +1 -0
  38. package/dist/assets/{select-DSuMhwVP.js → select-BVgD4EpG.js} +1 -1
  39. package/dist/assets/skeleton-ChUmcyys.js +1 -0
  40. package/dist/assets/{tag-chip-DFBLgsGf.js → tag-chip-DZ4ircUB.js} +1 -1
  41. package/dist/assets/x-BWDRfVWL.js +1 -0
  42. package/dist/index.html +21 -21
  43. package/package.json +5 -5
  44. package/src/app/components/layout/sidebar.tsx +12 -1
  45. package/src/app/configs/app-navigation.config.ts +17 -2
  46. package/src/app/hooks/use-feature-controls.ts +11 -0
  47. package/src/app/index.tsx +10 -0
  48. package/src/features/apps/components/app-package-card.tsx +29 -7
  49. package/src/features/apps/components/app-packages-panel.tsx +4 -0
  50. package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
  51. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
  52. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +16 -2
  53. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  54. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -0
  55. package/src/features/chat/components/layout/chat-sidebar.tsx +5 -1
  56. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  57. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  58. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +11 -3
  59. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +12 -5
  60. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  61. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +12 -0
  62. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +3 -0
  63. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +14 -1
  64. package/src/features/chat/features/session/utils/chat-session-deletion.utils.ts +25 -0
  65. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  66. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  67. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  68. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +47 -2
  69. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  70. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  71. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  72. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  73. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  74. package/src/features/chat/managers/__tests__/chat-thread-deletion.manager.test.ts +125 -0
  75. package/src/features/chat/managers/__tests__/chat-thread.manager.test.ts +0 -37
  76. package/src/features/chat/managers/chat-thread.manager.ts +17 -28
  77. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  78. package/src/features/cron/components/cron-config.tsx +20 -46
  79. package/src/features/cron/components/cron-job-row.tsx +1 -1
  80. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  81. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  82. package/src/features/cron/index.ts +2 -0
  83. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.test.tsx +58 -0
  84. package/src/features/desktop-capabilities/components/desktop-authorization-dialog.tsx +161 -0
  85. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.test.ts +10 -0
  86. package/src/features/desktop-capabilities/hooks/use-desktop-capabilities.ts +55 -0
  87. package/src/features/desktop-capabilities/index.ts +1 -0
  88. package/src/features/desktop-capabilities/managers/desktop-capability.manager.ts +25 -0
  89. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.test.tsx +44 -0
  90. package/src/features/desktop-capabilities/pages/desktop-capabilities-page.tsx +171 -0
  91. package/src/features/desktop-capabilities/stores/desktop-authorization.store.ts +21 -0
  92. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  93. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  94. package/src/features/marketplace/components/mcp/__tests__/mcp-connection-dialog.test.tsx +23 -0
  95. package/src/features/marketplace/components/mcp/mcp-connection-dialog.tsx +35 -0
  96. package/src/features/marketplace/components/mcp/mcp-marketplace-page.tsx +20 -2
  97. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  98. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  99. package/src/platforms/mobile/components/mobile-settings-shell.tsx +5 -1
  100. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  101. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  102. package/src/shared/components/ui/__tests__/confirm-dialog.test.tsx +31 -0
  103. package/src/shared/components/ui/confirm-dialog.tsx +11 -1
  104. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  105. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  106. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  107. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  108. package/src/shared/lib/api/index.ts +1 -0
  109. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  110. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  111. package/src/shared/lib/api/types.ts +1 -0
  112. package/src/shared/lib/api/utils/mcp-connection.utils.ts +10 -0
  113. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  114. package/src/shared/lib/i18n/locales/en-US/chat.json +10 -5
  115. package/src/shared/lib/i18n/locales/en-US/core.json +43 -0
  116. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  118. package/src/shared/lib/i18n/locales/en-US/marketplace.json +29 -1
  119. package/src/shared/lib/i18n/locales/zh-CN/chat.json +10 -5
  120. package/src/shared/lib/i18n/locales/zh-CN/core.json +43 -0
  121. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  122. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  123. package/src/shared/lib/i18n/locales/zh-CN/marketplace.json +29 -1
  124. package/src/shared/lib/navigation-history/index.ts +1 -0
  125. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  126. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  127. package/dist/assets/desktop-update-config-BzZFhMv9.js +0 -1
  128. package/dist/assets/doc-browser-DB22tQKU.js +0 -1
  129. package/dist/assets/doc-browser-sJjw9fl5.js +0 -1
  130. package/dist/assets/index-Dc_ZMkmo.css +0 -1
  131. package/dist/assets/index-N1HnesBp.js +0 -109
  132. package/dist/assets/loader-circle-BlPXxgDB.js +0 -1
  133. package/dist/assets/mcp-marketplace-page-BIgFTbMq.js +0 -9
  134. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +0 -1
  135. package/dist/assets/model-config-page-CnSm7kfa.js +0 -1
  136. package/dist/assets/plus-DVXol9Q5.js +0 -1
  137. package/dist/assets/privacy-settings-page-DVn2HU7W.js +0 -1
  138. package/dist/assets/providers-config-page-CzHJS3eg.js +0 -1
  139. package/dist/assets/react-DCVbGroo.js +0 -8
  140. package/dist/assets/remote-B2lsJF1c.js +0 -1
  141. package/dist/assets/runtime-config-page-D69buyGp.js +0 -1
  142. package/dist/assets/security-config-Dcu9C4OL.js +0 -1
  143. package/dist/assets/skeleton-QoWydNLp.js +0 -1
  144. package/dist/assets/x-BXvAzpjb.js +0 -1
@@ -0,0 +1,44 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { DesktopCapabilitiesPage } from "./desktop-capabilities-page";
4
+
5
+ const mocks = vi.hoisted(() => ({ refetch: vi.fn(), revoke: vi.fn() }));
6
+
7
+ vi.mock("../hooks/use-desktop-capabilities", () => ({
8
+ useDesktopCapabilityStatus: () => ({
9
+ data: {
10
+ online: true,
11
+ permissions: { accessibility: "granted", screenCapture: "not_supported" },
12
+ },
13
+ isLoading: false,
14
+ isFetching: false,
15
+ refetch: mocks.refetch,
16
+ }),
17
+ useDesktopCapabilityGrants: () => ({
18
+ data: [
19
+ {
20
+ subject: { type: "agent", id: "main" },
21
+ resource: { type: "desktop.application", target: { applicationId: "wechat" } },
22
+ access: ["ui.read"],
23
+ declarationFingerprint: "fingerprint",
24
+ grantedAt: "2026-08-26T00:00:00.000Z",
25
+ },
26
+ ],
27
+ isLoading: false,
28
+ }),
29
+ useRequestDesktopSystemPermission: () => ({ isPending: false, mutate: vi.fn() }),
30
+ useOpenDesktopSystemSettings: () => ({ isPending: false, mutate: vi.fn() }),
31
+ useRevokeDesktopGrant: () => ({ isPending: false, mutate: mocks.revoke }),
32
+ }));
33
+
34
+ describe("DesktopCapabilitiesPage", () => {
35
+ it("shows system permission and Agent grants with reachable actions", () => {
36
+ render(<DesktopCapabilitiesPage />);
37
+
38
+ expect(screen.getByText("Desktop Access")).toBeTruthy();
39
+ expect(screen.getByText("AI · main")).toBeTruthy();
40
+ expect(screen.getByText("wechat · ui.read")).toBeTruthy();
41
+ fireEvent.click(screen.getByRole("button", { name: "Check again" }));
42
+ expect(mocks.refetch).toHaveBeenCalledOnce();
43
+ });
44
+ });
@@ -0,0 +1,171 @@
1
+ import type { CapabilityGrantView } from "@nextclaw/client-sdk";
2
+ import { Button } from "@/shared/components/ui/button";
3
+ import { SettingsPage } from "@/shared/components/settings/settings-page";
4
+ import {
5
+ SettingRow,
6
+ SettingsGroup,
7
+ SettingsSection,
8
+ } from "@/shared/components/settings/setting-row";
9
+ import { t } from "@/shared/lib/i18n";
10
+ import {
11
+ useDesktopCapabilityGrants,
12
+ useDesktopCapabilityStatus,
13
+ useOpenDesktopSystemSettings,
14
+ useRequestDesktopSystemPermission,
15
+ useRevokeDesktopGrant,
16
+ } from "../hooks/use-desktop-capabilities";
17
+
18
+ export function DesktopCapabilitiesPage() {
19
+ const statusQuery = useDesktopCapabilityStatus();
20
+ const grantsQuery = useDesktopCapabilityGrants();
21
+ const requestPermission = useRequestDesktopSystemPermission();
22
+ const openSettings = useOpenDesktopSystemSettings();
23
+ const revokeGrant = useRevokeDesktopGrant();
24
+ const status = statusQuery.data;
25
+ const busy = requestPermission.isPending || openSettings.isPending;
26
+
27
+ return (
28
+ <SettingsPage
29
+ title={t("desktopCapabilitiesPageTitle")}
30
+ description={t("desktopCapabilitiesPageDescription")}
31
+ >
32
+ <SettingsSection
33
+ title={t("desktopCapabilitiesSystemTitle")}
34
+ description={t("desktopCapabilitiesSystemDescription")}
35
+ >
36
+ <SettingsGroup>
37
+ <SettingRow
38
+ title={t("desktopCapabilitiesHostStatus")}
39
+ description={
40
+ statusQuery.isLoading
41
+ ? t("loading")
42
+ : status?.online
43
+ ? t("desktopCapabilitiesHostOnline")
44
+ : t("desktopCapabilitiesHostOffline")
45
+ }
46
+ control={(
47
+ <Button
48
+ type="button"
49
+ variant="outline"
50
+ size="sm"
51
+ disabled={statusQuery.isFetching}
52
+ onClick={() => void statusQuery.refetch()}
53
+ >
54
+ {t("desktopCapabilitiesRecheck")}
55
+ </Button>
56
+ )}
57
+ />
58
+ <SettingRow
59
+ title={t("desktopCapabilitiesAccessibility")}
60
+ description={formatPermission(status?.permissions.accessibility)}
61
+ control={(
62
+ <div className="flex flex-wrap justify-end gap-2">
63
+ <Button
64
+ type="button"
65
+ variant="outline"
66
+ size="sm"
67
+ disabled={busy}
68
+ onClick={() => openSettings.mutate()}
69
+ >
70
+ {t("desktopCapabilitiesOpenSettings")}
71
+ </Button>
72
+ <Button
73
+ type="button"
74
+ size="sm"
75
+ disabled={busy}
76
+ onClick={() => requestPermission.mutate()}
77
+ >
78
+ {t("desktopCapabilitiesRequestPermission")}
79
+ </Button>
80
+ </div>
81
+ )}
82
+ />
83
+ </SettingsGroup>
84
+ </SettingsSection>
85
+
86
+ <SettingsSection
87
+ title={t("desktopCapabilitiesGrantsTitle")}
88
+ description={t("desktopCapabilitiesGrantsDescription")}
89
+ >
90
+ <SettingsGroup>
91
+ {grantsQuery.isLoading ? (
92
+ <SettingRow title={t("loading")} />
93
+ ) : grantsQuery.data?.length ? (
94
+ grantsQuery.data.map((grant) => (
95
+ <DesktopGrantRow
96
+ key={grantKey(grant)}
97
+ grant={grant}
98
+ disabled={revokeGrant.isPending}
99
+ onRevoke={() => revokeGrant.mutate(grant)}
100
+ />
101
+ ))
102
+ ) : (
103
+ <SettingRow
104
+ title={t("desktopCapabilitiesNoGrants")}
105
+ description={t("desktopCapabilitiesNoGrantsDescription")}
106
+ />
107
+ )}
108
+ </SettingsGroup>
109
+ </SettingsSection>
110
+ </SettingsPage>
111
+ );
112
+ }
113
+
114
+ function DesktopGrantRow({
115
+ disabled,
116
+ grant,
117
+ onRevoke,
118
+ }: {
119
+ disabled: boolean;
120
+ grant: CapabilityGrantView;
121
+ onRevoke: () => void;
122
+ }) {
123
+ const target = readApplicationId(grant.resource.target);
124
+ const subject = grant.subject.type === "agent"
125
+ ? `${t("desktopCapabilitiesAgent")} · ${grant.subject.id}`
126
+ : `${t("desktopCapabilitiesExtension")} · ${grant.subject.id}`;
127
+ return (
128
+ <SettingRow
129
+ title={subject}
130
+ description={`${target} · ${grant.access.join(", ")}`}
131
+ control={(
132
+ <Button
133
+ type="button"
134
+ variant="destructive"
135
+ size="sm"
136
+ disabled={disabled}
137
+ onClick={onRevoke}
138
+ >
139
+ {t("desktopCapabilitiesRevoke")}
140
+ </Button>
141
+ )}
142
+ />
143
+ );
144
+ }
145
+
146
+ function readApplicationId(target: unknown): string {
147
+ if (!target || typeof target !== "object" || Array.isArray(target)) {
148
+ return t("desktopCapabilitiesUnknownApplication");
149
+ }
150
+ const { applicationId } = target as { applicationId?: unknown };
151
+ return typeof applicationId === "string" && applicationId.trim()
152
+ ? applicationId.trim()
153
+ : t("desktopCapabilitiesUnknownApplication");
154
+ }
155
+
156
+ function formatPermission(value: string | undefined): string {
157
+ switch (value) {
158
+ case "granted":
159
+ return t("desktopCapabilitiesPermissionGranted");
160
+ case "not_granted":
161
+ return t("desktopCapabilitiesPermissionNotGranted");
162
+ case "not_supported":
163
+ return t("desktopCapabilitiesPermissionNotSupported");
164
+ default:
165
+ return t("desktopCapabilitiesPermissionUnknown");
166
+ }
167
+ }
168
+
169
+ function grantKey(grant: CapabilityGrantView): string {
170
+ return `${grant.subject.type}:${grant.subject.id}:${JSON.stringify(grant.resource.target)}:${grant.access.join(",")}`;
171
+ }
@@ -0,0 +1,21 @@
1
+ import { create } from "zustand";
2
+ import type { CapabilityGrantRequestView } from "@nextclaw/client-sdk";
3
+
4
+ export type DesktopAuthorizationRequest = {
5
+ applicationId: string;
6
+ request: CapabilityGrantRequestView;
7
+ };
8
+
9
+ type DesktopAuthorizationState = {
10
+ pending: DesktopAuthorizationRequest | null;
11
+ present: (request: DesktopAuthorizationRequest) => void;
12
+ clear: () => void;
13
+ };
14
+
15
+ export const useDesktopAuthorizationStore = create<DesktopAuthorizationState>(
16
+ (set) => ({
17
+ pending: null,
18
+ present: (pending) => set({ pending }),
19
+ clear: () => set({ pending: null }),
20
+ }),
21
+ );
@@ -12,6 +12,7 @@ import { Button } from "@/shared/components/ui/button";
12
12
  import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
13
13
  import { formatDateShort, formatDateTime, t } from "@/shared/lib/i18n";
14
14
  import { cn } from "@/shared/lib/utils";
15
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
15
16
 
16
17
  type InboxFilter = "unread" | "all" | "archived";
17
18
 
@@ -64,6 +65,10 @@ function InboxListPane({
64
65
  filter: InboxFilter;
65
66
  onFilterChange: (filter: InboxFilter) => void;
66
67
  }) {
68
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
69
+ restorationKey: "inbox:list",
70
+ });
71
+ const { onScroll, scrollRef } = scrollRestoration;
67
72
  const filterItems: Array<{ id: InboxFilter; label: string; count: number }> = [
68
73
  {
69
74
  id: "unread",
@@ -100,7 +105,11 @@ function InboxListPane({
100
105
  </button>
101
106
  ))}
102
107
  </div>
103
- <div className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
108
+ <div
109
+ ref={scrollRef}
110
+ onScroll={onScroll}
111
+ className="custom-scrollbar min-h-0 flex-1 overflow-y-auto p-2"
112
+ >
104
113
  {filteredDeliveries.length > 0 ? (
105
114
  <ul className="space-y-0.5">
106
115
  {filteredDeliveries.map((delivery) => (
@@ -166,6 +175,10 @@ function InboxDetailPane({
166
175
  onDelete: () => void;
167
176
  onReadToggle: () => void;
168
177
  }) {
178
+ const scrollRestoration = useScrollRestoration<HTMLDivElement>({
179
+ restorationKey: delivery ? `inbox:delivery:${delivery.id}` : null,
180
+ });
181
+ const { onScroll, scrollRef } = scrollRestoration;
169
182
  if (!delivery) {
170
183
  return <main className="flex min-h-0 flex-col"><InboxEmptyState selection /></main>;
171
184
  }
@@ -204,7 +217,10 @@ function InboxDetailPane({
204
217
  isHtml
205
218
  ? "p-3 sm:p-4"
206
219
  : "custom-scrollbar overflow-y-auto px-5 py-5 sm:px-6 sm:py-6",
207
- )}>
220
+ )}
221
+ ref={scrollRef}
222
+ onScroll={onScroll}
223
+ >
208
224
  <div className={cn("mx-auto max-w-5xl", isHtml && "h-full")}>
209
225
  <InboxDeliveryContent
210
226
  className={isHtml ? "h-full" : undefined}
@@ -30,6 +30,7 @@ import { PageLayout } from "@/app/components/layout/page-layout";
30
30
  import { useEffect, useMemo, useState } from "react";
31
31
  import { useNavigate, useParams } from "react-router-dom";
32
32
  import { useInfiniteScrollLoader } from "@/shared/hooks/use-infinite-scroll-loader";
33
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
33
34
  import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs";
34
35
 
35
36
  const PAGE_SIZE = 20;
@@ -95,6 +96,11 @@ export function MarketplacePage({
95
96
  onLoadMore: () => itemsQuery.fetchNextPage(),
96
97
  watchValue: `${typeFilter}:${scope}:${query}:${sceneParam ?? ""}:${sort}:${itemsQuery.data?.loadedItems ?? 0}:${itemsQuery.data?.loadedPages ?? 0}`,
97
98
  });
99
+ const scrollRestoration = useScrollRestoration({
100
+ restorationKey: `marketplace:${forcedType ?? "all"}`,
101
+ scrollRef: listContainerRef,
102
+ });
103
+ const { onScroll: onScrollPositionSave } = scrollRestoration;
98
104
 
99
105
  useEffect(() => {
100
106
  const container = listContainerRef.current;
@@ -177,6 +183,7 @@ export function MarketplacePage({
177
183
  <section className="flex min-h-0 flex-1 flex-col">
178
184
  <div
179
185
  ref={listContainerRef}
186
+ onScroll={onScrollPositionSave}
180
187
  className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1"
181
188
  aria-busy={listModel.showListSkeleton || itemsQuery.isFetchingNextPage}
182
189
  >
@@ -0,0 +1,23 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { McpConnectionDialog } from "../mcp-connection-dialog";
4
+
5
+ describe("McpConnectionDialog", () => {
6
+ it("requires a successful test before saving a stdio connection", async () => {
7
+ const onTest = vi.fn(async () => ({ name: "local-tools", transport: "stdio" as const, accessible: true, toolCount: 2 }));
8
+ const onSave = vi.fn(async () => undefined);
9
+ render(<McpConnectionDialog open testing={false} saving={false} onOpenChange={vi.fn()} onTest={onTest} onSave={onSave} />);
10
+
11
+ const saveButton = screen.getByRole("button", { name: "Save connection" });
12
+ expect(saveButton.hasAttribute("disabled")).toBe(true);
13
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "local-tools" } });
14
+ fireEvent.change(screen.getByLabelText("Command"), { target: { value: "node" } });
15
+ fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
16
+
17
+ await waitFor(() => expect(onTest).toHaveBeenCalledOnce());
18
+ expect(await screen.findByText("Connected. Found 2 tools.")).not.toBeNull();
19
+ expect(saveButton.hasAttribute("disabled")).toBe(false);
20
+ fireEvent.click(saveButton);
21
+ await waitFor(() => expect(onSave).toHaveBeenCalledOnce());
22
+ });
23
+ });
@@ -0,0 +1,35 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import type { McpConnectionRequest, McpConnectionTestResult } from "@nextclaw/client-sdk";
3
+ import { Button } from "@/shared/components/ui/button";
4
+ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/shared/components/ui/overlays/sheet";
5
+ import { Input } from "@/shared/components/ui/input";
6
+ import { Textarea } from "@/shared/components/ui/textarea";
7
+ import { Switch } from "@/shared/components/ui/switch";
8
+ import { t } from "@/shared/lib/i18n";
9
+
10
+ type Transport = "stdio" | "http" | "sse";
11
+
12
+ export function McpConnectionDialog({ open, testing, saving, onOpenChange, onTest, onSave }: {
13
+ open: boolean; testing: boolean; saving: boolean; onOpenChange: (open: boolean) => void;
14
+ onTest: (request: McpConnectionRequest) => Promise<McpConnectionTestResult>; onSave: (request: McpConnectionRequest) => Promise<void>;
15
+ }) {
16
+ const [transport, setTransport] = useState<Transport>("stdio");
17
+ const [name, setName] = useState(""); const [command, setCommand] = useState(""); const [args, setArgs] = useState("");
18
+ const [cwd, setCwd] = useState(""); const [env, setEnv] = useState(""); const [url, setUrl] = useState("");
19
+ const [headers, setHeaders] = useState(""); const [timeout, setTimeout] = useState("15000"); const [verifyTls, setVerifyTls] = useState(true);
20
+ const [result, setResult] = useState<McpConnectionTestResult | null>(null); const [error, setError] = useState<string | null>(null);
21
+ const request = useMemo(() => buildRequest({ transport, name, command, args, cwd, env, url, headers, timeout, verifyTls }), [args, command, cwd, env, headers, name, timeout, transport, url, verifyTls]);
22
+ const dirty = () => { setResult(null); setError(null); };
23
+ const test = async () => { if (request instanceof Error) return setError(request.message); dirty(); try { const next = await onTest(request); setResult(next); if (!next.accessible) setError(next.error ?? t("mcpConnectionTestFailed")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
24
+ const save = async () => { if (request instanceof Error || !result?.accessible) return; try { await onSave(request); onOpenChange(false); dirty(); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } };
25
+ return <Sheet open={open} onOpenChange={onOpenChange}><SheetContent side="right" closeLabel={t("cancel")} className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[560px]"><SheetHeader className="shrink-0 border-b border-border/60 px-5 pb-5 pt-6 pr-14 sm:px-7 sm:pt-7"><SheetTitle>{t("mcpConnectionTitle")}</SheetTitle><SheetDescription>{t("mcpConnectionDescription")}</SheetDescription></SheetHeader><div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-6 sm:px-7">
26
+ <Field label={t("mcpConnectionTransport")}><select className="flex h-10 w-full rounded-xl border border-border/75 bg-card px-3 text-sm" value={transport} onChange={(e) => { setTransport(e.target.value as Transport); dirty(); }}><option value="stdio">{t("mcpConnectionStdio")}</option><option value="http">HTTP</option><option value="sse">SSE</option></select></Field>
27
+ <Field label={t("mcpConnectionName")}><Input value={name} onChange={(e) => { setName(e.target.value); dirty(); }} placeholder="my-mcp" /></Field>
28
+ {transport === "stdio" ? <><Field label={t("mcpConnectionCommand")}><Input value={command} onChange={(e) => { setCommand(e.target.value); dirty(); }} placeholder="npx" /></Field><Field label={t("mcpConnectionArgs")} hint={t("mcpConnectionArgsHint")}><Input value={args} onChange={(e) => { setArgs(e.target.value); dirty(); }} placeholder="-y @example/mcp-server" /></Field><Field label={t("mcpConnectionCwd")}><Input value={cwd} onChange={(e) => { setCwd(e.target.value); dirty(); }} /></Field><Field label={t("mcpConnectionEnv")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={env} onChange={(e) => { setEnv(e.target.value); dirty(); }} placeholder="API_KEY=..." /></Field><p className="rounded-xl bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-100">{t("mcpConnectionLocalWarning")}</p></> : <><Field label={t("mcpConnectionUrl")}><Input type="url" value={url} onChange={(e) => { setUrl(e.target.value); dirty(); }} placeholder="https://example.com/mcp" /></Field><Field label={t("mcpConnectionHeaders")} hint={t("mcpConnectionPairsHint")}><Textarea className="min-h-20 font-mono" value={headers} onChange={(e) => { setHeaders(e.target.value); dirty(); }} placeholder="Authorization=Bearer ..." /></Field><Field label={t("mcpConnectionTimeout")}><Input type="number" min="1" value={timeout} onChange={(e) => { setTimeout(e.target.value); dirty(); }} /></Field><div className="flex items-center justify-between rounded-xl bg-muted/60 px-3 py-3"><span className="text-sm">{t("mcpConnectionVerifyTls")}</span><Switch checked={verifyTls} onCheckedChange={(value) => { setVerifyTls(value); dirty(); }} /></div><p className="rounded-xl bg-muted px-3 py-2 text-sm text-muted-foreground">{t("mcpConnectionRemoteWarning")}</p></>}
29
+ {result?.accessible ? <p className="rounded-xl bg-emerald-500/10 px-3 py-2 text-sm text-emerald-800 dark:text-emerald-100">{t("mcpConnectionTestSuccess").replace("{count}", String(result.toolCount))}</p> : null}{error ? <p role="alert" className="rounded-xl bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p> : null}
30
+ </div><SheetFooter className="shrink-0 border-t border-border/60 bg-background px-5 py-4 sm:px-7"><Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={testing || saving}>{t("cancel")}</Button><Button type="button" variant="outline" onClick={() => void test()} disabled={testing || saving}>{testing ? t("mcpConnectionTesting") : t("mcpConnectionTest")}</Button><Button type="button" onClick={() => void save()} disabled={!result?.accessible || testing || saving}>{saving ? t("saving") : t("mcpConnectionSave")}</Button></SheetFooter></SheetContent></Sheet>;
31
+ }
32
+
33
+ function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return <label className="block space-y-1.5"><span className="text-sm font-medium">{label}</span>{hint ? <span className="block text-xs text-muted-foreground">{hint}</span> : null}{children}</label>; }
34
+ function buildRequest(input: { transport: Transport; name: string; command: string; args: string; cwd: string; env: string; url: string; headers: string; timeout: string; verifyTls: boolean }): McpConnectionRequest | Error { try { const name = input.name.trim(); if (!name) throw new Error(t("mcpConnectionNameRequired")); if (input.transport === "stdio") { const command = input.command.trim(); if (!command) throw new Error(t("mcpConnectionCommandRequired")); return { name, definition: { enabled: true, transport: { type: "stdio", command, args: input.args.trim() ? input.args.trim().split(/\s+/) : [], cwd: input.cwd.trim(), env: pairs(input.env), stderr: "pipe" }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } const url = input.url.trim(); if (!url) throw new Error(t("mcpConnectionUrlRequired")); const timeoutMs = Number(input.timeout); if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(t("mcpConnectionTimeoutInvalid")); const common = { url, headers: pairs(input.headers), timeoutMs: Math.trunc(timeoutMs), verifyTls: input.verifyTls }; return { name, definition: { enabled: true, transport: input.transport === "http" ? { type: "http", ...common } : { type: "sse", ...common, reconnect: { enabled: true, initialDelayMs: 1000, maxDelayMs: 30000 } }, scope: { allAgents: true, agents: [] }, policy: { trust: "explicit", start: "eager" } } }; } catch (error) { return error instanceof Error ? error : new Error(String(error)); } }
35
+ function pairs(value: string): Record<string, string> { return Object.fromEntries(value.split("\n").filter(Boolean).map((line) => { const index = line.indexOf("="); if (index <= 0) throw new Error(t("mcpConnectionPairsInvalid")); return [line.slice(0, index).trim(), line.slice(index + 1)]; })); }
@@ -9,7 +9,9 @@ import type {
9
9
  } from "@/shared/lib/api";
10
10
  import {
11
11
  fetchMcpMarketplaceContent,
12
+ createMcpConnection,
12
13
  doctorMcpMarketplaceItem,
14
+ testMcpConnection,
13
15
  } from "@/shared/lib/api";
14
16
  import { SettingsPage } from "@/shared/components/settings/settings-page";
15
17
  import {
@@ -19,6 +21,7 @@ import {
19
21
  } from "@/features/marketplace/components/marketplace-page-parts";
20
22
  import { buildLocaleFallbacks } from "@/features/marketplace/components/marketplace-localization";
21
23
  import { McpMarketplaceCard } from "@/features/marketplace/components/mcp/mcp-marketplace-card";
24
+ import { McpConnectionDialog } from "@/features/marketplace/components/mcp/mcp-connection-dialog";
22
25
  import {
23
26
  buildInstalledRecordLookup,
24
27
  findInstalledRecordForItem,
@@ -46,6 +49,7 @@ import { t } from "@/shared/lib/i18n";
46
49
  import { useInfiniteScrollLoader } from "@/shared/hooks/use-infinite-scroll-loader";
47
50
  import { cn } from "@/shared/lib/utils";
48
51
  import { Sparkles, PackageCheck } from "lucide-react";
52
+ import { Button } from "@/shared/components/ui/button";
49
53
 
50
54
  type ScopeType = "catalog" | "installed";
51
55
 
@@ -61,6 +65,7 @@ export function McpMarketplacePage() {
61
65
  const [doctorTarget, setDoctorTarget] = useState<string | null>(null);
62
66
  const [doctorResult, setDoctorResult] =
63
67
  useState<MarketplaceMcpDoctorResult | null>(null);
68
+ const [connectionOpen, setConnectionOpen] = useState(false);
64
69
  const { language } = useI18n();
65
70
  const docBrowser = useDocBrowser();
66
71
  const { confirm, ConfirmDialog } = useConfirmDialog();
@@ -110,6 +115,8 @@ export function McpMarketplacePage() {
110
115
  setDoctorResult(result);
111
116
  },
112
117
  });
118
+ const testConnectionMutation = useMutation({ mutationFn: testMcpConnection });
119
+ const createConnectionMutation = useMutation({ mutationFn: createMcpConnection });
113
120
 
114
121
  const installedRecordLookup = useMemo(() => {
115
122
  return buildInstalledRecordLookup(installedQuery.data?.records ?? []);
@@ -325,6 +332,9 @@ export function McpMarketplacePage() {
325
332
  : (installedQuery.data?.total ?? 0)}
326
333
  </span>
327
334
  </h3>
335
+ <Button type="button" size="sm" onClick={() => setConnectionOpen(true)}>
336
+ {t("mcpConnectionOpen")}
337
+ </Button>
328
338
  </div>
329
339
 
330
340
  <div
@@ -441,13 +451,21 @@ export function McpMarketplacePage() {
441
451
  </div>
442
452
  </section>
443
453
 
444
- <InstallDialog
454
+ <InstallDialog
445
455
  item={installingItem}
446
456
  open={Boolean(installingItem)}
447
457
  pending={installMutation.isPending}
448
458
  onOpenChange={(open) => !open && setInstallingItem(null)}
449
459
  onSubmit={handleInstall}
450
- />
460
+ />
461
+ <McpConnectionDialog
462
+ open={connectionOpen}
463
+ testing={testConnectionMutation.isPending}
464
+ saving={createConnectionMutation.isPending}
465
+ onOpenChange={setConnectionOpen}
466
+ onTest={(request) => testConnectionMutation.mutateAsync(request)}
467
+ onSave={async (request) => { await createConnectionMutation.mutateAsync(request); }}
468
+ />
451
469
  <DoctorDialog
452
470
  open={Boolean(doctorTarget)}
453
471
  targetName={doctorTarget}
@@ -12,6 +12,7 @@ import {
12
12
  } from "@/features/panel-apps/utils/panel-app-iframe.utils";
13
13
  import { openApps } from "@/features/panel-apps/utils/panel-app-doc-browser.utils";
14
14
  import { useDocBrowser } from "@/shared/components/doc-browser";
15
+ import { usePanelAppScrollRestoration } from "@/shared/hooks/use-panel-app-scroll-restoration";
15
16
  import { t } from "@/shared/lib/i18n";
16
17
 
17
18
  export function PanelAppMainPage() {
@@ -25,6 +26,12 @@ export function PanelAppMainPage() {
25
26
  [appId, panelApps.data?.entries],
26
27
  );
27
28
  const runtime = usePanelAppMainRuntime(entry);
29
+ const restoreScroll = usePanelAppScrollRestoration({
30
+ currentUrl: entry?.contentPath ?? null,
31
+ iframeRef,
32
+ isEnabled: Boolean(entry),
33
+ restorationKey: entry ? `panel-app:main:${entry.appId}` : null,
34
+ });
28
35
 
29
36
  useEffect(() => {
30
37
  const handleMessage = (event: MessageEvent) => {
@@ -88,6 +95,7 @@ export function PanelAppMainPage() {
88
95
  title={entry.title}
89
96
  sandbox={PANEL_APP_IFRAME_SANDBOX}
90
97
  className="block h-full w-full border-0 bg-background"
98
+ onLoad={restoreScroll}
91
99
  onPointerOver={(event) => focusPanelAppIframe(event.currentTarget)}
92
100
  />
93
101
  </div>
@@ -14,6 +14,7 @@ import type { DocBrowserTabMenuGroupsResolver } from "@/shared/components/doc-br
14
14
  import { cn } from "@/shared/lib/utils";
15
15
  import { useViewportLayoutStore } from "@/app/stores/viewport-layout.store";
16
16
  import { SIDEBAR_RAIL_WIDTH_PX } from "@/app/components/layout/sidebar-rail.styles";
17
+ import { useScrollRestoration } from "@/shared/hooks/use-scroll-restoration";
17
18
 
18
19
  const DocBrowser = lazy(async () => ({
19
20
  default: (await import("@/shared/components/doc-browser/doc-browser"))
@@ -55,6 +56,10 @@ export function DesktopAppShell({
55
56
  : isMainRoute
56
57
  ? "280px"
57
58
  : "240px";
59
+ const settingsScroll = useScrollRestoration<HTMLElement>({
60
+ restorationKey: isMainRoute ? null : `settings-page:${pathname}`,
61
+ });
62
+ const { onScroll: onSettingsScroll, scrollRef: settingsScrollRef } = settingsScroll;
58
63
 
59
64
  return (
60
65
  <div
@@ -82,7 +87,11 @@ export function DesktopAppShell({
82
87
  {isMainRoute ? (
83
88
  <div className="flex-1 h-full overflow-hidden">{children}</div>
84
89
  ) : (
85
- <main className="flex-1 overflow-auto p-8 pb-16 custom-scrollbar">
90
+ <main
91
+ ref={settingsScrollRef}
92
+ onScroll={onSettingsScroll}
93
+ className="flex-1 overflow-auto p-8 pb-16 custom-scrollbar"
94
+ >
86
95
  <div className="mx-auto h-full max-w-6xl animate-fade-in">
87
96
  {children}
88
97
  </div>
@@ -2,9 +2,13 @@ import { NavLink } from "react-router-dom";
2
2
  import { getSettingsNavItems } from "@/app/configs/app-navigation.config";
3
3
  import { t } from "@/shared/lib/i18n";
4
4
  import { cn } from "@/shared/lib/utils";
5
+ import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities/hooks/use-desktop-capabilities";
5
6
 
6
7
  export function MobileSettingsShell() {
7
- const settingsNavItems = getSettingsNavItems(t);
8
+ const desktopCapabilityAvailable = useDesktopCapabilityAvailability();
9
+ const settingsNavItems = getSettingsNavItems(t, {
10
+ includeDesktopCapabilities: desktopCapabilityAvailable,
11
+ });
8
12
 
9
13
  return (
10
14
  <div
@@ -215,7 +215,6 @@ export function DocBrowser({
215
215
  currentTab,
216
216
  iframeRef,
217
217
  isEnabled: supportsScrollRestoration,
218
- tabs,
219
218
  });
220
219
  const { handleUrlSubmit, setUrlInput, urlInput } = useDocBrowserAddressBar({
221
220
  activeTabId,