@nextclaw/ui 0.20.1 → 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 (138) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/assets/{api-BBFMpRGl.js → api-iMkU_o5P.js} +3 -3
  3. package/dist/assets/{appearance-settings-page-C1Erayzl.js → appearance-settings-page-CU5k4b-M.js} +1 -1
  4. package/dist/assets/{book-open-BhTIpBKg.js → book-open-nD8gdMhA.js} +1 -1
  5. package/dist/assets/channels-list-page-Drlt62kY.js +8 -0
  6. package/dist/assets/{config-split-page-By7p-4X-.js → config-split-page-BtJZp1E5.js} +1 -1
  7. package/dist/assets/{confirm-dialog-xpl1Yl7r.js → confirm-dialog-k2-Jq_x3.js} +1 -1
  8. package/dist/assets/{createLucideIcon-B_8khHn8.js → createLucideIcon-DxlwKLr-.js} +1 -1
  9. package/dist/assets/{desktop-update-config-CGFfa7-Z.js → desktop-update-config-BiZmy7FC.js} +1 -1
  10. package/dist/assets/{dist-CXN62DSs.js → dist-Cy_e4kNb.js} +1 -1
  11. package/dist/assets/{dist-DdE-kCbi.js → dist-m08DUswB.js} +1 -1
  12. package/dist/assets/doc-browser-CPsPHh12.js +1 -0
  13. package/dist/assets/{doc-browser-context-B8NCuMv5.js → doc-browser-context-jiy-O4F8.js} +1 -1
  14. package/dist/assets/doc-browser-npOvWtgf.js +1 -0
  15. package/dist/assets/{extensions-CMctxyTf.js → extensions-CIb17__o.js} +1 -1
  16. package/dist/assets/{form-actions-BFgTdxqI.js → form-actions-DGf5zUg_.js} +1 -1
  17. package/dist/assets/index-C86hxgv5.css +1 -0
  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-kLWW6kzt.js +9 -0
  22. package/dist/assets/model-config-page-DSeAjVT_.js +1 -0
  23. package/dist/assets/{navigation-link-BwWrm4k1.js → navigation-link-DAfwFCj4.js} +1 -1
  24. package/dist/assets/plus-D4LtWtTk.js +1 -0
  25. package/dist/assets/privacy-settings-page-tN0Mt15x.js +1 -0
  26. package/dist/assets/providers-config-page-CgPrXR1u.js +1 -0
  27. package/dist/assets/react-8iBFECZE.js +8 -0
  28. package/dist/assets/{refresh-cw-B7NmPJ_Z.js → refresh-cw-D_Eibznw.js} +1 -1
  29. package/dist/assets/remote-Derr_AQp.js +1 -0
  30. package/dist/assets/{rotate-cw-B0ajMaHu.js → rotate-cw-QQjmoWuq.js} +1 -1
  31. package/dist/assets/runtime-config-page-Crq96Jjq.js +1 -0
  32. package/dist/assets/{save-DWnYYpgQ.js → save-W2xthJJ2.js} +1 -1
  33. package/dist/assets/{search-D6pX7FC-.js → search-Vih1R2BD.js} +1 -1
  34. package/dist/assets/{search-config-page-Cx3o_675.js → search-config-page-CtBcL2uc.js} +1 -1
  35. package/dist/assets/{secrets-config-page-tyFl7Rvt.js → secrets-config-page-Q6XfIj0h.js} +2 -2
  36. package/dist/assets/security-config-CE9rkQvq.js +1 -0
  37. package/dist/assets/{select-qEtFTWB5.js → select-lNdozU4o.js} +1 -1
  38. package/dist/assets/skeleton-B__aTcly.js +1 -0
  39. package/dist/assets/{tag-chip-vcjAYdV_.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 +7 -7
  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-read-state.test.tsx +4 -3
  49. package/src/features/chat/components/layout/__tests__/chat-sidebar-session-area.test.tsx +27 -6
  50. package/src/features/chat/components/layout/__tests__/chat-sidebar.test.tsx +17 -17
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -2
  52. package/src/features/chat/components/layout/chat-sidebar-desktop-layout.tsx +33 -5
  53. package/src/features/chat/components/layout/chat-sidebar.tsx +31 -21
  54. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +41 -1
  55. package/src/features/chat/features/conversation/hooks/use-session-pending-input-actions.ts +22 -4
  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/__tests__/chat-message-trigger-details.utils.test.ts +35 -0
  58. package/src/features/chat/features/message/utils/chat-message-timeline.utils.ts +2 -2
  59. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime-queue.test.tsx +50 -0
  60. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-session-list-view.test.tsx +35 -6
  61. package/src/features/chat/features/ncp/hooks/use-ncp-session-list-view.ts +43 -20
  62. package/src/features/chat/features/ncp/hooks/use-ncp-session-queries.ts +15 -1
  63. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +26 -2
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-session-item.test.tsx +55 -27
  65. package/src/features/chat/features/session/components/chat-sidebar-context-card.tsx +60 -0
  66. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +95 -54
  67. package/src/features/chat/features/session/components/chat-sidebar-session-entry.tsx +9 -8
  68. package/src/features/chat/features/session/components/chat-sidebar-session-item.tsx +141 -95
  69. package/src/features/chat/features/session/components/chat-sidebar-session-list.tsx +11 -2
  70. package/src/features/chat/features/session/hooks/use-chat-sidebar-context-counts.ts +40 -0
  71. package/src/features/chat/features/workspace/components/__tests__/chat-session-token-usage.test.tsx +4 -1
  72. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.scroll-restoration.test.tsx +182 -0
  73. package/src/features/chat/features/workspace/components/__tests__/chat-session-workspace-file-preview.test.tsx +16 -9
  74. package/src/features/chat/features/workspace/components/__tests__/session-cron-job-content.test.tsx +78 -0
  75. package/src/features/chat/features/workspace/components/__tests__/workspace-markdown-outline.test.tsx +128 -0
  76. package/src/features/chat/features/workspace/components/chat-session-workspace-file-breadcrumbs.tsx +3 -0
  77. package/src/features/chat/features/workspace/components/chat-session-workspace-file-preview.tsx +65 -2
  78. package/src/features/chat/features/workspace/components/chat-session-workspace-panel-content.tsx +14 -1
  79. package/src/features/chat/features/workspace/components/chat-session-workspace-panel.tsx +9 -0
  80. package/src/features/chat/features/workspace/components/overview/chat-session-token-usage.tsx +17 -0
  81. package/src/features/chat/features/workspace/components/session-cron-job-content.tsx +269 -75
  82. package/src/features/chat/features/workspace/components/workspace-markdown-outline.tsx +156 -0
  83. package/src/features/chat/features/workspace/hooks/use-chat-conversation-workspace-state.ts +5 -0
  84. package/src/features/cron/components/__tests__/cron-config.test.tsx +16 -5
  85. package/src/features/cron/components/cron-config.tsx +20 -46
  86. package/src/features/cron/components/cron-job-row.tsx +1 -1
  87. package/src/features/cron/hooks/__tests__/use-cron-job-actions.test.tsx +95 -0
  88. package/src/features/cron/hooks/use-cron-job-actions.ts +109 -0
  89. package/src/features/cron/index.ts +2 -0
  90. package/src/features/inbox/pages/inbox-page.tsx +18 -2
  91. package/src/features/marketplace/components/marketplace-page.tsx +7 -0
  92. package/src/features/panel-apps/pages/panel-app-main-page.tsx +8 -0
  93. package/src/features/settings/pages/__tests__/privacy-settings-page.test.tsx +14 -5
  94. package/src/features/settings/pages/privacy-settings-page.tsx +38 -2
  95. package/src/platforms/desktop/components/desktop-app-shell.tsx +10 -1
  96. package/src/shared/components/doc-browser/doc-browser.tsx +0 -1
  97. package/src/shared/components/doc-browser/hooks/use-doc-browser-scroll-restoration.ts +9 -113
  98. package/src/shared/components/ui/actions/icon-action-button.tsx +1 -8
  99. package/src/shared/components/ui/tooltip.tsx +16 -1
  100. package/src/shared/hooks/use-config.ts +11 -0
  101. package/src/shared/hooks/use-panel-app-scroll-restoration.test.tsx +66 -0
  102. package/src/shared/hooks/use-panel-app-scroll-restoration.ts +107 -0
  103. package/src/shared/hooks/use-scroll-restoration.test.tsx +62 -0
  104. package/src/shared/hooks/use-scroll-restoration.ts +61 -0
  105. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +1 -0
  106. package/src/shared/lib/api/ncp-session.types.ts +3 -0
  107. package/src/shared/lib/api/types.ts +9 -0
  108. package/src/shared/lib/api/utils/config.utils.ts +5 -0
  109. package/src/shared/lib/api/utils/ncp-session.utils.ts +3 -0
  110. package/src/shared/lib/i18n/locales/en-US/chat.json +15 -5
  111. package/src/shared/lib/i18n/locales/en-US/core.json +17 -9
  112. package/src/shared/lib/i18n/locales/en-US/cron.json +4 -2
  113. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +1 -0
  114. package/src/shared/lib/i18n/locales/zh-CN/chat.json +15 -5
  115. package/src/shared/lib/i18n/locales/zh-CN/core.json +17 -9
  116. package/src/shared/lib/i18n/locales/zh-CN/cron.json +4 -2
  117. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +1 -0
  118. package/src/shared/lib/navigation-history/index.ts +1 -0
  119. package/src/shared/lib/navigation-history/scroll-restoration.manager.test.ts +37 -0
  120. package/src/shared/lib/navigation-history/scroll-restoration.manager.ts +60 -0
  121. package/dist/assets/channels-list-page-OTCkjMOW.js +0 -8
  122. package/dist/assets/doc-browser-5xrHcQ2z.js +0 -1
  123. package/dist/assets/doc-browser-c2yO477-.js +0 -1
  124. package/dist/assets/index-DUCA8m2w.js +0 -109
  125. package/dist/assets/index-QrdUWHfm.css +0 -1
  126. package/dist/assets/loader-circle-CbdTrSp3.js +0 -1
  127. package/dist/assets/mcp-marketplace-page-DGve0muV.js +0 -9
  128. package/dist/assets/mcp-marketplace-page-_37VzG2q.js +0 -1
  129. package/dist/assets/model-config-page-DyC5H0bu.js +0 -1
  130. package/dist/assets/plus-DbeE4r-Q.js +0 -1
  131. package/dist/assets/privacy-settings-page--MOnhAZ6.js +0 -1
  132. package/dist/assets/providers-config-page-ChL3FUvK.js +0 -1
  133. package/dist/assets/react-DVMmnPWz.js +0 -8
  134. package/dist/assets/remote-DFoxj4ls.js +0 -1
  135. package/dist/assets/runtime-config-page-BQdT3nmd.js +0 -1
  136. package/dist/assets/security-config-CUTUm6JT.js +0 -1
  137. package/dist/assets/skeleton-7MYPzn8w.js +0 -1
  138. package/dist/assets/x-DThaS5ZL.js +0 -1
@@ -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
  }
