@ottttto/dsh-scheduled-send 0.2.3 → 0.3.1

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.
package/README.md CHANGED
@@ -13,11 +13,12 @@
13
13
  - 🔁 **断电补发**:任务持久化在本地(`<dataDir>/tasks.json`),重启 DSH 后恢复队列并向原会话补发;会话不在线时保留任务,会话恢复即补发。
14
14
  - ✕ **一键取消**:列表中每条任务均可随时取消。
15
15
  - 📱 **移动端适配**:近全宽弹窗、≥40px 触控高度、防横向溢出。
16
+ - 🗂️ **侧边栏「定时任务」面板**(v0.3.0):侧边栏底部入口(含全库未发送任务数角标)→ 独立面板列出**所有会话**的未发送任务(sendAt 升序、内容摘要、计划时间本地格式、倒计时、会话名);点击条目跳转到对应会话;条目取消按钮可跨会话取消(复用既有 DELETE 路由);会话已删除的条目置灰标注「会话不存在」仍可取消;空列表显示友好空态。挂载机制与 `@ychris12138/dsh-usage-stats` 同型(`sidebar.footer.action` 槽位),跳转使用 `sessions.open(sessionId)` 会话导航服务。
16
17
 
17
18
  ## 安装
18
19
 
19
20
  ```bash
20
- dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.2.0
21
+ dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.3.0
21
22
  ```
22
23
 
23
24
  然后重启 DSH Web GUI。
@@ -28,6 +29,7 @@ dsh plugin --profile web add @ottttto/dsh-scheduled-send@0.2.0
28
29
  2. 点输入框工具行的 **⏰** → 确认时间(默认 +5 分钟)→ 确认。
29
30
  3. 输入框被清空;上方出现已排程条目(含计划时间与倒计时),可随时点取消。
30
31
  4. 到点后消息以你的正常用户气泡出现在**创建时的会话**并触发模型响应。
32
+ 5. 侧边栏底部「⏰ 定时任务」:查看/跳转/取消**所有会话**的定时任务。
31
33
 
32
34
  ## 隐私
33
35
 
package/lib/client.js CHANGED
@@ -87,6 +87,35 @@ function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
87
87
  return now + offsetMs;
88
88
  }
89
89
 
