@nextclaw/ui 0.13.10-beta.1 → 0.13.11

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 (77) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/assets/{api-DhI2lb1f.js → api-DmxQnm30.js} +7 -7
  3. package/dist/assets/{app-presenter-provider-BFbrQp8q.js → app-presenter-provider-CDSjiqfE.js} +2 -2
  4. package/dist/assets/arrow-left-Dd45Mvr1.js +1 -0
  5. package/dist/assets/channels-list-page-bkKDqaO5.js +8 -0
  6. package/dist/assets/chat-page-YKdeGoVx.js +105 -0
  7. package/dist/assets/{config-split-page-bO0K6uQv.js → config-split-page-CdJEkWxv.js} +1 -1
  8. package/dist/assets/{confirm-dialog-DXRWJ5uB.js → confirm-dialog-BWRNsRgb.js} +1 -1
  9. package/dist/assets/{desktop-update-config-DPk5V1Nl.js → desktop-update-config-CLXdVz9r.js} +1 -1
  10. package/dist/assets/{dist-T6rf86Nf.js → dist-CyWdaJvr.js} +1 -1
  11. package/dist/assets/{dist-DQoV-62W.js → dist-DzM2ZEVN.js} +1 -1
  12. package/dist/assets/doc-browser-D5FJcAyz.js +1 -0
  13. package/dist/assets/{doc-browser-CTFB6ACN.js → doc-browser-DewXa6oX.js} +1 -1
  14. package/dist/assets/{doc-browser-context-CP2tI-Br.js → doc-browser-context-CpL2m4Sp.js} +1 -1
  15. package/dist/assets/{ellipsis-B7CbNPNr.js → ellipsis-BDgTYNDX.js} +1 -1
  16. package/dist/assets/{es2015-BSPOGpH4.js → es2015-WqekV0OK.js} +1 -1
  17. package/dist/assets/{external-link-CefT35zP.js → external-link-BLjo8z2a.js} +1 -1
  18. package/dist/assets/{index-DZoXsgdz.js → index-DKflQCOL.js} +15 -15
  19. package/dist/assets/index-Q891JiF_.css +1 -0
  20. package/dist/assets/{key-round-BpM3GZn4.js → key-round-Du8x3sWa.js} +1 -1
  21. package/dist/assets/{loader-circle-_edjOFsw.js → loader-circle-Dz4FssYC.js} +1 -1
  22. package/dist/assets/{mcp-marketplace-page-WAgpy0hp.js → mcp-marketplace-page-BaBuHB2L.js} +2 -2
  23. package/dist/assets/mcp-marketplace-page-ooEvG_bB.js +1 -0
  24. package/dist/assets/{model-config-CixioI5U.js → model-config-B-qg70S4.js} +1 -1
  25. package/dist/assets/{notice-card-BLklZuEa.js → notice-card-3a7FmAz-.js} +1 -1
  26. package/dist/assets/play-BziM-Y34.js +1 -0
  27. package/dist/assets/plus-DrpyArC8.js +1 -0
  28. package/dist/assets/{popover-D_pJfOlM.js → popover-BlJLguiZ.js} +1 -1
  29. package/dist/assets/{provider-scoped-model-input-BbWqk2yz.js → provider-scoped-model-input-BgPTQa8H.js} +1 -1
  30. package/dist/assets/providers-list-cBeeslbc.js +1 -0
  31. package/dist/assets/{react-Bl8iJUcF.js → react-BVjl1ZaK.js} +1 -1
  32. package/dist/assets/{refresh-cw-CqfS1EhK.js → refresh-cw-DFR87zei.js} +1 -1
  33. package/dist/assets/remote-DjhVMpVd.js +1 -0
  34. package/dist/assets/rotate-cw-DMBnsYKm.js +1 -0
  35. package/dist/assets/{runtime-config-page-ByvKO6Yn.js → runtime-config-page-DYH2ejUI.js} +1 -1
  36. package/dist/assets/{save-CQERCODg.js → save-BDmIlHvN.js} +1 -1
  37. package/dist/assets/search-CjHGYvTI.js +1 -0
  38. package/dist/assets/{search-config-Cl2_NgFY.js → search-config-BRsrQ4zK.js} +1 -1
  39. package/dist/assets/{secrets-config-BkR72J4g.js → secrets-config-eYdx5_AW.js} +2 -2
  40. package/dist/assets/{status-dot-DY0xUUvK.js → status-dot-SVPjIyG5.js} +1 -1
  41. package/dist/assets/{tabs-C77i1w90.js → tabs-Czd2mj6z.js} +1 -1
  42. package/dist/assets/{tabs-custom-CFpR54-m.js → tabs-custom-Ix4nkp2h.js} +1 -1
  43. package/dist/assets/{tag-chip-Kib_ljle.js → tag-chip-Cdwb3JSH.js} +1 -1
  44. package/dist/assets/{tooltip-fs7VQG7d.js → tooltip-CKGxLGnG.js} +1 -1
  45. package/dist/assets/{trash-2-Bb8mTTLl.js → trash-2-yLA8BEcE.js} +1 -1
  46. package/dist/assets/x-dpDISboO.js +1 -0
  47. package/dist/index.html +25 -25
  48. package/package.json +8 -8
  49. package/src/features/chat/components/chat-session-workspace-panel-nav.tsx +86 -64
  50. package/src/features/chat/components/chat-session-workspace-panel.tsx +14 -25
  51. package/src/features/chat/components/conversation/chat-conversation-panel.test.tsx +3 -3
  52. package/src/features/panel-apps/components/panel-app-toolbar.test.tsx +24 -0
  53. package/src/features/panel-apps/components/panel-app-toolbar.tsx +11 -2
  54. package/src/features/panel-apps/utils/panel-app-doc-browser.utils.tsx +2 -1
  55. package/src/shared/components/cron-config.tsx +367 -91
  56. package/src/shared/hooks/use-config.ts +2 -2
  57. package/src/shared/lib/api/ncp-session-query-cache.utils.test.ts +35 -0
  58. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +9 -1
  59. package/src/shared/lib/api/utils/ncp-session.utils.ts +2 -2
  60. package/src/shared/lib/cron/cron-job-view.utils.ts +124 -6
  61. package/src/shared/lib/i18n/README.md +7 -0
  62. package/src/shared/lib/i18n/cron-labels.utils.ts +32 -0
  63. package/src/shared/lib/i18n/index.ts +2 -25
  64. package/vite.config.ts +8 -0
  65. package/dist/assets/arrow-left-CI0Ocu9p.js +0 -1
  66. package/dist/assets/channels-list-page-B6CvmU9T.js +0 -8
  67. package/dist/assets/chat-page-Ch4ShlKB.js +0 -105
  68. package/dist/assets/doc-browser-yi02ASOL.js +0 -1
  69. package/dist/assets/index-CevK3jug.css +0 -1
  70. package/dist/assets/mcp-marketplace-page-9maf_9fL.js +0 -1
  71. package/dist/assets/play-DD0bg50i.js +0 -1
  72. package/dist/assets/plus-zZ2GC_AJ.js +0 -1
  73. package/dist/assets/providers-list-C_JOSjYd.js +0 -1
  74. package/dist/assets/remote-qtmzkte2.js +0 -1
  75. package/dist/assets/rotate-cw-C7EvaSlb.js +0 -1
  76. package/dist/assets/search-C0b2yzmt.js +0 -1
  77. package/dist/assets/x-BV4iiea8.js +0 -1
