@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
@@ -17,12 +17,8 @@ import {
17
17
  CronTaskComposer,
18
18
  CronTemplateGallery,
19
19
  } from "@/features/cron/components/cron-task-discovery";
20
- import {
21
- useCronJobs,
22
- useDeleteCronJob,
23
- useRunCronJob,
24
- useToggleCronJob,
25
- } from "@/features/cron/hooks/use-cron-jobs";
20
+ import { useCronJobs } from "@/features/cron/hooks/use-cron-jobs";
21
+ import { useCronJobActions } from "@/features/cron/hooks/use-cron-job-actions";
26
22
  import { Button } from "@/shared/components/ui/button";
27
23
  import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
28
24
  import { Input } from "@/shared/components/ui/input";
@@ -31,7 +27,6 @@ import type {
31
27
  CronListStatus,
32
28
  CronListSummaryView,
33
29
  } from "@/shared/lib/api";
34
- import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
35
30
  import { t } from "@/shared/lib/i18n";
36
31
  import { cn } from "@/shared/lib/utils";
37
32
 
@@ -167,10 +162,7 @@ export function CronConfig() {
167
162
  query: deferredQuery,
168
163
  status,
169
164
  });
170
- const deleteCronJob = useDeleteCronJob();
171
- const toggleCronJob = useToggleCronJob();
172
- const runCronJob = useRunCronJob();
173
- const { confirm, ConfirmDialog } = useConfirmDialog();
165
+ const cronActions = useCronJobActions();
174
166
  const jobs = cronQuery.data?.jobs ?? [];
175
167
  const total = cronQuery.data?.total ?? 0;
176
168
  const summary = cronQuery.data?.summary;
@@ -190,46 +182,28 @@ export function CronConfig() {
190
182
  composerRef.current?.focus();
191
183
  };
192
184
 