90
+ /**
91
+ * One-line content summary for the sidebar panel list (0.3.0): first line,
92
+ * trimmed; capped with an ellipsis past `max` chars. Empty input → ''.
93
+ */
94
+ function summarizeContent(text, max = 50) {
95
+ if (typeof text !== 'string') return '';
96
+ const first = text.split(/\r?\n/, 1)[0].trim();
97
+ if (!first) return '';
98
+ return first.length > max ? first.slice(0, max - 1) + '…' : first;
99
+ }
100
+
101
+ /**
102
+ * Attach session identity to panel rows (0.3.0): `sessionTitle` from the
103
+ * client session list's displayTitle, `sessionExists` false when the session
104
+ * is gone (deleted / not in the list) — those rows gray out but stay
105
+ * cancellable. Never throws on a missing/empty map.
106
+ */
107
+ function annotateSessions(tasks, sessionsById) {
108
+ const byId = sessionsById || {};
109
+ return (tasks || []).map((t) => {
110
+ const s = t?.conversationId ? byId[t.conversationId] : null;
111
+ return {
112
+ ...t,
113
+ sessionExists: !!s,
114
+ sessionTitle: s ? (s.displayTitle || s.title || t.conversationId) : '',
115
+ };
116
+ });
117
+ }
118
+
90
119
  /**
91
120
  * Stateful client controller: polls the host state route, holds the visible
92
121
  * task list (extended with the server-confirmed task right after POST so new
@@ -286,6 +315,9 @@ function createClientPluginBody(React) {
286
315
  try {
287
316
  const payload = { content, sendAt: at, conversationId: props.sessionId };
288
317
  await core.scheduleMessage(payload);
318
+ // sidebar panel has its own state: refresh it NOW (its 8s poll would
319
+ // otherwise lag ~10s behind a fresh task)
320
+ if (props.onTaskCreated) void props.onTaskCreated();
289
321
  // spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
290
322
  if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
291
323
  setOpen(false);
@@ -462,6 +494,148 @@ function createClientPluginBody(React) {
462
494
  ]);
463
495
  }
464
496
 
497
+ /* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
498
+ function ScheduledTasksPanel(props) {
499
+ const core = props.panelCore;
500
+ const [open, setOpen] = React.useState(false);
501
+ const [tick, setTick] = React.useState(0);
502
+ const mobile = isMobile();
503
+
504
+ // keep the badge alive even while the panel is closed: poll the FULL
505
+ // (unfiltered) state — every conversation's pending tasks, 8s cadence.
506
+ React.useEffect(() => {
507
+ let stopped = false;
508
+ let timer = null;
509
+ const loop = async () => {
510
+ if (stopped) return;
511
+ await core.refresh().catch(() => {});
512
+ if (!stopped) setTick((n) => n + 1);
513
+ if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
514
+ };
515
+ void loop();
516
+ return () => { stopped = true; clearTimeout(timer); };
517
+ }, []);
518
+
519
+ const all = core.visibleTasks(); // ALL sessions, sendAt ascending
520
+ const err = core.lastError();
521
+ const MAX_ROWS = 100;
522
+ const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
523
+ const overflow = Math.max(0, all.length - annotated.length);
524
+
525
+ const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
526
+ const entryBtn = {
527
+ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
528
+ border: "none", background: "transparent", color: "inherit",
529
+ fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
530
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
531
+ };
532
+ const badge = (n) => h("span", {
533
+ key: "b", "data-badge": n,
534
+ style: {
535
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
536
+ minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
537
+ background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
538
+ },
539
+ }, String(n));
540
+
541
+ const jump = (t) => {
542
+ // failure mode: jump unavailable/unknown session → keep the panel open,
543
+ // keep the row (still cancellable); never throw into the click handler.
544
+ const ok = props.openSession ? props.openSession(t.conversationId) : false;
545
+ if (ok) setOpen(false);
546
+ };
547
+ const cancelBtn = (t) => h("button", {
548
+ key: "x", type: "button",
549
+ onClick: (e) => {
550
+ if (e && typeof e.stopPropagation === "function") e.stopPropagation();
551
+ core.cancelTask(t.id).catch(() => {});
552
+ setTick((n) => n + 1);
553
+ },
554
+ style: {
555
+ cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
556
+ padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
557
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
558
+ },
559
+ }, "取消");
560
+
561
+ const footer = open
562
+ ? h("div", {
563
+ key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
564
+ style: {
565
+ position: "fixed", bottom: 56, left: 12, zIndex: 50,
566
+ width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
567
+ boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
568
+ borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
569
+ background: isDark ? "#1c1c1e" : "#fff",
570
+ boxShadow: "0 8px 24px rgba(0,0,0,.18)",
571
+ color: isDark ? "#eee" : "#111",
572
+ ...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
573
+ },
574
+ }, [
575
+ h("div", {
576
+ key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
577
+ }, [
578
+ h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
579
+ h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
580
+ h("button", {
581
+ key: "close", type: "button", onClick: () => setOpen(false),
582
+ style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
583
+ }, "✕"),
584
+ ]),
585
+ err
586
+ ? h("div", {
587
+ key: "err",
588
+ style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
589
+ }, "⚠ 加载失败,显示上一次列表:" + err)
590
+ : null,
591
+ !all.length && !err
592
+ ? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
593
+ h("div", { key: "l1" }, "暂无定时任务"),
594
+ h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
595
+ ])
596
+ : null,
597
+ annotated.map((t) => h("div", {
598
+ key: t.id, "data-task": t.id,
599
+ onClick: () => { if (t.sessionExists) jump(t); },
600
+ title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
601
+ style: {
602
+ display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
603
+ borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
604
+ cursor: t.sessionExists ? "pointer" : "default",
605
+ opacity: t.sessionExists ? 1 : 0.5,
606
+ wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
607
+ },
608
+ }, [
609
+ h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
610
+ summarizeContent(t.content) || "(空内容)"),
611
+ h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
612
+ h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
613
+ t.sessionExists ? t.sessionTitle : "会话不存在"),
614
+ h("span", { key: "at" }, formatLocalTime(t.sendAt)),
615
+ h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
616
+ h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
617
+ ]),
618
+ ])),
619
+ overflow > 0
620
+ ? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
621
+ `仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
622
+ : null,
623
+ ])
624
+ : null;
625
+
626
+ return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
627
+ h("button", {
628
+ key: "btn", type: "button",
629
+ onClick: () => { setOpen(!open); setTick((n) => n + 1); },
630
+ title: "定时任务", "aria-label": "定时任务", style: entryBtn,
631
+ }, [
632
+ h("span", { key: "l" }, "⏰ 定时任务"),
633
+ all.length ? badge(all.length) : null,
634
+ ]),
635
+ footer,
636
+ ]);
637
+ }
638
+
465
639
  /** Client plugin body. Returns the cordis plugin ({inject, apply}). */