@@ -1,22 +1,57 @@
1
- import { useMemo, useState } from 'react';
2
- import type { CronJobView } from '@/shared/lib/api';
3
- import { useConfirmDialog } from '@/shared/hooks/use-confirm-dialog';
4
- import { useCronJobs, useDeleteCronJob, useToggleCronJob, useRunCronJob } from '@/shared/hooks/use-config';
5
- import { Button } from '@/shared/components/ui/button';
6
- import { Input } from '@/shared/components/ui/input';
7
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
8
- import { Card, CardContent } from '@/shared/components/ui/card';
1
+ import { useMemo, useState } from "react";
2
+ import type { CronJobView } from "@/shared/lib/api";
3
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
4
+ import {
5
+ useCronJobs,
6
+ useDeleteCronJob,
7
+ useToggleCronJob,
8
+ useRunCronJob,
9
+ } from "@/shared/hooks/use-config";
10
+ import { Button } from "@/shared/components/ui/button";
11
+ import { Input } from "@/shared/components/ui/input";
12
+ import { Switch } from "@/shared/components/ui/switch";
13
+ import {
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue,
19
+ } from "@/shared/components/ui/select";
20
+ import {
21
+ Popover,
22
+ PopoverContent,
23
+ PopoverTrigger,
24
+ } from "@/shared/components/ui/popover";
25
+ import {
26
+ Tooltip,
27
+ TooltipContent,
28
+ TooltipProvider,
29
+ TooltipTrigger,
30
+ } from "@/shared/components/ui/tooltip";
9
31
  import {
10
32
  describeCronSchedule,
11
33
  describeCronSession,
12
34
  formatCronDate,
13
- } from '@/shared/lib/cron';
14
- import { cn } from '@/shared/lib/utils';
15
- import { t } from '@/shared/lib/i18n';
16
- import { PageLayout, PageHeader } from '@/app/components/layout/page-layout';
17
- import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
35
+ formatRelativeTime,
36
+ } from "@/shared/lib/cron";
37
+ import { cn } from "@/shared/lib/utils";
38
+ import { t } from "@/shared/lib/i18n";
39
+ import { PageLayout, PageHeader } from "@/app/components/layout/page-layout";
40
+ import {
41
+ AlarmClock,
42
+ RefreshCw,
43
+ Trash2,
44
+ Play,
45
+ MoreVertical,
46
+ ChevronDown,
47
+ ChevronRight,
48
+ Clock,
49
+ CalendarClock,
50
+ ListTodo,
51
+ AlertCircle,
52
+ } from "lucide-react";
18
53
 
