@ottttto/dsh-scheduled-send 0.2.2 → 0.3.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.
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/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "@ottttto/dsh-scheduled-send",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "main": "./src/index.js",
5
5
  "description": "定时发送:到点以你的身份把消息作为正常用户气泡发进当前会话,无人值守、会话隔离、断电补发。Scheduled send for DSH — fires as a normal user bubble into the bound conversation, unattended, per-session isolated, restart-safe.",
6
6
  "engines": {
package/lib/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // GENERATED by scripts/build-client.mjs — do not edit by hand.
2
2
  window.__ModuleLoader__.load({
3
- id: "dsh-scheduled-send",
3
+ id: "@ottttto/dsh-scheduled-send",
4
4
  factory: (require) => {
5
5
  var module = { exports: {} };
6
6
  var exports = module.exports;
@@ -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
@@ -462,6 +491,148 @@ function createClientPluginBody(React) {
462
491
  ]);
463
492
  }
464
493
 
494
+ /* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
495
+ function ScheduledTasksPanel(props) {
496
+ const core = props.panelCore;
497
+ const [open, setOpen] = React.useState(false);
498
+ const [tick, setTick] = React.useState(0);
499
+ const mobile = isMobile();
500
+
501
+ // keep the badge alive even while the panel is closed: poll the FULL
502
+ // (unfiltered) state — every conversation's pending tasks, 8s cadence.
503
+ React.useEffect(() => {
504
+ let stopped = false;
505
+ let timer = null;
506
+ const loop = async () => {
507
+ if (stopped) return;
508
+ await core.refresh().catch(() => {});
509
+ if (!stopped) setTick((n) => n + 1);
510
+ if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
511
+ };
512
+ void loop();
513
+ return () => { stopped = true; clearTimeout(timer); };
514
+ }, []);
515
+
516
+ const all = core.visibleTasks(); // ALL sessions, sendAt ascending
517
+ const err = core.lastError();
518
+ const MAX_ROWS = 100;
519
+ const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
520
+ const overflow = Math.max(0, all.length - annotated.length);
521
+
522
+ const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
523
+ const entryBtn = {
524
+ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
525
+ border: "none", background: "transparent", color: "inherit",
526
+ fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
527
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
528
+ };
529
+ const badge = (n) => h("span", {
530
+ key: "b", "data-badge": n,
531
+ style: {
532
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
533
+ minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
534
+ background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
535
+ },
536
+ }, String(n));
537
+
538
+ const jump = (t) => {
539
+ // failure mode: jump unavailable/unknown session → keep the panel open,
540
+ // keep the row (still cancellable); never throw into the click handler.
541
+ const ok = props.openSession ? props.openSession(t.conversationId) : false;
542
+ if (ok) setOpen(false);
543
+ };
544
+ const cancelBtn = (t) => h("button", {
545
+ key: "x", type: "button",
546
+ onClick: (e) => {
547
+ if (e && typeof e.stopPropagation === "function") e.stopPropagation();
548
+ core.cancelTask(t.id).catch(() => {});
549
+ setTick((n) => n + 1);
550
+ },
551
+ style: {
552
+ cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
553
+ padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
554
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
555
+ },
556
+ }, "取消");
557
+
558
+ const footer = open
559
+ ? h("div", {
560
+ key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
561
+ style: {
562
+ position: "fixed", bottom: 56, left: 12, zIndex: 50,
563
+ width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
564
+ boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
565
+ borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
566
+ background: isDark ? "#1c1c1e" : "#fff",
567
+ boxShadow: "0 8px 24px rgba(0,0,0,.18)",
568
+ color: isDark ? "#eee" : "#111",
569
+ ...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
570
+ },
571
+ }, [
572
+ h("div", {
573
+ key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
574
+ }, [
575
+ h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
576
+ h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
577
+ h("button", {
578
+ key: "close", type: "button", onClick: () => setOpen(false),
579
+ style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
580
+ }, "✕"),
581
+ ]),
582
+ err
583
+ ? h("div", {
584
+ key: "err",
585
+ style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
586
+ }, "⚠ 加载失败,显示上一次列表:" + err)
587
+ : null,
588
+ !all.length && !err
589
+ ? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
590
+ h("div", { key: "l1" }, "暂无定时任务"),
591
+ h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
592
+ ])
593
+ : null,
594
+ annotated.map((t) => h("div", {
595
+ key: t.id, "data-task": t.id,
596
+ onClick: () => { if (t.sessionExists) jump(t); },
597
+ title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
598
+ style: {
599
+ display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
600
+ borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
601
+ cursor: t.sessionExists ? "pointer" : "default",
602
+ opacity: t.sessionExists ? 1 : 0.5,
603
+ wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
604
+ },
605
+ }, [
606
+ h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
607
+ summarizeContent(t.content) || "(空内容)"),
608
+ h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
609
+ h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
610
+ t.sessionExists ? t.sessionTitle : "会话不存在"),
611
+ h("span", { key: "at" }, formatLocalTime(t.sendAt)),
612
+ h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
613
+ h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
614
+ ]),
615
+ ])),
616
+ overflow > 0
617
+ ? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
618
+ `仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
619
+ : null,
620
+ ])
621
+ : null;
622
+
623
+ return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
624
+ h("button", {
625
+ key: "btn", type: "button",
626
+ onClick: () => { setOpen(!open); setTick((n) => n + 1); },
627
+ title: "定时任务", "aria-label": "定时任务", style: entryBtn,
628
+ }, [
629
+ h("span", { key: "l" }, "⏰ 定时任务"),
630
+ all.length ? badge(all.length) : null,
631
+ ]),
632
+ footer,
633
+ ]);
634
+ }
635
+
465
636
  /** Client plugin body. Returns the cordis plugin ({inject, apply}). */
466
637
  return function buildPlugin({ stateRoutePath, fetchImpl }) {
467
638
  const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
@@ -469,6 +640,24 @@ function createClientPluginBody(React) {
469
640
 
470
641
  let currentSessionId = null;
471
642
 
643
+ const doCancel = async (id) => {
644
+ const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
645
+ return res.ok;
646
+ };
647
+
648
+ // 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
649
+ // (no conversationId filter) for the sidebar badge + panel; cancel reuses
650
+ // the same DELETE route (cross-conversation).
651
+ const panelCore = createScheduledClientState({
652
+ fetchState: async () => {
653
+ const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
654
+ if (!res.ok) throw new Error("state HTTP " + res.status);
655
+ return res.json();
656
+ },
657
+ cancelSchedule: doCancel,
658
+ });
659
+ panelCore.setSession(null);
660
+
472
661
  const core = createScheduledClientState({
473
662
  fetchState: async () => {
474
663
  // ask the host for THIS conversation's view (the core's bound session
@@ -496,12 +685,31 @@ function createClientPluginBody(React) {
496
685
  });
497
686
  core.defaultSendAt = () => defaultSendAt();
498
687
 
499
- const inject = ["slots"];
688
+ const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
500
689
  function apply(ctx) {
501
690
  ctx.inject(inject, (scope) => {
691
+ // 0.3.0 session navigation, sourced from the sessions service:
692
+ // - open(id) selects a session as current (unknown ids throw → false)
693
+ // - list.getSnapshot().byId maps id → {displayTitle} for row labels
694
+ const svc = scope.sessions;
695
+ const openSession = (sid) => {
696
+ try {
697
+ if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
698
+ } catch (err) { /* unknown session — degrade, row stays cancellable */ }
699
+ return false;
700
+ };
701
+ const sessionById = () => {
702
+ try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
703
+ catch (err) { return {}; }
704
+ };
705
+ scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
706
+ name: "sidebar.footer.action",
707
+ id: "@ottttto/dsh-scheduled-send",
708
+ order: 20,
709
+ }, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
502
710
  scope.slots.inject("conversation.input.right", () => scope.slots.register({
503
711
  name: "conversation.input.right",
504
- id: "dsh-scheduled-send",
712
+ id: "@ottttto/dsh-scheduled-send",
505
713
  order: 100,
506
714
  inject: (sessionId) => {
507
715
  currentSessionId = sessionId;
@@ -511,7 +719,7 @@ function createClientPluginBody(React) {
511
719
  }, ScheduleButton));
512
720
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
513
721
  name: "conversation.input.dock",
514
- id: "dsh-scheduled-send",
722
+ id: "@ottttto/dsh-scheduled-send",
515
723
  order: 30,
516
724
  inject: (sessionId) => {
517
725
  currentSessionId = sessionId;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ottttto/dsh-scheduled-send",
3
- "version": "0.2.2",
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.0",
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
  }
@@ -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
@@ -227,6 +227,148 @@ function createClientPluginBody(React) {
227
227
  ]);
228
228
  }
229
229
 
230
+ /* --- sidebar footer「定时任务」panel (0.3.0) ---------------------------- */
231
+ function ScheduledTasksPanel(props) {
232
+ const core = props.panelCore;
233
+ const [open, setOpen] = React.useState(false);
234
+ const [tick, setTick] = React.useState(0);
235
+ const mobile = isMobile();
236
+
237
+ // keep the badge alive even while the panel is closed: poll the FULL
238
+ // (unfiltered) state — every conversation's pending tasks, 8s cadence.
239
+ React.useEffect(() => {
240
+ let stopped = false;
241
+ let timer = null;
242
+ const loop = async () => {
243
+ if (stopped) return;
244
+ await core.refresh().catch(() => {});
245
+ if (!stopped) setTick((n) => n + 1);
246
+ if (!stopped) { timer = setTimeout(loop, 8000); timer.unref?.(); }
247
+ };
248
+ void loop();
249
+ return () => { stopped = true; clearTimeout(timer); };
250
+ }, []);
251
+
252
+ const all = core.visibleTasks(); // ALL sessions, sendAt ascending
253
+ const err = core.lastError();
254
+ const MAX_ROWS = 100;
255
+ const annotated = annotateSessions(all.slice(0, MAX_ROWS), props.sessionById ? props.sessionById() : null);
256
+ const overflow = Math.max(0, all.length - annotated.length);
257
+
258
+ const isDark = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
259
+ const entryBtn = {
260
+ cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 6,
261
+ border: "none", background: "transparent", color: "inherit",
262
+ fontSize: 12, fontWeight: 600, padding: "4px 8px", borderRadius: 8, whiteSpace: "nowrap",
263
+ ...(mobile ? { minHeight: TOUCH_MIN } : {}),
264
+ };
265
+ const badge = (n) => h("span", {
266
+ key: "b", "data-badge": n,
267
+ style: {
268
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
269
+ minWidth: 16, height: 16, padding: "0 4px", borderRadius: 999,
270
+ background: "#3b82f6", color: "#fff", fontSize: 10, fontWeight: 700,
271
+ },
272
+ }, String(n));
273
+
274
+ const jump = (t) => {
275
+ // failure mode: jump unavailable/unknown session → keep the panel open,
276
+ // keep the row (still cancellable); never throw into the click handler.
277
+ const ok = props.openSession ? props.openSession(t.conversationId) : false;
278
+ if (ok) setOpen(false);
279
+ };
280
+ const cancelBtn = (t) => h("button", {
281
+ key: "x", type: "button",
282
+ onClick: (e) => {
283
+ if (e && typeof e.stopPropagation === "function") e.stopPropagation();
284
+ core.cancelTask(t.id).catch(() => {});
285
+ setTick((n) => n + 1);
286
+ },
287
+ style: {
288
+ cursor: "pointer", border: "1px solid rgba(128,128,128,.4)", borderRadius: 999,
289
+ padding: "0 8px", fontSize: 11, background: "transparent", color: "inherit", flexShrink: 0,
290
+ ...(mobile ? { minHeight: TOUCH_MIN, boxSizing: "border-box" } : {}),
291
+ },
292
+ }, "取消");
293
+
294
+ const footer = open
295
+ ? h("div", {
296
+ key: "panel", "data-plugin": "dsh-scheduled-send-sidebar-panel",
297
+ style: {
298
+ position: "fixed", bottom: 56, left: 12, zIndex: 50,
299
+ width: 340, maxWidth: "calc(100vw - 24px)", maxHeight: "70vh", overflowY: "auto",
300
+ boxSizing: "border-box", padding: "10px 12px", fontSize: 12,
301
+ borderRadius: 12, border: "1px solid " + (isDark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.10)"),
302
+ background: isDark ? "#1c1c1e" : "#fff",
303
+ boxShadow: "0 8px 24px rgba(0,0,0,.18)",
304
+ color: isDark ? "#eee" : "#111",
305
+ ...(mobile ? { left: 8, width: "calc(100vw - 16px)", maxWidth: "calc(100vw - 16px)" } : {}),
306
+ },
307
+ }, [
308
+ h("div", {
309
+ key: "head", style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 },
310
+ }, [
311
+ h("span", { key: "t", style: { fontWeight: 700, fontSize: 13 } }, "⏰ 定时任务"),
312
+ h("span", { key: "n", style: { opacity: .6 } }, all.length ? `${all.length} 条待发送` : ""),
313
+ h("button", {
314
+ key: "close", type: "button", onClick: () => setOpen(false),
315
+ style: { cursor: "pointer", marginLeft: "auto", border: "none", background: "transparent", color: "inherit", fontSize: 14, lineHeight: 1 },
316
+ }, "✕"),
317
+ ]),
318
+ err
319
+ ? h("div", {
320
+ key: "err",
321
+ style: { padding: "3px 8px", borderRadius: 8, background: "rgba(220,38,38,.10)", color: "#dc2626", wordBreak: "break-word" },
322
+ }, "⚠ 加载失败,显示上一次列表:" + err)
323
+ : null,
324
+ !all.length && !err
325
+ ? h("div", { key: "empty", style: { padding: "18px 0", textAlign: "center", opacity: .6 } }, [
326
+ h("div", { key: "l1" }, "暂无定时任务"),
327
+ h("div", { key: "l2", style: { marginTop: 4 } }, "在会话输入框点击 ⏰ 定时 即可创建"),
328
+ ])
329
+ : null,
330
+ annotated.map((t) => h("div", {
331
+ key: t.id, "data-task": t.id,
332
+ onClick: () => { if (t.sessionExists) jump(t); },
333
+ title: t.sessionExists ? "打开对应会话" : "会话不存在,仅可取消",
334
+ style: {
335
+ display: "flex", flexDirection: "column", gap: 2, padding: "6px 8px", marginBottom: 4,
336
+ borderRadius: 8, background: "rgba(59,130,246,.10)", border: "1px solid rgba(59,130,246,.22)",
337
+ cursor: t.sessionExists ? "pointer" : "default",
338
+ opacity: t.sessionExists ? 1 : 0.5,
339
+ wordBreak: "break-word", maxWidth: "100%", boxSizing: "border-box",
340
+ },
341
+ }, [
342
+ h("div", { key: "c", style: { whiteSpace: "pre-wrap", fontFamily: "monospace" } },
343
+ summarizeContent(t.content) || "(空内容)"),
344
+ h("div", { key: "m", style: { display: "flex", alignItems: "center", gap: 8, color: "rgba(128,128,128,1)", flexWrap: "wrap" } }, [
345
+ h("span", { key: "s", style: { maxWidth: "60%", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
346
+ t.sessionExists ? t.sessionTitle : "会话不存在"),
347
+ h("span", { key: "at" }, formatLocalTime(t.sendAt)),
348
+ h("span", { key: "cd" }, formatCountdown(t.sendAt, Date.now())),
349
+ h("span", { key: "sp", style: { marginLeft: "auto" } }, cancelBtn(t)),
350
+ ]),
351
+ ])),
352
+ overflow > 0
353
+ ? h("div", { key: "more", style: { textAlign: "center", opacity: .6, padding: "4px 0" } },
354
+ `仅显示最近 ${annotated.length} 条(共 ${all.length} 条)`)
355
+ : null,
356
+ ])
357
+ : null;
358
+
359
+ return h("div", { "data-plugin": "dsh-scheduled-send-sidebar", style: { display: "inline-flex", alignItems: "center" } }, [
360
+ h("button", {
361
+ key: "btn", type: "button",
362
+ onClick: () => { setOpen(!open); setTick((n) => n + 1); },
363
+ title: "定时任务", "aria-label": "定时任务", style: entryBtn,
364
+ }, [
365
+ h("span", { key: "l" }, "⏰ 定时任务"),
366
+ all.length ? badge(all.length) : null,
367
+ ]),
368
+ footer,
369
+ ]);
370
+ }
371
+
230
372
  /** Client plugin body. Returns the cordis plugin ({inject, apply}). */
231
373
  return function buildPlugin({ stateRoutePath, fetchImpl }) {
232
374
  const schedulePath = stateRoutePath.replace(/\/state$/, "/schedule");
@@ -234,6 +376,24 @@ function createClientPluginBody(React) {
234
376
 
235
377
  let currentSessionId = null;
236
378
 
379
+ const doCancel = async (id) => {
380
+ const res = await doFetch(schedulePath + "?id=" + encodeURIComponent(id), { method: "DELETE" });
381
+ return res.ok;
382
+ };
383
+
384
+ // 0.3.0 panel core: UNBOUND (session null) — fetches the FULL task list
385
+ // (no conversationId filter) for the sidebar badge + panel; cancel reuses
386
+ // the same DELETE route (cross-conversation).
387
+ const panelCore = createScheduledClientState({
388
+ fetchState: async () => {
389
+ const res = await doFetch(stateRoutePath, { headers: { accept: "application/json" } });
390
+ if (!res.ok) throw new Error("state HTTP " + res.status);
391
+ return res.json();
392
+ },
393
+ cancelSchedule: doCancel,
394
+ });
395
+ panelCore.setSession(null);
396
+
237
397
  const core = createScheduledClientState({
238
398
  fetchState: async () => {
239
399
  // ask the host for THIS conversation's view (the core's bound session
@@ -261,12 +421,31 @@ function createClientPluginBody(React) {
261
421
  });
262
422
  core.defaultSendAt = () => defaultSendAt();
263
423
 
264
- const inject = ["slots"];
424
+ const inject = ["slots", "sessions"]; // sessions: sidebar panel jump (0.3.0)
265
425
  function apply(ctx) {
266
426
  ctx.inject(inject, (scope) => {
427
+ // 0.3.0 session navigation, sourced from the sessions service:
428
+ // - open(id) selects a session as current (unknown ids throw → false)
429
+ // - list.getSnapshot().byId maps id → {displayTitle} for row labels
430
+ const svc = scope.sessions;
431
+ const openSession = (sid) => {
432
+ try {
433
+ if (svc && typeof svc.open === "function" && sid) { svc.open(sid); return true; }
434
+ } catch (err) { /* unknown session — degrade, row stays cancellable */ }
435
+ return false;
436
+ };
437
+ const sessionById = () => {
438
+ try { return (svc && svc.list && svc.list.getSnapshot && svc.list.getSnapshot().byId) || {}; }
439
+ catch (err) { return {}; }
440
+ };
441
+ scope.slots.inject("sidebar.footer.action", () => scope.slots.register({
442
+ name: "sidebar.footer.action",
443
+ id: "@ottttto/dsh-scheduled-send",
444
+ order: 20,
445
+ }, (slotProps) => ScheduledTasksPanel({ ...(slotProps || {}), panelCore, openSession, sessionById })));
267
446
  scope.slots.inject("conversation.input.right", () => scope.slots.register({
268
447
  name: "conversation.input.right",
269
- id: "dsh-scheduled-send",
448
+ id: "@ottttto/dsh-scheduled-send",
270
449
  order: 100,
271
450
  inject: (sessionId) => {
272
451
  currentSessionId = sessionId;
@@ -276,7 +455,7 @@ function createClientPluginBody(React) {
276
455
  }, ScheduleButton));
277
456
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
278
457
  name: "conversation.input.dock",
279
- id: "dsh-scheduled-send",
458
+ id: "@ottttto/dsh-scheduled-send",
280
459
  order: 30,
281
460
  inject: (sessionId) => {
282
461
  currentSessionId = sessionId;