@@ -0,0 +1,156 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type RefObject,
6
+ } from "react";
7
+ import { ListTree } from "lucide-react";
8
+ import { ChatPopoverContent } from "@/features/chat/components/chat-popover-content";
9
+ import { IconActionButton } from "@/shared/components/ui/actions/icon-action-button";
10
+ import { Popover, PopoverTrigger } from "@/shared/components/ui/popover";
11
+ import { t } from "@/shared/lib/i18n";
12
+
13
+ const HEADING_SELECTOR = "h1, h2, h3, h4, h5, h6";
14
+ const HEADING_INDEX_ATTRIBUTE = "data-workspace-markdown-heading-index";
15
+ const HEADING_TOP_GAP = 16;
16
+
17
+ type WorkspaceMarkdownOutlineItem = {
18
+ depth: number;
19
+ index: number;
20
+ label: string;
21
+ };
22
+
23
+ function collectWorkspaceMarkdownOutline(
24
+ container: HTMLElement,
25
+ ): WorkspaceMarkdownOutlineItem[] {
26
+ const levelStack: number[] = [];
27
+ const items: WorkspaceMarkdownOutlineItem[] = [];
28
+
29
+ container
30
+ .querySelectorAll<HTMLElement>(HEADING_SELECTOR)
31
+ .forEach((heading) => {
32
+ heading.removeAttribute(HEADING_INDEX_ATTRIBUTE);
33
+ const label = heading.textContent?.replace(/\s+/g, " ").trim();
34
+ if (!label) return;
35
+
36
+ const level = Number(heading.tagName.slice(1));
37
+ while (
38
+ levelStack.length > 0 &&
39
+ levelStack[levelStack.length - 1]! >= level
40
+ ) {
41
+ levelStack.pop();
42
+ }
43
+
44
+ const index = items.length;
45
+ heading.setAttribute(HEADING_INDEX_ATTRIBUTE, String(index));
46
+ items.push({ depth: levelStack.length, index, label });
47
+ levelStack.push(level);
48
+ });
49
+
50
+ return items;
51
+ }
52
+
53
+ export function WorkspaceMarkdownOutline({
54
+ contentKey,
55
+ documentKey,
56
+ scrollContainerRef,
57
+ }: {
58
+ contentKey: string;
59
+ documentKey: string;
60
+ scrollContainerRef: RefObject<HTMLDivElement>;
61
+ }) {
62
+ const [items, setItems] = useState<WorkspaceMarkdownOutlineItem[]>([]);
63
+ const [open, setOpen] = useState(false);
64
+ const outlineScrollRef = useRef<HTMLElement | null>(null);
65
+ const outlineScrollTopRef = useRef(0);
66
+
67
+ useEffect(() => {
68
+ outlineScrollTopRef.current = 0;
69
+ setOpen(false);
70
+ const container = scrollContainerRef.current;
71
+ if (!container) {
72
+ setItems([]);
73
+ return;
74
+ }
75
+
76
+ setItems(collectWorkspaceMarkdownOutline(container));
77
+ return () => {
78
+ container
79
+ .querySelectorAll<HTMLElement>(`[${HEADING_INDEX_ATTRIBUTE}]`)
80
+ .forEach((heading) => heading.removeAttribute(HEADING_INDEX_ATTRIBUTE));
81
+ };
82
+ }, [contentKey, documentKey, scrollContainerRef]);
83
+
84
+ if (items.length === 0) return null;
85
+
86
+ const label = t("chatWorkspaceMarkdownOutline");
87
+ const handleOpenChange = (nextOpen: boolean) => {
88
+ if (!nextOpen && outlineScrollRef.current) {
89
+ outlineScrollTopRef.current = outlineScrollRef.current.scrollTop;
90
+ }
91
+ setOpen(nextOpen);
92
+ };
93
+ const handleSelect = (index: number) => {
94
+ const container = scrollContainerRef.current;
95
+ const heading = container?.querySelector<HTMLElement>(
96
+ `[${HEADING_INDEX_ATTRIBUTE}="${index}"]`,
97
+ );
98
+ if (!container || !heading) return;
99
+
100
+ handleOpenChange(false);
101
+ const top =
102
+ container.scrollTop +
103
+ heading.getBoundingClientRect().top -
104
+ container.getBoundingClientRect().top;
105
+ container.scrollTo({
106
+ top: Math.max(0, top - HEADING_TOP_GAP),
107
+ });
108
+ };
109
+
110
+ return (
111
+ <Popover open={open} onOpenChange={handleOpenChange}>
112
+ <PopoverTrigger asChild>
113
+ <IconActionButton
114
+ icon={<ListTree className="h-3.5 w-3.5" />}
115
+ label={label}
116
+ size="sm"
117
+ tone="surface"
118
+ className="rounded-sm data-[state=open]:bg-gray-200/80 data-[state=open]:text-gray-900"
119
+ />
120
+ </PopoverTrigger>
121
+
122
+ <ChatPopoverContent
123
+ align="end"
124
+ className="w-[min(20rem,calc(100vw-1.5rem))] rounded-lg p-1.5"
125
+ data-testid="workspace-markdown-outline-popover"
126
+ >
127
+ <nav
128
+ ref={(node) => {
129
+ outlineScrollRef.current = node;
130
+ if (node) node.scrollTop = outlineScrollTopRef.current;
131
+ }}
132
+ aria-label={label}
133
+ className="max-h-80 overflow-y-auto custom-scrollbar"
134
+ >
135
+ <ul className="space-y-0.5">
136
+ {items.map((item) => (
137
+ <li key={item.index}>
138
+ <button
139
+ type="button"
140
+ className="block w-full truncate rounded-md py-1.5 pr-2 text-left text-xs leading-4 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-950 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-border active:bg-gray-200"
141
+ style={{
142
+ paddingInlineStart: `${0.5 + item.depth * 0.75}rem`,
143
+ }}
144
+ title={item.label}
145
+ onClick={() => handleSelect(item.index)}
146
+ >
147
+ {item.label}
148
+ </button>
149
+ </li>
150
+ ))}
151
+ </ul>
152
+ </nav>
153
+ </ChatPopoverContent>
154
+ </Popover>
155
+ );
156
+ }
@@ -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 产品雷达" })