19
- type StatusFilter = 'all' | 'enabled' | 'disabled';
54
+ type StatusFilter = "all" | "enabled" | "disabled";
20
55
 
21
56
  function matchQuery(job: CronJobView, query: string): boolean {
22
57
  const q = query.trim().toLowerCase();
@@ -25,72 +60,293 @@ function matchQuery(job: CronJobView, query: string): boolean {
25
60
  job.id,
26
61
  job.name,
27
62
  job.payload.message,
28
- job.payload.sessionId ?? ''
29
- ].join(' ').toLowerCase();
63
+ job.payload.sessionId ?? "",
64
+ ]
65
+ .join(" ")
66
+ .toLowerCase();
30
67
  return haystack.includes(q);
31
68
  }
32
69
 
33
70
  function filterByStatus(job: CronJobView, status: StatusFilter): boolean {
34
- if (status === 'all') return true;
35
- if (status === 'enabled') return job.enabled;
71
+ if (status === "all") return true;
72
+ if (status === "enabled") return job.enabled;
36
73
  return !job.enabled;
37
74
  }
38
75
 
76
+ function StatusBadge({ job }: { job: CronJobView }) {
77
+ return (
78
+ <span
79
+ className={cn(
80
+ "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold",
81
+ job.enabled
82
+ ? "bg-emerald-50 text-emerald-700"
83
+ : "bg-gray-100 text-gray-500",
84
+ )}
85
+ >
86
+ <span
87
+ className={cn(
88
+ "h-1.5 w-1.5 rounded-full",
89
+ job.enabled ? "bg-emerald-400" : "bg-gray-400",
90
+ )}
91
+ />
92
+ {job.enabled ? t("enabled") : t("disabled")}
93
+ </span>
94
+ );
95
+ }
96
+
39
97
  function CronJobCard(props: {
40
98
  job: CronJobView;
41
99
  onDelete: (job: CronJobView) => void;
42
100
  onRun: (job: CronJobView) => void;
43
- onToggle: (job: CronJobView) => void;
101
+ onToggle: (job: CronJobView, nextEnabled: boolean) => void;
44
102
  }) {
45
103
  const { job, onDelete, onRun, onToggle } = props;
104
+ const [expanded, setExpanded] = useState(false);
105
+ const [menuOpen, setMenuOpen] = useState(false);
106
+
107
+ const barColor = job.enabled ? "bg-emerald-400" : "bg-gray-300";
108
+
109
+ const handleCardClick = (e: React.MouseEvent) => {
110
+ // Don't expand when clicking switch or menu
111
+ const target = e.target as HTMLElement;
112
+ if (target.closest("[data-no-expand]")) return;
113
+ setExpanded(!expanded);
114
+ };
115
+
46
116
  return (
47
- <Card className="border border-gray-200">
48
- <CardContent className="pt-5 pb-5">
49
- <div className="flex flex-wrap items-start justify-between gap-4">
50
- <div className="min-w-[220px] flex-1">
51
- <div className="flex flex-wrap items-center gap-2">
52
- <span className="text-sm font-semibold text-gray-900">{job.name || job.id}</span>
53
- <span className="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] font-semibold text-gray-500">{job.id}</span>
54
- <span className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold', job.enabled ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500')}>
55
- {job.enabled ? t('enabled') : t('disabled')}
117
+ <div
118
+ className={cn(
119
+ "group relative overflow-hidden rounded-xl border bg-white transition-all duration-150 cursor-pointer",
120
+ expanded
121
+ ? "border-gray-300 shadow-sm"
122
+ : "border-gray-200 hover:border-gray-300 hover:shadow-sm",
123
+ )}
124
+ onClick={handleCardClick}
125
+ >
126
+ {/* Left color bar */}
127
+ <div
128
+ className={cn(
129
+ "absolute left-0 top-0 bottom-0 w-1 rounded-l-xl",
130
+ barColor,
131
+ )}
132
+ />
133
+
134
+ <div className="pl-4 pr-3 py-3"> {/* Row 1: name + badges + actions */}
135
+ <div className="flex items-center gap-3">
136
+ <div className="min-w-0 flex-1">
137
+ <div className="flex items-center gap-2">
138
+ {expanded ? (
139
+ <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
140
+ ) : (
141
+ <ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0 group-hover:text-gray-500 transition-colors" />
142
+ )}
143
+ <span className="text-sm font-semibold text-gray-900 truncate">
144
+ {job.name || job.id}
56
145
  </span>
