dsh-pause-resume 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/LICENSE +21 -0
- package/README.md +120 -2
- package/client.js +264 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +6 -0
- package/index.js +284 -0
- package/lib/engine.js +382 -0
- package/lib/heartbeat.js +226 -0
- package/lib/probe.js +359 -0
- package/lib/snapshot.js +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 regrevia
|
|
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,121 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-pause-resume
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One button in the DeepSeek Harness session header. It stops what is running, writes a small plan to
|
|
4
|
+
disk, and after a restart the same button puts that work back.
|
|
5
|
+
|
|
6
|
+
* ⏸ — work is running. Press to stop it.
|
|
7
|
+
* ▶ — work is stopped, or a previous process died mid-turn. Press to restore it.
|
|
8
|
+
* ⏸ dimmed — nothing to stop and nothing to restore.
|
|
9
|
+
|
|
10
|
+
The glyph is the only status. There is no counter, no panel and no toast, because a status badge next
|
|
11
|
+
to the button would be a second source of truth for the same fact. A red dot is added only when the
|
|
12
|
+
last press left something for a person to look at.
|
|
13
|
+
|
|
14
|
+
## What it restores
|
|
15
|
+
|
|
16
|
+
| Work | Pause | Restore |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| A session with an unfinished turn | turn cancelled, session recorded | session mounted, then one visible continuation message |
|
|
19
|
+
| A goal that was armed | paused with the revision it had | `goals.resume` with that exact revision; the goal driver continues on its own |
|
|
20
|
+
| A session that only carried that goal | recorded, nothing stopped | mounted; the resumed goal takes over |
|
|
21
|
+
| A running background job | killed and recorded | recorded only: a job is a live process, not replayable |
|
|
22
|
+
| A session that ended normally | untouched | untouched, and never recorded in the first place |
|
|
23
|
+
| A terminal / finished session | untouched | untouched |
|
|
24
|
+
| A schedule | untouched | untouched |
|
|
25
|
+
|
|
26
|
+
Schedules are not paused: the schedule service has no disable operation, only delete, and deleting a
|
|
27
|
+
person's schedule to pause it is not a trade this plugin makes. A schedule that fires while paused
|
|
28
|
+
starts a turn; the button then shows ⏸ again, truthfully.
|
|
29
|
+
|
|
30
|
+
## How resume works
|
|
31
|
+
|
|
32
|
+
A restore is not a claim that work continued. It is three honest steps per item:
|
|
33
|
+
|
|
34
|
+
1. **Mount.** A stopped session is mounted through the Harness resume path, which preserves its agent
|
|
35
|
+
preset. If it is already mounted, that step is skipped.
|
|
36
|
+
2. **Continue, only where the press actually cancelled a turn.** The continuation is one ordinary
|
|
37
|
+
user message sent through `sessionController.prompt`, with a request id derived from the plan and
|
|
38
|
+
the session. The id makes a repeated restore idempotent, and the message is visible in the
|
|
39
|
+
transcript, so the person can see exactly what was resumed.
|
|
40
|
+
3. **Re-arm a goal** with the revision it had when paused. The revision is the person's veto: if the
|
|
41
|
+
goal changed while paused, it is not ours to resume and the item is `skipped`.
|
|
42
|
+
|
|
43
|
+
Every finished item is journaled before the plan is archived, so a crash in the middle of a restore
|
|
44
|
+
continues with the remainder instead of restoring the same session twice. A failure leaves the plan
|
|
45
|
+
and the journal on disk: the next press retries only the failed item.
|
|
46
|
+
|
|
47
|
+
## Crash, not just a press
|
|
48
|
+
|
|
49
|
+
A press writes a snapshot. A crash writes nothing, so the plugin keeps a heartbeat instead:
|
|
50
|
+
`running.json` holds the sessions whose turn is open right now and the id of the process that wrote
|
|
51
|
+
it. A new process finds the previous process's record and offers ▶ with no press needed.
|
|
52
|
+
|
|
53
|
+
A crash record is deliberately weaker than a press: it proves a turn was open, not whether the person
|
|
54
|
+
wanted it continued. So restore mounts those sessions and lists them in the outcome; it does not
|
|
55
|
+
type a continuation message on their behalf. Sessions that ended normally never enter the file.
|
|
56
|
+
|
|
57
|
+
The same process reloading the plugin (hot reload) sees its own id and knows there is nothing to
|
|
58
|
+
restore. A `pausing` plan found on disk means the press itself was interrupted: those sessions are
|
|
59
|
+
mounted but never continued, and the button carries a dot until a person presses restore.
|
|
60
|
+
|
|
61
|
+
## Installing
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
plugin_manager install_bundle dsh-pause-resume
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
or install the bundle from the Harness plugin manager with the package name. The plugin needs no
|
|
68
|
+
configuration and holds no credentials. `allowRemote` exists for a Harness reached from another
|
|
69
|
+
machine; local-only is the default.
|
|
70
|
+
|
|
71
|
+
## Guarantees and limits
|
|
72
|
+
|
|
73
|
+
* **Exclusive.** The phase lives on the Host and is written before any work starts, so two windows,
|
|
74
|
+
a double press and a tool call cannot overlap. The second caller is told `busy`.
|
|
75
|
+
* **Durable before destructive.** No turn is cancelled until the plan is on disk. A failed write means
|
|
76
|
+
nothing was stopped.
|
|
77
|
+
* **Idempotent.** A restore journals every finished item; the continuation request id is derived from
|
|
78
|
+
the plan, so a retry cannot post the same message twice.
|
|
79
|
+
* **Honest.** Unreadable state never reports `paused`. A session deleted by hand, a goal changed by
|
|
80
|
+
hand, a session already running: each is skipped and named in the outcome.
|
|
81
|
+
* **No hidden takeover.** There is no public API to detach an agent, so the pause guarantee is
|
|
82
|
+
"turns cancelled, goals paused, jobs killed" — not "the process is frozen". A goal continues because
|
|
83
|
+
the Harness has a mechanism for that; a plain session continues through one visible user message.
|
|
84
|
+
* **No third-party plugin state.** A plugin's in-process state belongs to that plugin's mount.
|
|
85
|
+
* **Local by default.** The routes require a same-origin request and a plugin header. Remote use must
|
|
86
|
+
be opted into and still cannot skip the header.
|
|
87
|
+
|
|
88
|
+
## Files
|
|
89
|
+
|
|
90
|
+
All state lives under `$DSH_HOME/pause-resume/`:
|
|
91
|
+
|
|
92
|
+
| File | Purpose |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| `snapshot.json` | the work a press stopped, `pausing` or `paused` |
|
|
95
|
+
| `journal/<planId>.jsonl` | restore items already finished |
|
|
96
|
+
| `restores/<planId>.json` | the archived plan, kept as a record of the last restore |
|
|
97
|
+
| `last-restore.json` | summary of the last restore |
|
|
98
|
+
| `running.json` | sessions with an open turn, for crash residue |
|
|
99
|
+
|
|
100
|
+
Every write is a unique temp file, `fsync`, `rename`, then a directory `fsync`. State is never inside
|
|
101
|
+
the package.
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm test
|
|
107
|
+
npm run audit
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`lib/engine.js` performs no I/O, `lib/snapshot.js` persists, `lib/probe.js` holds every Harness service
|
|
111
|
+
contract (agent identity, goal revision, per-owner jobs, continuation prompts), and `index.js` only
|
|
112
|
+
wires them to the web routes. All of them are testable without a Harness: `test/apply.test.mjs` mounts
|
|
113
|
+
the plugin against a fake registry.
|
|
114
|
+
|
|
115
|
+
MIT.
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
一行说明:会话头一个按钮,⏸ 停掉正在跑的回合、目标与后台任务;重启后同一个按钮变 ▶,把上次的工作
|
|
120
|
+
接回来。正常结束的会话不记录、不恢复;崩溃靠心跳文件识别,但只挂载不代发消息;日程不会被动
|
|
121
|
+
(该服务只有删除,没有停用),后台命令只记录不重跑。
|
package/client.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-pause-resume', // ui-copy-ignore
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react')
|
|
5
|
+
const h = React.createElement
|
|
6
|
+
|
|
7
|
+
const zh = /^zh/i.test(navigator.language || '')
|
|
8
|
+
const COPY = zh
|
|
9
|
+
? {
|
|
10
|
+
pause: '暂停', // ui-copy-ignore
|
|
11
|
+
resume: '继续', // ui-copy-ignore
|
|
12
|
+
busy: '正在处理', // ui-copy-ignore
|
|
13
|
+
error: '上次操作没有完成', // ui-copy-ignore
|
|
14
|
+
partial: '部分内容未能恢复,可以再按一次', // ui-copy-ignore
|
|
15
|
+
stopped: '上次暂停的内容还没有恢复', // ui-copy-ignore
|
|
16
|
+
attention: '有内容需要你处理',
|
|
17
|
+
}
|
|
18
|
+
: {
|
|
19
|
+
pause: 'Pause', // ui-copy-ignore
|
|
20
|
+
resume: 'Resume', // ui-copy-ignore
|
|
21
|
+
busy: 'Working', // ui-copy-ignore
|
|
22
|
+
error: 'The last action did not finish',
|
|
23
|
+
partial: 'Some items were not restored; press again to retry',
|
|
24
|
+
stopped: 'Work is still stopped',
|
|
25
|
+
attention: 'Some items need a look',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HOST_BASE = '/dsh-pause-resume' // ui-copy-ignore
|
|
29
|
+
const POLL_MS = 2000 // ui-copy-ignore
|
|
30
|
+
const REQUEST_MS = 30000 // ui-copy-ignore
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The glyph is the whole status, so it may not be guessed from anything the page can see: the
|
|
34
|
+
* Host owns the phase and this asks for it. Until an answer arrives the button renders the // ui-copy-ignore
|
|
35
|
+
* settled state (`none`), never a spinner, so a slow Host cannot look like a running press. // ui-copy-ignore
|
|
36
|
+
*/
|
|
37
|
+
const EMPTY = {
|
|
38
|
+
phase: 'idle', // ui-copy-ignore
|
|
39
|
+
action: 'none', // ui-copy-ignore
|
|
40
|
+
source: null, // ui-copy-ignore
|
|
41
|
+
complete: null, // ui-copy-ignore
|
|
42
|
+
outstanding: 0,
|
|
43
|
+
counts: { sessions: 0, goals: 0, jobs: 0 },
|
|
44
|
+
live: { sessions: 0, goals: 0, jobs: 0 },
|
|
45
|
+
attention: 0,
|
|
46
|
+
error: null, // ui-copy-ignore
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function withTimeout(promise, ms = REQUEST_MS) {
|
|
50
|
+
let id = null
|
|
51
|
+
const timer = new Promise((_resolve, reject) => {
|
|
52
|
+
id = setTimeout(() => reject(new Error('timeout')), ms) // ui-copy-ignore
|
|
53
|
+
})
|
|
54
|
+
return Promise.race([promise, timer]).finally(() => clearTimeout(id))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readState(signal) {
|
|
58
|
+
try {
|
|
59
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/state`, { cache: 'no-store', credentials: 'same-origin', signal })) // ui-copy-ignore
|
|
60
|
+
if (!response.ok) return null
|
|
61
|
+
const data = await withTimeout(response.json())
|
|
62
|
+
return data !== null && typeof data === 'object' ? data : null
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function press(action, signal) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await withTimeout(fetch(`${HOST_BASE}/${action}`, {
|
|
71
|
+
method: 'POST', // ui-copy-ignore
|
|
72
|
+
credentials: 'same-origin',
|
|
73
|
+
headers: { 'content-type': 'application/json', 'x-dsh-pause-resume': '1' }, // ui-copy-ignore
|
|
74
|
+
body: '{}', // ui-copy-ignore
|
|
75
|
+
signal,
|
|
76
|
+
}))
|
|
77
|
+
if (!response.ok) return null
|
|
78
|
+
const data = await withTimeout(response.json())
|
|
79
|
+
return data !== null && typeof data === 'object' ? data : null
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// One style element for the whole page: the in-place spinner is the only motion, and it is
|
|
86
|
+
// defined once instead of on every render.
|
|
87
|
+
const STYLE_ID = 'dsh-pause-resume-style' // ui-copy-ignore
|
|
88
|
+
function ensureStyle() {
|
|
89
|
+
try {
|
|
90
|
+
if (document.getElementById(STYLE_ID) !== null) return
|
|
91
|
+
const style = document.createElement('style')
|
|
92
|
+
style.id = STYLE_ID
|
|
93
|
+
style.textContent = '@keyframes dsh-pr-spin{to{transform:rotate(360deg)}}'
|
|
94
|
+
document.head.appendChild(style)
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const BUTTON_STYLE = {
|
|
99
|
+
display: 'inline-flex', // ui-copy-ignore
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
width: 28, // ui-copy-ignore
|
|
103
|
+
height: 28, // ui-copy-ignore
|
|
104
|
+
padding: 0, // ui-copy-ignore
|
|
105
|
+
border: 'none', // ui-copy-ignore
|
|
106
|
+
borderRadius: 6,
|
|
107
|
+
background: 'transparent',
|
|
108
|
+
color: 'inherit', // ui-copy-ignore
|
|
109
|
+
cursor: 'pointer', // ui-copy-ignore
|
|
110
|
+
opacity: 0.75, // ui-copy-ignore
|
|
111
|
+
position: 'relative', // ui-copy-ignore
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const DOT_STYLE = {
|
|
115
|
+
position: 'absolute', // ui-copy-ignore
|
|
116
|
+
top: 4, // ui-copy-ignore
|
|
117
|
+
right: 4, // ui-copy-ignore
|
|
118
|
+
width: 5, // ui-copy-ignore
|
|
119
|
+
height: 5, // ui-copy-ignore
|
|
120
|
+
borderRadius: '50%',
|
|
121
|
+
background: 'var(--dsh-color-danger, #d1242f)',
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const SPIN_STYLE = { display: 'inline-flex', animation: 'dsh-pr-spin 900ms linear infinite' } // ui-copy-ignore
|
|
125
|
+
|
|
126
|
+
function icon(paths, extra) {
|
|
127
|
+
return h('svg', {
|
|
128
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
|
|
129
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
130
|
+
'aria-hidden': true, style: extra,
|
|
131
|
+
}, paths.map((d) => h('path', { key: d, d })))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PAUSE_GLYPH = icon(['M6 3.5v9', 'M10 3.5v9'])
|
|
135
|
+
const PLAY_GLYPH = h('svg', {
|
|
136
|
+
width: 16, height: 16, viewBox: '0 0 16 16', fill: 'currentColor', 'aria-hidden': true,
|
|
137
|
+
}, h('path', { d: 'M5.2 3.4a.7.7 0 0 0-1.05.6v8a.7.7 0 0 0 1.05.6l6.9-4a.7.7 0 0 0 0-1.2l-6.9-4z' }))
|
|
138
|
+
const SPIN_GLYPH = icon(['M8 2.5a5.5 5.5 0 1 1-4.3 2.06'], SPIN_STYLE)
|
|
139
|
+
|
|
140
|
+
/** The one sentence behind the dot, in the tooltip only: the button itself stays a glyph. */
|
|
141
|
+
function hintOf(state) {
|
|
142
|
+
if (state.error === 'restore-partial') return COPY.partial
|
|
143
|
+
if (state.error !== null && state.error !== undefined) return COPY.error
|
|
144
|
+
if ((state.attention ?? 0) > 0) return COPY.attention
|
|
145
|
+
if ((state.outstanding ?? 0) > 0) return COPY.stopped
|
|
146
|
+
return ''
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function Button() {
|
|
150
|
+
const [state, setState] = React.useState(EMPTY)
|
|
151
|
+
const [hover, setHover] = React.useState(false)
|
|
152
|
+
const [pressing, setPressing] = React.useState(false)
|
|
153
|
+
const mounted = React.useRef(true)
|
|
154
|
+
/** Each answer carries a number: a slow reply may never overwrite a newer one. */
|
|
155
|
+
const seq = React.useRef(0)
|
|
156
|
+
const inflight = React.useRef(null)
|
|
157
|
+
|
|
158
|
+
const apply = React.useCallback((next) => {
|
|
159
|
+
if (!mounted.current || next === null) return
|
|
160
|
+
seq.current += 1
|
|
161
|
+
setState({ ...EMPTY, ...next })
|
|
162
|
+
}, [])
|
|
163
|
+
|
|
164
|
+
const pull = React.useCallback(async (signal) => {
|
|
165
|
+
const mine = seq.current
|
|
166
|
+
const next = await readState(signal)
|
|
167
|
+
if (!mounted.current || seq.current !== mine) return
|
|
168
|
+
if (next !== null) apply(next)
|
|
169
|
+
}, [apply])
|
|
170
|
+
|
|
171
|
+
React.useEffect(() => {
|
|
172
|
+
ensureStyle()
|
|
173
|
+
mounted.current = true
|
|
174
|
+
let timer = null
|
|
175
|
+
let current = null
|
|
176
|
+
const beat = () => {
|
|
177
|
+
if (document.visibilityState !== 'visible' || inflight.current !== null) return
|
|
178
|
+
try {
|
|
179
|
+
current?.abort()
|
|
180
|
+
} catch {}
|
|
181
|
+
current = typeof AbortController === 'function' ? new AbortController() : null
|
|
182
|
+
void pull(current?.signal)
|
|
183
|
+
}
|
|
184
|
+
beat()
|
|
185
|
+
timer = setInterval(beat, POLL_MS)
|
|
186
|
+
const onVisible = () => {
|
|
187
|
+
if (document.visibilityState === 'visible') beat()
|
|
188
|
+
}
|
|
189
|
+
document.addEventListener('visibilitychange', onVisible)
|
|
190
|
+
return () => {
|
|
191
|
+
mounted.current = false
|
|
192
|
+
if (timer !== null) clearInterval(timer)
|
|
193
|
+
try {
|
|
194
|
+
current?.abort()
|
|
195
|
+
} catch {}
|
|
196
|
+
document.removeEventListener('visibilitychange', onVisible)
|
|
197
|
+
}
|
|
198
|
+
}, [pull])
|
|
199
|
+
|
|
200
|
+
const busy = pressing || state.action === 'busy'
|
|
201
|
+
const action = state.action === 'pause' || state.action === 'resume' ? state.action : null
|
|
202
|
+
const disabled = action === null && !busy
|
|
203
|
+
|
|
204
|
+
const onClick = async () => {
|
|
205
|
+
if (busy || action === null || inflight.current !== null) return
|
|
206
|
+
// One press at a time, in this tab and across tabs: the Host also refuses an overlapping // ui-copy-ignore
|
|
207
|
+
// press, so a second window's button settles on the same answer instead of starting work.
|
|
208
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null
|
|
209
|
+
inflight.current = controller
|
|
210
|
+
setPressing(true)
|
|
211
|
+
try {
|
|
212
|
+
const answer = await press(action, controller?.signal)
|
|
213
|
+
const next = answer ?? (await readState())
|
|
214
|
+
if (mounted.current) {
|
|
215
|
+
if (next !== null) {
|
|
216
|
+
seq.current += 1
|
|
217
|
+
setState({ ...EMPTY, ...next })
|
|
218
|
+
} else {
|
|
219
|
+
// The press may still have landed: ask again rather than guessing the phase.
|
|
220
|
+
void pull(controller?.signal)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
inflight.current = null
|
|
225
|
+
if (mounted.current) setPressing(false)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const style = busy || disabled
|
|
230
|
+
? { ...BUTTON_STYLE, opacity: 0.4 }
|
|
231
|
+
: hover
|
|
232
|
+
? { ...BUTTON_STYLE, opacity: 1, background: 'var(--dsh-color-surface-hover, rgba(127,127,127,0.14))' }
|
|
233
|
+
: BUTTON_STYLE
|
|
234
|
+
|
|
235
|
+
const glyph = busy ? SPIN_GLYPH : action === 'resume' ? PLAY_GLYPH : PAUSE_GLYPH
|
|
236
|
+
const label = busy ? COPY.busy : action === 'resume' ? COPY.resume : COPY.pause
|
|
237
|
+
const hint = hintOf(state)
|
|
238
|
+
|
|
239
|
+
return h('button', {
|
|
240
|
+
type: 'button', // ui-copy-ignore
|
|
241
|
+
style,
|
|
242
|
+
onClick,
|
|
243
|
+
disabled,
|
|
244
|
+
'aria-label': label,
|
|
245
|
+
'aria-busy': busy,
|
|
246
|
+
title: hint === '' ? label : `${label} · ${hint}`,
|
|
247
|
+
onMouseEnter: () => setHover(true),
|
|
248
|
+
onMouseLeave: () => setHover(false),
|
|
249
|
+
}, glyph, hint === '' ? null : h('span', { style: DOT_STYLE, 'aria-hidden': true }))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
inject: ['slots'], // ui-copy-ignore
|
|
254
|
+
apply(ctx) {
|
|
255
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
256
|
+
name: 'conversation.session.header.utilities',
|
|
257
|
+
id: 'dsh-pause-resume', // ui-copy-ignore
|
|
258
|
+
order: 31, // ui-copy-ignore
|
|
259
|
+
label: () => COPY.pause, // ui-copy-ignore
|
|
260
|
+
}, Button))
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
})
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Pause and resume">
|
|
2
|
+
<rect x="1" y="1" width="22" height="22" rx="6" fill="#1f6feb"/>
|
|
3
|
+
<rect x="6.5" y="6" width="3.2" height="12" rx="1.2" fill="#ffffff"/>
|
|
4
|
+
<path d="M13.2 6.6v10.8a.6.6 0 0 0 .92.5l8.06-5.4a.7.7 0 0 0 0-1.16l-8.06-5.4a.6.6 0 0 0-.92.5z" fill="#ffffff" fill-opacity="0.55"/>
|
|
5
|
+
<path d="M12.3 6.4v11.2a.5.5 0 0 0 .77.42l5.6-5.6a.6.6 0 0 0 0-.85l-5.6-5.6a.5.5 0 0 0-.77.42z" fill="#ffffff"/>
|
|
6
|
+
</svg>
|