@v587d/capital-generation 2.15.0 → 2.16.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 (64) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +7 -12
  3. package/THIRD_PARTY_NOTICES.md +1 -0
  4. package/automation/TABLER-ICONS-LICENSE +21 -0
  5. package/automation/builtins.js +237 -10
  6. package/automation/client.js +79 -17
  7. package/automation/client.src.cjs +873 -210
  8. package/automation/contract.js +4 -0
  9. package/automation/defaults.js +6 -4
  10. package/automation/index.js +12 -7
  11. package/automation/package.json +5 -2
  12. package/automation/route.js +9 -2
  13. package/automation/runner.js +33 -6
  14. package/automation/scheduler.js +34 -29
  15. package/automation/schema.js +39 -5
  16. package/automation/service.js +102 -10
  17. package/automation/settings.js +6 -0
  18. package/automation/store.js +64 -21
  19. package/automation/task-icons.js +75 -0
  20. package/automation/workspaces.js +113 -13
  21. package/chart-ui/client.js +2 -2
  22. package/chart-ui/package.json +1 -1
  23. package/config/client.js +19 -5
  24. package/config/client.src.cjs +365 -44
  25. package/config/index.js +7 -5
  26. package/config/package.json +1 -1
  27. package/lib/agents/root-tool-policy.d.ts +3 -3
  28. package/lib/agents/root-tool-policy.js +4 -2
  29. package/lib/chart/runtime.d.ts +1 -1
  30. package/lib/chart/runtime.js +95 -0
  31. package/lib/chart/series.d.ts +4 -4
  32. package/lib/chart/series.js +44 -10
  33. package/lib/data-collector/dataset-tools.js +22 -0
  34. package/lib/data-collector/describe.js +1 -0
  35. package/lib/data-collector/hub.js +5 -0
  36. package/lib/data-collector/store.d.ts +5 -0
  37. package/lib/data-collector/store.js +17 -0
  38. package/lib/data-collector/time-axis.d.ts +15 -0
  39. package/lib/data-collector/time-axis.js +73 -0
  40. package/lib/data-collector/tools.d.ts +1 -1
  41. package/lib/data-collector/tools.js +12 -1
  42. package/lib/index.d.ts +5 -0
  43. package/lib/index.js +7 -5
  44. package/lib/sources/tencent-http.js +7 -2
  45. package/lib/sources/tencent-public-core.d.ts +2 -0
  46. package/lib/sources/tencent-public-core.js +5 -0
  47. package/lib/sources/wind-mcp.js +3 -3
  48. package/lib/sources-entry.d.ts +1 -1
  49. package/lib/web-retriever/local-fetch.d.ts +1 -1
  50. package/lib/web-retriever/local-fetch.js +1 -1
  51. package/lib/web-retriever/sources.d.ts +58 -0
  52. package/lib/web-retriever/sources.js +290 -0
  53. package/lib/web-retriever/tools.js +199 -8
  54. package/lib/web-retriever/wind-client.d.ts +1 -1
  55. package/lib/web-retriever/wind-client.js +1 -1
  56. package/package.json +1 -1
  57. package/preset/capital-generation/agent.patch.yml +20 -19
  58. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +6 -3
  59. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +9 -2
  60. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +2 -1
  61. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +2 -0
  62. package/preset/capital-generation/skills/capital-web-evidence/SKILL.md +18 -13
  63. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +30 -15
  64. package/watchlist/package.json +1 -1