57
- {job.deleteAfterRun ? (
58
- <span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700">{t('cronOneShot')}</span>
59
- ) : null}
146
+ <StatusBadge job={job} />
147
+ {job.deleteAfterRun && (
148
+ <span className="rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-600">
149
+ {t("cronOneShot")}
150
+ </span>
151
+ )}
60
152
  </div>
61
- <div className="mt-2 text-xs text-gray-500">{t('cronScheduleLabel')}: {describeCronSchedule(job)}</div>
62
- <div className="mt-2 whitespace-pre-wrap break-words text-sm text-gray-700">{job.payload.message}</div>
63
- <div className="mt-2 text-xs text-gray-500">{t('cronSessionLabel')}: {describeCronSession(job)}</div>
64
153
  </div>
65
- <div className="min-w-[220px] space-y-2 text-xs text-gray-500">
66
- <div><span className="font-medium text-gray-700">{t('cronNextRun')}:</span> {formatCronDate(job.state.nextRunAt)}</div>
67
- <div><span className="font-medium text-gray-700">{t('cronLastRun')}:</span> {formatCronDate(job.state.lastRunAt)}</div>
68
- <div><span className="font-medium text-gray-700">{t('cronLastStatus')}:</span> {job.state.lastStatus ?? '-'}</div>
69
- {job.state.lastError ? <div className="break-words text-[11px] text-red-500">{job.state.lastError}</div> : null}
154
+
155
+ {/* Actions - stop propagation so they don't toggle expand */}
156
+ <div className="flex items-center gap-1.5 shrink-0" data-no-expand>
157
+ <Switch
158
+ checked={job.enabled}
159
+ onCheckedChange={(checked) => onToggle(job, checked)}
160
+ />
161
+ <Popover open={menuOpen} onOpenChange={setMenuOpen}>
162
+ <PopoverTrigger asChild>
163
+ <Button
164
+ variant="ghost"
165
+ size="icon"
166
+ className="h-8 w-8 text-gray-400 hover:text-gray-600"
167
+ aria-label={t("cronMoreActions")}
168
+ >
169
+ <MoreVertical className="h-4 w-4" />
170
+ </Button>
171
+ </PopoverTrigger>
172
+ <PopoverContent align="end" sideOffset={4} className="w-40 p-1">
173
+ <button
174
+ type="button"
175
+ onClick={() => {
176
+ setMenuOpen(false);
177
+ onRun(job);
178
+ }}
179
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100"
180
+ >
181
+ <Play className="h-3.5 w-3.5 text-gray-500" />
182
+ {t("cronRunNow")}
183
+ </button>
184
+ <button
185
+ type="button"
186
+ onClick={() => {
187
+ setMenuOpen(false);
188
+ onDelete(job);
189
+ }}
190
+ className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-50"
191
+ >
192
+ <Trash2 className="h-3.5 w-3.5" />
193
+ {t("delete")}
194
+ </button>
195
+ </PopoverContent>
196
+ </Popover>
70
197
  </div>
