@better-than-coca/dsh-session-handoff 0.0.0-stage → 0.5.3

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/client.js ADDED
@@ -0,0 +1,837 @@
1
+ /**
2
+ * Client half of dsh-session-handoff: one icon button in the assistant message
3
+ * action row, the row that already carries copy / rating / branch / usage.
4
+ *
5
+ * The button only asks; it never summarizes in the browser. Clicking it POSTs to
6
+ * this plugin's Host half, which decides between "run now" (idle session) and
7
+ * "queue" (mid-turn), using exactly the same rules as the session_handoff tool.
8
+ *
9
+ * The row is rendered once per finalized assistant message, so every button
10
+ * shares one snapshot store and one refresh loop instead of polling per row.
11
+ *
12
+ * Contract with the Host half:
13
+ * GET /api/dsh-session-handoff/status?sessionId=... -> status payload
14
+ * POST /api/dsh-session-handoff/handoff { sessionId } -> { ok, running|queued }
15
+ */
16
+ window.__ModuleLoader__.load({
17
+ id: '@better-than-coca/dsh-session-handoff',
18
+ factory(require) {
19
+ const React = require('react')
20
+ const h = React.createElement
21
+
22
+ const STATUS_URL = '/api/dsh-session-handoff/status'
23
+ const HANDOFF_URL = '/api/dsh-session-handoff/handoff'
24
+ /** How often an accepted handoff is re-checked, and its budget. */
25
+ const POLL_MS = 2000
26
+ const POLL_TICKS = 90
27
+ /** Idle re-read cadence so the dot appears without a click. */
28
+ const IDLE_REFRESH_MS = 20000
29
+
30
+ const TEXT = {
31
+ zh: {
32
+ idle: '交接上下文',
33
+ attention: '上下文 {p}% · 点这里交接',
34
+ busy: '正在生成摘要…',
35
+ done: '摘要已就绪 · 开新对话即可接收',
36
+ dryRun: '试运行:未做任何改动',
37
+ refused: '暂时不能交接:{e}',
38
+ failed: '交接失败:{e}',
39
+ off: '插件已关闭',
40
+ dormant: '重启后这个会话还没跑过一轮,先在这里发一句话再点',
41
+ summarizing: '正在生成摘要…',
42
+ opening: '摘要好了,正在开新会话…',
43
+ opened: '已在新会话继续',
44
+ manual: '摘要已就绪,请新建对话取走',
45
+ unavailable: '宿主没提供新建会话的通道,请手动新建对话',
46
+ 'no-cwd': '拿不到工作目录,请手动新建对话',
47
+ slow: '新会话还没开出来,稍后重开对话就能取走',
48
+ 'create-failed': '新会话没建成:{e}',
49
+ 'prompt-failed': '新会话建好了,但第一条消息没发出去:{e}'
50
+ },
51
+ en: {
52
+ idle: 'Hand off context',
53
+ attention: 'Context at {p}% · click to hand off',
54
+ busy: 'Summarizing…',
55
+ done: 'Checkpoint ready · open a new chat to receive it',
56
+ dryRun: 'Dry run: nothing changed',
57
+ refused: 'Cannot hand off: {e}',
58
+ failed: 'Handoff failed: {e}',
59
+ off: 'Plugin is off',
60
+ dormant: 'No turn has run in this chat since the host started — send one message, then click',
61
+ summarizing: 'Summarizing…',
62
+ opening: 'Checkpoint ready, opening a new session…',
63
+ opened: 'Continued in a new session',
64
+ manual: 'Checkpoint ready — open a new chat to receive it',
65
+ unavailable: 'This host offers no session channel — start a new chat yourself',
66
+ 'no-cwd': 'No working directory to open a session in — start one yourself',
67
+ slow: 'The new session has not appeared yet; a new chat will still pick the checkpoint up',
68
+ 'create-failed': 'Could not create the new session: {e}',
69
+ 'prompt-failed': 'Session created, but the first message was not accepted: {e}'
70
+ }
71
+ }
72
+
73
+ const lang = () => {
74
+ const raw = (typeof document !== 'undefined' && document.documentElement.lang) || navigator.language || 'en'
75
+ return String(raw).toLowerCase().startsWith('zh') ? 'zh' : 'en'
76
+ }
77
+ const fill = (template, params) => {
78
+ let out = template
79
+ for (const key of Object.keys(params || {})) out = out.split('{' + key + '}').join(String(params[key]))
80
+ return out
81
+ }
82
+
83
+ /** Turn one follow-up outcome into the label the button shows. */
84
+ const noteFor = followUp => {
85
+ const dict = TEXT[lang()]
86
+ if (followUp === null || followUp === undefined) return dict.slow
87
+ const template = dict[followUp.code] || dict.failed
88
+ return followUp.detail === undefined ? template : fill(template, { e: followUp.detail })
89
+ }
90
+
91
+ // ----------------------------------------------------------------- store
92
+ const store = { snapshot: null, listeners: new Set(), inflight: null, pollTimer: null, ticking: false }
93
+
94
+ const emit = () => {
95
+ for (const listener of [...store.listeners]) {
96
+ try {
97
+ listener(store.snapshot)
98
+ } catch {
99
+ /* one bad subscriber must not stop the others */
100
+ }
101
+ }
102
+ }
103
+
104
+ const refresh = sessionId => {
105
+ if (store.inflight !== null) return store.inflight
106
+ const url = sessionId === undefined || sessionId === null || sessionId === ''
107
+ ? STATUS_URL
108
+ : STATUS_URL + '?sessionId=' + encodeURIComponent(String(sessionId))
109
+ store.inflight = fetch(url, { headers: { accept: 'application/json' } })
110
+ .then(response => response.json())
111
+ .then(body => {
112
+ if (body && body.ok === true) {
113
+ store.snapshot = body
114
+ emit()
115
+ }
116
+ return body
117
+ })
118
+ .catch(() => null)
119
+ .finally(() => {
120
+ store.inflight = null
121
+ })
122
+ return store.inflight
123
+ }
124
+
125
+ const stopPolling = () => {
126
+ if (store.pollTimer !== null) {
127
+ clearInterval(store.pollTimer)
128
+ store.pollTimer = null
129
+ }
130
+ }
131
+
132
+ const startPolling = sessionId => {
133
+ stopPolling()
134
+ let ticks = 0
135
+ store.pollTimer = setInterval(() => {
136
+ ticks += 1
137
+ const done = store.snapshot !== null && store.snapshot.running !== true
138
+ && store.snapshot.pending === 'waiting'
139
+ if (done || ticks > POLL_TICKS) {
140
+ stopPolling()
141
+ return
142
+ }
143
+ void refresh(sessionId)
144
+ }, POLL_MS)
145
+ }
146
+
147
+ // ------------------------------------------------- follow-up session
148
+ /**
149
+ * Navigation service the Host exposes to Client plugins. Captured only when
150
+ * it resolves: switching to the new Session is a convenience — the Session
151
+ * itself is created by the host half, not here.
152
+ */
153
+ let workspace = null
154
+
155
+ const captureServices = ctx => {
156
+ try {
157
+ ctx.inject(['uiWorkspace'], scoped => {
158
+ workspace = scoped.get('uiWorkspace') ?? scoped.uiWorkspace ?? null
159
+ })
160
+ } catch {
161
+ /* no session navigation service */
162
+ }
163
+ }
164
+
165
+ /** How long the button watches for the host's follow-up Session. */
166
+ const FOLLOW_UP_WAIT_MS = 240000
167
+ const FOLLOW_UP_TICK_MS = 900
168
+
169
+ const sleep = ms => new Promise(resolve => {
170
+ setTimeout(resolve, ms)
171
+ })
172
+
173
+ const statusNow = sessionId => {
174
+ const url = sessionId === undefined || sessionId === null || sessionId === ''
175
+ ? STATUS_URL
176
+ : STATUS_URL + '?sessionId=' + encodeURIComponent(String(sessionId))
177
+ return fetch(url, { headers: { accept: 'application/json' } })
178
+ .then(response => response.json())
179
+ .catch(() => null)
180
+ }
181
+
182
+ /**
183
+ * Wait for the host half to report how the follow-up Session ended.
184
+ * @param sessionId - the session being handed off.
185
+ * @returns the outcome record, or null when it never arrived.
186
+ */
187
+ const waitForFollowUp = async sessionId => {
188
+ const until = Date.now() + FOLLOW_UP_WAIT_MS
189
+ while (Date.now() < until) {
190
+ const status = await statusNow(sessionId)
191
+ const followUp = status === null || status === undefined ? null : status.followUp
192
+ if (followUp !== null && followUp !== undefined && typeof followUp.code === 'string') return followUp
193
+ await sleep(FOLLOW_UP_TICK_MS)
194
+ }
195
+ return null
196
+ }
197
+
198
+ /** Switch to the Session the host opened, when navigation is available. */
199
+ const revealSession = sessionId => {
200
+ if (workspace === null || typeof workspace.openSession !== 'function') return
201
+ try {
202
+ workspace.openSession(sessionId)
203
+ } catch {
204
+ /* the sidebar lists the new Session either way */
205
+ }
206
+ }
207
+
208
+ function useSnapshot() {
209
+ const [snapshot, setSnapshot] = React.useState(store.snapshot)
210
+ React.useEffect(() => {
211
+ const listener = value => setSnapshot(value)
212
+ store.listeners.add(listener)
213
+ return () => {
214
+ store.listeners.delete(listener)
215
+ }
216
+ }, [])
217
+ return snapshot
218
+ }
219
+
220
+ // ------------------------------------------------------------------ icon
221
+ const svg = (children, extra) => h('svg', {
222
+ viewBox: '0 0 24 24',
223
+ fill: 'none',
224
+ stroke: 'currentColor',
225
+ strokeWidth: 1.6,
226
+ strokeLinecap: 'round',
227
+ strokeLinejoin: 'round',
228
+ 'aria-hidden': 'true',
229
+ ...extra
230
+ }, children)
231
+
232
+ /** Candidate A: old session box -> arrow -> new session box. */
233
+ const HANDOFF_ICON = svg([
234
+ h('rect', { key: 'a', x: 2.5, y: 5.5, width: 7, height: 13, rx: 1.6 }),
235
+ h('path', { key: 'b', d: 'M12.2 12h6.6' }),
236
+ h('path', { key: 'c', d: 'M16.3 9.6 18.9 12l-2.6 2.4' }),
237
+ h('rect', { key: 'd', x: 20, y: 8.5, width: 1.6, height: 7, rx: 0.8, fill: 'currentColor', stroke: 'none' })
238
+ ])
239
+
240
+ const CHECK_ICON = svg([h('path', { key: 'a', d: 'M4 12.5 9.5 18 20 6.5' })], { strokeWidth: 1.8 })
241
+
242
+ const SPINNER_ICON = svg([h('path', { key: 'a', d: 'M12 3a9 9 0 1 1-9 9' })], { className: 'dsh-handoff-spin' })
243
+
244
+ // ------------------------------------------------------------------- css
245
+ const CSS = [
246
+ '.dsh-handoff-btn{position:relative;width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));padding:6px;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-tertiary,#8f8f88);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}',
247
+ '.dsh-handoff-btn svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px));display:block}',
248
+ '.dsh-handoff-btn:hover{background:var(--dsw-alias-fill-l1,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#5c5c57)}',
249
+ '.dsh-handoff-btn:focus-visible{outline:2px solid var(--dsw-alias-line-focus,#7aa7d8);outline-offset:1px}',
250
+ '.dsh-handoff-btn.is-attention{color:#b8862b}',
251
+ '.dsh-handoff-btn.is-done{color:#4f8a6d}',
252
+ '.dsh-handoff-btn.is-busy{cursor:progress}',
253
+ '.dsh-handoff-dot{position:absolute;top:5px;right:5px;width:5px;height:5px;border-radius:999px;background:#d98a2b}',
254
+ '.dsh-handoff-btn[data-tip]:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#2a2a27;color:#fff;font-size:11.5px;line-height:1.45;padding:4px 8px;border-radius:5px;white-space:nowrap;pointer-events:none;z-index:60}',
255
+ '@keyframes dsh-handoff-spin{to{transform:rotate(360deg)}}',
256
+ '.dsh-handoff-spin{animation:dsh-handoff-spin .9s linear infinite;transform-origin:center}'
257
+ ].join('\n')
258
+
259
+
260
+ // ------------------------------------------------------------ settings
261
+ const SETTINGS_URL = '/api/dsh-session-handoff/settings'
262
+ const PANEL_ID = 'session-handoff-panel'
263
+
264
+ const COPY = {
265
+ zh: {
266
+ title: '会话交接',
267
+ blurb: '上下文到阈值时把这一会话摘要出来,交给一个新会话接着做。旧会话永远不会被压缩或改动。',
268
+ live: '本会话上下文',
269
+ close: '关闭',
270
+ secTrigger: '触发',
271
+ secTriggerNote: '什么时候提醒你,要不要自动做',
272
+ secBehaviour: '行为',
273
+ secBehaviourNote: '交接本身怎么执行',
274
+ save: '已保存',
275
+ reset: '恢复默认',
276
+ ready: '可交接',
277
+ waiting: '没有等待中的摘要',
278
+ last: '最近一次',
279
+ label: '会话交接',
280
+ hint: {
281
+ mode: '询问=到阈值时提醒你;自动=达到自动阈值直接交接;关闭=只保留消息行里的手动按钮',
282
+ askRatio: '上下文占用超过这个比例就提醒你一次',
283
+ autoRatio: '必须高于询问阈值,否则自动交接会抢在提醒之前发生',
284
+ noticeCooldownMs: '同一会话里两次提醒之间至少隔这么久,避免反复打断',
285
+ dryRun: '只报告会做什么,不真的调用摘要模型',
286
+ maxHandoffsPerSession: '防止一个会话被反反复复地交接出去',
287
+ includeHistoryTool: '给模型 handoff_history,可以只读搜索旧会话日志',
288
+ autoOpenSession: '交接完成后自动开一个新会话、注入摘要并切过去'
289
+ },
290
+ name: {
291
+ mode: '模式', askRatio: '询问阈值', autoRatio: '自动阈值', noticeCooldownMs: '提醒冷却',
292
+ dryRun: '试运行', maxHandoffsPerSession: '每个会话最多交接次数', includeHistoryTool: '提供原文检索工具',
293
+ autoOpenSession: '自动开新会话'
294
+ },
295
+ modes: { ask: '询问', auto: '自动', off: '关闭' },
296
+ minute: '分'
297
+ },
298
+ en: {
299
+ title: 'Session handoff',
300
+ blurb: 'Summarize this session at a threshold and continue in a new one. The old session is never compacted or modified.',
301
+ live: 'This session',
302
+ close: 'Close',
303
+ secTrigger: 'Trigger',
304
+ secTriggerNote: 'when to ask, and whether to act alone',
305
+ secBehaviour: 'Behaviour',
306
+ secBehaviourNote: 'how the handoff itself runs',
307
+ save: 'Saved',
308
+ reset: 'Reset to defaults',
309
+ ready: 'Eligible',
310
+ waiting: 'No waiting checkpoint',
311
+ last: 'Last one',
312
+ label: 'Session handoff',
313
+ hint: {
314
+ mode: 'ask = check in at the threshold; auto = hand off at the auto threshold; off = keep only the manual button',
315
+ askRatio: 'How full the context must get before you are asked',
316
+ autoRatio: 'Must stay above the ask threshold, or auto mode would act before asking',
317
+ noticeCooldownMs: 'Minimum gap between two notices in one session',
318
+ dryRun: 'Report what would happen without calling the summarizer',
319
+ maxHandoffsPerSession: 'Stops one session being handed off over and over',
320
+ includeHistoryTool: 'Give the model handoff_history to search the old log read-only',
321
+ autoOpenSession: 'After the summary, open a new session, inject it there and switch over'
322
+ },
323
+ name: {
324
+ mode: 'Mode', askRatio: 'Ask threshold', autoRatio: 'Auto threshold', noticeCooldownMs: 'Notice cooldown',
325
+ dryRun: 'Dry run', maxHandoffsPerSession: 'Max handoffs per session', includeHistoryTool: 'Archive search tool',
326
+ autoOpenSession: 'Open the new session'
327
+ },
328
+ modes: { ask: 'Ask', auto: 'Auto', off: 'Off' },
329
+ minute: 'min'
330
+ }
331
+ }
332
+
333
+ const PANEL_CSS = [
334
+ '.dsh-ho-panel{height:100%;display:flex;flex-direction:column;background:#fff;color:#1c1c1a;font-size:13px;line-height:1.5;--ho-ink:#1c1c1a;--ho-ink2:#57574f;--ho-ink3:#8b8b82;--ho-ink4:#a8a89f;--ho-line:#e6e5df;--ho-line-soft:#efeee9;--ho-sunken:#faf9f6;--ho-hover:#f4f3ee;--ho-accent:#6b7f72;--ho-accent-soft:#eef2ef;--ho-ok:#4f8a6d}',
335
+ '.dsh-ho-scroll{flex:1;min-height:0;overflow:auto}',
336
+ '.dsh-ho-head{padding:16px 26px 13px;border-bottom:1px solid var(--ho-line-soft)}',
337
+ '.dsh-ho-title{display:flex;align-items:center;gap:9px}',
338
+ '.dsh-ho-title h1{font-size:15px;font-weight:600;letter-spacing:-.01em;margin:0}',
339
+ '.dsh-ho-badge{font-size:10.5px;font-weight:500;letter-spacing:.03em;color:var(--ho-accent);background:var(--ho-accent-soft);border-radius:4px;padding:2px 6px}',
340
+ '.dsh-ho-x{margin-left:auto;width:26px;height:26px;border:0;background:none;border-radius:6px;color:var(--ho-ink3);cursor:pointer;display:flex;align-items:center;justify-content:center}',
341
+ '.dsh-ho-x:hover{background:var(--ho-hover);color:var(--ho-ink)}',
342
+ '.dsh-ho-blurb{margin:4px 0 0;font-size:12px;color:var(--ho-ink3);max-width:56ch}',
343
+ '.dsh-ho-live{padding:13px 26px 15px;background:var(--ho-sunken);border-bottom:1px solid var(--ho-line-soft)}',
344
+ '.dsh-ho-liverow{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}',
345
+ '.dsh-ho-livelab{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ho-ink3)}',
346
+ '.dsh-ho-liveval{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-weight:600;font-variant-numeric:tabular-nums}',
347
+ '.dsh-ho-liveval small{font-weight:400;color:var(--ho-ink4);font-family:inherit}',
348
+ '.dsh-ho-meter{position:relative;height:8px;border-radius:4px;background:#e9e8e2}',
349
+ '.dsh-ho-fill{position:absolute;inset:0 auto 0 0;border-radius:4px;background:linear-gradient(90deg,#82968a,#6b7f72)}',
350
+ '.dsh-ho-tick{position:absolute;top:-3px;bottom:-3px;width:1.5px;background:var(--ho-ink4);border-radius:1px}',
351
+ '.dsh-ho-tickcap{position:absolute;top:13px;font-size:10.5px;color:var(--ho-ink3);transform:translateX(-50%);white-space:nowrap}',
352
+ '.dsh-ho-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:26px}',
353
+ '.dsh-ho-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--ho-ink2);background:#fff;border:1px solid var(--ho-line);border-radius:99px;padding:3px 9px}',
354
+ '.dsh-ho-chip b{font-weight:500;font-variant-numeric:tabular-nums;color:var(--ho-ink)}',
355
+ '.dsh-ho-dot{width:6px;height:6px;border-radius:99px;background:var(--ho-ink4)}',
356
+ '.dsh-ho-dot.on{background:var(--ho-ok)}',
357
+ '.dsh-ho-body{padding:4px 26px 16px}',
358
+ '.dsh-ho-sec{padding:15px 0}',
359
+ '.dsh-ho-sec + .dsh-ho-sec{border-top:1px solid var(--ho-line-soft)}',
360
+ '.dsh-ho-sechead{display:flex;align-items:baseline;gap:8px;margin-bottom:2px}',
361
+ '.dsh-ho-sechead h2{font-size:11px;font-weight:600;letter-spacing:.07em;color:var(--ho-ink3);margin:0;text-transform:uppercase}',
362
+ '.dsh-ho-sechead span{font-size:11px;color:var(--ho-ink4)}',
363
+ '.dsh-ho-field{padding:9px 0}',
364
+ '.dsh-ho-fieldtop{display:flex;align-items:baseline;gap:12px}',
365
+ '.dsh-ho-name{font-size:13px;font-weight:500;flex:1;min-width:0}',
366
+ '.dsh-ho-hint{font-size:11.5px;color:var(--ho-ink3);margin-top:1px}',
367
+ '.dsh-ho-out{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-variant-numeric:tabular-nums;background:var(--ho-sunken);border:1px solid var(--ho-line);border-radius:5px;padding:1px 7px;min-width:50px;text-align:center}',
368
+ '.dsh-ho-inline{display:flex;align-items:center;gap:12px}',
369
+ '.dsh-ho-inline .dsh-ho-txt{flex:1;min-width:0}',
370
+ '.dsh-ho-seg{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;background:var(--ho-sunken);border:1px solid var(--ho-line);border-radius:8px;padding:3px;margin-top:8px}',
371
+ '.dsh-ho-seg button{border:0;background:none;font-family:inherit;font-size:12.5px;color:var(--ho-ink2);padding:6px 0;border-radius:6px;cursor:pointer}',
372
+ '.dsh-ho-seg button:hover{color:var(--ho-ink)}',
373
+ '.dsh-ho-seg button[aria-pressed=true]{background:#fff;color:var(--ho-ink);font-weight:500;box-shadow:0 1px 2px rgba(28,28,26,.1),0 0 0 1px rgba(28,28,26,.05)}',
374
+ '.dsh-ho-range{appearance:none;-webkit-appearance:none;width:100%;height:18px;background:transparent;margin:7px 0 0;display:block;cursor:pointer}',
375
+ '.dsh-ho-range::-webkit-slider-runnable-track{height:5px;border-radius:3px;background:linear-gradient(90deg,#8c9a92 0%,#8c9a92 var(--p,50%),#e9e8e2 var(--p,50%),#e9e8e2 100%)}',
376
+ '.dsh-ho-range::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:99px;background:#fff;border:1.5px solid #6b7f72;box-shadow:0 1px 3px rgba(28,28,26,.22);margin-top:-4.5px}',
377
+ '.dsh-ho-range::-moz-range-track{height:5px;border-radius:3px;background:#e9e8e2}',
378
+ '.dsh-ho-range::-moz-range-thumb{width:13px;height:13px;border-radius:99px;background:#fff;border:1.5px solid #6b7f72}',
379
+ '.dsh-ho-tg{width:34px;height:19px;border-radius:99px;background:#dcdbd4;border:0;padding:0;cursor:pointer;position:relative;flex:0 0 auto;transition:background .14s}',
380
+ '.dsh-ho-tg i{position:absolute;top:2.5px;left:2.5px;width:14px;height:14px;border-radius:99px;background:#fff;box-shadow:0 1px 2px rgba(28,28,26,.25);transition:left .14s}',
381
+ '.dsh-ho-tg[aria-checked=true]{background:#7d9186}',
382
+ '.dsh-ho-tg[aria-checked=true] i{left:17.5px}',
383
+ '.dsh-ho-steps{display:inline-flex;align-items:center;border:1px solid var(--ho-line);border-radius:6px;background:#fff;overflow:hidden}',
384
+ '.dsh-ho-steps button{width:27px;height:26px;border:0;background:none;color:var(--ho-ink2);cursor:pointer;font-size:14px;line-height:1;font-family:inherit}',
385
+ '.dsh-ho-steps button:hover:not(:disabled){background:var(--ho-hover)}',
386
+ '.dsh-ho-steps button:disabled{color:var(--ho-ink4);cursor:not-allowed}',
387
+ '.dsh-ho-steps b{min-width:30px;text-align:center;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-weight:400;border-left:1px solid var(--ho-line-soft);border-right:1px solid var(--ho-line-soft);line-height:26px}',
388
+ '.dsh-ho-foot{display:flex;align-items:center;gap:12px;padding:10px 26px;border-top:1px solid var(--ho-line-soft);background:var(--ho-sunken)}',
389
+ '.dsh-ho-saved{font-size:11.5px;color:var(--ho-ok);opacity:0;transition:opacity .15s}',
390
+ '.dsh-ho-saved.show{opacity:1}',
391
+ '.dsh-ho-path{flex:1;font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--ho-ink4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
392
+ '.dsh-ho-btn{font-family:inherit;font-size:11.5px;color:var(--ho-ink2);background:#fff;border:1px solid var(--ho-line);border-radius:6px;padding:5px 11px;cursor:pointer}',
393
+ '.dsh-ho-btn:hover{background:var(--ho-hover);color:var(--ho-ink)}'
394
+ ].join('\n')
395
+
396
+ const injectCss = () => {
397
+ if (typeof document === 'undefined') return
398
+ if (document.getElementById('dsh-handoff-style') !== null) return
399
+ const style = document.createElement('style')
400
+ style.id = 'dsh-handoff-style'
401
+ style.textContent = CSS + '\n' + PANEL_CSS
402
+ document.head.appendChild(style)
403
+ }
404
+
405
+ // -------------------------------------------------------------- component
406
+ const toneOf = snapshot => {
407
+ if (snapshot === null) return 'idle'
408
+ if (snapshot.mode === 'off') return 'off'
409
+ if (snapshot.running === true) return 'busy'
410
+ if (snapshot.pending === 'waiting') return 'done'
411
+ const ratio = snapshot.pressure && typeof snapshot.pressure.ratio === 'number' ? snapshot.pressure.ratio : 0
412
+ const ask = typeof snapshot.askRatio === 'number' ? snapshot.askRatio : 0.55
413
+ return ratio >= ask ? 'attention' : 'idle'
414
+ }
415
+
416
+ const percentOf = snapshot => {
417
+ const ratio = snapshot && snapshot.pressure && typeof snapshot.pressure.ratio === 'number' ? snapshot.pressure.ratio : 0
418
+ return Math.round(ratio * 100)
419
+ }
420
+
421
+ function HandoffAction(props) {
422
+ const sessionId = props.sessionId
423
+ const snapshot = useSnapshot()
424
+ const tone = toneOf(snapshot)
425
+ const [note, setNote] = React.useState(null)
426
+ const clicking = React.useRef(false)
427
+
428
+ // One idle refresh loop for the whole row set; the dot should appear on its
429
+ // own without the user hovering or clicking anything.
430
+ React.useEffect(() => {
431
+ void refresh(sessionId)
432
+ const timer = setInterval(() => {
433
+ if (store.pollTimer === null) void refresh(sessionId)
434
+ }, IDLE_REFRESH_MS)
435
+ return () => clearInterval(timer)
436
+ }, [sessionId])
437
+
438
+ React.useEffect(() => () => stopPolling(), [])
439
+
440
+ const dict = TEXT[lang()]
441
+ let label
442
+ if (note !== null) label = note
443
+ else if (tone === 'attention') label = fill(dict.attention, { p: percentOf(snapshot) })
444
+ else label = dict[tone] || dict.idle
445
+
446
+ const onClick = () => {
447
+ if (clicking.current || tone === 'busy' || tone === 'off') return
448
+ clicking.current = true
449
+ const payload = sessionId === undefined || sessionId === null || sessionId === ''
450
+ ? {}
451
+ : { sessionId: String(sessionId) }
452
+ fetch(HANDOFF_URL, {
453
+ method: 'POST',
454
+ headers: { 'content-type': 'application/json' },
455
+ body: JSON.stringify(payload)
456
+ })
457
+ .then(response => response.json())
458
+ .then(body => {
459
+ const d = TEXT[lang()]
460
+ if (body && body.ok === true) {
461
+ if (body.dryRun === true) setNote(d.dryRun)
462
+ else if (body.running === true || body.queued === true) {
463
+ startPolling(sessionId)
464
+ if (body.autoOpen === false) {
465
+ setNote(null)
466
+ } else {
467
+ setNote(d.summarizing)
468
+ void waitForFollowUp(sessionId).then(followUp => {
469
+ if (followUp !== null && followUp !== undefined && followUp.code === 'opened'
470
+ && typeof followUp.sessionId === 'string') {
471
+ revealSession(followUp.sessionId)
472
+ }
473
+ setNote(noteFor(followUp))
474
+ void refresh(sessionId)
475
+ })
476
+ }
477
+ }
478
+ } else if (body && body.code === 'session-dormant') {
479
+ // The Session is real but its live surface is not loaded yet: the
480
+ // host creates an Agent on the first turn, and only then can the
481
+ // summarizer replay it.
482
+ setNote(d.dormant)
483
+ } else {
484
+ const reason = body && (body.error || body.code) ? String(body.error || body.code) : 'unknown'
485
+ setNote(fill(d.refused, { e: reason }))
486
+ }
487
+ void refresh(sessionId)
488
+ })
489
+ .catch(error => {
490
+ setNote(fill(TEXT[lang()].failed, { e: error instanceof Error ? error.message : String(error) }))
491
+ })
492
+ .finally(() => {
493
+ clicking.current = false
494
+ })
495
+ }
496
+
497
+ const className = 'dsh-handoff-btn' + (tone === 'idle' ? '' : ' is-' + tone)
498
+ return h('button', {
499
+ type: 'button',
500
+ className,
501
+ 'data-tip': label,
502
+ 'aria-label': label,
503
+ 'data-handoff-state': tone,
504
+ onClick
505
+ },
506
+ tone === 'busy' ? SPINNER_ICON : tone === 'done' ? CHECK_ICON : HANDOFF_ICON,
507
+ tone === 'attention' ? h('span', { className: 'dsh-handoff-dot' }) : null
508
+ )
509
+ }
510
+
511
+
512
+ // ------------------------------------------------------- settings panel
513
+ const copy = () => COPY[lang()] || COPY.en
514
+
515
+ const ratioText = value => Math.round(value * 100) + '%'
516
+
517
+ const durationText = value => {
518
+ const minutes = Math.round(value / 60000)
519
+ return minutes + ' ' + copy().minute
520
+ }
521
+
522
+ const barrier = (value, control) => {
523
+ const span = control.max - control.min
524
+ if (!(span > 0)) return '0%'
525
+ return ((value - control.min) / span * 100).toFixed(1) + '%'
526
+ }
527
+
528
+ const svgPanel = (children, extra) => h('svg', {
529
+ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6,
530
+ strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', ...extra
531
+ }, children)
532
+
533
+ function SidebarIcon({ size }) {
534
+ return h('span', { style: { display: 'inline-flex' } },
535
+ h('svg', {
536
+ viewBox: '0 0 24 24', width: size || 17, height: size || 17, fill: 'none',
537
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round'
538
+ },
539
+ h('rect', { key: 'a', x: 2.5, y: 5.5, width: 7, height: 13, rx: 1.6 }),
540
+ h('path', { key: 'b', d: 'M12.2 12h6.6' }),
541
+ h('path', { key: 'c', d: 'M16.3 9.6 18.9 12l-2.6 2.4' }),
542
+ h('rect', { key: 'd', x: 20, y: 8.5, width: 1.6, height: 7, rx: 0.8, fill: 'currentColor', stroke: 'none' })
543
+ )
544
+ )
545
+ }
546
+
547
+ function SettingsPanel(props) {
548
+ const c = copy()
549
+ const [data, setData] = React.useState(null)
550
+ const [draft, setDraft] = React.useState(null)
551
+ const [saved, setSaved] = React.useState(false)
552
+ const timer = React.useRef(null)
553
+ const snapshot = useSnapshot()
554
+
555
+ React.useEffect(() => {
556
+ let alive = true
557
+ fetch(SETTINGS_URL, { headers: { accept: 'application/json' } })
558
+ .then(response => response.json())
559
+ .then(body => {
560
+ if (!alive || !body || body.ok !== true) return
561
+ setData(body)
562
+ setDraft(body.values)
563
+ })
564
+ .catch(() => {})
565
+ void refresh()
566
+ return () => {
567
+ alive = false
568
+ if (timer.current !== null) clearTimeout(timer.current)
569
+ }
570
+ }, [])
571
+
572
+ const flash = () => {
573
+ setSaved(true)
574
+ if (timer.current !== null) clearTimeout(timer.current)
575
+ timer.current = setTimeout(() => setSaved(false), 1400)
576
+ }
577
+
578
+ const post = body => fetch(SETTINGS_URL, {
579
+ method: 'POST',
580
+ headers: { 'content-type': 'application/json' },
581
+ body: JSON.stringify(body)
582
+ })
583
+ .then(response => response.json())
584
+ .then(payload => {
585
+ if (!payload || payload.ok !== true) return
586
+ setData(payload)
587
+ setDraft(payload.values)
588
+ flash()
589
+ })
590
+ .catch(() => {})
591
+
592
+ const commit = patch => {
593
+ setDraft(current => ({ ...current, ...patch }))
594
+ void post({ values: patch })
595
+ }
596
+
597
+ const close = () => {
598
+ try {
599
+ if (props.close !== undefined) props.close()
600
+ } catch {
601
+ /* a host without the layout service simply keeps the panel open */
602
+ }
603
+ }
604
+
605
+ if (data === null || draft === null) {
606
+ return h('div', { className: 'dsh-ho-panel' },
607
+ h('div', { className: 'dsh-ho-head' }, h('div', { className: 'dsh-ho-title' }, h('h1', null, c.title)))
608
+ )
609
+ }
610
+
611
+ const controls = new Map(data.controls.map(control => [control.key, control]))
612
+ const get = key => (draft[key] === undefined ? controls.get(key).fallback : draft[key])
613
+
614
+ const ratio = snapshot && snapshot.pressure && typeof snapshot.pressure.ratio === 'number'
615
+ ? snapshot.pressure.ratio : null
616
+ const ask = get('askRatio')
617
+ const auto = get('autoRatio')
618
+
619
+ const nameOf = key => (c.name[key] || key)
620
+ const hintOf = key => (c.hint[key] || '')
621
+
622
+ const field = (control, body, inline) => h('div', { className: 'dsh-ho-field', key: control.key },
623
+ inline === true
624
+ ? h('div', { className: 'dsh-ho-inline' },
625
+ h('div', { className: 'dsh-ho-txt' },
626
+ h('div', { className: 'dsh-ho-name' }, nameOf(control.key)),
627
+ h('div', { className: 'dsh-ho-hint' }, hintOf(control.key))
628
+ ),
629
+ body
630
+ )
631
+ : h('div', null,
632
+ h('div', { className: 'dsh-ho-fieldtop' },
633
+ h('span', { className: 'dsh-ho-name' }, nameOf(control.key)),
634
+ h('span', { className: 'dsh-ho-out' }, body.label)
635
+ ),
636
+ h('div', { className: 'dsh-ho-hint' }, hintOf(control.key)),
637
+ body.input
638
+ )
639
+ )
640
+
641
+ const sliderField = control => {
642
+ const value = get(control.key)
643
+ const input = h('input', {
644
+ key: 'r', type: 'range', className: 'dsh-ho-range',
645
+ min: control.min, max: control.max, step: control.step, value,
646
+ 'aria-label': nameOf(control.key),
647
+ style: { '--p': barrier(value, control) },
648
+ onChange: event => setDraft(current => ({ ...current, [control.key]: Number(event.target.value) })),
649
+ onPointerUp: event => commit({ [control.key]: Number(event.target.value) }),
650
+ onKeyUp: event => commit({ [control.key]: Number(event.target.value) })
651
+ })
652
+ const label = control.kind === 'duration' ? durationText(value) : ratioText(value)
653
+ return field(control, { label, input })
654
+ }
655
+
656
+ const modeField = control => {
657
+ const value = get(control.key)
658
+ const seg = h('div', { className: 'dsh-ho-seg', role: 'group', 'aria-label': nameOf(control.key) },
659
+ control.options.map(option => h('button', {
660
+ key: option, type: 'button',
661
+ 'aria-pressed': value === option,
662
+ onClick: () => commit({ [control.key]: option })
663
+ }, c.modes[option] || option))
664
+ )
665
+ return h('div', { className: 'dsh-ho-field', key: control.key },
666
+ h('div', { className: 'dsh-ho-name' }, nameOf(control.key)),
667
+ h('div', { className: 'dsh-ho-hint' }, hintOf(control.key)),
668
+ seg
669
+ )
670
+ }
671
+
672
+ const toggleField = control => {
673
+ const value = get(control.key) === true
674
+ return field(control, h('button', {
675
+ key: 't', type: 'button', className: 'dsh-ho-tg', role: 'switch',
676
+ 'aria-checked': value, 'aria-label': nameOf(control.key),
677
+ onClick: () => commit({ [control.key]: !value })
678
+ }, h('i', null)), true)
679
+ }
680
+
681
+ const countField = control => {
682
+ const value = get(control.key)
683
+ return field(control, h('div', { className: 'dsh-ho-steps' },
684
+ h('button', {
685
+ key: 'm', type: 'button', disabled: value <= control.min,
686
+ 'aria-label': '−', onClick: () => commit({ [control.key]: Math.max(control.min, value - 1) })
687
+ }, '−'),
688
+ h('b', { key: 'v' }, String(value)),
689
+ h('button', {
690
+ key: 'p', type: 'button', disabled: value >= control.max,
691
+ 'aria-label': '+', onClick: () => commit({ [control.key]: Math.min(control.max, value + 1) })
692
+ }, '+')
693
+ ), true)
694
+ }
695
+
696
+ const renderControl = key => {
697
+ const control = controls.get(key)
698
+ if (control === undefined) return null
699
+ if (control.kind === 'enum') return modeField(control)
700
+ if (control.kind === 'boolean') return toggleField(control)
701
+ if (control.kind === 'count') return countField(control)
702
+ return sliderField(control)
703
+ }
704
+
705
+ const last = snapshot && snapshot.lastHandoff
706
+
707
+ return h('div', { className: 'dsh-ho-panel' },
708
+ h('div', { className: 'dsh-ho-scroll' },
709
+ h('div', { className: 'dsh-ho-head' },
710
+ h('div', { className: 'dsh-ho-title' },
711
+ h('h1', null, c.title),
712
+ h('span', { className: 'dsh-ho-badge' }, '0.5.3'),
713
+ h('button', { type: 'button', className: 'dsh-ho-x', 'aria-label': c.close, onClick: close },
714
+ svgPanel([h('path', { key: 'x', d: 'M6 6l12 12M18 6L6 18' })], { strokeWidth: 1.7 }))
715
+ ),
716
+ h('p', { className: 'dsh-ho-blurb' }, c.blurb)
717
+ ),
718
+
719
+ h('div', { className: 'dsh-ho-live' },
720
+ h('div', { className: 'dsh-ho-liverow' },
721
+ h('span', { className: 'dsh-ho-livelab' }, c.live),
722
+ h('span', { className: 'dsh-ho-liveval' },
723
+ ratio === null ? '—' : ratioText(ratio),
724
+ snapshot && snapshot.pressure && snapshot.pressure.text
725
+ ? h('small', null, ' · ' + snapshot.pressure.text.replace(/^[\d.]+%\s*/, ''))
726
+ : null
727
+ )
728
+ ),
729
+ h('div', { className: 'dsh-ho-meter' },
730
+ ratio === null ? null : h('div', { className: 'dsh-ho-fill', style: { width: ratioText(Math.min(1, ratio)) } }),
731
+ h('div', { className: 'dsh-ho-tick', style: { left: ratioText(ask) } }),
732
+ h('div', { className: 'dsh-ho-tickcap', style: { left: ratioText(ask) } }, c.name.askRatio + ' ' + ratioText(ask)),
733
+ h('div', { className: 'dsh-ho-tick', style: { left: ratioText(auto) } }),
734
+ h('div', { className: 'dsh-ho-tickcap', style: { left: ratioText(auto) } }, c.name.autoRatio + ' ' + ratioText(auto))
735
+ ),
736
+ h('div', { className: 'dsh-ho-chips' },
737
+ h('span', { className: 'dsh-ho-chip' },
738
+ h('span', { className: 'dsh-ho-dot' + (snapshot && snapshot.eligible ? ' on' : '') }),
739
+ snapshot && snapshot.eligible ? c.ready : (snapshot && snapshot.reason ? snapshot.reason : '—')
740
+ ),
741
+ h('span', { className: 'dsh-ho-chip' },
742
+ h('span', { className: 'dsh-ho-dot' + (snapshot && snapshot.pending === 'waiting' ? ' on' : '') }),
743
+ snapshot && snapshot.pending === 'waiting' ? '等待新会话取走' : c.waiting
744
+ ),
745
+ last === undefined || last === null
746
+ ? null
747
+ : h('span', { className: 'dsh-ho-chip' }, c.last + ' ',
748
+ h('b', null, String(Math.round(last.ratio || 0)) + ' : 1'),
749
+ last.summaryTokens ? ' · ' + last.summaryTokens + ' tokens' : '')
750
+ )
751
+ ),
752
+
753
+ h('div', { className: 'dsh-ho-body' },
754
+ h('div', { className: 'dsh-ho-sec' },
755
+ h('div', { className: 'dsh-ho-sechead' },
756
+ h('h2', null, c.secTrigger),
757
+ h('span', null, c.secTriggerNote)
758
+ ),
759
+ renderControl('mode'),
760
+ renderControl('askRatio'),
761
+ renderControl('autoRatio'),
762
+ renderControl('noticeCooldownMs')
763
+ ),
764
+ h('div', { className: 'dsh-ho-sec' },
765
+ h('div', { className: 'dsh-ho-sechead' },
766
+ h('h2', null, c.secBehaviour),
767
+ h('span', null, c.secBehaviourNote)
768
+ ),
769
+ renderControl('dryRun'),
770
+ renderControl('maxHandoffsPerSession'),
771
+ renderControl('includeHistoryTool'),
772
+ renderControl('autoOpenSession')
773
+ )
774
+ )
775
+ ),
776
+ h('div', { className: 'dsh-ho-foot' },
777
+ h('span', { className: 'dsh-ho-saved' + (saved ? ' show' : '') }, '✓ ' + c.save),
778
+ h('span', { className: 'dsh-ho-path' }, data.path),
779
+ h('button', {
780
+ type: 'button', className: 'dsh-ho-btn',
781
+ onClick: () => { void post({ reset: true }) }
782
+ }, c.reset)
783
+ )
784
+ )
785
+ }
786
+
787
+ return {
788
+ inject: ['slots'],
789
+ apply(ctx) {
790
+ injectCss()
791
+ // Capture the Host services the follow-up session needs; the button still
792
+ // works (manually) when they are absent.
793
+ captureServices(ctx)
794
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
795
+ name: 'conversation.chat.assistant-actions',
796
+ id: 'handoff',
797
+ order: 20,
798
+ inject: sessionId => ({ sessionId })
799
+ }, HandoffAction))
800
+
801
+ // The settings page is a sidebar panel of its own, next to the Memory
802
+ // System (order 30) and Skills (order 31). A layout without native panel
803
+ // seats simply never declares these two slots, so nothing is registered.
804
+ const closePanel = () => {
805
+ try {
806
+ if (ctx.layout !== undefined && typeof ctx.layout.selectPanel === 'function') {
807
+ ctx.layout.selectPanel(null)
808
+ }
809
+ } catch {
810
+ /* a host without the layout service keeps the panel open */
811
+ }
812
+ }
813
+ ctx.slots.inject('main', () => ctx.slots.inject('sidebar.panellist', () => {
814
+ const stopMain = ctx.slots.register({
815
+ name: 'main',
816
+ key: PANEL_ID,
817
+ inject: () => ({ close: closePanel })
818
+ }, SettingsPanel)
819
+ const stopIcon = ctx.slots.register({
820
+ name: 'sidebar.panellist',
821
+ id: PANEL_ID,
822
+ order: 32,
823
+ label: () => copy().label
824
+ }, SidebarIcon)
825
+ return () => {
826
+ stopIcon()
827
+ stopMain()
828
+ }
829
+ }))
830
+ },
831
+ HandoffAction,
832
+ SettingsPanel,
833
+ toneOf,
834
+ store
835
+ }
836
+ }
837
+ })