@nextclaw/ui 0.20.2 → 0.20.3

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 (105) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/{api-DvpQWjTS.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-U0m0ktu3.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-DRofSTWr.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/{channels-list-page-BURQarD0.js → channels-list-page-Drlt62kY.js} +1 -1
  6. package/dist/assets/{config-split-page-BrAQYTIl.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-DDJomLnO.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-DzkNHr5Z.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-BzZFhMv9.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-DXbLo2b1.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-NskPB_yW.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-CnKV4gJy.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-DvlFMpAs.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-UnPEZBkx.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/{index-Dc_ZMkmo.css → index-C86hxgv5.css} +1 -1
  18. package/dist/assets/index-CYBXR7zN.js +109 -0
  19. package/dist/assets/loader-circle-BlLuRFVW.js +1 -0
  20. package/dist/assets/mcp-marketplace-page-B12JNh7Q.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-BIgFTbMq.js → mcp-marketplace-page-kLWW6kzt.js} +1 -1
  22. package/dist/assets/{model-config-page-CnSm7kfa.js → model-config-page-DSeAjVT_.js} +1 -1
  23. package/dist/assets/{navigation-link-NjUdG6Vp.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/{privacy-settings-page-DVn2HU7W.js → privacy-settings-page-tN0Mt15x.js} +1 -1
  26. package/dist/assets/{providers-config-page-CzHJS3eg.js → providers-config-page-CgPrXR1u.js} +1 -1
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-CkeninE0.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-DP33PuXU.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/{runtime-config-page-D69buyGp.js → runtime-config-page-Crq96Jjq.js} +1 -1
  32. package/dist/assets/{save-Cw2Xz-pB.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-C2CFAVvd.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-dqNT0Swf.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-De6J3wVx.js → secrets-config-page-Q6XfIj0h.js} +1 -1
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-DSuMhwVP.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-DFBLgsGf.js → tag-chip-gdaU9bE7.js} +1 -1
  40. package/dist/assets/x-CWdWBu_U.js +1 -0
  41. package/dist/index.html +21 -21
  42. package/package.json +8 -8
  43. package/src/app/components/layout/sidebar.tsx +7 -0
  44. package/src/features/apps/components/app-package-card.tsx +29 -7
  45. package/src/features/apps/components/app-packages-panel.tsx +4 -0
  46. package/src/features/chat/components/conversation/chat-conversation-content.tsx +18 -2
  47. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +6 -0
  48. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +16 -2
  49. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  50. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +16 -0
  51. package/src/features/chat/components/layout/chat-sidebar.tsx +4 -1
  52. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline-compaction.utils.test.ts +2 -2
  53. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  54. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +11 -3
  55. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +12 -5
  56. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  57. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  58. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  59. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  60. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +47 -2
  61. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  62. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  63. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  64. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  65. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  66. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  67. package/src/features/cron/components/cron-config.tsx +20 -46
  68. package/src/features/cron/components/cron-job-row.tsx +1 -1
  69. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  70. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  71. package/src/features/cron/index.ts +2 -0
  72. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  73. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  74. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  75. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  76. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  77. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  78. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  79. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  80. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  81. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  82. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  83. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  84. package/src/shared/lib/api/types.ts +1 -0
  85. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  86. package/src/shared/lib/i18n/locales/en-US/chat.json +8 -5
  87. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  88. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  89. package/src/shared/lib/i18n/locales/zh-CN/chat.json +8 -5
  90. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  91. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  92. package/src/shared/lib/navigation-history/index.ts +1 -0
  93. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  94. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  95. package/dist/assets/doc-browser-DB22tQKU.js +0 -1
  96. package/dist/assets/doc-browser-sJjw9fl5.js +0 -1
  97. package/dist/assets/index-N1HnesBp.js +0 -109
  98. package/dist/assets/loader-circle-BlPXxgDB.js +0 -1
  99. package/dist/assets/mcp-marketplace-page-CZN4ovdK.js +0 -1
  100. package/dist/assets/plus-DVXol9Q5.js +0 -1
  101. package/dist/assets/react-DCVbGroo.js +0 -8
  102. package/dist/assets/remote-B2lsJF1c.js +0 -1
  103. package/dist/assets/security-config-Dcu9C4OL.js +0 -1
  104. package/dist/assets/skeleton-QoWydNLp.js +0 -1
  105. package/dist/assets/x-BXvAzpjb.js +0 -1
@@ -44,6 +44,9 @@ type ChatSessionWorkspacePanelProps = {
44
44
  workspaceNavigationHistoryIndex?: number;
45
45
  activePanelKind?: ChatWorkspacePanelKind | null;
46
46
  sessionCronJobs?: readonly CronJobView[];
47
+ sessionCronJobsError?: boolean;
48
+ sessionCronJobsLoading?: boolean;
49
+ onRetrySessionCronJobs?: () => void;
47
50
  sessionProjectRoot: string | null;
48
51
  sessionWorkingDir: string | null;
49
52
  workspacePanelWidth?: number;
@@ -62,6 +65,9 @@ export function ChatSessionWorkspacePanel({
62
65
  workspaceNavigationHistoryIndex = 0,
63
66
  activePanelKind,
64
67
  sessionCronJobs = [],
68
+ sessionCronJobsError = false,
69
+ sessionCronJobsLoading = false,
70
+ onRetrySessionCronJobs,
65
71
  sessionProjectRoot,
66
72
  sessionWorkingDir,
67
73
  workspacePanelWidth = CHAT_WORKSPACE_PANEL_DEFAULT_WIDTH,
@@ -218,6 +224,9 @@ export function ChatSessionWorkspacePanel({
218
224
  filePreviewRefreshVersion={filePreviewRefreshVersion}
219
225
  sessionKey={sessionKey}
220
226
  sessionCronJobs={sessionCronJobs}
227
+ sessionCronJobsError={sessionCronJobsError}
228
+ sessionCronJobsLoading={sessionCronJobsLoading}
229
+ onRetrySessionCronJobs={onRetrySessionCronJobs}
221
230
  sessionProjectRoot={sessionProjectRoot}
222
231
  sessionWorkingDir={sessionWorkingDir}
223
232
  />
@@ -8,6 +8,10 @@ function formatTokenCount(value: number | null): string {
8
8
  return value === null ? '—' : new Intl.NumberFormat().format(value);
9
9
  }
10
10
 
11
+ function formatCount(value: number | null): string {
12
+ return value === null ? '—' : new Intl.NumberFormat().format(value);
13
+ }
14
+
11
15
  function formatCacheHitRate(value: number | null): string {
12
16
  return value === null
13
17
  ? '—'
@@ -119,6 +123,14 @@ export function ChatSessionTokenUsage({ sessionKey }: { sessionKey: string | nul
119
123
  label={t('chatWorkspaceTokenUsageRuns')}
120
124
  value={String(usage.runCount)}
121
125
  />
126
+ <TokenUsageMetric
127
+ label={t('chatWorkspaceTokenUsageModelCalls')}
128
+ value={formatCount(usage.modelCallCount)}
129
+ />
130
+ <TokenUsageMetric
131
+ label={t('chatWorkspaceTokenUsageReportedModelCalls')}
132
+ value={formatCount(usage.reportedModelCallCount)}
133
+ />
122
134
  </div>
123
135
  <div>
124
136
  <div className="mb-1.5 text-[11px] font-medium text-gray-700">
@@ -136,6 +148,11 @@ export function ChatSessionTokenUsage({ sessionKey }: { sessionKey: string | nul
136
148
  <div className="mt-1 text-[10px] text-gray-500">
137
149
  {t('chatWorkspaceTokenUsageModelRunCount').replace('{count}', String(modelUsage.runCount))}
138
150
  {' · '}
151
+ {t('chatWorkspaceTokenUsageModelCallCount').replace(
152
+ '{count}',
153
+ formatCount(modelUsage.modelCallCount),
154
+ )}
155
+ {' · '}
139
156
  {t('chatWorkspaceTokenUsageModelCacheHitRate').replace(
140
157
  '{rate}',
141
158
  formatCacheHitRate(modelUsage.cacheHitRate),
@@ -1,103 +1,297 @@
1
- import { Trash2 } from "lucide-react";
1
+ import { useState } from "react";
2
+ import {
3
+ CalendarClock,
4
+ Check,
5
+ Clock,
6
+ MoreVertical,
7
+ Play,
8
+ TriangleAlert,
9
+ } from "lucide-react";
10
+
11
+ import {
12
+ CronJobDetailDialog,
13
+ useCronJobActions,
14
+ } from "@/features/cron";
2
15
  import type { CronJobView } from "@/shared/lib/api";
3
16
  import { Button } from "@/shared/components/ui/button";
4
- import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
5
- import { useDeleteCronJob } from "@/features/cron";
17
+ import {
18
+ Popover,
19
+ PopoverContent,
20
+ PopoverTrigger,
21
+ } from "@/shared/components/ui/popover";
22
+ import { Switch } from "@/shared/components/ui/switch";
23
+ import {
24
+ Tooltip,
25
+ TooltipContent,
26
+ TooltipProvider,
27
+ TooltipTrigger,
28
+ } from "@/shared/components/ui/tooltip";
6
29
  import {
7
30
  describeCronSchedule,
8
- describeCronSession,
9
- formatCronDate,
31
+ formatRelativeTime,
10
32
  } from "@/shared/lib/cron";
11
33
  import { t } from "@/shared/lib/i18n";
34
+ import { cn } from "@/shared/lib/utils";
35
+
36
+ function CronJobStatus({ job }: { readonly job: CronJobView }) {
37
+ const needsAttention = job.state.lastStatus === "error";
38
+ const label = needsAttention
39
+ ? t("cronNeedsAttention")
40
+ : job.enabled
41
+ ? t("cronRunningNormally")
42
+ : t("cronPaused");
43
+
44
+ return (
45
+ <span
46
+ className={cn(
47
+ "inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium",
48
+ needsAttention
49
+ ? "bg-red-50 text-red-700"
50
+ : job.enabled
51
+ ? "bg-emerald-50 text-emerald-700"
52
+ : "bg-muted text-muted-foreground",
53
+ )}
54
+ >
55
+ <span
56
+ className={cn(
57
+ "h-1.5 w-1.5 rounded-full",
58
+ needsAttention
59
+ ? "bg-red-500"
60
+ : job.enabled
61
+ ? "bg-emerald-500"
62
+ : "bg-muted-foreground/50",
63
+ )}
64
+ />
65
+ {label}
66
+ </span>
67
+ );
68
+ }
69
+
70
+ function SessionCronJobCard({
71
+ cronActions,
72
+ job,
73
+ onOpenDetails,
74
+ }: {
75
+ readonly cronActions: ReturnType<typeof useCronJobActions>;
76
+ readonly job: CronJobView;
77
+ readonly onOpenDetails: (job: CronJobView) => void;
78
+ }) {
79
+ const [menuOpen, setMenuOpen] = useState(false);
80
+ const isPending = cronActions.isPending(job);
81
+ const needsAttention = job.state.lastStatus === "error";
82
+ const StatusIcon = needsAttention ? TriangleAlert : Check;
83
+ const jobLabel = job.name || job.id;
12
84
 
13
- export function SessionCronJobContent({ jobs }: { jobs: readonly CronJobView[] }) {
14
- const deleteCronJob = useDeleteCronJob();
15
- const { confirm, ConfirmDialog } = useConfirmDialog();
85
+ return (
86
+ <article className="rounded-2xl border border-border/60 bg-background px-3.5 py-3 shadow-sm transition-colors hover:bg-muted/20 focus-within:bg-muted/20">
87
+ <div className="flex items-start gap-3">
88
+ <span
89
+ className={cn(
90
+ "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl",
91
+ needsAttention
92
+ ? "bg-amber-50 text-amber-600"
93
+ : job.enabled
94
+ ? "bg-emerald-50 text-emerald-600"
95
+ : "bg-muted text-muted-foreground",
96
+ )}
97
+ >
98
+ <StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
99
+ </span>
100
+ <div className="min-w-0 flex-1">
101
+ <div className="flex min-w-0 items-center gap-2">
102
+ <h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
103
+ {jobLabel}
104
+ </h3>
105
+ <CronJobStatus job={job} />
106
+ </div>
107
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
108
+ <span className="inline-flex items-center gap-1">
109
+ <CalendarClock className="h-3.5 w-3.5" aria-hidden="true" />
110
+ {describeCronSchedule(job)}
111
+ </span>
112
+ {job.enabled && job.state.nextRunAt ? (
113
+ <span className="inline-flex items-center gap-1">
114
+ <Clock className="h-3.5 w-3.5" aria-hidden="true" />
115
+ {t("cronNextRun")} {formatRelativeTime(job.state.nextRunAt)}
116
+ </span>
117
+ ) : null}
118
+ </div>
119
+ </div>
120
+ <TooltipProvider delayDuration={250}>
121
+ <Tooltip>
122
+ <TooltipTrigger asChild>
123
+ <span>
124
+ <Switch
125
+ checked={job.enabled}
126
+ disabled={isPending}
127
+ onCheckedChange={(enabled) => {
128
+ void cronActions.toggleJob(job, enabled);
129
+ }}
130
+ aria-label={job.enabled ? t("cronDisable") : t("cronEnable")}
131
+ />
132
+ </span>
133
+ </TooltipTrigger>
134
+ <TooltipContent>
135
+ {job.enabled ? t("cronDisable") : t("cronEnable")}
136
+ </TooltipContent>
137
+ </Tooltip>
138
+ </TooltipProvider>
139
+ </div>
140
+
141
+ <p className="mt-3 line-clamp-2 text-xs leading-5 text-muted-foreground">
142
+ {job.payload.message}
143
+ </p>
144
+ {!job.enabled ? (
145
+ <p className="mt-2 text-[11px] text-muted-foreground">
146
+ {t("cronToggleHint")}
147
+ </p>
148
+ ) : null}
149
+ {job.state.lastError ? (
150
+ <p className="mt-2 line-clamp-2 text-[11px] leading-5 text-red-700">
151
+ {job.state.lastError}
152
+ </p>
153
+ ) : null}
154
+
155
+ <div className="mt-3 flex items-center justify-between border-t border-border/50 pt-2.5">
156
+ <Button
157
+ type="button"
158
+ variant="ghost"
159
+ size="sm"
160
+ className="h-7 px-2 text-xs"
161
+ onClick={() => onOpenDetails(job)}
162
+ >
163
+ {t("cronEditDetails")}
164
+ </Button>
165
+ <TooltipProvider delayDuration={250}>
166
+ <Popover open={menuOpen} onOpenChange={setMenuOpen}>
167
+ <Tooltip>
168
+ <TooltipTrigger asChild>
169
+ <PopoverTrigger asChild>
170
+ <Button
171
+ type="button"
172
+ variant="ghost"
173
+ size="icon"
174
+ className="h-7 w-7"
175
+ disabled={isPending}
176
+ aria-label={`${t("cronMoreActions")} ${jobLabel}`}
177
+ >
178
+ <MoreVertical className="h-4 w-4" />
179
+ </Button>
180
+ </PopoverTrigger>
181
+ </TooltipTrigger>
182
+ <TooltipContent>{t("cronMoreActions")}</TooltipContent>
183
+ </Tooltip>
184
+ <PopoverContent align="end" sideOffset={4} className="w-40 p-1">
185
+ <button
186
+ type="button"
187
+ disabled={isPending}
188
+ onClick={() => {
189
+ setMenuOpen(false);
190
+ void cronActions.runJob(job);
191
+ }}
192
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
193
+ >
194
+ <Play className="h-3.5 w-3.5 text-muted-foreground" />
195
+ {t("cronRunNow")}
196
+ </button>
197
+ <button
198
+ type="button"
199
+ disabled={isPending}
200
+ onClick={() => {
201
+ setMenuOpen(false);
202
+ void cronActions.deleteJob(job);
203
+ }}
204
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
205
+ >
206
+ {t("delete")}
207
+ </button>
208
+ </PopoverContent>
209
+ </Popover>
210
+ </TooltipProvider>
211
+ </div>
212
+ </article>
213
+ );
214
+ }
16
215
 
17
- const handleDelete = async (job: CronJobView) => {
18
- const confirmed = await confirm({
19
- title: `${t("cronDeleteConfirm")}?`,
20
- description: job.name ? `${job.name} (${job.id})` : job.id,
21
- confirmLabel: t("delete"),
22
- });
23
- if (!confirmed) {
24
- return;
25
- }
26
- deleteCronJob.mutate({ id: job.id });
27
- };
216
+ export function SessionCronJobContent({
217
+ jobs,
218
+ isError = false,
219
+ isLoading = false,
220
+ onRetry,
221
+ }: {
222
+ readonly jobs: readonly CronJobView[];
223
+ readonly isError?: boolean;
224
+ readonly isLoading?: boolean;
225
+ readonly onRetry?: () => void;
226
+ }) {
227
+ const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
228
+ const cronActions = useCronJobActions();
229
+ const selectedJob = jobs.find((job) => job.id === selectedJobId) ?? null;
28
230
 
29
231
  return (
30
232
  <div className="h-full overflow-y-auto custom-scrollbar px-4 py-4">
31
233
  <div className="mb-4">
32
- <div className="text-sm font-semibold text-gray-900">
234
+ <div className="text-sm font-semibold text-foreground">
33
235
  {t("chatWorkspaceSessionCronJobs")}
34
236
  </div>
35
- <div className="mt-1 text-xs text-gray-500">
237
+ <div className="mt-1 text-xs text-muted-foreground">
36
238
  {t("cronTotalLabel")}: {jobs.length}
37
239
  </div>
38
240
  </div>
39
- {jobs.length === 0 ? (
40
- <div className="py-8 text-center text-sm text-gray-500">
241
+ {isLoading ? (
242
+ <div className="py-8 text-center text-sm text-muted-foreground">
243
+ {t("cronLoading")}
244
+ </div>
245
+ ) : isError ? (
246
+ <div className="py-8 text-center text-sm text-muted-foreground">
247
+ <p>{t("cronLoadFailed")}</p>
248
+ {onRetry ? (
249
+ <Button
250
+ type="button"
251
+ variant="ghost"
252
+ size="sm"
253
+ className="mt-2"
254
+ onClick={onRetry}
255
+ >
256
+ {t("cronRetry")}
257
+ </Button>
258
+ ) : null}
259
+ </div>
260
+ ) : jobs.length === 0 ? (
261
+ <div className="py-8 text-center text-sm text-muted-foreground">
41
262
  {t("chatWorkspaceCronJobEmpty")}
42
263
  </div>
43
264
  ) : (
44
- <div className="space-y-3">
265
+ <div className="space-y-2.5">
45
266
  {jobs.map((job) => (
46
- <div
267
+ <SessionCronJobCard
47
268
  key={job.id}
48
- className="rounded-lg border border-gray-200 bg-white px-3 py-3"
49
- >
50
- <div className="flex items-start justify-between gap-3">
51
- <div className="min-w-0">
52
- <div className="truncate text-sm font-semibold text-gray-900">
53
- {job.name || job.id}
54
- </div>
55
- <div className="mt-1 text-[11px] text-gray-400">
56
- {job.id}
57
- </div>
58
- </div>
59
- <Button
60
- type="button"
61
- variant="subtle"
62
- size="sm"
63
- className="h-7 gap-1 rounded-lg px-2"
64
- onClick={() => void handleDelete(job)}
65
- disabled={deleteCronJob.isPending}
66
- >
67
- <Trash2 className="h-3.5 w-3.5" />
68
- {t("delete")}
69
- </Button>
70
- </div>
71
- <div className="mt-3 space-y-1.5 text-xs text-gray-500">
72
- <div>
73
- <span className="font-medium text-gray-700">{t("cronScheduleLabel")}:</span>{" "}
74
- {describeCronSchedule(job)}
75
- </div>
76
- <div>
77
- <span className="font-medium text-gray-700">{t("cronNextRun")}:</span>{" "}
78
- {formatCronDate(job.state.nextRunAt)}
79
- </div>
80
- <div>
81
- <span className="font-medium text-gray-700">{t("cronLastRun")}:</span>{" "}
82
- {formatCronDate(job.state.lastRunAt)}
83
- </div>
84
- <div>
85
- <span className="font-medium text-gray-700">{t("cronLastStatus")}:</span>{" "}
86
- {job.state.lastStatus ?? "-"}
87
- </div>
88
- <div>
89
- <span className="font-medium text-gray-700">{t("cronSessionLabel")}:</span>{" "}
90
- {describeCronSession(job)}
91
- </div>
92
- </div>
93
- <div className="mt-3 whitespace-pre-wrap break-words text-sm text-gray-700">
94
- {job.payload.message}
95
- </div>
96
- </div>
269
+ cronActions={cronActions}
270
+ job={job}
271
+ onOpenDetails={(item) => setSelectedJobId(item.id)}
272
+ />
97
273
  ))}
98
274
  </div>
99
275
  )}
100
- <ConfirmDialog />
276
+ <CronJobDetailDialog
277
+ job={selectedJob}
278
+ open={Boolean(selectedJob)}
279
+ onOpenChange={(open) => {
280
+ if (!open) {
281
+ setSelectedJobId(null);
282
+ }
283
+ }}
284
+ onDelete={(job) => {
285
+ void cronActions.deleteJob(job, () => setSelectedJobId(null));
286
+ }}
287
+ onRun={(job) => {
288
+ void cronActions.runJob(job);
289
+ }}
290
+ onToggle={(job, enabled) => {
291
+ void cronActions.toggleJob(job, enabled);
292
+ }}
293
+ />
294
+ <cronActions.ConfirmDialog />
101
295
  </div>
102
296
  );
103
297
  }
@@ -62,6 +62,11 @@ export function useChatConversationWorkspaceState(
62
62
  activeSideChatDraft,
63
63
  workspaceFileTabs,
64
64
  sessionCronJobs,
65
+ sessionCronJobsError: cronQuery.isError,
66
+ sessionCronJobsLoading: cronQuery.isLoading,
67
+ retrySessionCronJobs: () => {
68
+ void cronQuery.refetch();
69
+ },
65
70
  showWorkspacePanel:
66
71
  snapshot.workspacePanelParentKey === sessionKey &&
67
72
  snapshot.activeWorkspacePanelKind != null,
@@ -52,6 +52,16 @@ vi.mock("@/features/cron/hooks/use-cron-jobs", () => ({
52
52
  useToggleCronJob: () => ({ mutate: mocks.toggleJob }),
53
53
  }));
54
54
 
55
+ vi.mock("@/features/cron/hooks/use-cron-job-actions", () => ({
56
+ useCronJobActions: () => ({
57
+ ConfirmDialog: () => null,
58
+ deleteJob: mocks.deleteJob,
59
+ isPending: () => false,
60
+ runJob: mocks.runJob,
61
+ toggleJob: mocks.toggleJob,
62
+ }),
63
+ }));
64
+
55
65
  vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
56
66
  useConfirmDialog: () => ({
57
67
  confirm: mocks.confirm,
@@ -268,7 +278,7 @@ describe("CronConfig", () => {
268
278
  renderCronConfig();
269
279
 
270
280
  await user.click(
271
- screen.getByRole("button", { name: "编辑 Agent 产品雷达" }),
281
+ screen.getByRole("button", { name: "查看任务详情 Agent 产品雷达" }),
272
282
  );
273
283
  expect(screen.getByRole("dialog")).toBeTruthy();
274
284
  expect(screen.getByText("当前系统只保存最近一次执行快照。")).toBeTruthy();
@@ -283,10 +293,11 @@ describe("CronConfig", () => {
283
293
  renderCronConfig();
284
294
 
285
295
  await user.click(screen.getAllByRole("switch")[0]);
286
- expect(mocks.toggleJob).toHaveBeenCalledWith({
287
- id: "agent-radar",
288
- enabled: false,
289
- });
296
+ expect(mocks.toggleJob).toHaveBeenCalledWith(
297
+ expect.objectContaining({ id: "agent-radar" }),
298
+ false,
299
+ expect.any(Function),
300
+ );
290
301
  expect(
291
302
  screen
292
303
  .getByRole("button", { name: "展开任务 Agent 产品雷达" })
@@ -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>