71
- <div className="flex flex-wrap items-start justify-end gap-2">
72
- <Button variant="subtle" size="sm" onClick={() => onRun(job)} className="gap-1">
73
- <Play className="h-3.5 w-3.5" />
74
- {t('cronRunNow')}
75
- </Button>
76
- <Button variant={job.enabled ? 'outline' : 'primary'} size="sm" onClick={() => onToggle(job)} className="gap-1">
77
- <Power className="h-3.5 w-3.5" />
78
- {job.enabled ? t('cronDisable') : t('cronEnable')}
79
- </Button>
80
- <Button variant="destructive" size="sm" onClick={() => onDelete(job)} className="gap-1">
81
- <Trash2 className="h-3.5 w-3.5" />
82
- {t('delete')}
83
- </Button>
198
+ </div>
199
+
200
+ {/* Row 2: schedule + next run + last run (always visible) */}
201
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 pl-6">
202
+ <span className="flex items-center gap-1">
203
+ <CalendarClock className="h-3 w-3 text-gray-400" />
204
+ <span className="font-mono text-gray-600">
205
+ {describeCronSchedule(job)}
206
+ </span>
207
+ </span>
208
+ {job.state.nextRunAt && (
209
+ <span className="flex items-center gap-1">
210
+ <Clock className="h-3 w-3 text-gray-400" />
211
+ <span>{formatRelativeTime(job.state.nextRunAt)}</span>
212
+ </span>
213
+ )}
214
+ {job.state.lastRunAt && (
215
+ <span className="flex items-center gap-1" data-no-expand>
216
+ <span className="text-gray-300">•</span>
217
+ {job.state.lastStatus === "error" && job.state.lastError ? (
218
+ <TooltipProvider delayDuration={200}>
219
+ <Tooltip>
220
+ <TooltipTrigger asChild>
221
+ <span className="inline-flex items-center gap-0.5 text-orange-400 cursor-help">
222
+ <AlertCircle className="h-3 w-3" />
223
+ </span>
224
+ </TooltipTrigger>
225
+ <TooltipContent
226
+ side="top"
227
+ align="start"
228
+ className="max-w-xs text-xs bg-red-50 text-red-700 border-red-200"
229
+ >
230
+ {job.state.lastError}
231
+ </TooltipContent>
232
+ </Tooltip>
233
+ </TooltipProvider>
234
+ ) : (
235
+ <span
236
+ className={cn(
237
+ job.state.lastStatus === "ok"
238
+ ? "text-emerald-400"
239
+ : "text-gray-400",
240
+ )}
241
+ >
242
+ {job.state.lastStatus === "ok" ? "✓" : ""}
243
+ </span>
244
+ )}
245
+ <span>{formatRelativeTime(job.state.lastRunAt)}</span>
246
+ </span>
247
+ )}
248
+ </div>
249
+
250
+ {/* Row 3: message preview (always visible, 2 lines) */}
251
+ <div className="mt-2 pl-6">
252
+ <div
253
+ className={cn(
254
+ "text-sm text-gray-600 break-words",
255
+ !expanded && "line-clamp-2",
256
+ )}
257
+ >
258
+ {job.payload.message}
84
259
  </div>
85
260
  </div>
