dsh-send-later 0.0.0-stage → 0.2.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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +50 -2
- package/client.js +817 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +5 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +78 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
First real release on npm (the name was reserved by a `0.0.1` placeholder).
|
|
6
|
+
|
|
7
|
+
- Pending sends for the current session are listed in the panel and can be cancelled (one tap on the ✕ of a row).
|
|
8
|
+
- The clock button shows a badge with the number of pending sends.
|
|
9
|
+
- The message and the chosen preset are remembered across page reloads.
|
|
10
|
+
- On narrow screens (under 480px) the panel renders as a full-width bottom sheet, fixing the clipped popover on phones.
|
|
11
|
+
- Double-tapping Schedule now sends exactly one request.
|
|
12
|
+
- Fix: viewport resize no longer closes the panel — on phones the soft keyboard fired a resize when the message field gained focus, which dismissed the card mid-edit; the panel repositions instead.
|
|
13
|
+
- Pending sends list their send time as a compact relative label ("10分钟后", fixed-width column, the absolute time on hover).
|
|
14
|
+
- Quota reset line: with dsh-quota-watch installed, the panel shows the GLM 5-hour window percent and expected reset time; the line hides itself when the data is unavailable.
|
|
15
|
+
- Session indicator: the panel header shows the current session title, so parallel sessions do not get mixed up.
|
|
16
|
+
- Keyboard-aware positioning: on phones the panel stays above the soft keyboard (visualViewport) instead of hiding behind it.
|
|
17
|
+
- Fix: scheduling failed with "cannot get property 'remote.schedule' without inject" — the face is now taken from the inject scope instead of the plugin context.
|
|
18
|
+
- Snippet strip beside the message-field label: a fixed "safe resume" chip (bounded continuation) plus the most recent custom prompt — two chips, no horizontal scrolling, long labels truncate in place with the full text on hover. Custom prompts are recorded locally on successful scheduling; the bare default is not recorded.
|
|
19
|
+
- Compact panel: the title row carries a close button, the five presets fit one row in a segmented control, the send-time summary moved into the action row, and spacing tightened throughout.
|
|
20
|
+
- Submit button and count badge follow a theme-safe color scheme: solid brand blue under the official theme, tonal accent under dsh-skins themes (detected at runtime from the token sheet), with readable text guaranteed on every theme.
|
|
21
|
+
- If the Automation tasks feature is disabled in the profile, the clock button renders greyed out with an explanatory tooltip instead of disappearing; it upgrades to the full control as soon as the schedule service is available.
|
|
22
|
+
- npm publish metadata: unscoped package, `engines.dsh >= 0.2.2-rc.1`, MIT license, GitHub Actions OIDC release workflow.
|
|
23
|
+
|
|
24
|
+
## 0.0.1
|
|
25
|
+
|
|
26
|
+
npm name placeholder only; not a usable release.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iasi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-send-later
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH 插件:在输入框旁设定延时或指定时间,向当前会话发送一条消息(默认「继续」)。适合「coding plan 五小时额度用尽 → 等额度恢复后续跑会话」这类场景。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- 发送按钮左侧出现一个时钟按钮。点开后可选 10 分钟后、30 分钟后、1 小时后、5 小时后,或指定日期和时间;发送内容默认「继续」,可改。
|
|
8
|
+
- 片段条:「发送内容」旁固定两颗快捷胶囊——`⚡安全继续`(带边界的继续文案)和最近一条自定义输入;自定义文案排程成功后自动记录,长文案原地截断、悬停看全文。
|
|
9
|
+
- 按钮角标显示本会话待发送的数量;面板里列出每条待发送(相对时间,悬停看绝对时刻),点 ✕ 即可取消。
|
|
10
|
+
- 面板顶部显示当前会话标题,多会话并行时不排错目标;装了 dsh-quota-watch 时还会显示 GLM 5 小时额度的预计恢复时间。
|
|
11
|
+
- 上次用的发送内容和时长会记住,重开面板自动带出。
|
|
12
|
+
- 手机上软键盘弹出时面板自动避开键盘(visualViewport 感知);窄屏(<480px)下面板贴底全宽展示。
|
|
13
|
+
- 投递使用 DSH 官方的自动化任务(`schedule`):任务持久化,重启不丢;也可在「自动化任务」页查看、暂停或删除。
|
|
14
|
+
- 纯前端插件:没有 Host 侧代码,不需要配置。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
要求 DSH ≥ 0.2.2-rc.1。
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
git clone https://github.com/iasiv5/dsh-send-later.git
|
|
22
|
+
cd dsh-send-later && npm install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
然后在 DSH 会话里用 `plugin_manager` 工具的 `install_bundle` 动作安装,`target` 填仓库目录的绝对路径,装完刷新页面。
|
|
26
|
+
|
|
27
|
+
npm 包 [dsh-send-later](https://www.npmjs.com/package/dsh-send-later) 与本仓库同步发布;`0.0.x` 是占位版本,请使用 0.2.0 及以上。
|
|
28
|
+
|
|
29
|
+
## 开发与测试
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install # 安装 react / react-dom / jsdom(仅测试用,不进入运行时依赖)
|
|
33
|
+
npm test # jsdom 离线渲染测试:面板、预设、排程请求、取消、待发列表、重复提交
|
|
34
|
+
npm run security:check # 依赖红线检查(运行时依赖必须为空)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 发布
|
|
38
|
+
|
|
39
|
+
发版 = 打 tag:`npm version x.y.z && git push --tags`,GitHub Actions 以 OIDC Trusted Publishing 自动发布(workflow 见 `.github/workflows/publish.yml`)。
|
|
40
|
+
|
|
41
|
+
## 已知限制
|
|
42
|
+
|
|
43
|
+
- 到点时如果模型额度仍未恢复,这条消息会失败,需要重新排程(额度恢复时间仅供参考,以服务商实际刷新为准)。
|
|
44
|
+
- 额度恢复时间依赖 dsh-quota-watch 插件提供的数据;未安装时该行自动隐藏。
|
|
45
|
+
- 会话被归档后,投递会被拒绝。
|
|
46
|
+
- DSH Host 必须在线才能投递;停机期间错过的一次性任务如何补发,尚未测试。
|
|
47
|
+
- 待发送列表按「本插件创建的任务 + 带标题前缀的同会话任务」识别;在「自动化任务」页把标题前缀改掉后不再被识别。
|
|
48
|
+
|
|
49
|
+
## 许可
|
|
50
|
+
|
|
51
|
+
MIT License,见 [LICENSE](LICENSE)。
|
package/client.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Client half of @iasiv5/dsh-send-later.
|
|
3
|
+
* Adds a clock button before the composer's submit action. The popover asks for a delay
|
|
4
|
+
* or a local time plus a message, then asks the Host Schedule service to deliver that
|
|
5
|
+
* message to the current session as a one-shot task. Pending sends for this session are
|
|
6
|
+
* listed in the panel and can be cancelled; the button shows a count badge. No timer or
|
|
7
|
+
* delivery logic lives here — the Host Schedule service owns persistence and delivery.
|
|
8
|
+
*/
|
|
9
|
+
window.__ModuleLoader__.load({
|
|
10
|
+
id: 'dsh-send-later',
|
|
11
|
+
factory(require) {
|
|
12
|
+
const React = require('react');
|
|
13
|
+
const ReactDOM = require('react-dom');
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
const { useEffect, useRef, useState } = React;
|
|
16
|
+
|
|
17
|
+
const NS = 'send-later';
|
|
18
|
+
const STORE_PENDING = 'dsh-send-later:v1:pending';
|
|
19
|
+
const STORE_PROMPT = 'dsh-send-later:v1:prompt';
|
|
20
|
+
const STORE_PRESET = 'dsh-send-later:v1:preset';
|
|
21
|
+
const STORE_SNIPPETS = 'dsh-send-later:v1:snippets';
|
|
22
|
+
|
|
23
|
+
const EN = {
|
|
24
|
+
button: 'Send later',
|
|
25
|
+
panelTitle: 'Send later to this session',
|
|
26
|
+
preset10m: '10m',
|
|
27
|
+
preset30m: '30m',
|
|
28
|
+
preset1h: '1h',
|
|
29
|
+
preset5h: '5h',
|
|
30
|
+
presetCustom: 'Custom',
|
|
31
|
+
close: 'Close',
|
|
32
|
+
date: 'Date',
|
|
33
|
+
time: 'Time',
|
|
34
|
+
promptLabel: 'Message',
|
|
35
|
+
promptDefault: 'Continue',
|
|
36
|
+
promptPlaceholder: 'Message to send',
|
|
37
|
+
summaryPrefix: 'Sends at ',
|
|
38
|
+
summarySuffix: ' to this session',
|
|
39
|
+
pendingTitle: 'Pending sends',
|
|
40
|
+
cancel: 'Cancel',
|
|
41
|
+
cancelTask: 'Cancel this send',
|
|
42
|
+
cancelFailed: 'Cancel failed: ',
|
|
43
|
+
submit: 'Schedule',
|
|
44
|
+
busy: 'Scheduling…',
|
|
45
|
+
cancelling: 'Cancelling…',
|
|
46
|
+
donePrefix: 'Scheduled for ',
|
|
47
|
+
doneSuffix: '',
|
|
48
|
+
titlePrefix: 'Send later: ',
|
|
49
|
+
errEmpty: 'The message cannot be empty.',
|
|
50
|
+
errPast: 'Choose a time at least one minute ahead.',
|
|
51
|
+
errNoSession: 'No session is available.',
|
|
52
|
+
errFailed: 'Scheduling failed: ',
|
|
53
|
+
errDisabled: 'Send later is unavailable: the Automation tasks feature is disabled.',
|
|
54
|
+
quotaLine: 'GLM 5h {p}% · resets ~{t} ({r})',
|
|
55
|
+
relSoon: '<1m',
|
|
56
|
+
relMin: 'in {m}m',
|
|
57
|
+
relHour: 'in {h}h',
|
|
58
|
+
relHourMin: 'in {h}h{m}m',
|
|
59
|
+
relDay: 'in {d}d',
|
|
60
|
+
snipResumeLabel: 'Safe resume',
|
|
61
|
+
snipResumeText: 'Resume from where you left off; do not start unrelated new work.',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const ZH = {
|
|
65
|
+
button: '延时发送',
|
|
66
|
+
panelTitle: '延时发送到本会话',
|
|
67
|
+
preset10m: '10 分',
|
|
68
|
+
preset30m: '30 分',
|
|
69
|
+
preset1h: '1 时',
|
|
70
|
+
preset5h: '5 时',
|
|
71
|
+
presetCustom: '指定',
|
|
72
|
+
close: '关闭',
|
|
73
|
+
date: '日期',
|
|
74
|
+
time: '时间',
|
|
75
|
+
promptLabel: '发送内容',
|
|
76
|
+
promptDefault: '继续',
|
|
77
|
+
promptPlaceholder: '要发送的内容',
|
|
78
|
+
summaryPrefix: '将于 ',
|
|
79
|
+
summarySuffix: ' 发送到本会话',
|
|
80
|
+
pendingTitle: '待发送',
|
|
81
|
+
cancelTask: '取消这条发送',
|
|
82
|
+
cancelFailed: '取消失败:',
|
|
83
|
+
cancel: '取消',
|
|
84
|
+
submit: '排程',
|
|
85
|
+
busy: '排程中…',
|
|
86
|
+
cancelling: '取消中…',
|
|
87
|
+
donePrefix: '已排程,',
|
|
88
|
+
doneSuffix: ' 发送',
|
|
89
|
+
titlePrefix: '延时发送:',
|
|
90
|
+
errEmpty: '发送内容不能为空。',
|
|
91
|
+
errPast: '请选择至少一分钟之后的时间。',
|
|
92
|
+
errNoSession: '当前没有可用的会话。',
|
|
93
|
+
errFailed: '排程失败:',
|
|
94
|
+
errDisabled: '延时发送不可用:自动化任务(排程)功能未启用。',
|
|
95
|
+
quotaLine: 'GLM 5h {p}% · 预计 {t} 恢复({r})',
|
|
96
|
+
relSoon: '1分钟内',
|
|
97
|
+
relMin: '{m}分钟后',
|
|
98
|
+
relHour: '{h}时后',
|
|
99
|
+
relHourMin: '{h}时{m}分后',
|
|
100
|
+
relDay: '{d}天后',
|
|
101
|
+
snipResumeLabel: '安全继续',
|
|
102
|
+
snipResumeText: '从中断处继续,不要开始与目标无关的新工作。',
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const PRESETS = [
|
|
106
|
+
{ id: '10m', seconds: 600, label: 'preset10m' },
|
|
107
|
+
{ id: '30m', seconds: 1800, label: 'preset30m' },
|
|
108
|
+
{ id: '1h', seconds: 3600, label: 'preset1h' },
|
|
109
|
+
{ id: '5h', seconds: 18000, label: 'preset5h' },
|
|
110
|
+
{ id: 'custom', seconds: 0, label: 'presetCustom' },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
const fallbackDict = /^zh/i.test(navigator.language || '') ? ZH : EN;
|
|
114
|
+
const fallbackT = (key) => (fallbackDict[key] !== undefined ? fallbackDict[key] : (EN[key] !== undefined ? EN[key] : key));
|
|
115
|
+
|
|
116
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
117
|
+
const dateValue = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
118
|
+
const timeValue = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
119
|
+
const compactWhen = (d) => (d.getTime() - Date.now() > 86400000 ? `${dateValue(d).slice(5)} ${timeValue(d)}` : timeValue(d));
|
|
120
|
+
|
|
121
|
+
/** Pending rows show a relative label ("in 10 min"); the absolute time rides on the title attribute. */
|
|
122
|
+
function relativeWhen(when, t) {
|
|
123
|
+
const d = when instanceof Date ? when : new Date(when);
|
|
124
|
+
const absolute = `${dateValue(d)} ${timeValue(d)}`;
|
|
125
|
+
const diff = d.getTime() - Date.now();
|
|
126
|
+
if (!Number.isFinite(diff) || diff < 0) return { label: absolute, absolute };
|
|
127
|
+
const minutes = Math.round(diff / 60000);
|
|
128
|
+
if (minutes < 1) return { label: t('relSoon'), absolute };
|
|
129
|
+
if (minutes < 60) return { label: t('relMin').replace('{m}', String(minutes)), absolute };
|
|
130
|
+
const hours = Math.floor(minutes / 60);
|
|
131
|
+
if (hours >= 24) return { label: t('relDay').replace('{d}', String(Math.floor(hours / 24))), absolute };
|
|
132
|
+
return minutes % 60 === 0
|
|
133
|
+
? { label: t('relHour').replace('{h}', String(hours)), absolute }
|
|
134
|
+
: { label: t('relHourMin').replace('{h}', String(hours)).replace('{m}', String(minutes % 60)), absolute };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseLocal(date, time) {
|
|
138
|
+
const [year, month, day] = String(date).split('-').map(Number);
|
|
139
|
+
const [hour, minute] = String(time).split(':').map(Number);
|
|
140
|
+
if ([year, month, day, hour, minute].some((value) => !Number.isInteger(value))) return undefined;
|
|
141
|
+
const when = new Date(year, month - 1, day, hour, minute, 0, 0);
|
|
142
|
+
return Number.isNaN(when.getTime()) ? undefined : when;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Official theme paints brand-primary as the neutral ink statics; skins themes paint their own
|
|
147
|
+
* accent. Comparing the computed value against the ink statics tells the two apart, so the
|
|
148
|
+
* submit button can be solid under the official theme and tonal under skins themes.
|
|
149
|
+
*/
|
|
150
|
+
function detectSolidAccent() {
|
|
151
|
+
try {
|
|
152
|
+
const cs = getComputedStyle(document.body);
|
|
153
|
+
const read = (n) => cs.getPropertyValue(n).trim().toLowerCase();
|
|
154
|
+
const primary = read('--dsw-alias-brand-primary');
|
|
155
|
+
if (primary === '') return false;
|
|
156
|
+
const inkDark = read('--dsw-static-neutral-bluish-1000');
|
|
157
|
+
const inkLight = read('--dsw-static-neutral-bluish-50');
|
|
158
|
+
return (inkDark !== '' && primary === inkDark) || (inkLight !== '' && primary === inkLight);
|
|
159
|
+
} catch (_error) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** GLM 5h window from the quota-watch snapshot; null when the plugin or the window is absent. */
|
|
165
|
+
async function fetchGlm5hQuota() {
|
|
166
|
+
try {
|
|
167
|
+
const res = await fetch('/api/dsh-quota-watch/overview', { headers: { accept: 'application/json' } });
|
|
168
|
+
if (!res || !res.ok) return null;
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
const providers = Array.isArray(data?.providers) ? data.providers : [];
|
|
171
|
+
const glm = providers.find((p) => p && p.key === 'glm' && p.status === 'ready')
|
|
172
|
+
?? providers.find((p) => typeof p?.providerId === 'string' && p.providerId.startsWith('zai'));
|
|
173
|
+
const window5h = glm?.plan?.windows?.find((w) => w && w.key === '5h');
|
|
174
|
+
if (!window5h || typeof window5h.resetsAt !== 'string') return null;
|
|
175
|
+
const resetAt = Date.parse(window5h.resetsAt);
|
|
176
|
+
if (!Number.isFinite(resetAt)) return null;
|
|
177
|
+
return { percent: typeof window5h.percent === 'number' ? window5h.percent : undefined, resetAt };
|
|
178
|
+
} catch (_error) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function browserTimeZone() {
|
|
184
|
+
try {
|
|
185
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
186
|
+
} catch (_error) {
|
|
187
|
+
return 'UTC';
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function loadStored(key, validate) {
|
|
192
|
+
try {
|
|
193
|
+
const raw = window.localStorage.getItem(key);
|
|
194
|
+
if (raw === null) return undefined;
|
|
195
|
+
const value = JSON.parse(raw);
|
|
196
|
+
return validate(value) ? value : undefined;
|
|
197
|
+
} catch (_error) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function storeValue(key, value) {
|
|
203
|
+
try {
|
|
204
|
+
window.localStorage.setItem(key, JSON.stringify(value));
|
|
205
|
+
} catch (_error) { /* storage unavailable; reminders still work without memory */ }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Pending sends this plugin created, keyed by schedule task id. Best-effort memory. */
|
|
209
|
+
function loadRegistry() {
|
|
210
|
+
return loadStored(STORE_PENDING, (v) => Array.isArray(v))
|
|
211
|
+
?.filter((e) => e && typeof e.id === 'string' && typeof e.sessionId === 'string') ?? [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The most recent custom prompt (the strip shows exactly two chips: builtin + this). */
|
|
215
|
+
function loadSnippets() {
|
|
216
|
+
return loadStored(STORE_SNIPPETS, (v) => Array.isArray(v))
|
|
217
|
+
?.filter((x) => typeof x === 'string' && x.trim() !== '').slice(0, 1) ?? [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const S = {
|
|
221
|
+
buttonWrap: { position: 'relative', display: 'inline-flex' },
|
|
222
|
+
button: (open) => ({
|
|
223
|
+
width: 28, height: 28, padding: 0, borderRadius: 8, cursor: 'pointer',
|
|
224
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
225
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
226
|
+
background: open ? 'var(--dsw-alias-interactive-bg-active)' : 'transparent',
|
|
227
|
+
color: open ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
228
|
+
}),
|
|
229
|
+
badge: {
|
|
230
|
+
position: 'absolute', top: -5, right: -5, minWidth: 14, height: 14, padding: '0 4px',
|
|
231
|
+
borderRadius: 7, border: '1px solid var(--dsw-alias-border-l1)',
|
|
232
|
+
background: 'var(--dsw-alias-bg-overlay)',
|
|
233
|
+
color: 'var(--dsw-alias-brand-text, var(--dsw-alias-label-primary))',
|
|
234
|
+
fontSize: 10, fontWeight: 600,
|
|
235
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none',
|
|
236
|
+
},
|
|
237
|
+
panel: (anchor) => ({
|
|
238
|
+
position: 'fixed', ...(anchor.narrow
|
|
239
|
+
? { left: 8, right: 8, bottom: 8, maxHeight: '70vh' }
|
|
240
|
+
: { left: anchor.left, bottom: anchor.bottom, width: anchor.width, maxHeight: anchor.maxHeight }),
|
|
241
|
+
boxSizing: 'border-box', overflowY: 'auto', padding: 10,
|
|
242
|
+
display: 'flex', flexDirection: 'column', gap: 8, borderRadius: 12,
|
|
243
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
244
|
+
background: 'var(--dsw-alias-bg-overlay)',
|
|
245
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
246
|
+
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.18)',
|
|
247
|
+
fontSize: 13, lineHeight: 1.4, zIndex: 1000,
|
|
248
|
+
}),
|
|
249
|
+
header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
|
|
250
|
+
heading: { fontWeight: 600, fontSize: 13 },
|
|
251
|
+
closeBtn: {
|
|
252
|
+
width: 22, height: 22, padding: 0, borderRadius: 6, cursor: 'pointer',
|
|
253
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
254
|
+
border: 'none', background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
|
|
255
|
+
},
|
|
256
|
+
chips: {
|
|
257
|
+
display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 2, padding: 2,
|
|
258
|
+
borderRadius: 10, border: '1px solid var(--dsw-alias-border-l1)',
|
|
259
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
260
|
+
},
|
|
261
|
+
chip: (on) => ({
|
|
262
|
+
padding: '4px 0', borderRadius: 8, cursor: 'pointer', fontSize: 12, textAlign: 'center',
|
|
263
|
+
border: `1px solid ${on ? 'var(--dsw-alias-border-l2)' : 'transparent'}`,
|
|
264
|
+
background: on ? 'var(--dsw-alias-bg-overlay)' : 'transparent',
|
|
265
|
+
color: on ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
266
|
+
boxShadow: on ? '0 1px 2px rgba(0, 0, 0, 0.08)' : 'none',
|
|
267
|
+
}),
|
|
268
|
+
row: { display: 'flex', gap: 8 },
|
|
269
|
+
field: {
|
|
270
|
+
display: 'flex', flexDirection: 'column', gap: 4, flex: 1, minWidth: 0,
|
|
271
|
+
color: 'var(--dsw-alias-label-secondary)', fontSize: 12,
|
|
272
|
+
},
|
|
273
|
+
input: {
|
|
274
|
+
boxSizing: 'border-box', width: '100%', padding: '6px 8px', borderRadius: 8, fontSize: 13,
|
|
275
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
276
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
277
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
278
|
+
},
|
|
279
|
+
summary: { flex: 1, minWidth: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: 12 },
|
|
280
|
+
error: { color: 'var(--dsw-alias-state-error-primary)' },
|
|
281
|
+
done: { color: 'var(--dsw-alias-state-success-primary)' },
|
|
282
|
+
fieldRow: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
283
|
+
fieldLabel: { flexShrink: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: 12 },
|
|
284
|
+
snips: { display: 'flex', gap: 6, minWidth: 0, overflow: 'hidden' },
|
|
285
|
+
snipChip: (on, shrink) => ({
|
|
286
|
+
flex: `0 ${shrink ? 1 : 0} auto`, minWidth: shrink ? 0 : undefined, maxWidth: 140,
|
|
287
|
+
padding: '3px 10px', borderRadius: 999, cursor: 'pointer', fontSize: 12,
|
|
288
|
+
display: 'inline-flex', alignItems: 'center', gap: 4, overflow: 'hidden',
|
|
289
|
+
whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
|
290
|
+
// Always filled: a pill with a surface reads as a clickable chip, not as caption text.
|
|
291
|
+
border: `1px solid ${on ? 'var(--dsw-alias-border-l2)' : 'var(--dsw-alias-border-l1)'}`,
|
|
292
|
+
background: on ? 'var(--dsw-alias-interactive-bg-active)' : 'var(--dsw-alias-bg-layer-1)',
|
|
293
|
+
color: on ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
|
|
294
|
+
}),
|
|
295
|
+
snipText: { minWidth: 0, flex: '0 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
296
|
+
sessionLine: { color: 'var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary))', fontSize: 12, marginTop: -4 },
|
|
297
|
+
quotaLine: { color: 'var(--dsw-alias-label-dimmed, var(--dsw-alias-label-secondary))', fontSize: 12 },
|
|
298
|
+
pendingList: {
|
|
299
|
+
display: 'flex', flexDirection: 'column', gap: 4,
|
|
300
|
+
borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: 8,
|
|
301
|
+
},
|
|
302
|
+
pendingLabel: { color: 'var(--dsw-alias-label-secondary)', fontSize: 12 },
|
|
303
|
+
pendingRow: {
|
|
304
|
+
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 8px', borderRadius: 8,
|
|
305
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
306
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
307
|
+
},
|
|
308
|
+
pendingWhen: { width: 78, flexShrink: 0, fontVariantNumeric: 'tabular-nums', textAlign: 'left', whiteSpace: 'nowrap' },
|
|
309
|
+
pendingText: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
310
|
+
pendingCancel: {
|
|
311
|
+
flexShrink: 0, width: 22, height: 22, padding: 0, borderRadius: 6, cursor: 'pointer',
|
|
312
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: 12,
|
|
313
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
314
|
+
background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
|
|
315
|
+
},
|
|
316
|
+
footer: { display: 'flex', alignItems: 'center', gap: 8 },
|
|
317
|
+
secondary: {
|
|
318
|
+
padding: '6px 12px', borderRadius: 8, cursor: 'pointer', fontSize: 13,
|
|
319
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
320
|
+
background: 'transparent', color: 'var(--dsw-alias-label-primary)',
|
|
321
|
+
},
|
|
322
|
+
primary: (solid) => ({
|
|
323
|
+
padding: '6px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 13,
|
|
324
|
+
border: solid ? '1px solid transparent' : '1px solid var(--dsw-alias-border-l2)',
|
|
325
|
+
opacity: solid ? 1 : 1,
|
|
326
|
+
// Solid under the official theme (its brand accent is ink; use the host's blue like the
|
|
327
|
+
// send button does), tonal under skins themes (their invert tokens are not guaranteed).
|
|
328
|
+
background: solid ? '#4176e6' : 'var(--dsw-alias-interactive-bg-active, var(--dsw-alias-bg-layer-2))',
|
|
329
|
+
color: solid ? '#ffffff' : 'var(--dsw-alias-brand-text, var(--dsw-alias-label-primary))',
|
|
330
|
+
}),
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const clockIcon = () => h('svg', {
|
|
334
|
+
width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
335
|
+
strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
336
|
+
'aria-hidden': 'true', focusable: 'false',
|
|
337
|
+
}, h('circle', { cx: 12, cy: 12, r: 9 }), h('path', { d: 'M12 7v5l3 2' }));
|
|
338
|
+
|
|
339
|
+
const crossIcon = () => h('svg', {
|
|
340
|
+
width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
|
|
341
|
+
strokeWidth: 2.4, strokeLinecap: 'round', 'aria-hidden': 'true', focusable: 'false',
|
|
342
|
+
}, h('path', { d: 'M6 6l12 12M18 6L6 18' }));
|
|
343
|
+
|
|
344
|
+
const boltIcon = () => h('svg', {
|
|
345
|
+
width: 10, height: 10, viewBox: '0 0 24 24', fill: 'currentColor', stroke: 'none',
|
|
346
|
+
'aria-hidden': 'true', focusable: 'false',
|
|
347
|
+
style: { flexShrink: 0 },
|
|
348
|
+
}, h('path', { d: 'M13 2L4.5 12.5H11L9.5 22 19 9.5h-6.5L13 2z' }));
|
|
349
|
+
|
|
350
|
+
/** Placeholder shown while the Host schedule face is not mounted (feature disabled in the profile). */
|
|
351
|
+
function InertControl(props) {
|
|
352
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT;
|
|
353
|
+
return h('button', {
|
|
354
|
+
type: 'button',
|
|
355
|
+
disabled: true,
|
|
356
|
+
'aria-label': t('button'),
|
|
357
|
+
'aria-disabled': 'true',
|
|
358
|
+
title: t('errDisabled'),
|
|
359
|
+
style: { ...S.button(false), opacity: 0.45, cursor: 'default' },
|
|
360
|
+
}, clockIcon());
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function SendLater(props) {
|
|
364
|
+
const t = typeof props.t === 'function' ? props.t : fallbackT;
|
|
365
|
+
const [open, setOpen] = useState(false);
|
|
366
|
+
const [anchor, setAnchor] = useState({ left: 8, width: 300, bottom: 72, maxHeight: 480, narrow: false });
|
|
367
|
+
const [choice, setChoice] = useState(() => {
|
|
368
|
+
const stored = loadStored(STORE_PRESET, (v) => PRESETS.some((p) => p.id === v));
|
|
369
|
+
return stored ?? '10m';
|
|
370
|
+
});
|
|
371
|
+
const [prompt, setPrompt] = useState(() => {
|
|
372
|
+
const stored = loadStored(STORE_PROMPT, (v) => typeof v === 'string' && v.trim() !== '');
|
|
373
|
+
return stored ?? t('promptDefault');
|
|
374
|
+
});
|
|
375
|
+
const [date, setDate] = useState(() => dateValue(new Date(Date.now() + 3600000)));
|
|
376
|
+
const [time, setTime] = useState(() => timeValue(new Date(Date.now() + 3600000)));
|
|
377
|
+
const [busy, setBusy] = useState(false);
|
|
378
|
+
const [busyCancelId, setBusyCancelId] = useState(undefined);
|
|
379
|
+
const [pending, setPending] = useState([]);
|
|
380
|
+
const [snippets, setSnippets] = useState(() => loadSnippets());
|
|
381
|
+
const [quota, setQuota] = useState(undefined);
|
|
382
|
+
const [sessionTitle, setSessionTitle] = useState('');
|
|
383
|
+
const [message, setMessage] = useState(undefined);
|
|
384
|
+
const [solidAccent, setSolidAccent] = useState(false);
|
|
385
|
+
const buttonRef = useRef(null);
|
|
386
|
+
const panelRef = useRef(null);
|
|
387
|
+
const aliveRef = useRef(true);
|
|
388
|
+
const timerRef = useRef(undefined);
|
|
389
|
+
const busyRef = useRef(false);
|
|
390
|
+
const pendingRef = useRef([]);
|
|
391
|
+
const propsRef = useRef(props);
|
|
392
|
+
propsRef.current = props;
|
|
393
|
+
pendingRef.current = pending;
|
|
394
|
+
const openRef = useRef(false);
|
|
395
|
+
openRef.current = open;
|
|
396
|
+
|
|
397
|
+
useEffect(() => () => {
|
|
398
|
+
aliveRef.current = false;
|
|
399
|
+
if (timerRef.current !== undefined) clearTimeout(timerRef.current);
|
|
400
|
+
}, []);
|
|
401
|
+
|
|
402
|
+
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
|
|
403
|
+
const canList = typeof props.listCatalog === 'function';
|
|
404
|
+
|
|
405
|
+
const refreshPending = async () => {
|
|
406
|
+
const sid = propsRef.current.sessionId;
|
|
407
|
+
if (!canList || typeof sid !== 'string' || sid === '') return;
|
|
408
|
+
let entries;
|
|
409
|
+
try {
|
|
410
|
+
const result = await propsRef.current.listCatalog();
|
|
411
|
+
entries = result && result.ok ? result.value : undefined;
|
|
412
|
+
} catch (_error) {
|
|
413
|
+
entries = undefined;
|
|
414
|
+
}
|
|
415
|
+
if (!aliveRef.current) return;
|
|
416
|
+
const prefix = t('titlePrefix');
|
|
417
|
+
const isOurs = (entry) => entry && entry.status === 'active'
|
|
418
|
+
&& entry.target && entry.target.kind === 'session' && entry.target.sessionId === sid;
|
|
419
|
+
if (!Array.isArray(entries)) {
|
|
420
|
+
// Catalog unavailable: fall back to the local registry so the badge stays honest.
|
|
421
|
+
setPending(loadRegistry().filter((r) => r.sessionId === sid)
|
|
422
|
+
.map((r) => ({ id: r.id, at: r.at, prompt: typeof r.prompt === 'string' ? r.prompt : '' })));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
426
|
+
const live = [];
|
|
427
|
+
const seen = new Set();
|
|
428
|
+
for (const r of loadRegistry()) {
|
|
429
|
+
const entry = byId.get(r.id);
|
|
430
|
+
if (entry && isOurs(entry) && !seen.has(r.id)) {
|
|
431
|
+
live.push({ id: r.id, at: entry.scheduledAt || r.at, prompt: typeof entry.prompt === 'string' ? entry.prompt : r.prompt || '' });
|
|
432
|
+
seen.add(r.id);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Adopt tasks created elsewhere (another browser/device) that carry our title marker.
|
|
436
|
+
for (const entry of entries) {
|
|
437
|
+
if (isOurs(entry) && !seen.has(entry.id)
|
|
438
|
+
&& typeof entry.title === 'string' && entry.title.startsWith(prefix)) {
|
|
439
|
+
live.push({ id: entry.id, at: entry.scheduledAt, prompt: typeof entry.prompt === 'string' ? entry.prompt : '' });
|
|
440
|
+
seen.add(entry.id);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Keep just-created entries the catalog does not know about yet (catalog can lag behind a create).
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
for (const r of loadRegistry()) {
|
|
446
|
+
if (!seen.has(r.id) && r.sessionId === sid
|
|
447
|
+
&& typeof r.ts === 'number' && now - r.ts < 300000) {
|
|
448
|
+
live.push({ id: r.id, at: r.at, prompt: typeof r.prompt === 'string' ? r.prompt : '' });
|
|
449
|
+
seen.add(r.id);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
live.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
453
|
+
storeValue(STORE_PENDING, live.map((x) => ({ id: x.id, sessionId: sid, at: x.at, prompt: x.prompt })));
|
|
454
|
+
setPending(live);
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Refresh on mount, when the session changes, and after any mutation; light interval while a send is pending.
|
|
458
|
+
useEffect(() => { refreshPending(); /* eslint-disable-line no-unused-vars */ }, [sessionId]);
|
|
459
|
+
useEffect(() => {
|
|
460
|
+
const id = window.setInterval(() => {
|
|
461
|
+
if (aliveRef.current && openRef.current) {
|
|
462
|
+
if (pendingRef.current.length > 0) refreshPending();
|
|
463
|
+
if (typeof propsRef.current.fetchQuota === 'function') {
|
|
464
|
+
propsRef.current.fetchQuota().then((q) => { if (aliveRef.current) setQuota(q && q.resetAt ? q : null); }).catch(() => {});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}, 60000);
|
|
468
|
+
return () => window.clearInterval(id);
|
|
469
|
+
}, []);
|
|
470
|
+
|
|
471
|
+
// Re-detect on mount and whenever the panel opens (a skin switch swaps the token sheet).
|
|
472
|
+
useEffect(() => { setSolidAccent(detectSolidAccent()); }, []);
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
if (open) setSolidAccent(detectSolidAccent());
|
|
475
|
+
}, [open]);
|
|
476
|
+
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!open) return undefined;
|
|
479
|
+
refreshPending();
|
|
480
|
+
if (typeof propsRef.current.sessionTitle === 'function') setSessionTitle(propsRef.current.sessionTitle() || '');
|
|
481
|
+
if (typeof propsRef.current.fetchQuota === 'function') {
|
|
482
|
+
propsRef.current.fetchQuota().then((q) => { if (aliveRef.current) setQuota(q && q.resetAt ? q : null); }).catch(() => {});
|
|
483
|
+
}
|
|
484
|
+
const onPointer = (event) => {
|
|
485
|
+
if (panelRef.current && panelRef.current.contains(event.target)) return;
|
|
486
|
+
if (buttonRef.current && buttonRef.current.contains(event.target)) return;
|
|
487
|
+
setOpen(false);
|
|
488
|
+
};
|
|
489
|
+
const onKey = (event) => {
|
|
490
|
+
if (event.key === 'Escape') setOpen(false);
|
|
491
|
+
};
|
|
492
|
+
// Resize must NOT close the panel: on phones the soft keyboard fires a resize when the
|
|
493
|
+
// message field gains focus, which used to dismiss the card mid-edit. Reposition instead.
|
|
494
|
+
const onResize = () => updateAnchor();
|
|
495
|
+
document.addEventListener('mousedown', onPointer);
|
|
496
|
+
document.addEventListener('keydown', onKey);
|
|
497
|
+
window.addEventListener('resize', onResize);
|
|
498
|
+
// Soft keyboards announce themselves on the visual viewport, not the window.
|
|
499
|
+
const vv = window.visualViewport;
|
|
500
|
+
if (vv) {
|
|
501
|
+
vv.addEventListener('resize', onResize);
|
|
502
|
+
vv.addEventListener('scroll', onResize);
|
|
503
|
+
}
|
|
504
|
+
return () => {
|
|
505
|
+
document.removeEventListener('mousedown', onPointer);
|
|
506
|
+
document.removeEventListener('keydown', onKey);
|
|
507
|
+
window.removeEventListener('resize', onResize);
|
|
508
|
+
if (vv) {
|
|
509
|
+
vv.removeEventListener('resize', onResize);
|
|
510
|
+
vv.removeEventListener('scroll', onResize);
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
}, [open]);
|
|
514
|
+
|
|
515
|
+
const preset = PRESETS.find((item) => item.id === choice) || PRESETS[0];
|
|
516
|
+
const previewAt = choice === 'custom'
|
|
517
|
+
? parseLocal(date, time)
|
|
518
|
+
: new Date(Date.now() + preset.seconds * 1000);
|
|
519
|
+
|
|
520
|
+
const updateAnchor = () => {
|
|
521
|
+
const rect = buttonRef.current ? buttonRef.current.getBoundingClientRect() : undefined;
|
|
522
|
+
const narrow = window.innerWidth < 480;
|
|
523
|
+
// Soft keyboards shrink the visual viewport; keep the panel above that edge when present.
|
|
524
|
+
const vv = window.visualViewport;
|
|
525
|
+
const keyboardEdge = vv ? Math.max(0, window.innerHeight - (vv.offsetTop + vv.height)) : 0;
|
|
526
|
+
const visualHeight = vv ? vv.height : window.innerHeight;
|
|
527
|
+
if (rect) {
|
|
528
|
+
// Keep the whole panel inside the viewport: on narrow phones render a bottom sheet.
|
|
529
|
+
const width = Math.min(300, window.innerWidth - 16);
|
|
530
|
+
const left = Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8);
|
|
531
|
+
const aboveButton = window.innerHeight - rect.top + 8;
|
|
532
|
+
setAnchor({
|
|
533
|
+
left,
|
|
534
|
+
width,
|
|
535
|
+
bottom: narrow ? Math.max(8, keyboardEdge) : Math.max(aboveButton, keyboardEdge),
|
|
536
|
+
maxHeight: Math.max(200, Math.min(rect.top - 16, visualHeight - 40)),
|
|
537
|
+
narrow,
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
setAnchor((current) => ({ ...current, narrow }));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const toggle = () => {
|
|
545
|
+
if (open) {
|
|
546
|
+
setOpen(false);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
updateAnchor();
|
|
550
|
+
setMessage(undefined);
|
|
551
|
+
setOpen(true);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const submit = async () => {
|
|
555
|
+
if (busyRef.current) return;
|
|
556
|
+
const text = prompt.trim();
|
|
557
|
+
if (text === '') {
|
|
558
|
+
setMessage({ kind: 'error', text: t('errEmpty') });
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const current = propsRef.current;
|
|
562
|
+
if (typeof current.sessionId !== 'string' || current.sessionId === '' || typeof current.schedule !== 'function') {
|
|
563
|
+
setMessage({ kind: 'error', text: t('errNoSession') });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const now = Date.now();
|
|
567
|
+
let when;
|
|
568
|
+
let timing;
|
|
569
|
+
if (choice === 'custom') {
|
|
570
|
+
when = parseLocal(date, time);
|
|
571
|
+
if (when === undefined || when.getTime() - now < 60000) {
|
|
572
|
+
setMessage({ kind: 'error', text: t('errPast') });
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const hhmmss = time.length === 5 ? `${time}:00` : time;
|
|
576
|
+
timing = { kind: 'at', at: { date, time: hhmmss, time_zone: browserTimeZone() } };
|
|
577
|
+
} else {
|
|
578
|
+
when = new Date(now + preset.seconds * 1000);
|
|
579
|
+
timing = { kind: 'after', after_seconds: preset.seconds };
|
|
580
|
+
}
|
|
581
|
+
busyRef.current = true;
|
|
582
|
+
setBusy(true);
|
|
583
|
+
setMessage(undefined);
|
|
584
|
+
let result;
|
|
585
|
+
try {
|
|
586
|
+
result = await current.schedule({
|
|
587
|
+
title: (t('titlePrefix') + text).slice(0, 120),
|
|
588
|
+
prompt: text,
|
|
589
|
+
timing,
|
|
590
|
+
target: { kind: 'session', sessionId: current.sessionId },
|
|
591
|
+
});
|
|
592
|
+
} catch (error) {
|
|
593
|
+
result = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
594
|
+
}
|
|
595
|
+
busyRef.current = false;
|
|
596
|
+
if (!aliveRef.current) return;
|
|
597
|
+
setBusy(false);
|
|
598
|
+
if (result && result.ok) {
|
|
599
|
+
storeValue(STORE_PROMPT, text);
|
|
600
|
+
storeValue(STORE_PRESET, choice);
|
|
601
|
+
// Custom prompts feed the snippet strip (only the latest one is kept); the default and the builtin stay out.
|
|
602
|
+
const builtinText = t('snipResumeText');
|
|
603
|
+
if (text !== t('promptDefault') && text !== builtinText) {
|
|
604
|
+
storeValue(STORE_SNIPPETS, [text]);
|
|
605
|
+
setSnippets([text]);
|
|
606
|
+
}
|
|
607
|
+
const createdId = result.value && typeof result.value.id === 'string' ? result.value.id : undefined;
|
|
608
|
+
if (createdId) {
|
|
609
|
+
const registry = loadRegistry().filter((r) => r.id !== createdId);
|
|
610
|
+
registry.push({ id: createdId, sessionId: current.sessionId, at: when.toISOString(), prompt: text, ts: Date.now() });
|
|
611
|
+
storeValue(STORE_PENDING, registry);
|
|
612
|
+
}
|
|
613
|
+
setMessage({ kind: 'done', text: t('donePrefix') + compactWhen(when) + t('doneSuffix') });
|
|
614
|
+
refreshPending();
|
|
615
|
+
timerRef.current = setTimeout(() => {
|
|
616
|
+
if (aliveRef.current) setOpen(false);
|
|
617
|
+
}, 1600);
|
|
618
|
+
} else {
|
|
619
|
+
const detail = (result && result.error && (result.error.message || result.error.code)) || '';
|
|
620
|
+
setMessage({ kind: 'error', text: t('errFailed') + detail });
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const cancelPending = async (id) => {
|
|
625
|
+
const current = propsRef.current;
|
|
626
|
+
if (busyCancelId !== undefined || typeof current.cancelTask !== 'function') return;
|
|
627
|
+
setBusyCancelId(id);
|
|
628
|
+
let result;
|
|
629
|
+
try {
|
|
630
|
+
result = await current.cancelTask(id);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
result = { ok: false, error: { message: error instanceof Error ? error.message : String(error) } };
|
|
633
|
+
}
|
|
634
|
+
if (!aliveRef.current) return;
|
|
635
|
+
setBusyCancelId(undefined);
|
|
636
|
+
if (result && result.ok) {
|
|
637
|
+
storeValue(STORE_PENDING, loadRegistry().filter((r) => r.id !== id));
|
|
638
|
+
refreshPending();
|
|
639
|
+
} else {
|
|
640
|
+
const detail = (result && result.error && (result.error.message || result.error.code)) || '';
|
|
641
|
+
setMessage({ kind: 'error', text: t('cancelFailed') + detail });
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
const quotaText = quota && quota.resetAt
|
|
646
|
+
? t('quotaLine')
|
|
647
|
+
.replace('{p}', quota.percent === undefined ? '?' : String(quota.percent))
|
|
648
|
+
.replace('{t}', timeValue(new Date(quota.resetAt)))
|
|
649
|
+
.replace('{r}', relativeWhen(new Date(quota.resetAt), t).label)
|
|
650
|
+
: '';
|
|
651
|
+
|
|
652
|
+
const button = h('span', { style: S.buttonWrap },
|
|
653
|
+
h('button', {
|
|
654
|
+
type: 'button',
|
|
655
|
+
ref: buttonRef,
|
|
656
|
+
onClick: toggle,
|
|
657
|
+
'aria-label': t('button'),
|
|
658
|
+
title: t('button'),
|
|
659
|
+
'aria-haspopup': 'dialog',
|
|
660
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
661
|
+
style: S.button(open),
|
|
662
|
+
}, clockIcon()),
|
|
663
|
+
pending.length > 0 ? h('span', { 'aria-hidden': 'true', style: S.badge }, pending.length > 9 ? '9+' : String(pending.length)) : null);
|
|
664
|
+
|
|
665
|
+
const panelBody = h('div', {
|
|
666
|
+
ref: panelRef,
|
|
667
|
+
role: 'dialog',
|
|
668
|
+
'aria-label': t('panelTitle'),
|
|
669
|
+
style: S.panel(anchor),
|
|
670
|
+
},
|
|
671
|
+
h('div', { style: S.header },
|
|
672
|
+
h('div', { style: S.heading }, t('panelTitle')),
|
|
673
|
+
h('button', {
|
|
674
|
+
type: 'button',
|
|
675
|
+
'aria-label': t('close'),
|
|
676
|
+
title: t('close'),
|
|
677
|
+
onClick: () => setOpen(false),
|
|
678
|
+
style: S.closeBtn,
|
|
679
|
+
}, crossIcon())),
|
|
680
|
+
sessionTitle ? h('div', { style: S.sessionLine }, sessionTitle) : null,
|
|
681
|
+
h('div', { style: S.chips }, PRESETS.map((item) => h('button', {
|
|
682
|
+
key: item.id,
|
|
683
|
+
type: 'button',
|
|
684
|
+
'aria-pressed': item.id === choice ? 'true' : 'false',
|
|
685
|
+
onClick: () => {
|
|
686
|
+
setChoice(item.id);
|
|
687
|
+
setMessage(undefined);
|
|
688
|
+
},
|
|
689
|
+
style: S.chip(item.id === choice),
|
|
690
|
+
}, t(item.label)))),
|
|
691
|
+
choice === 'custom'
|
|
692
|
+
? h('div', { style: S.row },
|
|
693
|
+
h('label', { style: S.field }, t('date'),
|
|
694
|
+
h('input', { type: 'date', value: date, onChange: (event) => setDate(event.target.value), style: S.input })),
|
|
695
|
+
h('label', { style: S.field }, t('time'),
|
|
696
|
+
h('input', { type: 'time', value: time, onChange: (event) => setTime(event.target.value), style: S.input })))
|
|
697
|
+
: null,
|
|
698
|
+
h('div', { style: S.fieldRow },
|
|
699
|
+
h('label', { htmlFor: 'dsh-send-later-prompt', style: S.fieldLabel }, t('promptLabel')),
|
|
700
|
+
h('div', { style: S.snips },
|
|
701
|
+
h('button', {
|
|
702
|
+
key: 'snip-builtin',
|
|
703
|
+
type: 'button',
|
|
704
|
+
title: t('snipResumeText'),
|
|
705
|
+
onClick: () => {
|
|
706
|
+
setPrompt(t('snipResumeText'));
|
|
707
|
+
setMessage(undefined);
|
|
708
|
+
},
|
|
709
|
+
style: S.snipChip(prompt.trim() === t('snipResumeText')),
|
|
710
|
+
}, boltIcon(), t('snipResumeLabel')),
|
|
711
|
+
snippets.slice(0, 1).map((x) => h('button', {
|
|
712
|
+
key: x,
|
|
713
|
+
type: 'button',
|
|
714
|
+
title: x,
|
|
715
|
+
onClick: () => {
|
|
716
|
+
setPrompt(x);
|
|
717
|
+
setMessage(undefined);
|
|
718
|
+
},
|
|
719
|
+
style: S.snipChip(prompt.trim() === x, true),
|
|
720
|
+
}, boltIcon(), h('span', { style: S.snipText }, x))))),
|
|
721
|
+
quota && quota.resetAt ? h('div', { style: S.quotaLine }, quotaText) : null,
|
|
722
|
+
h('input', {
|
|
723
|
+
id: 'dsh-send-later-prompt',
|
|
724
|
+
type: 'text',
|
|
725
|
+
value: prompt,
|
|
726
|
+
placeholder: t('promptPlaceholder'),
|
|
727
|
+
onChange: (event) => setPrompt(event.target.value),
|
|
728
|
+
onKeyDown: (event) => {
|
|
729
|
+
if (event.key === 'Enter' && !(event.nativeEvent && event.nativeEvent.isComposing)) {
|
|
730
|
+
event.preventDefault();
|
|
731
|
+
submit();
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
style: S.input,
|
|
735
|
+
}),
|
|
736
|
+
pending.length > 0 ? h('div', { style: S.pendingList },
|
|
737
|
+
h('div', { style: S.pendingLabel }, `${t('pendingTitle')} · ${pending.length}`),
|
|
738
|
+
pending.map((item) => {
|
|
739
|
+
const rel = relativeWhen(new Date(item.at), t);
|
|
740
|
+
return h('div', { key: item.id, style: S.pendingRow },
|
|
741
|
+
h('span', { style: S.pendingWhen, 'data-when': '', title: rel.absolute }, rel.label),
|
|
742
|
+
h('span', { style: S.pendingText, title: item.prompt }, item.prompt || item.id),
|
|
743
|
+
h('button', {
|
|
744
|
+
type: 'button',
|
|
745
|
+
'aria-label': t('cancelTask'),
|
|
746
|
+
title: t('cancelTask'),
|
|
747
|
+
disabled: busyCancelId !== undefined,
|
|
748
|
+
onClick: () => cancelPending(item.id),
|
|
749
|
+
style: S.pendingCancel,
|
|
750
|
+
}, busyCancelId === item.id ? t('cancelling').slice(0, 1) : crossIcon()));
|
|
751
|
+
}))
|
|
752
|
+
: null,
|
|
753
|
+
message ? h('div', { role: 'status', style: message.kind === 'error' ? S.error : S.done }, message.text) : null,
|
|
754
|
+
h('div', { style: S.footer },
|
|
755
|
+
previewAt ? h('div', { style: S.summary }, t('summaryPrefix') + compactWhen(previewAt) + t('summarySuffix')) : null,
|
|
756
|
+
h('button', { type: 'button', onClick: submit, disabled: busy, style: { ...S.primary(solidAccent), opacity: busy ? 0.6 : 1 } }, busy ? t('busy') : t('submit'))));
|
|
757
|
+
|
|
758
|
+
return h(React.Fragment, null,
|
|
759
|
+
button,
|
|
760
|
+
open ? ReactDOM.createPortal(panelBody, document.body) : null);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
inject: ['locale', 'slots', 'remote', 'sessions'],
|
|
765
|
+
apply(ctx) {
|
|
766
|
+
ctx.effect(() => ctx.locale.register(NS, 'en', EN), 'send-later: en dictionary');
|
|
767
|
+
ctx.effect(() => ctx.locale.register(NS, 'zh', ZH), 'send-later: zh dictionary');
|
|
768
|
+
ctx.effect(() => {
|
|
769
|
+
// Placeholder first: keeps the entry point visible (greyed out) even when the
|
|
770
|
+
// Automation tasks feature is disabled, where the schedule face never appears.
|
|
771
|
+
const disposePlaceholder = ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
772
|
+
name: 'conversation.input.right',
|
|
773
|
+
id: 'send-later',
|
|
774
|
+
locale: NS,
|
|
775
|
+
inject: () => ({}),
|
|
776
|
+
}, InertControl));
|
|
777
|
+
let disposeFull;
|
|
778
|
+
const undoInject = ctx.inject(['remote.schedule'], (scope) => {
|
|
779
|
+
if (disposeFull) return;
|
|
780
|
+
// Access the face through the inject scope: touching ctx.remote.schedule here
|
|
781
|
+
// would throw "cannot get property ... without inject" (strict Local getters).
|
|
782
|
+
const api = (scope && scope.remote && scope.remote.schedule) || (scope && scope['remote.schedule']);
|
|
783
|
+
disposePlaceholder();
|
|
784
|
+
disposeFull = ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
785
|
+
name: 'conversation.input.right',
|
|
786
|
+
id: 'send-later',
|
|
787
|
+
locale: NS,
|
|
788
|
+
inject: (sessionId) => ({
|
|
789
|
+
sessionId,
|
|
790
|
+
schedule: (request) => api.create(request),
|
|
791
|
+
listCatalog: () => api.catalog(),
|
|
792
|
+
cancelTask: (id) => api.edit({ id, action: 'delete' }),
|
|
793
|
+
sessionTitle: () => {
|
|
794
|
+
try {
|
|
795
|
+
const snap = ctx.sessions.list.getSnapshot();
|
|
796
|
+
const entries = snap && Array.isArray(snap.entries) ? snap.entries
|
|
797
|
+
: (Array.isArray(snap) ? snap : (snap && Array.isArray(snap.sessions) ? snap.sessions : []));
|
|
798
|
+
const hit = entries.find((e) => e && e.sessionId === sessionId);
|
|
799
|
+
return hit && typeof hit.title === 'string' ? hit.title : '';
|
|
800
|
+
} catch (_error) {
|
|
801
|
+
return '';
|
|
802
|
+
}
|
|
803
|
+
},
|
|
804
|
+
fetchQuota: () => fetchGlm5hQuota(),
|
|
805
|
+
}),
|
|
806
|
+
}, SendLater));
|
|
807
|
+
});
|
|
808
|
+
return () => {
|
|
809
|
+
if (typeof undoInject === 'function') undoInject();
|
|
810
|
+
disposePlaceholder();
|
|
811
|
+
if (disposeFull) disposeFull();
|
|
812
|
+
};
|
|
813
|
+
}, 'send-later: composer control');
|
|
814
|
+
},
|
|
815
|
+
};
|
|
816
|
+
},
|
|
817
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="4" y="4" width="56" height="56" rx="14" fill="#247bbf"/>
|
|
3
|
+
<circle cx="32" cy="32" r="18" fill="none" stroke="#ffffff" stroke-width="5"/>
|
|
4
|
+
<path d="M32 22v11l7 5" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
</svg>
|
package/index.js
ADDED
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-send-later",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "DSH composer plugin that schedules a delayed message (e.g. Continue) into the current session",
|
|
5
|
+
"icon": "./icon.svg",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./index.js",
|
|
10
|
+
"./client": "./client.js",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./icon": "./icon.svg",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"client.js",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"locale",
|
|
20
|
+
"icon.svg",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"CHANGELOG.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node test/run.cjs",
|
|
30
|
+
"security:check": "node scripts/check-public-package.mjs",
|
|
31
|
+
"prepublishOnly": "npm test && node --check client.js && node --check index.js && npm run security:check"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/iasiv5/dsh-send-later.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/iasiv5/dsh-send-later/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/iasiv5/dsh-send-later#readme",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"dsh",
|
|
43
|
+
"deepseek-harness",
|
|
44
|
+
"plugin",
|
|
45
|
+
"composer",
|
|
46
|
+
"schedule",
|
|
47
|
+
"delayed-send"
|
|
48
|
+
],
|
|
49
|
+
"author": "iasi",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public",
|
|
53
|
+
"registry": "https://registry.npmjs.org/"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"jsdom": "^29.1.1",
|
|
60
|
+
"react": "^19.3.1",
|
|
61
|
+
"react-dom": "^19.3.1"
|
|
62
|
+
},
|
|
63
|
+
"dsh": {
|
|
64
|
+
"engines": {
|
|
65
|
+
"dsh": ">=0.2.2-rc.1"
|
|
66
|
+
},
|
|
67
|
+
"bundle": {
|
|
68
|
+
"patch": "./cordis.patch.yml"
|
|
69
|
+
},
|
|
70
|
+
"client": {
|
|
71
|
+
"platform": "web",
|
|
72
|
+
"immediately": true,
|
|
73
|
+
"inject": [
|
|
74
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
75
|
+
"@deepseek-ai/dsh-client-locale",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|