193
- const handleDelete = async (job: CronJobView) => {
194
- const confirmed = await confirm({
195
- title: `${t("cronDeleteConfirm")}?`,
196
- description: job.name ? `${job.name} (${job.id})` : job.id,
197
- variant: "destructive",
198
- confirmLabel: t("delete"),
185
+ const handleDelete = (job: CronJobView) => {
186
+ void cronActions.deleteJob(job, () => {
187
+ setExpandedJobId(null);
188
+ setSelectedJobId(null);
189
+ if (jobs.length === 1 && page > 0) {
190
+ setPage(page - 1);
191
+ }
199
192
  });
200
- if (!confirmed) {
201
- return;
202
- }
203
- setExpandedJobId(null);
204
- setSelectedJobId(null);
205
- if (jobs.length === 1 && page > 0) {
206
- setPage(page - 1);
207
- }
208
- deleteCronJob.mutate({ id: job.id });
209
193
  };
210
194
 
211
195
  const handleToggle = (job: CronJobView, enabled: boolean) => {
212
- const leavesCurrentFilter =
213
- (status === "enabled" && !enabled) || (status === "disabled" && enabled);
214
- if (leavesCurrentFilter && jobs.length === 1 && page > 0) {
215
- setPage(page - 1);
216
- }
217
- toggleCronJob.mutate({ id: job.id, enabled });
196
+ void cronActions.toggleJob(job, enabled, () => {
197
+ const leavesCurrentFilter =
198
+ (status === "enabled" && !enabled) || (status === "disabled" && enabled);
199
+ if (leavesCurrentFilter && jobs.length === 1 && page > 0) {
200
+ setPage(page - 1);
201
+ }
202
+ });
218
203
  };
219
204
 
220
- const handleRun = async (job: CronJobView) => {
221
- const force = !job.enabled;
222
- if (force) {
223
- const confirmed = await confirm({
224
- title: `${t("cronRunForceConfirm")}?`,
225
- description: job.name ? `${job.name} (${job.id})` : job.id,
226
- confirmLabel: t("cronRunNow"),
227
- });
228
- if (!confirmed) {
229
- return;
230
- }
231
- }
232
- runCronJob.mutate({ id: job.id, force });
205
+ const handleRun = (job: CronJobView) => {
206
+ void cronActions.runJob(job);
233
207
  };
234
208
 
235
209
  const handlePageChange = (nextPage: number) => {
@@ -446,7 +420,7 @@ export function CronConfig() {
446
420
  onRun={handleRun}
447
421
  onToggle={handleToggle}
448
422
  />
449
- <ConfirmDialog />
423
+ <cronActions.ConfirmDialog />
450
424
  </PageLayout>
451
425
  );
452
426
  }
@@ -166,7 +166,7 @@ export function CronJobRow({
166
166
  size="icon"
167
167
  className="h-8 w-8"
168
168
  onClick={() => onEdit(job)}
169
- aria-label={`${t("edit")} ${jobLabel}`}
169
+ aria-label={`${t("cronEditDetails")} ${jobLabel}`}
170
170
  >
171
171
  <Pencil className="h-3.5 w-3.5" />
172
172
  </Button>
@@ -0,0 +1,95 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import type { CronJobView } from "@/shared/lib/api";
5
+ import { setLanguage } from "@/shared/lib/i18n";
6
+ import { useCronJobActions } from "@/features/cron/hooks/use-cron-job-actions";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ confirm: vi.fn(),
10
+ deleteJob: vi.fn(),
11
+ runJob: vi.fn(),
12
+ toggleJob: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/features/cron/hooks/use-cron-jobs", () => ({
16
+ useDeleteCronJob: () => ({ mutateAsync: mocks.deleteJob }),
17
+ useRunCronJob: () => ({ mutateAsync: mocks.runJob }),
18
+ useToggleCronJob: () => ({ mutateAsync: mocks.toggleJob }),
19
+ }));
20
+
21
+ vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
22
+ useConfirmDialog: () => ({
23
+ confirm: mocks.confirm,
24
+ ConfirmDialog: () => null,
25
+ }),
26
+ }));
27
+
28
+ function createJob(overrides: Partial<CronJobView> = {}): CronJobView {
29
+ return {
30
+ id: "paint-daily",
31
+ name: "每日绘画",
32
+ enabled: true,
33
+ schedule: { kind: "cron", expr: "0 9 * * *", tz: "Asia/Shanghai" },
34
+ payload: {
35
+ kind: "agent_turn",
36
+ message: "生成一张今日绘画。",
37
+ agentId: "main",
38
+ sessionId: "session:paint",
39
+ },
40
+ state: {
41
+ nextRunAt: "2026-08-27T01:00:00.000Z",
42
+ lastRunAt: null,
43
+ lastStatus: null,
44
+ lastError: null,
45
+ },
46
+ createdAt: "2026-08-26T01:00:00.000Z",
47
+ updatedAt: "2026-08-26T01:00:00.000Z",
48
+ deleteAfterRun: false,
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe("useCronJobActions", () => {
54
+ beforeEach(() => {
55
+ setLanguage("zh");
56
+ vi.clearAllMocks();
57
+ mocks.confirm.mockResolvedValue(true);
58
+ mocks.deleteJob.mockResolvedValue({ deleted: true });
59
+ mocks.runJob.mockResolvedValue({ job: createJob(), executed: true });
60
+ mocks.toggleJob.mockResolvedValue({ job: createJob() });
61
+ });
62
+
63
+ it("requires confirmation and forces a paused task to run without enabling it", async () => {
64
+ const { result } = renderHook(() => useCronJobActions());
65
+ const job = createJob({ enabled: false });
66
+
67
+ await act(async () => {
68
+ await result.current.runJob(job);
69
+ });
70
+
71
+ expect(mocks.confirm).toHaveBeenCalledWith({
72
+ title: "任务已暂停,仍要立即执行?",
73
+ description: "每日绘画 (paint-daily)",
74
+ confirmLabel: "立即执行",
75
+ });
76
+ expect(mocks.runJob).toHaveBeenCalledWith({ id: "paint-daily", force: true });
77
+ expect(mocks.toggleJob).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("uses a destructive confirmation before deleting", async () => {
81
+ const { result } = renderHook(() => useCronJobActions());
82
+
83
+ await act(async () => {
84
+ await result.current.deleteJob(createJob());
85
+ });
86
+
87
+ expect(mocks.confirm).toHaveBeenCalledWith({
88
+ title: "确认删除定时任务?",
89
+ description: "每日绘画 (paint-daily)",
90
+ confirmLabel: "删除",
91
+ variant: "destructive",
92
+ });
93
+ expect(mocks.deleteJob).toHaveBeenCalledWith({ id: "paint-daily" });
94
+ });
95
+ });
@@ -0,0 +1,109 @@
1
+ import { useState } from "react";
2
+
3
+ import type { CronJobView } from "@/shared/lib/api";
4
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
5
+ import { t } from "@/shared/lib/i18n";
6
+ import {
7
+ useDeleteCronJob,
8
+ useRunCronJob,
9
+ useToggleCronJob,
10
+ } from "./use-cron-jobs";
11
+
12
+ type AfterSuccess = () => void;
13
+
14
+ function describeJob(job: CronJobView): string {
15
+ return job.name ? `${job.name} (${job.id})` : job.id;
16
+ }
17
+
18
+ export function useCronJobActions() {
19
+ const deleteCronJob = useDeleteCronJob();
20
+ const runCronJob = useRunCronJob();
21
+ const toggleCronJob = useToggleCronJob();
22
+ const { confirm, ConfirmDialog } = useConfirmDialog();
23
+ const [pendingJobIds, setPendingJobIds] = useState<ReadonlySet<string>>(
24
+ new Set(),
25
+ );
26
+
27
+ const execute = async (
28
+ job: CronJobView,
29
+ action: () => Promise<unknown>,
30
+ afterSuccess?: AfterSuccess,
31
+ ): Promise<boolean> => {
32
+ setPendingJobIds((ids) => new Set(ids).add(job.id));
33
+ try {
34
+ await action();
35
+ afterSuccess?.();
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ } finally {
40
+ setPendingJobIds((ids) => {
41
+ const nextIds = new Set(ids);
42
+ nextIds.delete(job.id);
43
+ return nextIds;
44
+ });
45
+ }
46
+ };
47
+
48
+ const deleteJob = async (
49
+ job: CronJobView,
50
+ afterSuccess?: AfterSuccess,
51
+ ): Promise<boolean> => {
52
+ const confirmed = await confirm({
53
+ title: `${t("cronDeleteConfirm")}?`,
54
+ description: describeJob(job),
55
+ confirmLabel: t("delete"),
56
+ variant: "destructive",
57
+ });
58
+ if (!confirmed) {
59
+ return false;
60
+ }
61
+ return await execute(
62
+ job,
63
+ () => deleteCronJob.mutateAsync({ id: job.id }),
64
+ afterSuccess,
65
+ );
66
+ };
67
+
68
+ const runJob = async (
69
+ job: CronJobView,
70
+ afterSuccess?: AfterSuccess,
71
+ ): Promise<boolean> => {
72
+ const force = !job.enabled;
73
+ if (force) {
74
+ const confirmed = await confirm({
75
+ title: `${t("cronRunForceConfirm")}?`,
76
+ description: describeJob(job),
77
+ confirmLabel: t("cronRunNow"),
78
+ });
79
+ if (!confirmed) {
80
+ return false;
81
+ }
82
+ }
83
+ return await execute(
84
+ job,
85
+ () => runCronJob.mutateAsync({ id: job.id, force }),
86
+ afterSuccess,
87
+ );
88
+ };
89
+
90
+ const toggleJob = async (
91
+ job: CronJobView,
92
+ enabled: boolean,
93
+ afterSuccess?: AfterSuccess,
94
+ ): Promise<boolean> => {
95
+ return await execute(
96
+ job,
97
+ () => toggleCronJob.mutateAsync({ id: job.id, enabled }),
98
+ afterSuccess,
99
+ );
100
+ };
101
+
102
+ return {
103
+ ConfirmDialog,
104
+ deleteJob,
105
+ isPending: (job: CronJobView) => pendingJobIds.has(job.id),
106
+ runJob,
107
+ toggleJob,
108
+ };
109
+ }
@@ -1,4 +1,6 @@
1
1
  export { CronConfig } from "./components/cron-config";
2
+ export { CronJobDetailDialog } from "./components/cron-job-detail-dialog";
3
+ export { useCronJobActions } from "./hooks/use-cron-job-actions";
2
4
  export {
3
5
  useCronJobs,
4
6
  useDeleteCronJob,
@@ -0,0 +1,58 @@
1
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { DesktopAuthorizationDialog } from "./desktop-authorization-dialog";
4
+ import { useDesktopAuthorizationStore } from "../stores/desktop-authorization.store";
5
+
6
+ const mocks = vi.hoisted(() => ({ grantAccess: vi.fn() }));
7
+
8
+ vi.mock("../managers/desktop-capability.manager", () => ({
9
+ desktopCapabilityManager: {
10
+ grantAccess: mocks.grantAccess,
11
+ },
12
+ }));
13
+
14
+ describe("DesktopAuthorizationDialog", () => {
15
+ beforeEach(() => {
16
+ mocks.grantAccess.mockReset();
17
+ mocks.grantAccess.mockResolvedValue({});
18
+ useDesktopAuthorizationStore.getState().clear();
19
+ });
20
+
21
+ it("shows the trusted subject and grants only after explicit approval", async () => {
22
+ useDesktopAuthorizationStore.getState().present({
23
+ applicationId: "wechat",
24
+ request: {
25
+ subject: { type: "agent", id: "main" },
26
+ resource: {
27
+ type: "desktop.application",
28
+ target: { applicationId: "wechat", platform: "darwin", bundleId: "com.tencent.xinWeChat" },
29
+ },
30
+ access: ["ui.write"],
31
+ declarationFingerprint: "fingerprint",
32
+ },
33
+ });
34
+
35
+ render(<DesktopAuthorizationDialog />);
36
+ expect(screen.getByText("AI · main")).toBeTruthy();
37
+ expect(screen.getByText("wechat")).toBeTruthy();
38
+ expect(mocks.grantAccess).not.toHaveBeenCalled();
39
+
40
+ fireEvent.click(screen.getByRole("button", { name: "Allow" }));
41
+ await waitFor(() => expect(mocks.grantAccess).toHaveBeenCalledOnce());
42
+ });
43
+
44
+ it("explains the bounded pointer permission in user terms", () => {
45
+ useDesktopAuthorizationStore.getState().present({
46
+ applicationId: "wechat",
47
+ request: {
48
+ subject: { type: "agent", id: "main" },
49
+ resource: { type: "desktop.application", target: { applicationId: "wechat", platform: "darwin", bundleId: "com.tencent.xinWeChat" } },
50
+ access: ["input.pointer"],
51
+ declarationFingerprint: "fingerprint",
52
+ },
53
+ });
54
+ render(<DesktopAuthorizationDialog />);
55
+ expect(screen.getByText("Click inside the app window")).toBeTruthy();
56
+ expect(screen.getByText(/cannot use keys, scroll, drag, send, or confirm/i)).toBeTruthy();
57
+ });
58
+ });
@@ -0,0 +1,161 @@
1
+ import { useEffect, useState } from "react";
2
+ import { ShieldAlert } from "lucide-react";
3
+ import { toast } from "sonner";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/shared/components/ui/dialog";
12
+ import { Button } from "@/shared/components/ui/button";
13
+ import { appQueryClient } from "@/app-query-client";
14
+ import { nextclawClient } from "@/shared/lib/api/managers/client.manager";
15
+ import { t } from "@/shared/lib/i18n";
16
+ import { desktopCapabilityManager } from "../managers/desktop-capability.manager";
17
+ import {
18
+ type DesktopAuthorizationRequest,
19
+ useDesktopAuthorizationStore,
20
+ } from "../stores/desktop-authorization.store";
21
+
22
+ const AUTHORIZATION_EVENT_TYPE = "desktop.authorization.required";
23
+
24
+ export function DesktopAuthorizationDialog() {
25
+ const pending = useDesktopAuthorizationStore((state) => state.pending);
26
+ const present = useDesktopAuthorizationStore((state) => state.present);
27
+ const clear = useDesktopAuthorizationStore((state) => state.clear);
28
+ const [isGranting, setIsGranting] = useState(false);
29
+
30
+ useEffect(() => nextclawClient.eventBus.subscribeAll((event) => {
31
+ if (event.type !== AUTHORIZATION_EVENT_TYPE) return;
32
+ const request = readAuthorizationRequest(event.payload);
33
+ if (request) present(request);
34
+ }), [present]);
35
+
36
+ const allow = async () => {
37
+ if (!pending || isGranting) return;
38
+ setIsGranting(true);
39
+ try {
40
+ await desktopCapabilityManager.grantAccess(pending.request);
41
+ await appQueryClient.invalidateQueries({
42
+ queryKey: ["desktop-capability", "grants"],
43
+ });
44
+ clear();
45
+ toast.success(t("desktopAuthorizationAllowed"));
46
+ } catch (error) {
47
+ toast.error(
48
+ error instanceof Error
49
+ ? error.message
50
+ : t("desktopAuthorizationFailed"),
51
+ );
52
+ } finally {
53
+ setIsGranting(false);
54
+ }
55
+ };
56
+
57
+ return (
58
+ <Dialog
59
+ open={Boolean(pending)}
60
+ onOpenChange={(open) => {
61
+ if (!open && !isGranting) clear();
62
+ }}
63
+ >
64
+ <DialogContent className="[&>:last-child]:hidden">
65
+ <DialogHeader>
66
+ <DialogTitle className="flex items-center gap-2">
67
+ <ShieldAlert className="h-4 w-4 text-amber-600" />
68
+ {t("desktopAuthorizationTitle")}
69
+ </DialogTitle>
70
+ <DialogDescription>
71
+ {t("desktopAuthorizationDescription")}
72
+ </DialogDescription>
73
+ </DialogHeader>
74
+ {pending ? (
75
+ <div className="space-y-3 rounded-xl bg-muted/55 p-4 text-sm">
76
+ <AuthorizationField
77
+ label={t("desktopAuthorizationCaller")}
78
+ value={formatSubject(pending)}
79
+ />
80
+ <AuthorizationField
81
+ label={t("desktopAuthorizationApplication")}
82
+ value={pending.applicationId}
83
+ />
84
+ <AuthorizationField
85
+ label={t("desktopAuthorizationAccess")}
86
+ value={pending.request.access.map(formatAccess).join(", ")}
87
+ />
88
+ <p className="text-xs leading-5 text-muted-foreground">
89
+ {formatRisk(pending.request.access)}
90
+ </p>
91
+ </div>
92
+ ) : null}
93
+ <DialogFooter className="gap-2 sm:gap-0">
94
+ <Button
95
+ type="button"
96
+ variant="outline"
97
+ disabled={isGranting}
98
+ onClick={clear}
99
+ >
100
+ {t("desktopAuthorizationReject")}
101
+ </Button>
102
+ <Button type="button" disabled={isGranting} onClick={() => void allow()}>
103
+ {isGranting ? t("desktopAuthorizationAllowing") : t("desktopAuthorizationAllow")}
104
+ </Button>
105
+ </DialogFooter>
106
+ </DialogContent>
107
+ </Dialog>
108
+ );
109
+ }
110
+
111
+ function AuthorizationField({ label, value }: { label: string; value: string }) {
112
+ return (
113
+ <div className="grid grid-cols-[5rem_minmax(0,1fr)] gap-3">
114
+ <div className="text-xs font-medium text-muted-foreground">{label}</div>
115
+ <div className="min-w-0 break-words text-xs text-foreground">{value}</div>
116
+ </div>
117
+ );
118
+ }
119
+
120
+ function readAuthorizationRequest(value: unknown): DesktopAuthorizationRequest | null {
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
122
+ const payload = value as Record<string, unknown>;
123
+ const { request } = payload;
124
+ if (!request || typeof request !== "object" || Array.isArray(request)) return null;
125
+ const candidate = request as Record<string, unknown>;
126
+ if (
127
+ typeof payload.applicationId !== "string" ||
128
+ !candidate.subject ||
129
+ !candidate.resource ||
130
+ !Array.isArray(candidate.access) ||
131
+ typeof candidate.declarationFingerprint !== "string"
132
+ ) return null;
133
+ return {
134
+ applicationId: payload.applicationId,
135
+ request: candidate as DesktopAuthorizationRequest["request"],
136
+ };
137
+ }
138
+
139
+ function formatSubject(pending: DesktopAuthorizationRequest): string {
140
+ const { subject } = pending.request;
141
+ const label = subject.type === "agent"
142
+ ? t("desktopCapabilitiesAgent")
143
+ : t("desktopCapabilitiesExtension");
144
+ return `${label} · ${subject.id}`;
145
+ }
146
+
147
+ function formatAccess(access: string): string {
148
+ if (access === "ui.read") return t("desktopAuthorizationRead");
149
+ if (access === "ui.observe") return t("desktopAuthorizationObserve");
150
+ if (access === "ui.write") return t("desktopAuthorizationWrite");
151
+ if (access === "screen.capture-window") return t("desktopAuthorizationScreenCapture");
152
+ if (access === "input.pointer") return t("desktopAuthorizationPointerInput");
153
+ return access;
154
+ }
155
+
156
+ function formatRisk(access: string[]): string {
157
+ if (access.includes("ui.write")) return t("desktopAuthorizationWriteRisk");
158
+ if (access.includes("screen.capture-window")) return t("desktopAuthorizationScreenCaptureRisk");
159
+ if (access.includes("input.pointer")) return t("desktopAuthorizationPointerInputRisk");
160
+ return t("desktopAuthorizationReadRisk");
161
+ }
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isDesktopAutomationAvailable } from "./use-desktop-capabilities";
3
+
4
+ describe("isDesktopAutomationAvailable", () => {
5
+ it("uses only the backend product feature-control value", () => {
6
+ expect(isDesktopAutomationAvailable({ desktopAutomation: { available: true } })).toBe(true);
7
+ expect(isDesktopAutomationAvailable({ desktopAutomation: { available: false } })).toBe(false);
8
+ expect(isDesktopAutomationAvailable(undefined)).toBe(false);
9
+ });
10
+ });
@@ -0,0 +1,55 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { CapabilityGrantView, ProductFeatureControlsView } from "@nextclaw/client-sdk";
3
+ import { useFeatureControls } from "@/app/hooks/use-feature-controls";
4
+ import { desktopCapabilityManager } from "../managers/desktop-capability.manager";
5
+
6
+ const STATUS_QUERY_KEY = ["desktop-capability", "status"] as const;
7
+ const GRANTS_QUERY_KEY = ["desktop-capability", "grants"] as const;
8
+
9
+ export function useDesktopCapabilityStatus() {
10
+ return useQuery({
11
+ queryKey: STATUS_QUERY_KEY,
12
+ queryFn: desktopCapabilityManager.getStatus,
13
+ });
14
+ }
15
+
16
+ export function useDesktopCapabilityAvailability(): boolean {
17
+ const featureControls = useFeatureControls();
18
+ return isDesktopAutomationAvailable(featureControls.data);
19
+ }
20
+
21
+ export function isDesktopAutomationAvailable(controls: ProductFeatureControlsView | undefined): boolean {
22
+ return controls?.desktopAutomation.available === true;
23
+ }
24
+
25
+ export function useDesktopCapabilityGrants() {
26
+ return useQuery({
27
+ queryKey: GRANTS_QUERY_KEY,
28
+ queryFn: desktopCapabilityManager.listGrants,
29
+ });
30
+ }
31
+
32
+ export function useRequestDesktopSystemPermission() {
33
+ const queryClient = useQueryClient();
34
+ return useMutation({
35
+ mutationFn: desktopCapabilityManager.requestSystemPermission,
36
+ onSuccess: async () => {
37
+ await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
38
+ },
39
+ });
40
+ }
41
+
42
+ export function useOpenDesktopSystemSettings() {
43
+ return useMutation({ mutationFn: desktopCapabilityManager.openSystemSettings });
44
+ }
45
+
46
+ export function useRevokeDesktopGrant() {
47
+ const queryClient = useQueryClient();
48
+ return useMutation({
49
+ mutationFn: async (grant: CapabilityGrantView) =>
50
+ await desktopCapabilityManager.revokeGrant(grant),
51
+ onSuccess: async () => {
52
+ await queryClient.invalidateQueries({ queryKey: GRANTS_QUERY_KEY });
53
+ },
54
+ });
55
+ }
@@ -0,0 +1 @@
1
+ export { DesktopAuthorizationDialog } from "./components/desktop-authorization-dialog";
@@ -0,0 +1,25 @@
1
+ import type {
2
+ CapabilityGrantRequestView,
3
+ CapabilityGrantView,
4
+ } from "@nextclaw/client-sdk";
5
+ import { nextclawClient } from "@/shared/lib/api/managers/client.manager";
6
+
7
+ export class DesktopCapabilityManager {
8
+ getStatus = async () => await nextclawClient.capabilityAccess.getDesktopStatus();
9
+
10
+ listGrants = async () => await nextclawClient.capabilityAccess.listDesktopGrants();
11
+
12
+ grantAccess = async (request: CapabilityGrantRequestView) =>
13
+ await nextclawClient.capabilityAccess.grantDesktopAccess(request);
14
+
15
+ requestSystemPermission = async () =>
16
+ await nextclawClient.capabilityAccess.requestDesktopPermissions();
17
+
18
+ openSystemSettings = async () =>
19
+ await nextclawClient.capabilityAccess.openDesktopPermissionSettings();
20
+
21
+ revokeGrant = async (grant: CapabilityGrantView) =>
22
+ await nextclawClient.capabilityAccess.revokeDesktopAccess(grant);
23
+ }
24
+
25
+ export const desktopCapabilityManager = new DesktopCapabilityManager();