@@ -15,11 +15,7 @@ const {
15
15
  IconEditOutlineRegular,
16
16
  IconEllipsisOutlineRegular,
17
17
  IconFolderOpenOutlineRegular,
18
- IconGaugeOutlineRegular,
19
- IconGlobeOutlineRegular,
20
18
  IconInfoOutlineRegular,
21
- IconLightOutlineMedium,
22
- IconListPenOutlineRegular,
23
19
  IconPauseOutlineRegular,
24
20
  IconPlayOutlineRegular,
25
21
  IconPlusOutlineRegular,
@@ -34,6 +30,12 @@ const {
34
30
  Modal,
35
31
  Switch,
36
32
  } = require('@deepseek-ai/dsh-client-ui-primitives')
33
+ const {
34
+ TASK_ICON_CATALOG,
35
+ TASK_ICON_IDS,
36
+ DEFAULT_CUSTOM_ICON,
37
+ builtinTaskIcon,
38
+ } = require('./task-icons.js')
37
39
 
38
40
  const ENTRY_NAME = 'capital-automation'
39
41
  const ROUTE = '/capital-automation'
@@ -42,7 +44,7 @@ const STYLE_TAG = 'capital-automation-css'
42
44
  const ACTIVE_RUN_STATUSES = ['queued', 'starting', 'running']
43
45
  const FAILURE_RUN_STATUSES = ['failed', 'timed_out', 'blocked_requires_user', 'unknown_after_restart']
44
46
  const SKIPPED_RUN_STATUSES = ['skipped_overlap', 'skipped_calendar_unavailable', 'missed']
45
- const DEFAULT_LIMITS = { runTimeoutMinutes: 30, idleTimeoutMinutes: 5, maxSteps: 300, maxToolCalls: 1200 }
47
+ const DEFAULT_LIMITS = { runTimeoutMinutes: 60, idleTimeoutMinutes: 5, maxSteps: 300, maxToolCalls: 1200 }
46
48
  const WEEKDAYS = [
47
49
  { value: 1, label: '一' },
48
50
  { value: 2, label: '二' },
@@ -70,10 +72,14 @@ const TIMEZONES = [
70
72
  'America/Sao_Paulo',
71
73
  'Australia/Sydney',
72
74
  ]
73
- const HISTORY_DAYS = 30
75
+ const HISTORY_DAYS = 371
76
+ const HEATMAP_WEEKS = 53
77
+ const WEEKDAY_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
74
78
  const DETAIL_RECENT_RUN_LIMIT = 3
75
79
  const TITLE_MAX_LENGTH = 12
76
80
  const DESCRIPTION_MAX_LENGTH = 100
81
+ const INSTRUCTION_MAX_LENGTH = 12_000
82
+ const AUTOSAVE_DELAY_MS = 600
77
83
 
78
84
  const zh = {
79
85
  panel: 'CapGen 投研自动化',
@@ -81,10 +87,10 @@ const zh = {
81
87
  subtitle: '定时投研,自动执行,结果可追溯',
82
88
  tabs: { tasks: '任务', runs: '运行' },
83
89
  storageUnavailable: '存储不可用,页面只读',
84
- standbyBanner: '调度器未取得运行权:定时触发与手动运行已暂停,任务配置仍可查看和编辑。',
90
+ standbyBanner: '本窗口的定时任务已暂停:定时触发与手动运行不可用;若另一个 CapGen 窗口在运行,定时任务仍会由它执行。',
85
91
  storageBanner: '投研自动化存储暂时不可用:页面只读,调度器不会启动。',
86
92
  calendarBanner: '交易日历暂时不可用:仅交易日的任务会跳过,其他任务仍按设定时间运行。',
87
- calendarCoverageBanner: '交易日历只覆盖到 {through}:之后仅交易日的任务会按 fail-closed 跳过;交易所公布新一年安排后会自动接上。',
93
+ calendarCoverageBanner: '交易日历只覆盖到 {through}:之后仅交易日的任务会跳过;交易所公布新一年安排后会自动接上。',
88
94
  calendarField: '交易日历',
89
95
  calendarSourceSzse: '深交所官方日历',
90
96
  calendarSourceBundled: '内置表',
@@ -102,7 +108,6 @@ const zh = {
102
108
  clearFilter: '清除筛选',
103
109
  newTask: '新建任务',
104
110
  newTaskTitle: '新建投研任务',
105
- newTaskHint: '任务会在后台新建一个 session 执行,不需要你守在页面上。',
106
111
  editTask: '编辑任务',
107
112
  allTasks: '全部任务',
108
113
  backToTasks: '所有任务',
@@ -123,7 +128,7 @@ const zh = {
123
128
  filterReview: '复盘',
124
129
  filterResearch: '研究',
125
130
  filterLabel: '筛选任务',
126
- category: { news: '资讯', market: '行情', review: '复盘', research: '研究' },
131
+ category: { news: '资讯', market: '行情', review: '复盘', research: '研究', portfolio: '组合', monitor: '监控' },
127
132
  categoryField: '任务分类',
128
133
  categoryFixed: `'复制为我的任务'后,可调整任务分类。`,
129
134
  cadence: { daily: '每天', weekly: '每周', monthly: '每月', once: '单次' },
@@ -146,17 +151,21 @@ const zh = {
146
151
  remove: '删除任务',
147
152
  edit: '编辑',
148
153
  more: '更多操作',
149
- save: '保存',
150
154
  saving: '保存中…',
151
155
  processing: '处理中…',
152
- saved: '已保存',
153
156
  cancel: '取消',
154
157
  create: '创建任务',
155
158
  creating: '创建中…',
156
159
  titleRequired: '标题不能为空。',
157
160
  instructionRequired: '任务指令不能为空。',
158
161
  invalidLimit: '请输入有效数字。',
159
- dirty: '有未保存的修改',
162
+ invalidLimitRange: '请输入 {min}–{max} 的整数。',
163
+ autosaveHint: '修改会自动保存',
164
+ autosavePending: '等待保存…',
165
+ autosaveSaved: '已自动保存',
166
+ autosaveFailed: '保存失败',
167
+ autosaveRetry: '重试',
168
+ autosaveInvalid: '填写完整后自动保存',
160
169
  noDirty: '配置已同步',
161
170
  nextRun: '下次运行',
162
171
  nextRunDormant: '添加后开始调度',
@@ -173,6 +182,9 @@ const zh = {
173
182
  notifications: '通知',
174
183
  descriptionField: '任务说明',
175
184
  titleField: '任务标题',
185
+ iconField: '任务图标',
186
+ iconPickHint: '点击图标更换',
187
+ iconBuiltinHint: '内置模板的图标固定;复制为我的任务后可更换。',
176
188
  instructionField: '任务指令',
177
189
  instructionPlaceholder: '例如:读取今日自选股行情、公告与相关新闻,按「结论—证据—风险」整理成一份可复核的盘后复盘。',
178
190
  titlePlaceholder: '给这个任务起个名字',
@@ -225,10 +237,10 @@ const zh = {
225
237
  maxSteps: '最大步骤',
226
238
  maxToolCalls: '最大工具调用',
227
239
  notifyField: '通知',
228
- notifyInApp: '应用内通知',
229
- notifyFailure: '失败时通知',
240
+ notifyInApp: '写入任务通知',
241
+ notifyFailure: '失败、超时或需人工处理时',
230
242
  notifySuccess: '成功时通知',
231
- notifyHint: '通知只记录在当前 profile;不会发送邮件或外部消息。',
243
+ notifyHint: '只写入当前 profile 的任务通知中心;不会发送邮件或外部消息。',
232
244
  presetLabel: '运行模式',
233
245
  presetValue: '自动模式',
234
246
  presetHint: '所有运行固定使用自动模式。',
@@ -239,11 +251,19 @@ const zh = {
239
251
  techInfo: '技术信息',
240
252
  profileKey: 'profileKey',
241
253
  runHistory: '运行历史',
242
- last30Days: '最近 30 天',
254
+ last12Months: '最近 12 个月',
243
255
  runCount: '{n} 次运行',
244
256
  successCount: '{n} 成功',
245
257
  failureCount: '{n} 失败',
246
- historyEmpty: '最近 30 天还没有运行记录。',
258
+ activeCount: '{n} 进行中',
259
+ historyEmpty: '最近 12 个月还没有运行记录。',
260
+ heatLess: '少',
261
+ heatMore: '多',
262
+ heatFailed: '有失败',
263
+ heatActive: '进行中',
264
+ heatNoRuns: '没有运行',
265
+ heatFuture: '尚未到来',
266
+ heatSummary: '最近 12 个月共 {total} 次运行:{success} 成功,{failure} 失败。',
247
267
  historyViewAll: '查看全部运行',
248
268
  runFilterAll: '全部',
249
269
  runFilterActive: '进行中',
@@ -270,15 +290,32 @@ const zh = {
270
290
  triggerScheduled: '定时',
271
291
  openSession: '打开 session',
272
292
  runSummaryFallback: '运行结束但没有摘要。',
273
- notificationsEmpty: '没有通知。',
293
+ notificationsEmpty: '没有任务通知。',
274
294
  markRead: '全部标为已读',
275
295
  markTaskRead: '本任务标为已读',
276
- notificationsTitle: '通知',
277
- notificationsHint: '运行失败、超时、调度器待命与交易日历问题会出现在这里。',
296
+ notificationsTitle: '任务通知',
297
+ notificationsEntry: '任务通知',
298
+ notificationsHint: '运行失败、超时、成功与交易日历问题会出现在这里。',
278
299
  notificationsUnread: '{n} 条未读',
279
300
  allRead: '全部已读',
280
- noNotifications: '没有未读或历史通知。',
301
+ noNotifications: '没有未读或历史任务通知。',
281
302
  notificationOpenTask: '查看任务',
303
+ systemStatus: '系统状态',
304
+ systemActive: '定时调度正常',
305
+ systemStandby: '本窗口待命,不启动新的定时运行',
306
+ systemStarting: '定时调度启动中',
307
+ systemStopped: '定时调度未运行',
308
+ systemUnavailable: '定时调度不可用',
309
+ systemCalendarOk: '交易日历正常',
310
+ systemCalendarUnavailable: '交易日历不可用',
311
+ systemDetails: '详情',
312
+ systemHideDetails: '收起',
313
+ systemLastStandby: '上次中断',
314
+ systemNoStandby: '没有调度中断记录。',
315
+ systemReasonLeaseHeld: '另一个 CapGen 窗口正在负责定时任务。',
316
+ systemReasonLeaseLost: '本窗口的调度权已失效,可能已由另一个窗口接管。',
317
+ systemReasonUnverified: '本窗口的调度权校验未通过。',
318
+ systemTechnical: '技术信息',
282
319
  close: '关闭',
283
320
  confirmDeleteTitle: '删除这个任务?',
284
321
  confirmDeleteBody: '任务配置、运行记录与相关通知会一起删除,无法恢复。',
@@ -299,7 +336,7 @@ const zh = {
299
336
  markReadDone: '已标为已读',
300
337
  errorGeneric: '操作失败,请稍后重试。',
301
338
  needConfirm: '需要用户确认',
302
- schedulerBlocked: '调度器未取得运行权,暂时不能手动运行。',
339
+ schedulerBlocked: '本窗口的定时任务已暂停,暂时不能手动运行。',
303
340
  runBlocked: '任务正在运行,结束或取消后才能再次运行。',
304
341
  configWhileRunning: '任务正在运行;修改会在下一次运行时生效。',
305
342
  detailRecentRuns: '最近 {n} 次运行',
@@ -312,8 +349,8 @@ const zh = {
312
349
  notificationFallbackFailed: '运行失败;打开对应任务或 session 查看详情。',
313
350
  notificationFallbackTimeout: '运行超时;打开对应任务或 session 查看详情。',
314
351
  notificationFallbackSuccess: '运行完成。',
315
- notificationFallbackStandby: '调度器未取得运行权,定时触发暂停。',
316
- notificationFallbackCalendar: '交易日历不可用,交易日任务已跳过。',
352
+ notificationFallbackStandby: '定时触发已暂停。',
353
+ notificationFallbackCalendar: '暂时无法确认交易日,相关任务已跳过。',
317
354
  notificationFallbackSkipped: '本次计划已跳过。',
318
355
  statusLabel: '状态',
319
356
  triggerSummary: '日程',
@@ -343,8 +380,8 @@ function installStyles() {
343
380
  const style = document.createElement('style')
344
381
  style.setAttribute(`data-${STYLE_TAG}`, 'true')
345
382
  style.textContent = `
346
- .capital-automation-page, .capital-automation-modal { --ca-card: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1)); --ca-line: var(--dsw-alias-border-l3, var(--dsw-alias-border-l1)); --ca-line-soft: var(--dsw-alias-border-l2, var(--dsw-alias-border-l1)); --ca-table-line: var(--ca-line); --ca-muted: var(--dsw-alias-label-tertiary); --ca-caption: var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary)); --ca-hover: var(--dsw-alias-interactive-bg-hover); --ca-field: var(--dsw-alias-bg-module-platform); --ca-business: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); --ca-success: var(--dsw-alias-state-success-primary); --ca-error: var(--dsw-alias-state-error-primary); --ca-warn: var(--dsw-alias-state-warn-label); --ca-business-text: var(--ca-business); --ca-success-text: var(--ca-success); --ca-error-text: var(--ca-error); --ca-warn-text: var(--ca-warn); }
347
- body:not([data-ds-dark-theme]) .capital-automation-page, body:not([data-ds-dark-theme]) .capital-automation-modal { --ca-line: #0000001f; --ca-line-soft: #0000001a; --ca-table-line: #00000026; --ca-muted: #5d626a; --ca-caption: #6a6f77; --ca-hover: #eef1f5; --ca-field: #f7f8fa; --ca-business: #2f63d6; --ca-success: #16a34a; --ca-error: #dc2626; --ca-warn: #d97706; --ca-business-text: #2f63d6; --ca-success-text: #15803d; --ca-error-text: #c81e1e; --ca-warn-text: #b45309; --ca-tag-news: #2f6fd0; --ca-tag-market: #a85a08; --ca-tag-review: #6d4ac4; --ca-tag-research: #0f766e; --ca-tag-custom: #b3246d; }
383
+ .capital-automation-page, .capital-automation-modal { --ca-card: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1)); --ca-line: var(--dsw-alias-border-l3, var(--dsw-alias-border-l1)); --ca-line-soft: var(--dsw-alias-border-l2, var(--dsw-alias-border-l1)); --ca-table-line: var(--ca-line); --ca-muted: var(--dsw-alias-label-tertiary); --ca-caption: var(--dsw-alias-label-caption, var(--dsw-alias-label-tertiary)); --ca-hover: var(--dsw-alias-interactive-bg-hover); --ca-field: var(--dsw-alias-bg-module-platform); --ca-business: var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)); --ca-success: var(--dsw-alias-state-success-primary); --ca-error: var(--dsw-alias-state-error-primary); --ca-warn: var(--dsw-alias-state-warn-label); --ca-business-text: var(--ca-business); --ca-success-text: var(--ca-success); --ca-error-text: var(--ca-error); --ca-warn-text: var(--ca-warn); --ca-heat-0: #262626; --ca-heat-1: #1b5e3a; --ca-heat-2: #2f8a56; --ca-heat-3: #40b366; --ca-heat-4: #56d364; --ca-heat-fail: #f85149; }
384
+ body:not([data-ds-dark-theme]) .capital-automation-page, body:not([data-ds-dark-theme]) .capital-automation-modal { --ca-line: #0000001f; --ca-line-soft: #0000001a; --ca-table-line: #00000026; --ca-muted: #5d626a; --ca-caption: #6a6f77; --ca-hover: #eef1f5; --ca-field: #f7f8fa; --ca-business: #2f63d6; --ca-success: #16a34a; --ca-error: #dc2626; --ca-warn: #d97706; --ca-business-text: #2f63d6; --ca-success-text: #15803d; --ca-error-text: #c81e1e; --ca-warn-text: #b45309; --ca-tag-news: #2f6fd0; --ca-tag-market: #a85a08; --ca-tag-review: #6d4ac4; --ca-tag-research: #0f766e; --ca-tag-portfolio: #4d7c0f; --ca-tag-monitor: #0e7490; --ca-tag-custom: #b3246d; --ca-heat-0: #eef1f4; --ca-heat-1: #b7e4c0; --ca-heat-2: #74c98a; --ca-heat-3: #3ba55c; --ca-heat-4: #1f7a3a; --ca-heat-fail: #cf222e; }
348
385
  .capital-automation-page { display: flex; flex-direction: column; min-height: 0; height: 100%; color: var(--dsw-alias-label-primary); }
349
386
  .capital-automation-head { flex: none; padding: 18px 0 0; }
350
387
  .capital-automation-headinner { box-sizing: border-box; width: min(1080px, 100%); margin: 0 auto; padding: 0 clamp(20px, 3vw, 36px); }
@@ -384,7 +421,7 @@ function installStyles() {
384
421
  .capital-automation-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
385
422
  .capital-automation-container { box-sizing: border-box; width: min(1080px, 100%); margin: 0 auto; padding: 20px clamp(20px, 3vw, 36px) 72px; }
386
423
  .capital-automation-card { box-sizing: border-box; border: .5px solid var(--ca-line); border-radius: 16px; background: var(--ca-card); }
387
- .capital-automation-history { padding: 18px 20px 14px; }
424
+ .capital-automation-history { position: relative; padding: 18px 20px 14px; }
388
425
  .capital-automation-historyhead { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px 18px; }
389
426
  .capital-automation-historytitle { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
390
427
  .capital-automation-historytitle h2 { margin: 0; font-size: 14px; line-height: 22px; font-weight: 600; }
@@ -393,18 +430,45 @@ function installStyles() {
393
430
  .capital-automation-historystat { display: inline-flex; align-items: center; gap: 5px; font-variant-numeric: tabular-nums; }
394
431
  .capital-automation-historystat[data-tone="ok"] { color: var(--ca-success-text); }
395
432
  .capital-automation-historystat[data-tone="error"] { color: var(--ca-error-text); }
433
+ .capital-automation-historystat[data-tone="active"] { color: var(--ca-business-text); }
396
434
  .capital-automation-statusdot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
397
435
  .capital-automation-historylink { display: inline-flex; align-items: center; gap: 2px; height: 24px; padding: 0 6px; border: 0; border-radius: 7px; background: transparent; color: var(--ca-muted); font: inherit; font-size: 12px; cursor: pointer; }
398
436
  .capital-automation-historylink:hover { background: var(--ca-hover); color: var(--dsw-alias-label-primary); }
399
- .capital-automation-timeline { display: grid; grid-template-columns: repeat(${HISTORY_DAYS}, minmax(0, 1fr)); align-items: end; gap: 2px; height: 28px; margin-top: 18px; border-bottom: 1px dashed var(--ca-line); }
400
- .capital-automation-timeline-day { position: relative; display: flex; align-items: flex-end; justify-content: center; height: 100%; padding-bottom: 3px; }
401
- .capital-automation-timeline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ca-caption); box-shadow: 0 0 0 2px var(--ca-card); }
402
- .capital-automation-timeline-dot[data-tone="ok"] { background: var(--ca-success); }
403
- .capital-automation-timeline-dot[data-tone="error"] { background: var(--ca-error); }
404
- .capital-automation-timeline-dot[data-tone="active"] { background: var(--ca-business); }
405
- .capital-automation-timeline-dot[data-tone="warn"] { background: var(--ca-warn); }
406
- .capital-automation-timelinelabels { display: flex; justify-content: space-between; margin-top: 7px; color: var(--ca-caption); font-size: 11px; line-height: 16px; }
407
- .capital-automation-historyempty { margin-top: 12px; text-align: center; color: var(--ca-muted); font-size: 12px; line-height: 18px; }
437
+ .capital-automation-heatmap { display: flex; align-items: flex-start; gap: 8px; margin-top: 16px; }
438
+ .capital-automation-heatweekdays { flex: none; display: grid; grid-template-rows: repeat(7, 1fr); gap: 3px; width: 28px; padding-top: 22px; color: var(--ca-caption); font-size: 10.5px; line-height: 1; align-self: stretch; }
439
+ .capital-automation-heatweekday { display: flex; align-items: center; }
440
+ .capital-automation-heatscroll { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px; scrollbar-width: thin; }
441
+ .capital-automation-heatscroll[data-faded="true"] { -webkit-mask-image: linear-gradient(to right, transparent 0, #000 18px); mask-image: linear-gradient(to right, transparent 0, #000 18px); }
442
+ .capital-automation-heatcanvas { min-width: 620px; }
443
+ .capital-automation-heatmonths { display: grid; grid-template-columns: repeat(${HEATMAP_WEEKS}, minmax(0, 1fr)); gap: 3px; height: 16px; margin-bottom: 6px; }
444
+ .capital-automation-heatmonth { color: var(--ca-caption); font-size: 10.5px; line-height: 16px; white-space: nowrap; letter-spacing: .01em; }
445
+ .capital-automation-heatgrid { display: grid; grid-template-columns: repeat(${HEATMAP_WEEKS}, minmax(0, 1fr)); grid-template-rows: repeat(7, auto); grid-auto-flow: column; gap: 3px; }
446
+ .capital-automation-heatcell { display: block; min-width: 0; aspect-ratio: 1; border-radius: 2.5px; background: var(--ca-heat-0); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--dsw-alias-label-primary) 4%, transparent); transition: background .12s, box-shadow .12s, outline-color .12s; }
447
+ .capital-automation-heatcell[data-level="1"] { background: var(--ca-heat-1); }
448
+ .capital-automation-heatcell[data-level="2"] { background: var(--ca-heat-2); }
449
+ .capital-automation-heatcell[data-level="3"] { background: var(--ca-heat-3); }
450
+ .capital-automation-heatcell[data-level="4"] { background: var(--ca-heat-4); }
451
+ .capital-automation-heatcell[data-future="true"] { opacity: .45; }
452
+ .capital-automation-heatcell[data-failed="true"] { box-shadow: inset 0 0 0 1.5px var(--ca-heat-fail); }
453
+ .capital-automation-heatcell[data-active="true"] { box-shadow: inset 0 0 0 1.5px var(--ca-business); animation: capital-automation-heatpulse 1.8s ease-in-out infinite; }
454
+ .capital-automation-heatcell:hover { outline: 1px solid var(--dsw-alias-label-primary); outline-offset: 1px; }
455
+ .capital-automation-heattip { position: absolute; z-index: 5; min-width: 128px; max-width: 220px; padding: 7px 9px; border: .5px solid var(--ca-line); border-radius: 9px; background: var(--dsw-alias-bg-layer-3, var(--ca-card)); box-shadow: 0 8px 22px rgba(0, 0, 0, .28); color: var(--dsw-alias-label-primary); pointer-events: none; transform: translate(-50%, -100%); }
456
+ .capital-automation-heattipdate { display: block; font-size: 11.5px; font-weight: 600; line-height: 16px; white-space: nowrap; }
457
+ .capital-automation-heattipmeta { display: block; margin-top: 2px; color: var(--ca-muted); font-size: 11px; line-height: 16px; white-space: nowrap; font-variant-numeric: tabular-nums; }
458
+ .capital-automation-heatfoot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 14px; margin-top: 8px; color: var(--ca-caption); font-size: 11px; line-height: 16px; }
459
+ .capital-automation-heatrange { font-variant-numeric: tabular-nums; }
460
+ .capital-automation-heatlegend { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 5px; }
461
+ .capital-automation-heatlegendlabel { color: var(--ca-caption); }
462
+ .capital-automation-heatsample { width: 10px; height: 10px; border-radius: 2px; background: var(--ca-heat-0); box-shadow: inset 0 0 0 .5px color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent); }
463
+ .capital-automation-heatsample[data-level="1"] { background: var(--ca-heat-1); }
464
+ .capital-automation-heatsample[data-level="2"] { background: var(--ca-heat-2); }
465
+ .capital-automation-heatsample[data-level="3"] { background: var(--ca-heat-3); }
466
+ .capital-automation-heatsample[data-level="4"] { background: var(--ca-heat-4); }
467
+ .capital-automation-heatkey { display: inline-flex; align-items: center; gap: 5px; margin-right: 7px; }
468
+ .capital-automation-heatfailbox { width: 10px; height: 10px; border-radius: 2px; background: var(--ca-heat-0); box-shadow: inset 0 0 0 1.5px var(--ca-heat-fail); }
469
+ .capital-automation-heatactivebox { width: 10px; height: 10px; border-radius: 2px; background: var(--ca-heat-0); box-shadow: inset 0 0 0 1.5px var(--ca-business); }
470
+ @keyframes capital-automation-heatpulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
471
+ @media (prefers-reduced-motion: reduce) { .capital-automation-heatcell[data-active="true"] { animation: none; } }
408
472
  .capital-automation-section { margin-top: 26px; }
409
473
  .capital-automation-sectionhead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 10px; }
410
474
  .capital-automation-sectionhead h2 { margin: 0; font-size: 14px; line-height: 22px; font-weight: 600; }
@@ -444,6 +508,8 @@ function installStyles() {
444
508
  .capital-automation-glyph[data-tone="market"] { background: linear-gradient(135deg, #ffbd4a, #f97316); }
445
509
  .capital-automation-glyph[data-tone="review"] { background: linear-gradient(135deg, #b18cff, #7c3aed); }
446
510
  .capital-automation-glyph[data-tone="research"] { background: linear-gradient(135deg, #39d7a8, #0d9488); }
511
+ .capital-automation-glyph[data-tone="portfolio"] { background: linear-gradient(135deg, #84cc16, #4d7c0f); }
512
+ .capital-automation-glyph[data-tone="monitor"] { background: linear-gradient(135deg, #06b6d4, #0e7490); }
447
513
  .capital-automation-glyph[data-tone="custom"] { background: linear-gradient(135deg, #f472b6, #db2777); }
448
514
  .capital-automation-identitytext { min-width: 0; }
449
515
  .capital-automation-tasktitlerow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; min-width: 0; }
@@ -456,12 +522,16 @@ function installStyles() {
456
522
  .capital-automation-tag[data-tone="market"] { color: #ffb45c; }
457
523
  .capital-automation-tag[data-tone="review"] { color: #b79aff; }
458
524
  .capital-automation-tag[data-tone="research"] { color: #56d9b4; }
525
+ .capital-automation-tag[data-tone="portfolio"] { color: #a3e635; }
526
+ .capital-automation-tag[data-tone="monitor"] { color: #22d3ee; }
459
527
  .capital-automation-tag[data-tone="custom"] { color: #f79bc3; }
460
528
  body:not([data-ds-dark-theme]) .capital-automation-tag { background: color-mix(in srgb, currentColor 9%, var(--ca-card)); }
461
529
  body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="news"] { color: var(--ca-tag-news); }
462
530
  body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="market"] { color: var(--ca-tag-market); }
463
531
  body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="review"] { color: var(--ca-tag-review); }
464
532
  body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="research"] { color: var(--ca-tag-research); }
533
+ body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="portfolio"] { color: var(--ca-tag-portfolio); }
534
+ body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="monitor"] { color: var(--ca-tag-monitor); }
465
535
  body:not([data-ds-dark-theme]) .capital-automation-tag[data-tone="custom"] { color: var(--ca-tag-custom); }
466
536
  .capital-automation-schedule { display: block; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
467
537
  .capital-automation-schedulesub { display: block; margin-top: 1px; color: var(--ca-caption); font-size: 11px; line-height: 16px; }
@@ -483,8 +553,11 @@ function installStyles() {
483
553
  .capital-automation-loading { display: flex; flex-direction: column; pointer-events: none; }
484
554
  .capital-automation-skeletonlabel { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
485
555
  .capital-automation-skeleton { display: block; flex: none; max-width: 100%; border-radius: 6px; background: var(--dsw-alias-bg-skeleton); animation: capital-automation-breathe 1.8s ease-in-out infinite; }
486
- .capital-automation-skeletontimeline { display: flex; align-items: flex-end; gap: 2px; height: 28px; margin-top: 18px; border-bottom: 1px dashed var(--ca-line); }
487
- .capital-automation-skeletontimeline > .capital-automation-skeleton { flex: 1 1 0; min-width: 0; }
556
+ .capital-automation-skeletonheat { margin-top: 16px; padding-left: 36px; }
557
+ .capital-automation-skeletonmonths { display: grid; grid-template-columns: repeat(${HEATMAP_WEEKS}, minmax(0, 1fr)); gap: 3px; height: 16px; margin-bottom: 6px; }
558
+ .capital-automation-skeletonmonths > .capital-automation-skeleton { width: 22px; height: 8px; border-radius: 3px; }
559
+ .capital-automation-skeletongrid { display: grid; grid-template-columns: repeat(${HEATMAP_WEEKS}, minmax(0, 1fr)); grid-template-rows: repeat(7, auto); grid-auto-flow: column; gap: 3px; }
560
+ .capital-automation-skeletongrid > .capital-automation-skeleton { width: 100%; aspect-ratio: 1; border-radius: 2.5px; }
488
561
  .capital-automation-skeletonstack { display: flex; flex: 1 1 auto; flex-direction: column; gap: 6px; min-width: 0; }
489
562
  .capital-automation-skeletonrow .capital-automation-trowmain { min-height: 57px; cursor: default; }
490
563
  .capital-automation-skeletonrow .capital-automation-cell[data-cell] { display: flex; flex-direction: column; gap: 5px; }
@@ -558,6 +631,22 @@ function installStyles() {
558
631
  .capital-automation-fieldhead { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; min-width: 0; }
559
632
  .capital-automation-fieldlabel { color: var(--ca-muted); font-size: 11.5px; line-height: 17px; }
560
633
  .capital-automation-fieldhint { color: var(--ca-caption); font-size: 11px; line-height: 16px; font-variant-numeric: tabular-nums; }
634
+ .capital-automation-glyphbtn { display: inline-flex; flex: none; align-items: center; gap: 4px; padding: 0; border: 0; border-radius: 14px; background: transparent; cursor: pointer; }
635
+ .capital-automation-glyphbtn:hover:not(:disabled) .capital-automation-glyph { box-shadow: inset 0 1px 0 rgba(255,255,255,.22), 0 0 0 2px color-mix(in srgb, var(--ca-business) 52%, transparent); }
636
+ .capital-automation-glyphbtn:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--ca-business)); outline-offset: 2px; }
637
+ .capital-automation-glyphbtn:disabled { cursor: not-allowed; opacity: .55; }
638
+ .capital-automation-glyphbtnchevron { display: grid; flex: none; place-items: center; width: 12px; color: var(--ca-muted); }
639
+ .capital-automation-glyphbtn:hover:not(:disabled) .capital-automation-glyphbtnchevron { color: var(--ca-business-text); }
640
+ .capital-automation-iconrow { display: flex; align-items: center; gap: 12px; min-width: 0; }
641
+ .capital-automation-iconrowbody { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
642
+ /* The menu is portaled to body, outside the page's --ca-* scope; use host tokens. */
643
+ .capital-automation-iconmenu { box-sizing: border-box; width: 400px; max-width: calc(100vw - 24px); padding: 10px; }
644
+ .capital-automation-iconmenu-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); gap: 6px; }
645
+ button.capital-automation-iconmenu-item { box-sizing: border-box; display: inline-grid; place-items: center; width: 100%; max-width: 34px; aspect-ratio: 1; justify-self: center; padding: 0; border: 1px solid transparent; border-radius: 9px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; transition: background .15s, border-color .15s, color .15s; }
646
+ button.capital-automation-iconmenu-item:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
647
+ button.capital-automation-iconmenu-item[data-selected="true"] { border-color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); color: var(--dsw-alias-brand-primary); }
648
+ button.capital-automation-iconmenu-item:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
649
+ button.capital-automation-iconmenu-item:disabled { opacity: .4; cursor: not-allowed; }
561
650
  .capital-automation-field input { box-sizing: border-box; width: 100%; padding: 0 10px; border: .5px solid var(--ca-line-soft); border-radius: 10px; background: var(--ca-field); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12.5px; line-height: 32px; }
562
651
  .capital-automation-field input { height: 34px; }
563
652
  .capital-automation-texteditor { color: var(--dsw-alias-label-primary); font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto; outline: 0; cursor: text; }
@@ -586,6 +675,9 @@ function installStyles() {
586
675
  .capital-automation-formactions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: .5px solid var(--ca-line); }
587
676
  .capital-automation-formactions[data-sticky="true"] { position: sticky; bottom: -1px; z-index: 2; margin-top: 2px; padding: 12px 0 4px; background: var(--dsw-alias-bg-base); }
588
677
  .capital-automation-formstatus { margin-right: auto; color: var(--ca-caption); font-size: 11.5px; line-height: 18px; }
678
+ .capital-automation-autosavestate { min-width: 0; max-width: min(520px, 70%); overflow: hidden; color: var(--ca-caption); font-size: 11.5px; line-height: 18px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
679
+ .capital-automation-autosavestate[data-tone="ok"] { color: var(--ca-success-text); }
680
+ .capital-automation-autosavestate[data-tone="error"] { color: var(--ca-error-text); }
589
681
  .capital-automation-triggerbox { display: flex; flex-direction: column; gap: 12px; padding: 14px 16px; border: .5px solid var(--ca-line); border-radius: 14px; background: var(--ca-field); }
590
682
  .capital-automation-triggerbox[data-invalid="true"] { border-color: color-mix(in srgb, var(--ca-error) 52%, var(--ca-line)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ca-error) 9%, transparent); }
591
683
  .capital-automation-triggerhead { display: flex; flex-wrap: nowrap; align-items: center; justify-content: space-between; gap: 12px; min-width: 0; }
@@ -668,6 +760,13 @@ function installStyles() {
668
760
  .capital-automation-notificationtitle { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--dsw-alias-label-primary); font-size: 12.5px; line-height: 19px; font-weight: 500; }
669
761
  .capital-automation-notificationmessage { display: -webkit-box; overflow: hidden; margin-top: 2px; color: var(--ca-muted); font-size: 11.5px; line-height: 18px; overflow-wrap: anywhere; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
670
762
  .capital-automation-notificationtime { display: block; margin-top: 3px; color: var(--ca-caption); font-size: 11px; line-height: 16px; }
763
+ .capital-automation-systemstatus { margin-top: 22px; padding: 12px 0 4px; border-top: .5px solid var(--ca-line); color: var(--ca-caption); font-size: 11px; line-height: 17px; }
764
+ .capital-automation-systemline { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
765
+ .capital-automation-systemtoggle { border: 0; padding: 0; background: transparent; color: var(--ca-muted); font: inherit; cursor: pointer; }
766
+ .capital-automation-systemtoggle:hover { color: var(--dsw-alias-label-primary); text-decoration: underline; }
767
+ .capital-automation-systemdetail { margin-top: 6px; color: var(--ca-muted); }
768
+ .capital-automation-systemdetail p { margin: 2px 0; }
769
+ .capital-automation-systemdetail summary { color: var(--ca-caption); cursor: pointer; }
671
770
  .capital-automation-modal { width: min(720px, calc(100vw - 32px)); max-width: min(720px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); }
672
771
  .capital-automation-modal-wizard { width: min(600px, calc(100vw - 32px)); max-width: min(600px, calc(100vw - 32px)); }
673
772
  .capital-automation-modal-content { max-height: calc(100dvh - 128px); overflow: auto; }
@@ -755,6 +854,7 @@ function createApi() {
755
854
  credentials: 'same-origin',
756
855
  headers: options.body === undefined ? {} : { 'content-type': 'application/json' },
757
856
  body: options.body === undefined ? undefined : JSON.stringify(options.body),
857
+ ...(options.keepalive === true ? { keepalive: true } : {}),
758
858
  ...(controller === undefined ? {} : { signal: controller.signal }),
759
859
  })
760
860
  try {
@@ -777,12 +877,13 @@ function createApi() {
777
877
  tasks: () => request('/tasks'),
778
878
  detail: (id) => request(`/tasks/${encodeURIComponent(id)}`),
779
879
  create: (body) => request('/tasks', { method: 'POST', body }),
780
- patch: (id, body) => request(`/tasks/${encodeURIComponent(id)}`, { method: 'PATCH', body }),
880
+ patch: (id, body) => request(`/tasks/${encodeURIComponent(id)}`, { method: 'PATCH', body, keepalive: true }),
781
881
  remove: (id) => request(`/tasks/${encodeURIComponent(id)}`, { method: 'DELETE' }),
782
882
  action: (id, action) => request(`/tasks/${encodeURIComponent(id)}/${action}`, { method: 'POST' }),
783
883
  run: (id) => request(`/tasks/${encodeURIComponent(id)}/run`, { method: 'POST' }),
784
884
  cancel: (id) => request(`/runs/${encodeURIComponent(id)}/cancel`, { method: 'POST' }),
785
885
  runs: (query = '') => request(`/runs${query}`),
886
+ history: (query = '') => request(`/runs/history${query}`),
786
887
  notifications: () => request('/notifications'),
787
888
  markRead: (body) => request('/notifications/read', { method: 'POST', body }),
788
889
  }
@@ -841,24 +942,124 @@ function toneOf(task) {
841
942
  return task?.origin === 'custom' ? 'custom' : (task?.category ?? 'news')
842
943
  }
843
944
 
844
- function taskGlyph(task) {
845
- if (task?.origin === 'custom') return IconSparkleRegular
846
- if (task?.sourceBuiltinId === 'morning-brief') return IconLightOutlineMedium
847
- if (task?.category === 'market') return IconGaugeOutlineRegular
848
- if (task?.category === 'review') return IconListPenOutlineRegular
849
- if (task?.category === 'research') return IconSearchOutlineRegular
850
- return IconGlobeOutlineRegular
945
+ // Twelve templates cannot stay in record-id order: read them daily → weekly → monthly, then by
946
+ // day and wall-clock time, so the card grid matches how the user plans the week.
947
+ const CADENCE_RANK = { daily: 0, weekly: 1, monthly: 2, once: 3 }
948
+ function firstWeekday(trigger) {
949
+ const days = Array.isArray(trigger?.weekdays) ? trigger.weekdays : []
950
+ return days.length === 0 ? 0 : Math.min(...days)
951
+ }
952
+ function compareTemplates(left, right) {
953
+ const rank = (CADENCE_RANK[left.trigger?.cadence] ?? 9) - (CADENCE_RANK[right.trigger?.cadence] ?? 9)
954
+ if (rank !== 0) return rank
955
+ const day = firstWeekday(left.trigger) - firstWeekday(right.trigger)
956
+ if (day !== 0) return day
957
+ return String(left.trigger?.time ?? '').localeCompare(String(right.trigger?.time ?? ''))
958
+ || String(left.title).localeCompare(String(right.title))
959
+ }
960
+
961
+ function iconDefinition(id) {
962
+ return typeof id === 'string' && Object.prototype.hasOwnProperty.call(TASK_ICON_CATALOG, id)
963
+ ? TASK_ICON_CATALOG[id]
964
+ : undefined
965
+ }
966
+
967
+ function taskIconId(task) {
968
+ if (iconDefinition(task?.icon) !== undefined) return task.icon
969
+ if (task?.origin === 'custom') return DEFAULT_CUSTOM_ICON
970
+ return builtinTaskIcon(task?.sourceBuiltinId)
971
+ }
972
+
973
+ function taskIcon(task) {
974
+ return iconDefinition(taskIconId(task)) ?? TASK_ICON_CATALOG[DEFAULT_CUSTOM_ICON]
975
+ }
976
+
977
+ // Tabler glyphs are a 24 grid with 2px strokes; the tile supplies size and color so one drawing
978
+ // works on every category tone.
979
+ function TaskGlyph(props) {
980
+ const { task, size } = props
981
+ const icon = taskIcon(task)
982
+ return h('svg', {
983
+ viewBox: '0 0 24 24',
984
+ width: size,
985
+ height: size,
986
+ fill: 'none',
987
+ stroke: 'currentColor',
988
+ strokeWidth: 2,
989
+ strokeLinecap: 'round',
990
+ strokeLinejoin: 'round',
991
+ 'aria-hidden': 'true',
992
+ focusable: 'false',
993
+ }, h('path', { d: icon.d }))
851
994
  }
852
995
 
853
996
  function Glyph(props) {
854
997
  const { task, size = 34, large = false } = props
855
- const GlyphIcon = taskGlyph(task)
856
998
  const edge = large ? 44 : size
857
999
  return h('span', {
858
1000
  className: 'capital-automation-glyph',
859
1001
  'data-tone': toneOf(task),
860
1002
  style: { width: edge, height: edge, borderRadius: large ? 13 : 10 },
861
- }, h(GlyphIcon, { size: large ? 22 : 17 }))
1003
+ }, h(TaskGlyph, { task, size: large ? 22 : 17 }))
1004
+ }
1005
+
1006
+ function IconPickerTrigger(props) {
1007
+ const { task, size = 38, large = false, onChange, disabled } = props
1008
+ const [open, setOpen] = useState(false)
1009
+ const selectedId = taskIconId(task)
1010
+ const selected = iconDefinition(selectedId) ?? TASK_ICON_CATALOG[DEFAULT_CUSTOM_ICON]
1011
+ useEffect(() => {
1012
+ if (disabled === true && open) setOpen(false)
1013
+ }, [disabled, open])
1014
+ // The host Menu owns the portal, outside-click, Escape and button walk; the grid is only content.
1015
+ return h(Menu, {
1016
+ open,
1017
+ anchor: h('button', {
1018
+ type: 'button',
1019
+ className: 'capital-automation-glyphbtn',
1020
+ disabled,
1021
+ 'aria-label': `${zh.iconField}:${selected.label},点击更换`,
1022
+ 'aria-haspopup': 'menu',
1023
+ 'aria-expanded': open ? 'true' : 'false',
1024
+ title: zh.iconPickHint,
1025
+ onClick: () => setOpen((current) => !current),
1026
+ },
1027
+ h(Glyph, { task, size, large }),
1028
+ h('span', { className: 'capital-automation-glyphbtnchevron', 'aria-hidden': 'true' },
1029
+ h(IconChevronDownOutlineRegular, { size: 12 }))),
1030
+ items: [],
1031
+ onClose: () => setOpen(false),
1032
+ align: 'start',
1033
+ side: 'bottom',
1034
+ portal: true,
1035
+ listClassName: 'capital-automation-iconmenu',
1036
+ }, h('div', { className: 'capital-automation-iconmenu-grid' },
1037
+ TASK_ICON_IDS.map((id) => {
1038
+ const icon = TASK_ICON_CATALOG[id]
1039
+ const active = id === selectedId
1040
+ return h('button', {
1041
+ key: id,
1042
+ type: 'button',
1043
+ role: 'menuitemradio',
1044
+ 'aria-checked': active ? 'true' : 'false',
1045
+ 'aria-label': icon.label,
1046
+ title: icon.label,
1047
+ className: 'capital-automation-iconmenu-item',
1048
+ 'data-selected': active ? 'true' : undefined,
1049
+ onClick: () => { onChange(id); setOpen(false) },
1050
+ }, h('svg', {
1051
+ viewBox: '0 0 24 24',
1052
+ width: 18,
1053
+ height: 18,
1054
+ fill: 'none',
1055
+ stroke: 'currentColor',
1056
+ strokeWidth: 2,
1057
+ strokeLinecap: 'round',
1058
+ strokeLinejoin: 'round',
1059
+ 'aria-hidden': 'true',
1060
+ focusable: 'false',
1061
+ }, h('path', { d: icon.d })))
1062
+ })))
862
1063
  }
863
1064
 
864
1065
  function StateBadge(props) {
@@ -917,10 +1118,6 @@ function formatRelative(value) {
917
1118
  return ''
918
1119
  }
919
1120
 
920
- function formatRangeLabel(date) {
921
- return new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
922
- }
923
-
924
1121
  function weekdayText(trigger) {
925
1122
  return (trigger.weekdays ?? []).slice().sort((left, right) => left - right)
926
1123
  .map((day) => WEEKDAYS.find((item) => item.value === day)?.label ?? '')
@@ -1119,16 +1316,28 @@ function triggerChanged(cadence, trigger) {
1119
1316
  return next
1120
1317
  }
1121
1318
 
1122
- function defaultDraft() {
1319
+ /** New-task limits start from the settings card; leaving them untouched keeps the task inherited. */
1320
+ function defaultLimits(meta) {
1321
+ const settings = meta?.execution?.settings
1322
+ return {
1323
+ runTimeoutMinutes: settings?.runTimeoutMinutes ?? DEFAULT_LIMITS.runTimeoutMinutes,
1324
+ idleTimeoutMinutes: settings?.idleTimeoutMinutes ?? DEFAULT_LIMITS.idleTimeoutMinutes,
1325
+ maxSteps: settings?.maxSteps ?? DEFAULT_LIMITS.maxSteps,
1326
+ maxToolCalls: settings?.maxToolCalls ?? DEFAULT_LIMITS.maxToolCalls,
1327
+ }
1328
+ }
1329
+
1330
+ function defaultDraft(meta) {
1123
1331
  return {
1124
1332
  title: '我的投研任务',
1125
1333
  description: '',
1126
1334
  instruction: '',
1127
1335
  category: 'research',
1336
+ icon: DEFAULT_CUSTOM_ICON,
1128
1337
  trigger: { kind: 'schedule', cadence: 'daily', time: '09:00', timezone: 'Asia/Shanghai', tradingDaysOnly: true },
1129
1338
  workspaceId: '',
1130
- limits: { ...DEFAULT_LIMITS },
1131
- notifications: { inApp: true, onFailure: true, onSuccess: false },
1339
+ limits: defaultLimits(meta),
1340
+ notifications: { inApp: meta?.execution?.settings?.inAppNotifications !== false, onFailure: true, onSuccess: false },
1132
1341
  }
1133
1342
  }
1134
1343
 
@@ -1138,6 +1347,7 @@ function draftPayload(task) {
1138
1347
  description: task.description ?? '',
1139
1348
  instruction: task.instruction ?? '',
1140
1349
  category: task.category,
1350
+ ...(task.origin === 'builtin' ? {} : { icon: task.icon ?? DEFAULT_CUSTOM_ICON }),
1141
1351
  trigger: normalizeTrigger(task.trigger),
1142
1352
  workspaceId: task.workspaceId ?? '',
1143
1353
  limits: task.limits,
@@ -1145,46 +1355,138 @@ function draftPayload(task) {
1145
1355
  }
1146
1356
  }
1147
1357
 
1358
+ function payloadKey(task) {
1359
+ return JSON.stringify(draftPayload(task))
1360
+ }
1361
+
1148
1362
  function samePayload(left, right) {
1149
- return JSON.stringify(draftPayload(left)) === JSON.stringify(draftPayload(right))
1150
- }
1151
-
1152
- function groupRunCounts(runs) {
1153
- const since = new Date()
1154
- since.setHours(0, 0, 0, 0)
1155
- since.setDate(since.getDate() - (HISTORY_DAYS - 1))
1156
- const days = []
1157
- const byKey = new Map()
1158
- for (let index = 0; index < HISTORY_DAYS; index += 1) {
1159
- const date = new Date(since)
1160
- date.setDate(since.getDate() + index)
1161
- const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
1162
- const bucket = { date, key, runs: [] }
1163
- days.push(bucket)
1164
- byKey.set(key, bucket)
1363
+ return payloadKey(left) === payloadKey(right)
1364
+ }
1365
+
1366
+ function cloneTask(task) {
1367
+ return {
1368
+ ...task,
1369
+ trigger: normalizeTrigger(task.trigger),
1370
+ limits: { ...task.limits },
1371
+ notifications: { ...task.notifications },
1165
1372
  }
1373
+ }
1374
+
1375
+ function pad2(value) {
1376
+ return String(value).padStart(2, '0')
1377
+ }
1378
+
1379
+ function localDateKey(date) {
1380
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`
1381
+ }
1382
+
1383
+ function addLocalDays(date, delta) {
1384
+ const next = new Date(date)
1385
+ next.setDate(next.getDate() + delta)
1386
+ return next
1387
+ }
1388
+
1389
+ function startOfLocalWeek(date) {
1390
+ const start = new Date(date)
1391
+ start.setHours(0, 0, 0, 0)
1392
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
1393
+ return start
1394
+ }
1395
+
1396
+ function parseLocalDateKey(key) {
1397
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key ?? ''))
1398
+ if (match === null) return null
1399
+ return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }
1400
+ }
1401
+
1402
+ function historyBucketMap(history) {
1403
+ const buckets = new Map()
1404
+ for (const bucket of history?.days ?? []) {
1405
+ if (typeof bucket?.date === 'string') buckets.set(bucket.date, bucket)
1406
+ }
1407
+ return buckets
1408
+ }
1409
+
1410
+ function heatLevel(total) {
1411
+ if (total <= 0) return 0
1412
+ if (total === 1) return 1
1413
+ if (total === 2) return 2
1414
+ if (total <= 4) return 3
1415
+ return 4
1416
+ }
1417
+
1418
+ function buildHeatmap(history) {
1419
+ const byDate = historyBucketMap(history)
1420
+ const today = new Date()
1421
+ today.setHours(0, 0, 0, 0)
1422
+ const first = addLocalDays(startOfLocalWeek(today), -7 * (HEATMAP_WEEKS - 1))
1423
+ const weeks = []
1424
+ let total = 0
1166
1425
  let success = 0
1167
1426
  let failure = 0
1168
- let total = 0
1169
- for (const run of runs) {
1170
- const date = new Date(run.queuedAt)
1171
- if (Number.isNaN(date.getTime()) || date < since) continue
1172
- if (run.status === 'succeeded') success += 1
1173
- if (isFailureStatus(run.status)) failure += 1
1174
- total += 1
1175
- const key = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`
1176
- const bucket = byKey.get(key)
1177
- if (bucket !== undefined) bucket.runs.push(run)
1427
+ let active = 0
1428
+ for (let weekIndex = 0; weekIndex < HEATMAP_WEEKS; weekIndex += 1) {
1429
+ const weekStart = addLocalDays(first, weekIndex * 7)
1430
+ const days = []
1431
+ let label = null
1432
+ for (let dayIndex = 0; dayIndex < 7; dayIndex += 1) {
1433
+ const date = addLocalDays(weekStart, dayIndex)
1434
+ const key = localDateKey(date)
1435
+ const bucket = byDate.get(key)
1436
+ const dayTotal = Number.isFinite(bucket?.total) ? bucket.total : 0
1437
+ total += dayTotal
1438
+ success += Number.isFinite(bucket?.success) ? bucket.success : 0
1439
+ failure += Number.isFinite(bucket?.failure) ? bucket.failure : 0
1440
+ active += Number.isFinite(bucket?.active) ? bucket.active : 0
1441
+ if (date.getDate() === 1) label = `${date.getMonth() + 1}月`
1442
+ days.push({
1443
+ date: key,
1444
+ total: dayTotal,
1445
+ success: Number.isFinite(bucket?.success) ? bucket.success : 0,
1446
+ failure: Number.isFinite(bucket?.failure) ? bucket.failure : 0,
1447
+ active: Number.isFinite(bucket?.active) ? bucket.active : 0,
1448
+ level: heatLevel(dayTotal),
1449
+ future: date.getTime() > today.getTime(),
1450
+ })
1451
+ }
1452
+ weeks.push({ key: localDateKey(weekStart), label, days })
1453
+ }
1454
+ // A trailing window starts mid-month; label it only when the next month is far enough away.
1455
+ const firstLabelIndex = weeks.findIndex((week) => week.label !== null)
1456
+ if (weeks[0].label === null && firstLabelIndex >= 3) weeks[0].label = `${first.getMonth() + 1}月`
1457
+ return {
1458
+ weeks,
1459
+ total: Number.isFinite(history?.total) ? history.total : total,
1460
+ success: Number.isFinite(history?.success) ? history.success : success,
1461
+ failure: Number.isFinite(history?.failure) ? history.failure : failure,
1462
+ active,
1463
+ first: localDateKey(first),
1464
+ last: localDateKey(addLocalDays(first, HEATMAP_WEEKS * 7 - 1)),
1178
1465
  }
1179
- return { days, success, failure, total }
1180
1466
  }
1181
1467
 
1182
- function dayTone(bucket) {
1183
- if (bucket.runs.some((run) => isActiveRun(run))) return 'active'
1184
- if (bucket.runs.some((run) => isFailureStatus(run.status))) return 'error'
1185
- if (bucket.runs.some((run) => run.status === 'succeeded')) return 'ok'
1186
- if (bucket.runs.length > 0) return 'warn'
1187
- return null
1468
+ function formatHeatDay(key) {
1469
+ const parts = parseLocalDateKey(key)
1470
+ if (parts === null) return String(key ?? '')
1471
+ const date = new Date(parts.year, parts.month - 1, parts.day)
1472
+ return `${parts.year}年${parts.month}月${parts.day}日 ${WEEKDAY_SHORT[date.getDay()]}`
1473
+ }
1474
+
1475
+ function formatHeatRange(first, last) {
1476
+ const left = parseLocalDateKey(first)
1477
+ const right = parseLocalDateKey(last)
1478
+ if (left === null || right === null) return ''
1479
+ return `${left.year}.${pad2(left.month)} – ${right.year}.${pad2(right.month)}`
1480
+ }
1481
+
1482
+ function heatDayMeta(day) {
1483
+ if (day.future) return zh.heatFuture
1484
+ if (day.total === 0) return zh.heatNoRuns
1485
+ const parts = [zh.runCount.replace('{n}', String(day.total))]
1486
+ if (day.success > 0) parts.push(zh.successCount.replace('{n}', String(day.success)))
1487
+ if (day.failure > 0) parts.push(zh.failureCount.replace('{n}', String(day.failure)))
1488
+ if (day.active > 0) parts.push(zh.activeCount.replace('{n}', String(day.active)))
1489
+ return parts.join(' · ')
1188
1490
  }
1189
1491
 
1190
1492
  function eventTone(kind) {
@@ -1204,11 +1506,7 @@ function notificationMessage(item) {
1204
1506
  return ''
1205
1507
  }
1206
1508
 
1207
- function draftChanged(draft, task) {
1208
- return !samePayload(draft, task)
1209
- }
1210
-
1211
- function draftIssues(task) {
1509
+ function draftIssues(task, meta) {
1212
1510
  const issues = {}
1213
1511
  if (String(task.title ?? '').trim().length === 0) issues.title = zh.titleRequired
1214
1512
  if (String(task.instruction ?? '').trim().length === 0) issues.instruction = zh.instructionRequired
@@ -1230,7 +1528,15 @@ function draftIssues(task) {
1230
1528
  }
1231
1529
  const limits = task.limits ?? {}
1232
1530
  for (const key of ['runTimeoutMinutes', 'idleTimeoutMinutes', 'maxSteps', 'maxToolCalls']) {
1233
- if (!Number.isFinite(limits[key])) issues[key] = zh.invalidLimit
1531
+ const value = limits[key]
1532
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
1533
+ issues[key] = zh.invalidLimit
1534
+ continue
1535
+ }
1536
+ const range = meta?.execution?.limits?.[key]
1537
+ if (Array.isArray(range) && range.length === 2 && (value < range[0] || value > range[1])) {
1538
+ issues[key] = zh.invalidLimitRange.replace('{min}', String(range[0])).replace('{max}', String(range[1]))
1539
+ }
1234
1540
  }
1235
1541
  return issues
1236
1542
  }
@@ -1239,9 +1545,9 @@ function draftIssues(task) {
1239
1545
  * Tracks which fields the user has left so errors appear on blur, then update live.
1240
1546
  * `resetKey` clears that state when the edited task or modal session changes.
1241
1547
  */
1242
- function useDraftIssues(task, resetKey) {
1548
+ function useDraftIssues(task, resetKey, meta) {
1243
1549
  const [touched, setTouched] = useState({})
1244
- const issues = draftIssues(task)
1550
+ const issues = draftIssues(task, meta)
1245
1551
  useEffect(() => { setTouched({}) }, [resetKey])
1246
1552
  const errorFor = (field) => (touched[field] === true ? issues[field] : undefined)
1247
1553
  const touch = (field) => setTouched((current) => (current[field] === true ? current : { ...current, [field]: true }))
@@ -1250,8 +1556,7 @@ function useDraftIssues(task, resetKey) {
1250
1556
  for (const field of Object.keys(issues)) next[field] = true
1251
1557
  return next
1252
1558
  })
1253
- const clear = () => setTouched({})
1254
- return { issues, errorFor, touch, touchAll, clear }
1559
+ return { issues, errorFor, touch, touchAll }
1255
1560
  }
1256
1561
 
1257
1562
  function limitValue(meta, key, fallback) {
@@ -1260,13 +1565,57 @@ function limitValue(meta, key, fallback) {
1260
1565
  }
1261
1566
 
1262
1567
  function RunHistoryCard(props) {
1263
- const { runs, onViewAll, showLink = true } = props
1264
- const { days, success, failure, total } = groupRunCounts(runs)
1265
- return h('section', { className: 'capital-automation-card capital-automation-history', 'aria-label': zh.runHistory },
1568
+ const { history, onViewAll, showLink = true } = props
1569
+ const model = buildHeatmap(history)
1570
+ const { total, success, failure, active, weeks } = model
1571
+ const cardRef = useRef(null)
1572
+ const scrollRef = useRef(null)
1573
+ const [tip, setTip] = useState(null)
1574
+
1575
+ useEffect(() => {
1576
+ const node = scrollRef.current
1577
+ if (node === null) return undefined
1578
+ node.scrollLeft = node.scrollWidth
1579
+ const syncFade = () => { node.dataset.faded = node.scrollLeft > 1 ? 'true' : 'false' }
1580
+ node.addEventListener('scroll', syncFade, { passive: true })
1581
+ syncFade()
1582
+ return () => node.removeEventListener('scroll', syncFade)
1583
+ }, [])
1584
+
1585
+ function showTip(day, event) {
1586
+ const card = cardRef.current
1587
+ if (card === null || event === undefined) return
1588
+ const cell = event.currentTarget.getBoundingClientRect()
1589
+ const base = card.getBoundingClientRect()
1590
+ const half = 110
1591
+ const left = Math.min(Math.max(cell.left - base.left + cell.width / 2, half), Math.max(half, base.width - half))
1592
+ setTip({ day, left, top: cell.top - base.top - 7 })
1593
+ }
1594
+
1595
+ const monthLabels = weeks.map((week, index) => week.label === null ? null : h('span', {
1596
+ key: week.key,
1597
+ className: 'capital-automation-heatmonth',
1598
+ style: index >= HEATMAP_WEEKS - 2
1599
+ ? { gridColumn: index + 1, justifySelf: 'end' }
1600
+ : { gridColumn: index + 1 },
1601
+ }, week.label))
1602
+
1603
+ const cells = weeks.flatMap((week) => week.days.map((day) => h('span', {
1604
+ key: day.date,
1605
+ className: 'capital-automation-heatcell',
1606
+ 'data-level': day.level,
1607
+ 'data-future': day.future ? 'true' : undefined,
1608
+ 'data-failed': day.failure > 0 ? 'true' : undefined,
1609
+ 'data-active': day.active > 0 ? 'true' : undefined,
1610
+ title: `${formatHeatDay(day.date)} · ${heatDayMeta(day)}`,
1611
+ onMouseEnter: (event) => showTip(day, event),
1612
+ })))
1613
+
1614
+ return h('section', { ref: cardRef, className: 'capital-automation-card capital-automation-history', 'aria-label': zh.runHistory },
1266
1615
  h('div', { className: 'capital-automation-historyhead' },
1267
1616
  h('div', { className: 'capital-automation-historytitle' },
1268
1617
  h('h2', null, zh.runHistory),
1269
- h('span', null, zh.last30Days)),
1618
+ h('span', null, zh.last12Months)),
1270
1619
  h('div', { className: 'capital-automation-historystats' },
1271
1620
  h('span', { className: 'capital-automation-historystat', 'data-tone': 'ok' },
1272
1621
  h('span', { className: 'capital-automation-statusdot' }),
@@ -1274,21 +1623,56 @@ function RunHistoryCard(props) {
1274
1623
  h('span', { className: 'capital-automation-historystat', 'data-tone': 'error' },
1275
1624
  h('span', { className: 'capital-automation-statusdot' }),
1276
1625
  zh.failureCount.replace('{n}', String(failure))),
1626
+ active > 0
1627
+ ? h('span', { className: 'capital-automation-historystat', 'data-tone': 'active' },
1628
+ h('span', { className: 'capital-automation-statusdot' }),
1629
+ zh.activeCount.replace('{n}', String(active)))
1630
+ : null,
1277
1631
  showLink && onViewAll !== undefined
1278
1632
  ? h('button', { type: 'button', className: 'capital-automation-historylink', onClick: onViewAll },
1279
1633
  zh.runCount.replace('{n}', String(total)),
1280
1634
  h(IconChevronRightOutlineRegular, { size: 13 }))
1281
1635
  : h('span', { className: 'capital-automation-historystat' }, zh.runCount.replace('{n}', String(total))))),
1282
- h('div', { className: 'capital-automation-timeline', 'aria-hidden': 'true' },
1283
- days.map((bucket) => {
1284
- const tone = dayTone(bucket)
1285
- return h('div', { key: bucket.key, className: 'capital-automation-timeline-day', title: `${formatRangeLabel(bucket.date)}:${bucket.runs.length} 次运行` },
1286
- tone === null ? null : h('span', { className: 'capital-automation-timeline-dot', 'data-tone': tone }))
1287
- })),
1288
- h('div', { className: 'capital-automation-timelinelabels' },
1289
- h('span', null, formatRangeLabel(days[0].date)),
1290
- h('span', null, formatRangeLabel(days[days.length - 1].date))),
1291
- total === 0 ? h('p', { className: 'capital-automation-historyempty' }, zh.historyEmpty) : null)
1636
+ h('div', { className: 'capital-automation-heatmap' },
1637
+ h('div', { className: 'capital-automation-heatweekdays', 'aria-hidden': 'true' },
1638
+ [0, 1, 2, 3, 4, 5, 6].map((index) => h('span', { key: index, className: 'capital-automation-heatweekday' },
1639
+ index === 0 ? '周一' : index === 2 ? '周三' : index === 4 ? '周五' : ''))),
1640
+ h('div', { ref: scrollRef, className: 'capital-automation-heatscroll' },
1641
+ h('div', { className: 'capital-automation-heatcanvas' },
1642
+ h('div', { className: 'capital-automation-heatmonths', 'aria-hidden': 'true' }, monthLabels),
1643
+ h('div', {
1644
+ className: 'capital-automation-heatgrid',
1645
+ role: 'img',
1646
+ 'aria-label': zh.heatSummary
1647
+ .replace('{total}', String(total))
1648
+ .replace('{success}', String(success))
1649
+ .replace('{failure}', String(failure)),
1650
+ onMouseLeave: () => setTip(null),
1651
+ }, cells)))),
1652
+ h('div', { className: 'capital-automation-heatfoot' },
1653
+ h('span', { className: 'capital-automation-heatrange' },
1654
+ total === 0 ? zh.historyEmpty : formatHeatRange(model.first, model.last)),
1655
+ h('div', { className: 'capital-automation-heatlegend' },
1656
+ active > 0
1657
+ ? h('span', { className: 'capital-automation-heatkey' },
1658
+ h('span', { className: 'capital-automation-heatactivebox' }),
1659
+ zh.heatActive)
1660
+ : null,
1661
+ failure > 0
1662
+ ? h('span', { className: 'capital-automation-heatkey' },
1663
+ h('span', { className: 'capital-automation-heatfailbox' }),
1664
+ zh.heatFailed)
1665
+ : null,
1666
+ h('span', { className: 'capital-automation-heatlegendlabel' }, zh.heatLess),
1667
+ [0, 1, 2, 3, 4].map((level) => h('span', { key: level, className: 'capital-automation-heatsample', 'data-level': level })),
1668
+ h('span', { className: 'capital-automation-heatlegendlabel' }, zh.heatMore))),
1669
+ tip === null ? null : h('div', {
1670
+ className: 'capital-automation-heattip',
1671
+ role: 'tooltip',
1672
+ style: { left: `${tip.left}px`, top: `${tip.top}px` },
1673
+ },
1674
+ h('span', { className: 'capital-automation-heattipdate' }, formatHeatDay(tip.day.date)),
1675
+ h('span', { className: 'capital-automation-heattipmeta' }, heatDayMeta(tip.day))))
1292
1676
  }
1293
1677
 
1294
1678
  function RunStatus(props) {
@@ -1378,14 +1762,60 @@ function NotificationsButton(props) {
1378
1762
  className: 'capital-automation-btn',
1379
1763
  'data-variant': 'ghost',
1380
1764
  onClick,
1381
- title: zh.notificationsTitle,
1382
- 'aria-label': zh.notificationsTitle,
1765
+ title: zh.notificationsEntry,
1766
+ 'aria-label': zh.notificationsEntry,
1383
1767
  },
1384
1768
  h(IconAlarmClockOutlineRegular, { size: 14 }),
1385
- h('span', { className: 'capital-automation-btnlabel' }, zh.notifications),
1769
+ h('span', { className: 'capital-automation-btnlabel' }, zh.notificationsEntry),
1386
1770
  unread > 0 ? h('span', { className: 'capital-automation-count' }, String(unread > 99 ? '99+' : unread)) : null)
1387
1771
  }
1388
1772
 
1773
+ function systemStatusReason(code) {
1774
+ if (code === 'lease_held') return zh.systemReasonLeaseHeld
1775
+ if (code === 'owner_unverified') return zh.systemReasonUnverified
1776
+ return zh.systemReasonLeaseLost
1777
+ }
1778
+
1779
+ /** Profile-level scheduling state lives here, not in the task inbox. */
1780
+ function SystemStatus(props) {
1781
+ const { meta } = props
1782
+ const [open, setOpen] = useState(false)
1783
+ if (meta === null || meta === undefined) return null
1784
+ const scheduler = meta.scheduler ?? {}
1785
+ const state = scheduler.state
1786
+ const summary = state === 'active'
1787
+ ? zh.systemActive
1788
+ : state === 'standby'
1789
+ ? zh.systemStandby
1790
+ : state === 'starting'
1791
+ ? zh.systemStarting
1792
+ : state === 'stopped'
1793
+ ? zh.systemStopped
1794
+ : zh.systemUnavailable
1795
+ const calendarSummary = meta.calendar?.available === false ? zh.systemCalendarUnavailable : zh.systemCalendarOk
1796
+ const lastStandbyAt = scheduler.lastStandbyAt
1797
+ return h('section', { className: 'capital-automation-systemstatus', 'aria-label': zh.systemStatus },
1798
+ h('div', { className: 'capital-automation-systemline' },
1799
+ h('span', null, `${zh.systemStatus}:${summary} · ${calendarSummary}`),
1800
+ h('button', {
1801
+ type: 'button',
1802
+ className: 'capital-automation-systemtoggle',
1803
+ 'aria-expanded': open ? 'true' : 'false',
1804
+ onClick: () => setOpen((value) => !value),
1805
+ }, open ? zh.systemHideDetails : zh.systemDetails)),
1806
+ open
1807
+ ? h('div', { className: 'capital-automation-systemdetail' },
1808
+ lastStandbyAt
1809
+ ? h('p', null, `${zh.systemLastStandby}:${formatShortTime(lastStandbyAt)} · ${systemStatusReason(scheduler.lastStandbyCode)}`)
1810
+ : h('p', null, zh.systemNoStandby),
1811
+ lastStandbyAt && scheduler.lastStandbyReason
1812
+ ? h('details', null,
1813
+ h('summary', null, zh.systemTechnical),
1814
+ h('p', null, scheduler.lastStandbyReason))
1815
+ : null)
1816
+ : null)
1817
+ }
1818
+
1389
1819
  function Header(props) {
1390
1820
  const { tab, onTab, onNewTask, unread, onOpenInbox } = props
1391
1821
  return h('header', { className: 'capital-automation-head' },
@@ -1649,15 +2079,21 @@ function SkeletonHistory() {
1649
2079
  h(SkeletonBlock, { width: 64, height: 12 }),
1650
2080
  h(SkeletonBlock, { width: 64, height: 12 }),
1651
2081
  h(SkeletonBlock, { width: 56, height: 12 }))),
1652
- h('div', { className: 'capital-automation-skeletontimeline' },
1653
- Array.from({ length: HISTORY_DAYS }, (_, index) => h(SkeletonBlock, {
1654
- key: index,
1655
- height: 6 + ((index * 11) % 22),
1656
- radius: 2,
1657
- }))),
1658
- h('div', { className: 'capital-automation-timelinelabels' },
1659
- h(SkeletonBlock, { width: 34, height: 9 }),
1660
- h(SkeletonBlock, { width: 34, height: 9 })))
2082
+ h('div', { className: 'capital-automation-skeletonheat' },
2083
+ h('div', { className: 'capital-automation-skeletonmonths' },
2084
+ Array.from({ length: 12 }, (_, index) => h('span', {
2085
+ key: index,
2086
+ className: 'capital-automation-skeleton',
2087
+ style: { gridColumn: 1 + index * 4, width: 22, height: 8 },
2088
+ }))),
2089
+ h('div', { className: 'capital-automation-skeletongrid' },
2090
+ Array.from({ length: HEATMAP_WEEKS * 7 }, (_, index) => h('span', {
2091
+ key: index,
2092
+ className: 'capital-automation-skeleton',
2093
+ })))),
2094
+ h('div', { className: 'capital-automation-heatfoot' },
2095
+ h(SkeletonBlock, { width: 108, height: 9 }),
2096
+ h(SkeletonBlock, { width: 96, height: 9 })))
1661
2097
  }
1662
2098
 
1663
2099
  function SkeletonSectionHead(props) {
@@ -1745,8 +2181,7 @@ function LoadingSkeleton(props) {
1745
2181
 
1746
2182
  function TaskDashboardSkeleton() {
1747
2183
  return h(LoadingSkeleton, null,
1748
- h(SkeletonHistory),
1749
- h('section', { className: 'capital-automation-section' },
2184
+ h('section', { className: 'capital-automation-section', style: { marginTop: 0 } },
1750
2185
  h(SkeletonSectionHead),
1751
2186
  h(SkeletonTaskTable, { rows: 4 })),
1752
2187
  h('section', { className: 'capital-automation-section' },
@@ -1766,7 +2201,7 @@ function RunsSkeleton() {
1766
2201
  }
1767
2202
 
1768
2203
  function TaskDashboard(props) {
1769
- const { tasks, templates, runs, onOpenTask, onNewTask, onEnableTemplate, onAction, onRequestConfirm, onEdit, onGoRuns, onRequestCancelRun, schedulerActive, storageReady, busyTaskId } = props
2204
+ const { tasks, templates, runs, onOpenTask, onNewTask, onEnableTemplate, onAction, onRequestConfirm, onEdit, onRequestCancelRun, schedulerActive, storageReady, busyTaskId } = props
1770
2205
  const [filter, setFilter] = useState('all')
1771
2206
  const [query, setQuery] = useState('')
1772
2207
  const runningTasks = tasks.filter((task) => isActiveRun(task.lastRun))
@@ -1786,11 +2221,12 @@ function TaskDashboard(props) {
1786
2221
  market: roster.filter((task) => task.category === 'market').length,
1787
2222
  review: roster.filter((task) => task.category === 'review').length,
1788
2223
  research: roster.filter((task) => task.category === 'research').length,
2224
+ portfolio: roster.filter((task) => task.category === 'portfolio').length,
2225
+ monitor: roster.filter((task) => task.category === 'monitor').length,
1789
2226
  }
1790
- const filters = ['all', 'custom', 'news', 'market', 'review', 'research']
2227
+ const filters = ['all', 'custom', 'news', 'market', 'review', 'research', 'portfolio', 'monitor']
1791
2228
  return h('div', null,
1792
- h(RunHistoryCard, { runs, onViewAll: onGoRuns }),
1793
- h('section', { className: 'capital-automation-section' },
2229
+ h('section', { className: 'capital-automation-section', style: { marginTop: 0 } },
1794
2230
  h('div', { className: 'capital-automation-sectionhead' },
1795
2231
  h('h2', null, zh.taskSection),
1796
2232
  h('span', null, zh.taskSectionHint),
@@ -1992,12 +2428,13 @@ function MonthlySelect(props) {
1992
2428
  }
1993
2429
 
1994
2430
  function TriggerFields(props) {
1995
- const { trigger, onChange, disabled, meta, errorFor, touch } = props
2431
+ const { trigger, onChange, disabled, meta, errorFor, touch, onFlush } = props
1996
2432
  const update = (patch) => onChange(normalizeTrigger({ ...trigger, ...patch }))
1997
2433
  const setCadence = (cadence) => onChange(triggerChanged(cadence, trigger))
1998
2434
 
1999
2435
  const issue = (field) => (typeof errorFor === 'function' ? errorFor(field) : undefined)
2000
2436
  const touchField = (field) => { if (typeof touch === 'function') touch(field) }
2437
+ const commit = () => { if (typeof onFlush === 'function') onFlush() }
2001
2438
  const timeError = issue('time')
2002
2439
  const timezoneError = issue('timezone')
2003
2440
  const weekdayError = issue('weekdays')
@@ -2051,7 +2488,7 @@ function TriggerFields(props) {
2051
2488
  'aria-invalid': dateError ? 'true' : undefined,
2052
2489
  'data-invalid': dateError ? 'true' : undefined,
2053
2490
  onChange: (event) => update({ date: event.target.value }),
2054
- onBlur: () => touchField('date'),
2491
+ onBlur: () => { touchField('date'); commit() },
2055
2492
  }))
2056
2493
  : null)),
2057
2494
  h('div', { className: 'capital-automation-triggerfield' },
@@ -2065,7 +2502,7 @@ function TriggerFields(props) {
2065
2502
  'aria-invalid': timeError ? 'true' : undefined,
2066
2503
  'data-invalid': timeError ? 'true' : undefined,
2067
2504
  onChange: (event) => update({ time: event.target.value }),
2068
- onBlur: () => touchField('time'),
2505
+ onBlur: () => { touchField('time'); commit() },
2069
2506
  }))),
2070
2507
  h('div', { className: 'capital-automation-triggersettings' },
2071
2508
  h('span', { className: 'capital-automation-triggerzone' },
@@ -2282,7 +2719,7 @@ function RunFilters(props) {
2282
2719
  }
2283
2720
 
2284
2721
  function RunsPage(props) {
2285
- const { runs, tasks, onCancel, onOpenSession, onOpenTask, onGoTasks, onNewTask, busy } = props
2722
+ const { runs, history, tasks, onCancel, onOpenSession, onOpenTask, onGoTasks, onNewTask, busy } = props
2286
2723
  const [filter, setFilter] = useState('all')
2287
2724
  const counts = {
2288
2725
  all: runs.length,
@@ -2297,7 +2734,7 @@ function RunsPage(props) {
2297
2734
  return true
2298
2735
  })
2299
2736
  return h('div', null,
2300
- h(RunHistoryCard, { runs, showLink: false }),
2737
+ h(RunHistoryCard, { history, showLink: false }),
2301
2738
  h('section', { className: 'capital-automation-section' },
2302
2739
  h('div', { className: 'capital-automation-sectionhead' },
2303
2740
  h('h2', null, zh.runsTitle),
@@ -2393,41 +2830,31 @@ function OverviewTab(props) {
2393
2830
  }
2394
2831
 
2395
2832
  function ConfigTab(props) {
2396
- const { draft, setDraft, meta, busy, dirty, save, reset, disabled } = props
2833
+ const { draft, setDraft, meta, busy, dirty, saveState, saveError, retry, onFlush, disabled } = props
2397
2834
  const task = draft
2398
- const { issues, errorFor, touch, touchAll, clear } = useDraftIssues(task, task.id)
2399
- const formRef = useRef(null)
2835
+ const { errorFor, touch } = useDraftIssues(task, task.id, meta)
2400
2836
  const titleError = errorFor('title')
2401
2837
  const instructionError = errorFor('instruction')
2402
2838
  const set = (patch) => setDraft((current) => ({
2403
2839
  ...current,
2404
2840
  ...(typeof patch === 'function' ? patch(current) : patch),
2405
2841
  }))
2406
- const submit = () => {
2407
- if (Object.keys(issues).length > 0) {
2408
- touchAll()
2409
- setTimeout(() => {
2410
- const target = formRef.current?.querySelector('[data-invalid="true"] input, [data-invalid="true"] .capital-automation-texteditor')
2411
- target?.focus?.()
2412
- }, 0)
2413
- return
2414
- }
2415
- void save()
2416
- }
2842
+ const flush = () => { if (typeof onFlush === 'function') onFlush() }
2417
2843
  const timeoutRange = limitValue(meta, 'runTimeoutMinutes', [5, 120])
2418
2844
  const idleRange = limitValue(meta, 'idleTimeoutMinutes', [1, 30])
2419
- const stepsRange = limitValue(meta, 'maxSteps', [10, 2000])
2420
- const toolsRange = limitValue(meta, 'maxToolCalls', [10, 6000])
2845
+ const stepsRange = limitValue(meta, 'maxSteps', [100, 2000])
2846
+ const toolsRange = limitValue(meta, 'maxToolCalls', [100, 5000])
2421
2847
  const instructionEditor = h(Field, { label: zh.instructionField, error: instructionError },
2422
2848
  h(PlainTextEditor, {
2423
2849
  className: 'capital-automation-texteditor',
2424
2850
  value: task.instruction,
2851
+ maxLength: INSTRUCTION_MAX_LENGTH,
2425
2852
  disabled: busy || disabled,
2426
2853
  placeholder: zh.instructionPlaceholder,
2427
2854
  ariaLabel: zh.instructionField,
2428
2855
  invalid: instructionError ? true : undefined,
2429
2856
  onChange: (instruction) => set({ instruction }),
2430
- onBlur: () => touch('instruction'),
2857
+ onBlur: () => { touch('instruction'); flush() },
2431
2858
  }))
2432
2859
  const instructionSide = h('div', { className: 'capital-automation-instructionside' },
2433
2860
  h(SelectField, {
@@ -2440,24 +2867,43 @@ function ConfigTab(props) {
2440
2867
  meta?.workspaceError ? h('p', { className: 'capital-automation-hint', 'data-tone': 'error' }, zh.workspaceUnavailable) : null,
2441
2868
  h('div', null,
2442
2869
  h('span', { className: 'capital-automation-formlabel' }, zh.triggerField),
2443
- h(TriggerFields, { trigger: task.trigger, disabled: busy || disabled, onChange: (trigger) => set({ trigger }), meta, errorFor, touch })))
2870
+ h(TriggerFields, { trigger: task.trigger, disabled: busy || disabled, onChange: (trigger) => set({ trigger }), meta, errorFor, touch, onFlush: flush })))
2444
2871
  const advanced = h('details', { className: 'capital-automation-advanced' },
2445
2872
  h('summary', null, zh.advanced),
2446
2873
  h('div', { className: 'capital-automation-advancedbody' },
2447
2874
  h('div', { className: 'capital-automation-formgrid' },
2448
- h(NumberField, { label: zh.runTimeout, value: task.limits.runTimeoutMinutes, min: timeoutRange[0], max: timeoutRange[1], disabled: busy || disabled, error: errorFor('runTimeoutMinutes'), onBlur: () => touch('runTimeoutMinutes'), onChange: (value) => set((current) => ({ limits: { ...current.limits, runTimeoutMinutes: value } })) }),
2449
- h(NumberField, { label: zh.idleTimeout, value: task.limits.idleTimeoutMinutes, min: idleRange[0], max: idleRange[1], disabled: busy || disabled, error: errorFor('idleTimeoutMinutes'), onBlur: () => touch('idleTimeoutMinutes'), onChange: (value) => set((current) => ({ limits: { ...current.limits, idleTimeoutMinutes: value } })) }),
2450
- h(NumberField, { label: zh.maxSteps, value: task.limits.maxSteps, min: stepsRange[0], max: stepsRange[1], disabled: busy || disabled, error: errorFor('maxSteps'), onBlur: () => touch('maxSteps'), onChange: (value) => set((current) => ({ limits: { ...current.limits, maxSteps: value } })) }),
2451
- h(NumberField, { label: zh.maxToolCalls, value: task.limits.maxToolCalls, min: toolsRange[0], max: toolsRange[1], disabled: busy || disabled, error: errorFor('maxToolCalls'), onBlur: () => touch('maxToolCalls'), onChange: (value) => set((current) => ({ limits: { ...current.limits, maxToolCalls: value } })) })),
2875
+ h(NumberField, { label: zh.runTimeout, value: task.limits.runTimeoutMinutes, min: timeoutRange[0], max: timeoutRange[1], disabled: busy || disabled, error: errorFor('runTimeoutMinutes'), onBlur: () => { touch('runTimeoutMinutes'); flush() }, onChange: (value) => set((current) => ({ limits: { ...current.limits, runTimeoutMinutes: value } })) }),
2876
+ h(NumberField, { label: zh.idleTimeout, value: task.limits.idleTimeoutMinutes, min: idleRange[0], max: idleRange[1], disabled: busy || disabled, error: errorFor('idleTimeoutMinutes'), onBlur: () => { touch('idleTimeoutMinutes'); flush() }, onChange: (value) => set((current) => ({ limits: { ...current.limits, idleTimeoutMinutes: value } })) }),
2877
+ h(NumberField, { label: zh.maxSteps, value: task.limits.maxSteps, min: stepsRange[0], max: stepsRange[1], disabled: busy || disabled, error: errorFor('maxSteps'), onBlur: () => { touch('maxSteps'); flush() }, onChange: (value) => set((current) => ({ limits: { ...current.limits, maxSteps: value } })) }),
2878
+ h(NumberField, { label: zh.maxToolCalls, value: task.limits.maxToolCalls, min: toolsRange[0], max: toolsRange[1], disabled: busy || disabled, error: errorFor('maxToolCalls'), onBlur: () => { touch('maxToolCalls'); flush() }, onChange: (value) => set((current) => ({ limits: { ...current.limits, maxToolCalls: value } })) })),
2452
2879
  h('p', { className: 'capital-automation-hint' }, zh.budgetHint)))
2453
2880
  const notificationRows = h('div', null,
2454
2881
  h('span', { className: 'capital-automation-formlabel' }, zh.notifyField),
2455
2882
  h('div', { className: 'capital-automation-switchlist' },
2456
2883
  h(SwitchRow, { label: zh.notifyInApp, checked: task.notifications.inApp, disabled: busy || disabled, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, inApp: value } })) }),
2457
- h(SwitchRow, { label: zh.notifyFailure, checked: task.notifications.onFailure, disabled: busy || disabled, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onFailure: value } })) }),
2458
- h(SwitchRow, { label: zh.notifySuccess, checked: task.notifications.onSuccess, disabled: busy || disabled, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onSuccess: value } })) })),
2884
+ h(SwitchRow, { label: zh.notifyFailure, checked: task.notifications.onFailure, disabled: busy || disabled || task.notifications.inApp !== true, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onFailure: value } })) }),
2885
+ h(SwitchRow, { label: zh.notifySuccess, checked: task.notifications.onSuccess, disabled: busy || disabled || task.notifications.inApp !== true, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onSuccess: value } })) })),
2459
2886
  h('p', { className: 'capital-automation-hint' }, zh.notifyHint))
2460
- return h('div', { className: 'capital-automation-form', ref: formRef },
2887
+ const state = disabled
2888
+ ? 'disabled'
2889
+ : dirty && (saveState === 'idle' || saveState === 'saved')
2890
+ ? 'pending'
2891
+ : !dirty && saveState === 'pending'
2892
+ ? 'idle'
2893
+ : saveState
2894
+ const statusText = state === 'saving'
2895
+ ? zh.saving
2896
+ : state === 'error'
2897
+ ? `${zh.autosaveFailed}:${saveError || zh.errorGeneric}`
2898
+ : state === 'invalid'
2899
+ ? zh.autosaveInvalid
2900
+ : state === 'pending'
2901
+ ? zh.autosavePending
2902
+ : state === 'saved'
2903
+ ? zh.autosaveSaved
2904
+ : zh.noDirty
2905
+ const statusTone = state === 'error' ? 'error' : state === 'saved' ? 'ok' : undefined
2906
+ return h('div', { className: 'capital-automation-form' },
2461
2907
  h('div', { className: 'capital-automation-titlerow' },
2462
2908
  h(Field, {
2463
2909
  label: zh.titleField,
@@ -2470,7 +2916,7 @@ function ConfigTab(props) {
2470
2916
  disabled: busy || disabled,
2471
2917
  'aria-invalid': titleError ? 'true' : undefined,
2472
2918
  onChange: (event) => set({ title: event.target.value }),
2473
- onBlur: () => touch('title'),
2919
+ onBlur: () => { touch('title'); flush() },
2474
2920
  })),
2475
2921
  h(SelectField, {
2476
2922
  label: zh.categoryField,
@@ -2480,6 +2926,18 @@ function ConfigTab(props) {
2480
2926
  disabled: busy || disabled || task.origin === 'builtin',
2481
2927
  onChange: (category) => set({ category }),
2482
2928
  })),
2929
+ task.origin === 'custom'
2930
+ ? h('div', { className: 'capital-automation-iconrow' },
2931
+ h(IconPickerTrigger, {
2932
+ task,
2933
+ size: 38,
2934
+ disabled: busy || disabled,
2935
+ onChange: (icon) => set({ icon }),
2936
+ }),
2937
+ h('div', { className: 'capital-automation-iconrowbody' },
2938
+ h('span', { className: 'capital-automation-formlabel' }, zh.iconField),
2939
+ h('span', { className: 'capital-automation-hint' }, `${taskIcon(task).label} · ${zh.iconPickHint}`)))
2940
+ : h('p', { className: 'capital-automation-hint' }, zh.iconBuiltinHint),
2483
2941
  h(Field, {
2484
2942
  label: zh.descriptionField,
2485
2943
  note: zh.charCount.replace('{n}', String(Array.from(task.description ?? '').length)).replace('{max}', String(DESCRIPTION_MAX_LENGTH)),
@@ -2492,6 +2950,7 @@ function ConfigTab(props) {
2492
2950
  placeholder: zh.descriptionPlaceholder,
2493
2951
  ariaLabel: zh.descriptionField,
2494
2952
  onChange: (description) => set({ description }),
2953
+ onBlur: flush,
2495
2954
  })),
2496
2955
  h('div', { className: 'capital-automation-instructionrow' },
2497
2956
  instructionEditor,
@@ -2499,10 +2958,18 @@ function ConfigTab(props) {
2499
2958
  advanced,
2500
2959
  notificationRows,
2501
2960
  h('div', { className: 'capital-automation-formactions', 'data-sticky': 'true' },
2502
- h('span', { className: 'capital-automation-formstatus' },
2503
- disabled ? zh.storageUnavailable : dirty ? zh.dirty : zh.noDirty),
2504
- h('button', { type: 'button', className: 'capital-automation-btn', disabled: busy || !dirty, onClick: () => { clear(); reset() } }, zh.cancel),
2505
- h('button', { type: 'button', className: 'capital-automation-btn', 'data-variant': 'primary', disabled: busy || !dirty || disabled, onClick: submit }, busy === 'save' ? zh.saving : zh.save)))
2961
+ h('span', { className: 'capital-automation-formstatus' }, disabled ? zh.storageUnavailable : zh.autosaveHint),
2962
+ state === 'disabled'
2963
+ ? null
2964
+ : h('span', {
2965
+ className: 'capital-automation-autosavestate',
2966
+ role: 'status',
2967
+ 'data-tone': statusTone,
2968
+ title: state === 'error' ? saveError : undefined,
2969
+ }, statusText),
2970
+ state === 'error'
2971
+ ? h('button', { type: 'button', className: 'capital-automation-btn', 'data-size': 'sm', disabled: busy || disabled, onClick: retry }, zh.autosaveRetry)
2972
+ : null))
2506
2973
  }
2507
2974
 
2508
2975
  function NotificationsTab(props) {
@@ -2553,19 +3020,72 @@ function DetailBar(props) {
2553
3020
  function TaskDetailPage(props) {
2554
3021
  const { detail, meta, api, onChanged, openSession, tab, onTab, onRequestConfirm, onRequestCancelRun, schedulerActive, storageReady } = props
2555
3022
  const [draft, setDraft] = useState(null)
3023
+ const [savedTask, setSavedTask] = useState(null)
2556
3024
  const [busy, setBusy] = useState('')
2557
3025
  const [notice, setNotice] = useState('')
2558
3026
  const [error, setError] = useState('')
3027
+ const [saveState, setSaveState] = useState('idle')
3028
+ const [saveError, setSaveError] = useState('')
2559
3029
  const [menuOpen, setMenuOpen] = useState(false)
2560
3030
  const lastTaskIdRef = useRef(null)
3031
+ const activeTaskIdRef = useRef(null)
3032
+ const draftRef = useRef(null)
3033
+ const savedTaskRef = useRef(null)
3034
+ const ackedPayloadRef = useRef(null)
3035
+ const failedPayloadRef = useRef(null)
3036
+ const saveTimerRef = useRef(null)
3037
+ const saveInFlightRef = useRef(false)
3038
+ const saveQueuedRef = useRef(false)
3039
+ const savePromiseRef = useRef(null)
3040
+ const flushRef = useRef(null)
3041
+ const storageReadyNow = storageReady !== false
3042
+ activeTaskIdRef.current = detail.task?.id ?? null
3043
+
3044
+ const updateDraft = (patch) => {
3045
+ const current = draftRef.current
3046
+ if (current === null) return
3047
+ const next = {
3048
+ ...current,
3049
+ ...(typeof patch === 'function' ? patch(current) : patch),
3050
+ }
3051
+ draftRef.current = next
3052
+ setDraft(next)
3053
+ }
2561
3054
 
2562
3055
  useEffect(() => {
2563
- setDraft(detail.task ? {
2564
- ...detail.task,
2565
- trigger: normalizeTrigger(detail.task.trigger),
2566
- limits: { ...detail.task.limits },
2567
- notifications: { ...detail.task.notifications },
2568
- } : null)
3056
+ const server = detail.task
3057
+ if (!server) {
3058
+ savedTaskRef.current = null
3059
+ ackedPayloadRef.current = null
3060
+ setSavedTask(null)
3061
+ setDraft(null)
3062
+ draftRef.current = null
3063
+ return
3064
+ }
3065
+ const nextSaved = cloneTask(server)
3066
+ const previousAcked = ackedPayloadRef.current
3067
+ savedTaskRef.current = nextSaved
3068
+ ackedPayloadRef.current = payloadKey(nextSaved)
3069
+ setSavedTask(nextSaved)
3070
+ const current = draftRef.current
3071
+ if (current === null || current.id !== nextSaved.id) {
3072
+ draftRef.current = nextSaved
3073
+ setDraft(nextSaved)
3074
+ return
3075
+ }
3076
+ if (samePayload(current, nextSaved)) return
3077
+ // Hibernate clears overrides on the server; discard a local draft that a failed save
3078
+ // could otherwise resurrect after the task returns to its template defaults.
3079
+ if (nextSaved.state === 'dormant' && current.state !== 'dormant') {
3080
+ draftRef.current = nextSaved
3081
+ setDraft(nextSaved)
3082
+ return
3083
+ }
3084
+ // A poll or another action must not clobber edits the user has not saved yet.
3085
+ if (previousAcked !== null && payloadKey(current) === previousAcked) {
3086
+ draftRef.current = nextSaved
3087
+ setDraft(nextSaved)
3088
+ }
2569
3089
  }, [detail])
2570
3090
 
2571
3091
  useEffect(() => {
@@ -2574,20 +3094,57 @@ function TaskDetailPage(props) {
2574
3094
  lastTaskIdRef.current = id
2575
3095
  setNotice('')
2576
3096
  setError('')
3097
+ setSaveState('idle')
3098
+ setSaveError('')
3099
+ failedPayloadRef.current = null
2577
3100
  }, [detail])
2578
3101
 
3102
+ useEffect(() => {
3103
+ if (draft === null || savedTask === null || storageReadyNow === false) return undefined
3104
+ if (samePayload(draft, savedTask)) {
3105
+ setSaveState((state) => (state === 'pending' || state === 'invalid' || state === 'error' ? 'idle' : state))
3106
+ return undefined
3107
+ }
3108
+ if (busy) return undefined
3109
+ if (Object.keys(draftIssues(draft, meta)).length > 0) {
3110
+ setSaveState('invalid')
3111
+ return undefined
3112
+ }
3113
+ if (failedPayloadRef.current !== null && payloadKey(draft) === failedPayloadRef.current) {
3114
+ setSaveState('error')
3115
+ return undefined
3116
+ }
3117
+ setSaveState((state) => (state === 'saving' ? state : 'pending'))
3118
+ saveTimerRef.current = setTimeout(() => { void flushRef.current?.() }, AUTOSAVE_DELAY_MS)
3119
+ return () => {
3120
+ if (saveTimerRef.current !== null) {
3121
+ clearTimeout(saveTimerRef.current)
3122
+ saveTimerRef.current = null
3123
+ }
3124
+ }
3125
+ }, [draft, savedTask, busy, storageReadyNow, meta])
3126
+
3127
+ useEffect(() => {
3128
+ if (tab === 'config') return
3129
+ void flushRef.current?.()
3130
+ }, [tab])
3131
+
3132
+ useEffect(() => () => { void flushRef.current?.() }, [])
3133
+
3134
+ useEffect(() => {
3135
+ if (typeof document === 'undefined') return undefined
3136
+ const onHide = () => { if (document.visibilityState === 'hidden') void flushRef.current?.() }
3137
+ document.addEventListener?.('visibilitychange', onHide)
3138
+ return () => document.removeEventListener?.('visibilitychange', onHide)
3139
+ }, [])
3140
+
2579
3141
  if (draft === null) return h('p', { className: 'capital-automation-empty' }, zh.loading)
2580
3142
  const task = draft
2581
- const storageReadyNow = storageReady !== false
2582
3143
  const running = detail.runs.some((run) => isActiveRun(run))
2583
3144
  const activeRun = detail.runs.find((run) => isActiveRun(run))
2584
3145
  const unread = detail.notifications.filter((item) => item.readAt === undefined).length
2585
- const dirty = draftChanged(draft, detail.task)
3146
+ const dirty = savedTask === null ? false : !samePayload(draft, savedTask)
2586
3147
  const canRun = storageReadyNow && schedulerActive === true && !running
2587
- const set = (patch) => setDraft((current) => ({
2588
- ...current,
2589
- ...(typeof patch === 'function' ? patch(current) : patch),
2590
- }))
2591
3148
 
2592
3149
  async function act(name, action, success) {
2593
3150
  if (busy) return
@@ -2604,10 +3161,90 @@ function TaskDetailPage(props) {
2604
3161
  }
2605
3162
  }
2606
3163
 
2607
- async function save() {
2608
- await act('save', async () => {
2609
- await api.patch(detail.task.id, draftPayload(task))
2610
- }, zh.saved)
3164
+ async function flush() {
3165
+ if (saveTimerRef.current !== null) {
3166
+ clearTimeout(saveTimerRef.current)
3167
+ saveTimerRef.current = null
3168
+ }
3169
+ if (saveInFlightRef.current) {
3170
+ saveQueuedRef.current = true
3171
+ return savePromiseRef.current ?? undefined
3172
+ }
3173
+ const current = draftRef.current
3174
+ const base = savedTaskRef.current
3175
+ if (current === null || base === null || storageReadyNow === false) return
3176
+ if (samePayload(current, base)) {
3177
+ setSaveState((state) => (state === 'saving' ? state : 'idle'))
3178
+ return
3179
+ }
3180
+ if (Object.keys(draftIssues(current, meta)).length > 0) {
3181
+ setSaveState('invalid')
3182
+ return
3183
+ }
3184
+ const payload = draftPayload(current)
3185
+ const taskId = current.id
3186
+ saveInFlightRef.current = true
3187
+ saveQueuedRef.current = false
3188
+ setSaveState('saving')
3189
+ setSaveError('')
3190
+ const savePromise = (async () => {
3191
+ try {
3192
+ const result = await api.patch(taskId, payload)
3193
+ if (activeTaskIdRef.current !== taskId) {
3194
+ void onChanged?.(taskId)
3195
+ return
3196
+ }
3197
+ const serverTask = result?.task
3198
+ const nextSaved = serverTask === undefined ? cloneTask({ ...base, ...payload }) : cloneTask(serverTask)
3199
+ savedTaskRef.current = nextSaved
3200
+ ackedPayloadRef.current = payloadKey(nextSaved)
3201
+ failedPayloadRef.current = null
3202
+ setSavedTask(nextSaved)
3203
+ setSaveState('saved')
3204
+ void onChanged?.(taskId)
3205
+ } catch (failure) {
3206
+ if (activeTaskIdRef.current === taskId) {
3207
+ failedPayloadRef.current = payloadKey(current)
3208
+ setSaveState('error')
3209
+ setSaveError(failure?.message ?? zh.errorGeneric)
3210
+ }
3211
+ } finally {
3212
+ saveInFlightRef.current = false
3213
+ const latest = draftRef.current
3214
+ const baseNow = savedTaskRef.current
3215
+ const queued = saveQueuedRef.current
3216
+ saveQueuedRef.current = false
3217
+ if (queued || (latest !== null && baseNow !== null && latest.id === baseNow.id && !samePayload(latest, baseNow) && failedPayloadRef.current === null)) {
3218
+ saveTimerRef.current = setTimeout(() => { void flushRef.current?.() }, 0)
3219
+ }
3220
+ }
3221
+ })()
3222
+ savePromiseRef.current = savePromise
3223
+ try {
3224
+ await savePromise
3225
+ } finally {
3226
+ if (savePromiseRef.current === savePromise) savePromiseRef.current = null
3227
+ }
3228
+ }
3229
+ flushRef.current = flush
3230
+
3231
+ function retrySave() {
3232
+ failedPayloadRef.current = null
3233
+ setSaveError('')
3234
+ void flushRef.current?.()
3235
+ }
3236
+
3237
+ async function flushBeforeAction() {
3238
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3239
+ await flushRef.current?.()
3240
+ const current = draftRef.current
3241
+ const base = savedTaskRef.current
3242
+ if (current === null || base === null) return true
3243
+ if (samePayload(current, base)) return true
3244
+ if (Object.keys(draftIssues(current, meta)).length > 0) return false
3245
+ if (failedPayloadRef.current !== null) return false
3246
+ }
3247
+ return false
2611
3248
  }
2612
3249
 
2613
3250
  async function toggleState() {
@@ -2618,6 +3255,11 @@ function TaskDetailPage(props) {
2618
3255
  }
2619
3256
 
2620
3257
  async function runNow() {
3258
+ if (!(await flushBeforeAction())) {
3259
+ const current = draftRef.current
3260
+ setError(current !== null && Object.keys(draftIssues(current, meta)).length > 0 ? zh.autosaveInvalid : zh.autosaveFailed)
3261
+ return
3262
+ }
2621
3263
  await act('run', async () => {
2622
3264
  await api.run(detail.task.id)
2623
3265
  }, zh.runSubmitted.replace('{title}', detail.task.title))
@@ -2632,6 +3274,11 @@ function TaskDetailPage(props) {
2632
3274
  }
2633
3275
 
2634
3276
  async function copyTask() {
3277
+ if (!(await flushBeforeAction())) {
3278
+ const current = draftRef.current
3279
+ setError(current !== null && Object.keys(draftIssues(current, meta)).length > 0 ? zh.autosaveInvalid : zh.autosaveFailed)
3280
+ return
3281
+ }
2635
3282
  await act('copy', async () => {
2636
3283
  await api.action(detail.task.id, 'copy')
2637
3284
  }, zh.copied.replace('{title}', detail.task.title))
@@ -2654,17 +3301,6 @@ function TaskDetailPage(props) {
2654
3301
  onClick: () => { void runNow() },
2655
3302
  }, h(IconPlayOutlineRegular, { size: 14 }), zh.runNow)
2656
3303
 
2657
- const resetDraft = () => {
2658
- setDraft({
2659
- ...detail.task,
2660
- trigger: normalizeTrigger(detail.task.trigger),
2661
- limits: { ...detail.task.limits },
2662
- notifications: { ...detail.task.notifications },
2663
- })
2664
- setError('')
2665
- setNotice('')
2666
- }
2667
-
2668
3304
  return h('div', null,
2669
3305
  h('div', { className: 'capital-automation-hero' },
2670
3306
  h(Glyph, { task, large: true }),
@@ -2696,7 +3332,18 @@ function TaskDetailPage(props) {
2696
3332
  else if (id === 'copy') void copyTask()
2697
3333
  else if (id === 'activate' || id === 'pause') void toggleState()
2698
3334
  },
2699
- onRequestConfirm: (kind, target) => { setMenuOpen(false); onRequestConfirm(kind, target) },
3335
+ onRequestConfirm: (kind, target) => {
3336
+ setMenuOpen(false)
3337
+ // A pending autosave must not land after hibernate clears the overrides.
3338
+ if (kind === 'hibernate' || kind === 'delete') {
3339
+ void (async () => {
3340
+ await flushBeforeAction()
3341
+ onRequestConfirm(kind, target)
3342
+ })()
3343
+ return
3344
+ }
3345
+ onRequestConfirm(kind, target)
3346
+ },
2700
3347
  canRun,
2701
3348
  running,
2702
3349
  busy: busy.length > 0,
@@ -2728,12 +3375,14 @@ function TaskDetailPage(props) {
2728
3375
  }) : null,
2729
3376
  tab === 'config' ? h(ConfigTab, {
2730
3377
  draft,
2731
- setDraft,
3378
+ setDraft: updateDraft,
2732
3379
  meta,
2733
3380
  busy,
2734
3381
  dirty,
2735
- save,
2736
- reset: resetDraft,
3382
+ saveState,
3383
+ saveError,
3384
+ retry: retrySave,
3385
+ onFlush: () => { void flushRef.current?.() },
2737
3386
  disabled: !storageReadyNow,
2738
3387
  }) : null,
2739
3388
  tab === 'runs' ? h('section', { className: 'capital-automation-card capital-automation-panel' },
@@ -2756,15 +3405,15 @@ function TaskDetailPage(props) {
2756
3405
 
2757
3406
  function NewTaskModal(props) {
2758
3407
  const { open, onClose, api, onCreated, meta } = props
2759
- const [draft, setDraft] = useState(defaultDraft)
3408
+ const [draft, setDraft] = useState(() => defaultDraft(meta))
2760
3409
  const [busy, setBusy] = useState(false)
2761
3410
  const [error, setError] = useState('')
2762
- const { issues, errorFor, touch, touchAll } = useDraftIssues(draft, open)
3411
+ const { issues, errorFor, touch, touchAll } = useDraftIssues(draft, open, meta)
2763
3412
  const titleError = errorFor('title')
2764
3413
  const instructionError = errorFor('instruction')
2765
3414
  useEffect(() => {
2766
3415
  if (!open) return
2767
- setDraft(defaultDraft())
3416
+ setDraft(defaultDraft(meta))
2768
3417
  setBusy(false)
2769
3418
  setError('')
2770
3419
  }, [open])
@@ -2774,8 +3423,8 @@ function NewTaskModal(props) {
2774
3423
  }))
2775
3424
  const timeoutRange = limitValue(meta, 'runTimeoutMinutes', [5, 120])
2776
3425
  const idleRange = limitValue(meta, 'idleTimeoutMinutes', [1, 30])
2777
- const stepsRange = limitValue(meta, 'maxSteps', [10, 2000])
2778
- const toolsRange = limitValue(meta, 'maxToolCalls', [10, 6000])
3426
+ const stepsRange = limitValue(meta, 'maxSteps', [100, 2000])
3427
+ const toolsRange = limitValue(meta, 'maxToolCalls', [100, 5000])
2779
3428
  async function create() {
2780
3429
  if (Object.keys(issues).length > 0) {
2781
3430
  touchAll()
@@ -2806,7 +3455,7 @@ function NewTaskModal(props) {
2806
3455
  },
2807
3456
  h('div', { className: 'capital-automation-wizard' },
2808
3457
  h('div', { className: 'capital-automation-modalheadline', 'data-invalid': titleError ? 'true' : undefined },
2809
- h(Glyph, { task: draft, size: 42 }),
3458
+ h(IconPickerTrigger, { task: draft, size: 42, onChange: (icon) => set({ icon }) }),
2810
3459
  h('input', {
2811
3460
  className: 'capital-automation-modaltitleinput',
2812
3461
  value: draft.title,
@@ -2818,7 +3467,6 @@ function NewTaskModal(props) {
2818
3467
  onBlur: () => touch('title'),
2819
3468
  })),
2820
3469
  titleError ? h('span', { className: 'capital-automation-fielderror', role: 'alert' }, titleError) : null,
2821
- h('p', { className: 'capital-automation-hint' }, zh.newTaskHint),
2822
3470
  h(Field, {
2823
3471
  label: zh.descriptionField,
2824
3472
  note: zh.charCount.replace('{n}', String(Array.from(draft.description ?? '').length)).replace('{max}', String(DESCRIPTION_MAX_LENGTH)),
@@ -2839,6 +3487,7 @@ function NewTaskModal(props) {
2839
3487
  h(PlainTextEditor, {
2840
3488
  className: 'capital-automation-texteditor',
2841
3489
  value: draft.instruction,
3490
+ maxLength: INSTRUCTION_MAX_LENGTH,
2842
3491
  placeholder: zh.instructionPlaceholder,
2843
3492
  ariaLabel: zh.instructionField,
2844
3493
  invalid: instructionError ? true : undefined,
@@ -2885,8 +3534,8 @@ function NewTaskModal(props) {
2885
3534
  h('span', { className: 'capital-automation-formlabel' }, zh.notifyField),
2886
3535
  h('div', { className: 'capital-automation-switchlist' },
2887
3536
  h(SwitchRow, { label: zh.notifyInApp, checked: draft.notifications.inApp, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, inApp: value } })) }),
2888
- h(SwitchRow, { label: zh.notifyFailure, checked: draft.notifications.onFailure, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onFailure: value } })) }),
2889
- h(SwitchRow, { label: zh.notifySuccess, checked: draft.notifications.onSuccess, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onSuccess: value } })) })),
3537
+ h(SwitchRow, { label: zh.notifyFailure, checked: draft.notifications.onFailure, disabled: draft.notifications.inApp !== true, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onFailure: value } })) }),
3538
+ h(SwitchRow, { label: zh.notifySuccess, checked: draft.notifications.onSuccess, disabled: draft.notifications.inApp !== true, onChange: (value) => set((current) => ({ notifications: { ...current.notifications, onSuccess: value } })) })),
2890
3539
  h('p', { className: 'capital-automation-hint' }, zh.notifyHint)),
2891
3540
  error.length > 0 ? h('p', { className: 'capital-automation-error', role: 'alert' }, error) : null))
2892
3541
  }
@@ -2951,11 +3600,21 @@ function InboxModal(props) {
2951
3600
  }))))
2952
3601
  }
2953
3602
 
3603
+ function browserTimeZone() {
3604
+ try {
3605
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
3606
+ return typeof zone === 'string' && zone.length > 0 ? zone : 'Asia/Shanghai'
3607
+ } catch {
3608
+ return 'Asia/Shanghai'
3609
+ }
3610
+ }
3611
+
2954
3612
  function AutomationPage(props) {
2955
3613
  const { api, openSession } = props
2956
3614
  const [meta, setMeta] = useState(null)
2957
3615
  const [tasks, setTasks] = useState([])
2958
3616
  const [runs, setRuns] = useState([])
3617
+ const [history, setHistory] = useState(null)
2959
3618
  const [notifications, setNotifications] = useState([])
2960
3619
  const [unread, setUnread] = useState(0)
2961
3620
  const [selectedId, setSelectedId] = useState(null)
@@ -2991,10 +3650,11 @@ function AutomationPage(props) {
2991
3650
  refreshSequenceRef.current = requestSequence
2992
3651
  const request = (async () => {
2993
3652
  try {
2994
- const [nextMeta, nextTasks, nextRuns, nextNotifications] = await Promise.all([
3653
+ const [nextMeta, nextTasks, nextRuns, nextHistory, nextNotifications] = await Promise.all([
2995
3654
  api.meta(),
2996
3655
  api.tasks(),
2997
3656
  api.runs('?limit=200'),
3657
+ api.history(`?days=${HISTORY_DAYS}&tz=${encodeURIComponent(browserTimeZone())}`),
2998
3658
  api.notifications(),
2999
3659
  ])
3000
3660
  if (!mountedRef.current || requestSequence !== refreshSequenceRef.current) return
@@ -3003,6 +3663,7 @@ function AutomationPage(props) {
3003
3663
  tasksRef.current = nextTaskList
3004
3664
  setTasks(nextTaskList)
3005
3665
  setRuns(Array.isArray(nextRuns.runs) ? nextRuns.runs : [])
3666
+ setHistory(nextHistory ?? null)
3006
3667
  setNotifications(Array.isArray(nextNotifications.notifications) ? nextNotifications.notifications : [])
3007
3668
  setUnread(typeof nextNotifications.unread === 'number' ? nextNotifications.unread : 0)
3008
3669
  setLoadError('')
@@ -3201,7 +3862,9 @@ function AutomationPage(props) {
3201
3862
 
3202
3863
  const schedulerActive = meta?.scheduler?.state === 'active'
3203
3864
  const storageReady = meta?.storage?.state !== 'unavailable'
3204
- const templates = tasks.filter((task) => task.origin === 'builtin' && task.state === 'dormant')
3865
+ const templates = tasks
3866
+ .filter((task) => task.origin === 'builtin' && task.state === 'dormant')
3867
+ .sort(compareTemplates)
3205
3868
  const selectedTask = tasks.find((task) => task.id === selectedId)
3206
3869
  const detailOpen = tab === 'tasks' && selectedId !== null
3207
3870
  // `/meta` arrives in the same batch as `/tasks`; until then the empty state would be a lie.
@@ -3253,7 +3916,6 @@ function AutomationPage(props) {
3253
3916
  onAction: (task, action) => { void performTaskAction(task, action) },
3254
3917
  onRequestConfirm: requestConfirm,
3255
3918
  onEdit: (id) => selectTask(id, 'config'),
3256
- onGoRuns: () => switchTab('runs'),
3257
3919
  onRequestCancelRun: (task, run) => requestConfirm('cancel-run', task, run),
3258
3920
  schedulerActive,
3259
3921
  storageReady,
@@ -3263,6 +3925,7 @@ function AutomationPage(props) {
3263
3925
  ? h(RunsSkeleton)
3264
3926
  : h(RunsPage, {
3265
3927
  runs,
3928
+ history,
3266
3929
  tasks,
3267
3930
  onCancel: (run) => requestConfirm('cancel-run', { id: run.taskId, title: run.taskTitle }, run),
3268
3931
  onOpenSession: openSession,
@@ -3270,7 +3933,7 @@ function AutomationPage(props) {
3270
3933
  onGoTasks: () => switchTab('tasks'),
3271
3934
  onNewTask: () => setWizard(true),
3272
3935
  busy: confirmBusy,
3273
- }))),
3936
+ }), detailOpen ? null : h(SystemStatus, { meta }))),
3274
3937
  h(NewTaskModal, {
3275
3938
  open: wizard,
3276
3939
  onClose: () => setWizard(false),