86
- </CardContent>
87
- </Card>
261
+
262
+ {/* Expanded details */}
263
+ {expanded && (
264
+ <div className="mt-3 ml-6 space-y-3 animate-in fade-in-0 slide-in-from-top-1 duration-150">
265
+ {/* Metadata grid */}
266
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-2 text-xs">
267
+ <div>
268
+ <div className="text-[11px] text-gray-400 mb-0.5">
269
+ {t("cronSessionLabel")}
270
+ </div>
271
+ <div
272
+ className="text-gray-700 font-mono text-[11px] truncate"
273
+ title={describeCronSession(job)}
274
+ >
275
+ {describeCronSession(job)}
276
+ </div>
277
+ </div>
278
+ <div>
279
+ <div className="text-[11px] text-gray-400 mb-0.5">
280
+ {t("cronNextRun")}
281
+ </div>
282
+ <div className="text-gray-700">
283
+ {formatCronDate(job.state.nextRunAt)}
284
+ </div>
285
+ </div>
286
+ <div>
287
+ <div className="text-[11px] text-gray-400 mb-0.5">
288
+ {t("cronLastRun")}
289
+ </div>
290
+ <div className="text-gray-700">
291
+ {formatCronDate(job.state.lastRunAt)}
292
+ </div>
293
+ </div>
294
+ <div>
295
+ <div className="text-[11px] text-gray-400 mb-0.5">
296
+ {t("cronLastStatus")}
297
+ </div>
298
+ <div
299
+ className={cn(
300
+ "inline-flex items-center gap-1 font-medium",
301
+ job.state.lastStatus === "ok"
302
+ ? "text-emerald-600"
303
+ : job.state.lastStatus === "error"
304
+ ? "text-red-600"
305
+ : job.state.lastStatus === "skipped"
306
+ ? "text-amber-600"
307
+ : "text-gray-500",
308
+ )}
309
+ >
310
+ {job.state.lastStatus ?? "-"}
311
+ </div>
312
+ </div>
313
+ <div>
314
+ <div className="text-[11px] text-gray-400 mb-0.5">
315
+ {t("cronCreatedAt")}
316
+ </div>
317
+ <div className="text-gray-700">
318
+ {formatCronDate(job.createdAt)}
319
+ </div>
320
+ </div>
321
+ <div>
322
+ <div className="text-[11px] text-gray-400 mb-0.5">
323
+ {t("cronId")}
324
+ </div>
325
+ <div
326
+ className="text-gray-700 font-mono text-[11px] truncate"
327
+ title={job.id}
328
+ >
329
+ {job.id}
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ {/* Error */}
335
+ {job.state.lastError && (
336
+ <div className="rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-xs text-red-600 break-words">
337
+ {job.state.lastError}
338
+ </div>
339
+ )}
340
+ </div>
341
+ )}
342
+ </div>
343
+ </div>
88
344
  );
89
345
  }
90
346
 