466
640
  return function buildPlugin({ stateRoutePath, fetchImpl }) {
467
641
  const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
@@ -469,6 +643,24 @@ function createClientPluginBody(React) {
469
643
 
470
644
  let currentSessionId = null;
471
645
 
646
+ const doCancel = async (id) => {
647
+ const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
648
+ return res.ok;
649
+ };
650
+
651
+ // 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
652
+ // (no conversationId filter) for the sidebar badge + panel; cancel reuses
653
+ // the same DELETE route (cross-conversation).
654
+ const panelCore = createScheduledClientState({
655
+ fetchState: async () => {
656
+ const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
657
+ if (!res.ok) throw new Error("state HTTP " + res.status);
658
+ return res.json();
659
+ },
660
+ cancelSchedule: doCancel,
661
+ });
662
+ panelCore.setSession(null);
663
+
472
664
  const core = createScheduledClientState({
473
665
  fetchState: async () => {
474
666
  // ask the host for THIS conversation's view (the core's bound session
@@ -496,9 +688,28 @@ function createClientPluginBody(React) {
496
688
  });
497
689
  core.defaultSendAt = () => defaultSendAt();
498
690
 
499
- const inject = ["slots"];
691
+ const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
500
692
  function apply(ctx) {
501
693
  ctx.inject(inject, (scope) => {
694
+ // 0.3.0 session navigation, sourced from the sessions service:
695
+ // - open(id) selects a session as current (unknown ids throw → false)
696
+ // - list.getSnapshot().byId maps id → {displayTitle} for row labels
697
+ const svc = scope.sessions;
698
+ const openSession = (sid) => {
699
+ try {
700
+ if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
701
+ } catch (err) { /* unknown session — degrade, row stays cancellable */ }
702
+ return false;
703
+ };
704
+ const sessionById = () => {
705
+ try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
706
+ catch (err) { return {}; }
707
+ };
708
+ scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
709
+ name: "sidebar.footer.action",
710
+ id: "@ottttto/dsh-scheduled-send",
711
+ order: 20,
712
+ }, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
502
713
  scope.slots.inject("conversation.input.right", () => scope.slots.register({
503
714
  name: "conversation.input.right",
504
715
  id: "@ottttto/dsh-scheduled-send",
@@ -506,7 +717,7 @@ function createClientPluginBody(React) {
506
717
  inject: (sessionId) => {
507
718
  currentSessionId = sessionId;
508
719
  core.setSession(sessionId); // dock/button are conversation-scoped
509
- return { sessionId, core };
720
+ return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
510
721
  },
511
722
  }, ScheduleButton));
512
723
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
@@ -516,7 +727,7 @@ function createClientPluginBody(React) {
516
727
  inject: (sessionId) => {
517
728
  currentSessionId = sessionId;
518
729
  core.setSession(sessionId);
519
- return { sessionId, core };
730
+ return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
520
731
  },
521
732
  }, ScheduledDock));
522
733
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ottttto/dsh-scheduled-send",
3
- "version": "0.2.3",
4
- "description": "DSH plugin: scheduled sending (⏰ composer button, dock list of pending tasks, due-time delivery as a normal user bubble)",
3
+ "version": "0.3.1",
4
+ "description": "DSH plugin: scheduled sending (⏰ composer button, dock list of pending tasks, sidebar 定时任务 panel with cross-session cancel, due-time delivery as a normal user bubble)",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "exports": {
@@ -38,7 +38,8 @@
38
38
  "client": {
39
39
  "platform": "web",
40
40
  "inject": [
41
- "@deepseek-ai/dsh-client-ui-conversation"
41
+ "@deepseek-ai/dsh-client-ui-conversation",
42
+ "@deepseek-ai/dsh-client-ui-sidebar"
42
43
  ],
43
44
  "external": []
44
45
  }
@@ -51,4 +52,4 @@
51
52
  "engines": {
52
53
  "node": ">=18"
53
54
  }
54
- }
55
+ }
@@ -78,6 +78,35 @@ export function defaultSendAt(now = Date.now(), offsetMs = 5 * 60_000) {
78
78
  return now + offsetMs;
79
79
  }
80
80
 
81
+ /**
82
+ * One-line content summary for the sidebar panel list (0.3.0): first line,
83
+ * trimmed; capped with an ellipsis past `max` chars. Empty input → ''.
84
+ */
85
+ export function summarizeContent(text, max = 50) {
86
+ if (typeof text !== 'string') return '';
87
+ const first = text.split(/\r?\n/, 1)[0].trim();
88
+ if (!first) return '';
89
+ return first.length > max ? first.slice(0, max - 1) + '…' : first;
90
+ }
91
+
92
+ /**
93
+ * Attach session identity to panel rows (0.3.0): `sessionTitle` from the
94
+ * client session list's displayTitle, `sessionExists` false when the session
95
+ * is gone (deleted / not in the list) — those rows gray out but stay
96
+ * cancellable. Never throws on a missing/empty map.
97
+ */
98
+ export function annotateSessions(tasks, sessionsById) {
99
+ const byId = sessionsById || {};
100
+ return (tasks || []).map((t) => {
101
+ const s = t?.conversationId ? byId[t.conversationId] : null;
102
+ return {
103
+ ...t,
104
+ sessionExists: !!s,
105
+ sessionTitle: s ? (s.displayTitle || s.title || t.conversationId) : '',
106
+ };
107
+ });
108
+ }
109
+
81
110
  /**
82
111
  * Stateful client controller: polls the host state route, holds the visible
83
112
  * task list (extended with the server-confirmed task right after POST so new
@@ -51,6 +51,9 @@ function createClientPluginBody(React) {
51
51
  try {
52
52
  const payload = { content, sendAt: at, conversationId: props.sessionId };
53
53
  await core.scheduleMessage(payload);
54
+ // sidebar panel has its own state: refresh it NOW (its 8s poll would
55
+ // otherwise lag ~10s behind a fresh task)
56
+ if (props.onTaskCreated) void props.onTaskCreated();
54
57
  // spec: 内容转为定时任务并清空输入框 — 绝不立即发送(不调 submit)
55
58
  if (props.inputActions && typeof props.inputActions.setDraft === "function") props.inputActions.setDraft("");
56
59
  setOpen(false);
@@ -227,6 +230,148 @@ function createClientPluginBody(React) {
227
230
  ]);
228
231
  }
229
232
 
233
+ /* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
234
+ function ScheduledTasksPanel(props) {
235
+ const core = props.panelCore;
236
+ const [open, setOpen] = React.useState(false);
237
+ const [tick, setTick] = React.useState(0);
238
+ const mobile = isMobile();
239
+
240
+ // keep the badge alive even while the panel is closed: poll the FULL
241
+ // (unfiltered) state — every conversation's pending tasks, 8s cadence.
242
+ React.useEffect(() => {
243
+ let stopped = false;
244
+ let timer = null;
245
+ const loop = async () => {
246
+ if (stopped) return;
247
+ await core.refresh().catch(() => {});
248
+ if (!stopped) setTick((n) => n + 1);
249
+ if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
250
+ };
251
+ void loop();
252
+ return () => { stopped = true; clearTimeout(timer); };
253
+ }, []);
254
+
255
+ const all = core.visibleTasks(); // ALL sessions, sendAt ascending
256
+ const err = core.lastError();
257
+ const MAX_ROWS = 100;
258
+ const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
259
+ const overflow = Math.max(0, all.length - annotated.length);
260
+
261
+ const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
262
+ const entryBtn = {
263
+ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
264
+ border: "none", background: "transparent", color: "inherit",
265
+ fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
266
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
267
+ };
268
+ const badge = (n) => h("span", {
269
+ key: "b", "data-badge": n,
270
+ style: {
271
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
272
+ minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
273
+ background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
274
+ },
275
+ }, String(n));
276
+
277
+ const jump = (t) => {
278
+ // failure mode: jump unavailable/unknown session → keep the panel open,
279
+ // keep the row (still cancellable); never throw into the click handler.
280
+ const ok = props.openSession ? props.openSession(t.conversationId) : false;
281
+ if (ok) setOpen(false);
282
+ };
283
+ const cancelBtn = (t) => h("button", {
284
+ key: "x", type: "button",
285
+ onClick: (e) => {
286
+ if (e && typeof e.stopPropagation === "function") e.stopPropagation();
287
+ core.cancelTask(t.id).catch(() => {});
288
+ setTick((n) => n + 1);
289
+ },
290
+ style: {
291
+ cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
292
+ padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
293
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
294
+ },
295
+ }, "取消");
296
+
297
+ const footer = open
298
+ ? h("div", {
299
+ key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
300
+ style: {
301
+ position: "fixed", bottom: 56, left: 12, zIndex: 50,
302
+ width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
303
+ boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
304
+ borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
305
+ background: isDark ? "#1c1c1e" : "#fff",
306
+ boxShadow: "0 8px 24px rgba(0,0,0,.18)",
307
+ color: isDark ? "#eee" : "#111",
308
+ ...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
309
+ },
310
+ }, [
311
+ h("div", {
312
+ key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
313
+ }, [
314
+ h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
315
+ h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
316
+ h("button", {
317
+ key: "close", type: "button", onClick: () => setOpen(false),
318
+ style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
319
+ }, "✕"),
320
+ ]),
321
+ err
322
+ ? h("div", {
323
+ key: "err",
324
+ style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
325
+ }, "⚠ 加载失败,显示上一次列表:" + err)
326
+ : null,
327
+ !all.length && !err
328
+ ? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
329
+ h("div", { key: "l1" }, "暂无定时任务"),
330
+ h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
331
+ ])
332
+ : null,
333
+ annotated.map((t) => h("div", {
334
+ key: t.id, "data-task": t.id,
335
+ onClick: () => { if (t.sessionExists) jump(t); },
336
+ title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
337
+ style: {
338
+ display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
339
+ borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
340
+ cursor: t.sessionExists ? "pointer" : "default",
341
+ opacity: t.sessionExists ? 1 : 0.5,
342
+ wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
343
+ },
344
+ }, [
345
+ h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
346
+ summarizeContent(t.content) || "(空内容)"),
347
+ h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
348
+ h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
349
+ t.sessionExists ? t.sessionTitle : "会话不存在"),
350
+ h("span", { key: "at" }, formatLocalTime(t.sendAt)),
351
+ h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
352
+ h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
353
+ ]),
354
+ ])),
355
+ overflow > 0
356
+ ? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
357
+ `仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
358
+ : null,
359
+ ])
360
+ : null;
361
+
362
+ return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
363
+ h("button", {
364
+ key: "btn", type: "button",
365
+ onClick: () => { setOpen(!open); setTick((n) => n + 1); },
366
+ title: "定时任务", "aria-label": "定时任务", style: entryBtn,
367
+ }, [
368
+ h("span", { key: "l" }, "⏰ 定时任务"),
369
+ all.length ? badge(all.length) : null,
370
+ ]),
371
+ footer,
372
+ ]);
373
+ }
374
+
230
375
  /** Client plugin body. Returns the cordis plugin ({inject, apply}). */
231
376
  return function buildPlugin({ stateRoutePath, fetchImpl }) {
232
377
  const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
@@ -234,6 +379,24 @@ function createClientPluginBody(React) {
234
379
 
235
380
  let currentSessionId = null;
236
381
 
382
+ const doCancel = async (id) => {
383
+ const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
384
+ return res.ok;
385
+ };
386
+
387
+ // 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
388
+ // (no conversationId filter) for the sidebar badge + panel; cancel reuses
389
+ // the same DELETE route (cross-conversation).
390
+ const panelCore = createScheduledClientState({
391
+ fetchState: async () => {
392
+ const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
393
+ if (!res.ok) throw new Error("state HTTP " + res.status);
394
+ return res.json();
395
+ },
396
+ cancelSchedule: doCancel,
397
+ });
398
+ panelCore.setSession(null);
399
+
237
400
  const core = createScheduledClientState({
238
401
  fetchState: async () => {
239
402
  // ask the host for THIS conversation's view (the core's bound session
@@ -261,9 +424,28 @@ function createClientPluginBody(React) {
261
424
  });
262
425
  core.defaultSendAt = () => defaultSendAt();
263
426
 
264
- const inject = ["slots"];
427
+ const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
265
428
  function apply(ctx) {
266
429
  ctx.inject(inject, (scope) => {
430
+ // 0.3.0 session navigation, sourced from the sessions service:
431
+ // - open(id) selects a session as current (unknown ids throw → false)
432
+ // - list.getSnapshot().byId maps id → {displayTitle} for row labels
433
+ const svc = scope.sessions;
434
+ const openSession = (sid) => {
435
+ try {
436
+ if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
437
+ } catch (err) { /* unknown session — degrade, row stays cancellable */ }
438
+ return false;
439
+ };
440
+ const sessionById = () => {
441
+ try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
442
+ catch (err) { return {}; }
443
+ };
444
+ scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
445
+ name: "sidebar.footer.action",
446
+ id: "@ottttto/dsh-scheduled-send",
447
+ order: 20,
448
+ }, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
267
449
  scope.slots.inject("conversation.input.right", () => scope.slots.register({
268
450
  name: "conversation.input.right",
269
451
  id: "@ottttto/dsh-scheduled-send",
@@ -271,7 +453,7 @@ function createClientPluginBody(React) {
271
453
  inject: (sessionId) => {
272
454
  currentSessionId = sessionId;
273
455
  core.setSession(sessionId); // dock/button are conversation-scoped
274
- return { sessionId, core };
456
+ return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
275
457
  },
276
458
  }, ScheduleButton));
277
459
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
@@ -281,7 +463,7 @@ function createClientPluginBody(React) {
281
463
  inject: (sessionId) => {
282
464
  currentSessionId = sessionId;
283
465
  core.setSession(sessionId);
284
- return { sessionId, core };
466
+ return { sessionId, core, onTaskCreated: () => panelCore.refresh() };
285
467
  },
286
468
  }, ScheduledDock));
287
469
  });