91
347
  export function CronConfig() {
92
- const [query, setQuery] = useState('');
93
- const [status, setStatus] = useState<StatusFilter>('all');
348
+ const [query, setQuery] = useState("");
349
+ const [status, setStatus] = useState<StatusFilter>("all");
94
350
  const cronQuery = useCronJobs({ all: true });
95
351
  const deleteCronJob = useDeleteCronJob();
96
352
  const toggleCronJob = useToggleCronJob();
@@ -106,43 +362,37 @@ export function CronConfig() {
106
362
 
107
363
  const handleDelete = async (job: CronJobView) => {
108
364
  const confirmed = await confirm({
109
- title: `${t('cronDeleteConfirm')}?`,
365
+ title: `${t("cronDeleteConfirm")}?`,
110
366
  description: job.name ? `${job.name} (${job.id})` : job.id,
111
- variant: 'destructive',
112
- confirmLabel: t('delete')
367
+ variant: "destructive",
368
+ confirmLabel: t("delete"),
113
369
  });
114
370
  if (!confirmed) return;
115
371
  deleteCronJob.mutate({ id: job.id });
116
372
  };
117
373
 
118
- const handleToggle = async (job: CronJobView) => {
119
- const nextEnabled = !job.enabled;
120
- const confirmed = await confirm({
121
- title: nextEnabled ? `${t('cronEnableConfirm')}?` : `${t('cronDisableConfirm')}?`,
122
- description: job.name ? `${job.name} (${job.id})` : job.id,
123
- variant: nextEnabled ? 'default' : 'destructive',
124
- confirmLabel: nextEnabled ? t('cronEnable') : t('cronDisable')
125
- });
126
- if (!confirmed) return;
374
+ const handleToggle = (job: CronJobView, nextEnabled: boolean) => {
127
375
  toggleCronJob.mutate({ id: job.id, enabled: nextEnabled });
128
376
  };
129
377
 
130
378
  const handleRun = async (job: CronJobView) => {
131
379
  const force = !job.enabled;
132
- const confirmed = await confirm({
133
- title: force ? `${t('cronRunForceConfirm')}?` : `${t('cronRunConfirm')}?`,
134
- description: job.name ? `${job.name} (${job.id})` : job.id,
135
- confirmLabel: t('cronRunNow')
136
- });
137
- if (!confirmed) return;
380
+ if (force) {
381
+ const confirmed = await confirm({
382
+ title: `${t("cronRunForceConfirm")}?`,
383
+ description: job.name ? `${job.name} (${job.id})` : job.id,
384
+ confirmLabel: t("cronRunNow"),
385
+ });
386
+ if (!confirmed) return;
387
+ }
138
388
  runCronJob.mutate({ id: job.id, force });
139
389
  };
140
390
 
141
391
  return (
142
392
  <PageLayout fullHeight>
143
393
  <PageHeader
144
- title={t('cronPageTitle')}
145
- description={t('cronPageDescription')}
394
+ title={t("cronPageTitle")}
395
+ description={t("cronPageDescription")}
146
396
  actions={
147
397
  <Button
148
398
  variant="ghost"
@@ -150,49 +400,75 @@ export function CronConfig() {
150
400
  className="h-9 w-9 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100"
151
401
  onClick={() => cronQuery.refetch()}
152
402
  >
153
- <RefreshCw className={cn('h-4 w-4', cronQuery.isFetching && 'animate-spin')} />
403
+ <RefreshCw
404
+ className={cn("h-4 w-4", cronQuery.isFetching && "animate-spin")}
405
+ />
154
406
  </Button>
155
407
  }
156
408
  />
157
409
 
158
- <div className="mb-6">
410
+ <div className="mb-5">
159
411
  <div className="flex flex-wrap gap-3 items-center">
160
412
  <div className="relative flex-1 min-w-[240px]">
161
413
  <Input
162
414
  value={query}
163
415
  onChange={(e) => setQuery(e.target.value)}
164
- placeholder={t('cronSearchPlaceholder')}
416
+ placeholder={t("cronSearchPlaceholder")}
165
417
  className="pl-9"
166
418
  />
167
419
  <AlarmClock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
168
420
  </div>
169
- <div className="min-w-[180px]">
170
- <Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
421
+ <div className="min-w-[140px]">
422
+ <Select
423
+ value={status}
424
+ onValueChange={(value) => setStatus(value as StatusFilter)}
425
+ >
171
426
  <SelectTrigger className="w-full">
172
- <SelectValue placeholder={t('cronStatusLabel')} />
427
+ <SelectValue placeholder={t("cronStatusLabel")} />
173
428
  </SelectTrigger>
174
429
  <SelectContent>
175
- <SelectItem value="all">{t('cronStatusAll')}</SelectItem>
176
- <SelectItem value="enabled">{t('cronStatusEnabled')}</SelectItem>
177
- <SelectItem value="disabled">{t('cronStatusDisabled')}</SelectItem>
430
+ <SelectItem value="all">{t("cronStatusAll")}</SelectItem>
431
+ <SelectItem value="enabled">
432
+ {t("cronStatusEnabled")}
433
+ </SelectItem>
434
+ <SelectItem value="disabled">
435
+ {t("cronStatusDisabled")}
436
+ </SelectItem>
178
437
  </SelectContent>
179
438
  </Select>
180
439
  </div>
181
- <div className="text-xs text-gray-500 ml-auto">
182
- {t('cronTotalLabel')}: {cronQuery.data?.total ?? 0} / {jobs.length}
440
+ <div className="text-xs text-gray-400 ml-auto tabular-nums">
441
+ {jobs.length} / {cronQuery.data?.total ?? 0}
183
442
  </div>
184
443
  </div>
185
444
  </div>
186
445
 
187
446
  <div className="flex-1 overflow-auto custom-scrollbar">
188
447
  {cronQuery.isLoading ? (
189
- <div className="text-sm text-gray-400 p-4 text-center">{t('cronLoading')}</div>
448
+ <div className="flex flex-col items-center justify-center py-16 text-gray-400">
449
+ <RefreshCw className="h-6 w-6 animate-spin mb-3" />
450
+ <span className="text-sm">{t("cronLoading")}</span>
451
+ </div>
190
452
  ) : jobs.length === 0 ? (
191
- <div className="text-sm text-gray-400 p-4 text-center">{t('cronEmpty')}</div>
453
+ <div className="flex flex-col items-center justify-center py-16 text-gray-400">
454
+ <ListTodo className="h-10 w-10 mb-3 text-gray-300" />
455
+ <span className="text-sm font-medium text-gray-500">
456
+ {t("cronEmpty")}
457
+ </span>
458
+ <span className="text-xs text-gray-400 mt-1">
459
+ {t("cronEmptyGuide")}
460
+ </span>
461
+ </div>
192
462
  ) : (
193
- <div className="space-y-4">
463
+ <div className="space-y-2.5">
194
464
  {jobs.map((job) => (
195
- <CronJobCard key={job.id} job={job} onDelete={handleDelete} onRun={handleRun} onToggle={handleToggle} />
465
+ <CronJobCard
466
+ key={job.id}
467
+ job={job}
468
+ onDelete={handleDelete}
469
+ onRun={handleRun}
470
+ onToggle={handleToggle}
471
+ />
196
472
  ))}
197
473
  </div>
198
474
  )}
@@ -257,9 +257,9 @@ export function useExecuteConfigAction() {
257
257
  });
258
258
  }
259
259
 
260
- export function useNcpSessions(params?: { limit?: number }) {
260
+ export function useNcpSessions(params?: { limit?: number; peerId?: string }) {
261
261
  return useQuery({
262
- queryKey: ['ncp-sessions', params?.limit ?? null],
262
+ queryKey: ['ncp-sessions', params?.limit ?? null, params?.peerId?.trim() || null],
263
263
  queryFn: () => fetchNcpSessions(params),
264
264
  staleTime: 5_000,
265
265
  retry: false
@@ -198,4 +198,39 @@ describe('ncp-session-query-cache', () => {
198
198
  queryClient.getQueryData<NcpSessionsListView>(['ncp-sessions', 200])?.sessions.map((session) => session.sessionId)
199
199
  ).toEqual(['session-3', 'session-2']);
200
200
  });
201
+
202
+ it('does not upsert a peer summary into another peer filtered session cache', () => {
203
+ const queryClient = new QueryClient();
204
+ queryClient.setQueryData(['ncp-sessions', 200, 'peer-a'], {
205
+ sessions: [],
206
+ total: 0
207
+ });
208
+ queryClient.setQueryData(['ncp-sessions', 200, 'peer-b'], {
209
+ sessions: [],
210
+ total: 0
211
+ });
212
+
213
+ applyNcpSessionRealtimeEvent(queryClient, {
214
+ type: 'session.summary.upsert',
215
+ payload: {
216
+ summary: {
217
+ sessionId: 'session-peer-a',
218
+ peerId: 'peer-a',
219
+ messageCount: 1,
220
+ updatedAt: '2026-03-29T12:00:00.000Z',
221
+ status: 'idle'
222
+ }
223
+ }
224
+ });
225
+
226
+ expect(
227
+ queryClient.getQueryData<NcpSessionsListView>(['ncp-sessions', 200, 'peer-a'])?.sessions
228
+ ).toEqual([
229
+ expect.objectContaining({
230
+ peerId: 'peer-a',
231
+ sessionId: 'session-peer-a'
232
+ })
233
+ ]);
234
+ expect(queryClient.getQueryData<NcpSessionsListView>(['ncp-sessions', 200, 'peer-b'])?.sessions).toEqual([]);
235
+ });
201
236
  });
@@ -21,6 +21,14 @@ function shouldReplaceSessionSummary(
21
21
  return current.status === next.status || next.status === 'idle';
22
22
  }
23
23
 
24
+ function queryKeyAcceptsSessionSummary(queryKey: readonly unknown[], summary: NcpSessionSummaryView): boolean {
25
+ if (queryKey[0] !== 'ncp-sessions') {
26
+ return false;
27
+ }
28
+ const peerId = typeof queryKey[2] === 'string' ? queryKey[2].trim() : '';
29
+ return !peerId || summary.peerId === peerId;
30
+ }
31
+
24
32
  export function upsertNcpSessionSummaryList(
25
33
  current: NcpSessionsListView | undefined,
26
34
  summary: NcpSessionSummaryView
@@ -80,7 +88,7 @@ export function upsertNcpSessionSummaryInQueryClient(
80
88
  summary: NcpSessionSummaryView
81
89
  ): void {
82
90
  queryClient?.setQueriesData<NcpSessionsListView>(
83
- { queryKey: ['ncp-sessions'] },
91
+ { predicate: (query) => queryKeyAcceptsSessionSummary(query.queryKey, summary) },
84
92
  (current) => upsertNcpSessionSummaryList(current, summary)
85
93
  );
86
94
  }
@@ -1,4 +1,4 @@
1
- import { nextclawClient } from '../managers/client.manager';
1
+ import { nextclawClient } from '@/shared/lib/api/managers/client.manager';
2
2
  import type {
3
3
  NcpSessionMessagesView,
4
4
  NcpSessionSkillsView,
@@ -8,7 +8,7 @@ import type {
8
8
  } from '@/shared/lib/api/types';
9
9
 
10
10
  // GET /api/ncp/sessions
11
- export async function fetchNcpSessions(params?: { limit?: number }): Promise<NcpSessionsListView> {
11
+ export async function fetchNcpSessions(params?: { limit?: number; peerId?: string }): Promise<NcpSessionsListView> {
12
12
  return await nextclawClient.sessions.list(params) as NcpSessionsListView;
13
13
  }
14
14