@wjj-8283/dsh-temp-workspace 0.1.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/lib/client.js ADDED
@@ -0,0 +1,827 @@
1
+ // @wjj-8283/dsh-temp-workspace — built by build.mjs from src/client.js; do not edit this file.
2
+ window.__ModuleLoader__.load({
3
+ id: "@wjj-8283/dsh-temp-workspace",
4
+ factory: (require) => {
5
+ var module = { exports: {} };
6
+ var exports = module.exports;
7
+
8
+ // @wjj-8283/dsh-temp-workspace — browser half SOURCE (editable).
9
+ //
10
+ // Three responsibilities:
11
+ // 1. Inject a small icon button beside the sidebar's "Add workspace" (+)
12
+ // button. The workspace section header has no pluggable header-action
13
+ // slot, so (like dsh-workspace-auto-approval) we decorate the live DOM
14
+ // with a MutationObserver — React re-renders drop our node and the
15
+ // observer puts it back.
16
+ // 2. Register a Settings -> Plugins card (settings.plugin.item, keyed by the
17
+ // settings namespace) that lets the user choose delete timing (immediate
18
+ // vs. delayed + a delay) and whether to confirm before deleting.
19
+ // 3. On boot, if there are temporary workspaces held for confirmation and
20
+ // confirmBeforeDelete is on, show a confirm dialog before the host purges
21
+ // them. There is no generic "ask the human" API for a browser plugin, so
22
+ // this renders its own fixed-position overlay via a dedicated
23
+ // react-dom/client root (inline-styled, so it renders regardless of the
24
+ // app's CSS-module scope).
25
+ //
26
+ // Every data read/write rides the plugin's own fenced /temp-workspace/api route
27
+ // (same idiom as dsh-workspace-auto-approval's /config contract). The host
28
+ // holds the authoritative settings; the card just GETs/POSTs them.
29
+ //
30
+ // This file is plain CJS and is wrapped by ./build.mjs into a
31
+ // window.__ModuleLoader__.load({ id, factory }) bundle at ./lib/client.js. In
32
+ // that bundle `require` is the shell module loader, so react/react-dom/client
33
+ // resolve without bundling.
34
+ const react = require('react')
35
+ const reactDOMClient = require('react-dom/client')
36
+
37
+ const SETTINGS_NS = 'dsh-temp-workspace'
38
+
39
+ const ICON_SVG =
40
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none">' +
41
+ '<path d="M6 3h12M6 21h12" />' +
42
+ '<path d="M18 3v3a6 6 0 0 1-6 6 6 6 0 0 1-6-6V3" />' +
43
+ '<path d="M18 21v-3a6 6 0 0 0-6-6 6 6 0 0 0-6 6v3" />' +
44
+ '</svg>'
45
+
46
+ // A "keep/pin" glyph for the permanent-keep row button.
47
+ const KEEP_SVG =
48
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;flex:none">' +
49
+ '<path d="M9 2h6M9 2v7l-3 4h12l-3-4V2" />' +
50
+ '<path d="M12 13v9" />' +
51
+ '</svg>'
52
+
53
+ const ICON_CLASS = 'dsh-temp-workspace-icon'
54
+ const BUTTON_CLASS = 'dsh-temp-workspace-button'
55
+ const ADD_LABELS = ['Add workspace', '添加工作区']
56
+
57
+ // Required cordis services: the browser workspace runtime (to open a New
58
+ // Session in the created workspace), the slot registry (for the settings
59
+ // card), and the locale seat (bilingual copy).
60
+ const inject = ['workspaces', 'slots', 'locale']
61
+
62
+ function apply(ctx) {
63
+ const workspaces = ctx.get ? ctx.get('workspaces') : ctx.workspaces
64
+ const slots = ctx.get ? ctx.get('slots') : ctx.slots
65
+ const locale = ctx.get ? ctx.get('locale') : ctx.locale
66
+
67
+ // Keep the module-level activeLocale in sync so every UI text follows the
68
+ // user's language, and re-render the decorated DOM on a locale switch.
69
+ const syncLocale = () => {
70
+ try { activeLocale = locale && locale.getLocale ? (locale.getLocale().active === 'en' ? 'en' : 'zh') : 'zh' } catch { activeLocale = 'zh' }
71
+ }
72
+ syncLocale()
73
+ if (locale && typeof locale.subscribe === 'function') {
74
+ ctx.effect(() => {
75
+ const off = locale.subscribe(() => {
76
+ syncLocale()
77
+ insertIcon()
78
+ injectRowButtons(true)
79
+ })
80
+ return () => { if (off) off() }
81
+ }, 'dsh-temp-workspace: locale sync')
82
+ }
83
+
84
+ // ── inject card/dialog CSS once ──────────────────────────────────────────
85
+ ctx.effect(() => injectStyle(), 'dsh-temp-workspace: styles')
86
+
87
+ // ── icon injection (unchanged from the first version) ────────────────────
88
+ let inserting = false
89
+ let scheduled = null
90
+
91
+ async function createTempWorkspace() {
92
+ try {
93
+ const res = await fetch('/temp-workspace/api/create', {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json' },
96
+ body: '{}',
97
+ })
98
+ const parsed = (await res.json().catch(() => null)) || {}
99
+ if (!res.ok || parsed.ok !== true) {
100
+ throw new Error(parsed?.error?.message || `HTTP ${res.status}`)
101
+ }
102
+ const workspace = parsed.value?.workspace
103
+ if (workspace === undefined || workspace === null) {
104
+ throw new Error('create returned no workspace')
105
+ }
106
+ if (workspaces && typeof workspaces.refresh === 'function') {
107
+ await workspaces.refresh()
108
+ }
109
+ if (workspaces) workspaces.startSession(workspace.workspaceId)
110
+ } catch (error) {
111
+ console.error('[temp-workspace] create failed', error)
112
+ }
113
+ }
114
+
115
+ // ── native-delete hook (client half) ─────────────────────────────────────
116
+ // DSH's native workspace delete retains the directory and EVERY session log,
117
+ // so deleting a temp workspace from the sidebar would leave its conversations
118
+ // behind — they reappear as "Ungrouped" after the next boot. When the deleted
119
+ // workspace is one of our temp workspaces, route it through the plugin's own
120
+ // delete route, which reaps the conversations + throwaway dir + marker entry.
121
+ // The host-side registry hook does the same after a host restart; this client
122
+ // hook covers the running session (client code reloads on a page refresh).
123
+ if (workspaces && typeof workspaces.delete === 'function') {
124
+ const nativeDelete = workspaces.delete.bind(workspaces)
125
+ workspaces.delete = async (workspaceId) => {
126
+ const result = await nativeDelete(workspaceId)
127
+ try {
128
+ // Refresh the temp marker list first: the native delete removes the
129
+ // registry record but not the marker entry, so it will still be present
130
+ // (until either this hook or the host hook reaps it).
131
+ await fetchTempEntries()
132
+ if (tempEntries.some((entry) => entry.workspaceId === workspaceId)) {
133
+ await post('/temp-workspace/api/delete', { workspaceId })
134
+ if (workspaces && typeof workspaces.refresh === 'function') {
135
+ await workspaces.refresh()
136
+ }
137
+ }
138
+ } catch (error) {
139
+ console.error('[temp-workspace] delete cleanup failed', error)
140
+ }
141
+ return result
142
+ }
143
+ }
144
+
145
+ function findAddButton() {
146
+ const buttons = document.querySelectorAll('button')
147
+ for (const button of buttons) {
148
+ const label = button.getAttribute('aria-label')
149
+ if (label !== null && ADD_LABELS.includes(label)) return button
150
+ }
151
+ return null
152
+ }
153
+
154
+ function insertIcon() {
155
+ if (inserting) return
156
+ const add = findAddButton()
157
+ if (add === null) return
158
+ const container = add.parentElement
159
+ if (container !== null) {
160
+ container.style.maxWidth = 'none'
161
+ container.style.overflow = 'visible'
162
+ }
163
+ const title = localeText('iconTitle', 'Temporary workspace')
164
+ const next = add.nextElementSibling
165
+ if (next !== null && next.classList.contains(BUTTON_CLASS)) {
166
+ // Locale changed: refresh the existing button's accessible text.
167
+ next.setAttribute('aria-label', title)
168
+ next.setAttribute('title', title)
169
+ return
170
+ }
171
+
172
+ inserting = true
173
+ try {
174
+ const button = document.createElement('button')
175
+ button.type = 'button'
176
+ button.className = BUTTON_CLASS
177
+ button.setAttribute('aria-label', title)
178
+ button.setAttribute('title', title)
179
+ button.innerHTML =
180
+ '<span class="' + ICON_CLASS + '" aria-hidden="true">' + ICON_SVG + '</span>'
181
+ button.addEventListener('click', (event) => {
182
+ event.preventDefault()
183
+ event.stopPropagation()
184
+ void createTempWorkspace()
185
+ })
186
+ const dims = add.getBoundingClientRect()
187
+ const size = Math.max(dims.width, dims.height) || 28
188
+ button.style.width = size + 'px'
189
+ button.style.height = size + 'px'
190
+ button.style.display = 'inline-flex'
191
+ button.style.alignItems = 'center'
192
+ button.style.justifyContent = 'center'
193
+ button.style.flex = 'none'
194
+ button.style.padding = '0'
195
+ button.style.border = 'none'
196
+ button.style.background = 'transparent'
197
+ button.style.color = 'inherit'
198
+ button.style.cursor = 'pointer'
199
+ button.style.borderRadius = '50%'
200
+ button.style.marginLeft = '2px'
201
+ add.insertAdjacentElement('afterend', button)
202
+ } finally {
203
+ inserting = false
204
+ }
205
+ }
206
+
207
+ // ── per-row "permanent keep" button in temp workspace rows ────────────────
208
+ // A temp workspace renders as a normal workspace row (title "临时工作区") with
209
+ // a "+" (New Session) button in its hover actions. We inject a small permanent-
210
+ // keep button next to that "+" so the user can move a temp folder out of the
211
+ // temp area without going through the boot dialog. Temp workspaces share the
212
+ // title "临时工作区", which is how their rows are matched.
213
+ let tempEntries = [] // [{ workspaceId, path }] from /list, cached
214
+ let rowEntriesFetchedAt = 0
215
+
216
+ async function fetchTempEntries() {
217
+ try {
218
+ const res = await fetch('/temp-workspace/api/list', { method: 'GET' })
219
+ const parsed = (await res.json().catch(() => null)) || {}
220
+ const value = unwrapValue(parsed)
221
+ if (res.ok && parsed.ok === true && value && Array.isArray(value.entries)) {
222
+ tempEntries = value.entries.map((e) => ({ workspaceId: e.workspaceId, path: e.path }))
223
+ }
224
+ } catch (e) { /* ignore */ }
225
+ }
226
+
227
+ function injectRowButtons(force) {
228
+ // Refresh temp entries occasionally (or immediately when forced, e.g. on a
229
+ // locale switch) so newly created temp workspaces / re-renders are caught.
230
+ if (force || Date.now() - rowEntriesFetchedAt > 5000) {
231
+ rowEntriesFetchedAt = Date.now()
232
+ void fetchTempEntries()
233
+ }
234
+ if (tempEntries.length === 0) return
235
+ // All temp workspaces share the title "临时工作区", so the temp-titled rows
236
+ // correspond 1:1 (in registry/DOM order) to the marker entries. Inject the
237
+ // permanent-keep button next to each temp row's New Session (+).
238
+ const keepTitle = localeText('keepTitle', 'Keep permanently (move out of temp)')
239
+ const keepAria = localeText('keepAria', 'Keep permanently')
240
+ const rows = document.querySelectorAll('[role="treeitem"]')
241
+ const tempRows = []
242
+ for (const row of rows) {
243
+ // A workspace row's own title lives in its projectText block; a session
244
+ // row's title is also classed "title", so scope to the project text.
245
+ const projText = row.querySelector('[class*="projectText"]')
246
+ const titleEl = projText !== null ? projText.querySelector('[class*="title"]') : null
247
+ const title = titleEl !== null ? (titleEl.textContent || '').trim() : ''
248
+ if (title === '临时工作区') tempRows.push(row)
249
+ }
250
+ for (let i = 0; i < tempRows.length && i < tempEntries.length; i += 1) {
251
+ const row = tempRows[i]
252
+ const entry = tempEntries[i]
253
+ const actions = row.querySelector('[class*="rowActions"]')
254
+ if (actions === null) continue
255
+ const existing = actions.querySelector('.dsh-temp-workspace-keep')
256
+ if (existing !== null) {
257
+ // Locale changed: refresh the existing button's accessible text.
258
+ existing.setAttribute('aria-label', keepAria)
259
+ existing.setAttribute('title', keepTitle)
260
+ continue
261
+ }
262
+ const plus = row.querySelector('button[aria-label*="新建会话"], button[aria-label*="New session"]') || row.querySelector('button')
263
+ const btn = document.createElement('button')
264
+ btn.type = 'button'
265
+ btn.className = 'dsh-temp-workspace-keep'
266
+ btn.setAttribute('aria-label', keepAria)
267
+ btn.setAttribute('title', keepTitle)
268
+ btn.innerHTML = KEEP_SVG
269
+ btn.addEventListener('click', (event) => {
270
+ event.preventDefault()
271
+ event.stopPropagation()
272
+ void doPermanentKeep(entry.workspaceId)
273
+ })
274
+ const dims = plus ? plus.getBoundingClientRect() : null
275
+ const size = Math.max(dims ? dims.width : 16, dims ? dims.height : 16) || 16
276
+ btn.style.width = size + 'px'
277
+ btn.style.height = size + 'px'
278
+ btn.style.display = 'inline-flex'
279
+ btn.style.alignItems = 'center'
280
+ btn.style.justifyContent = 'center'
281
+ btn.style.flex = 'none'
282
+ btn.style.padding = '0'
283
+ btn.style.border = 'none'
284
+ btn.style.background = 'transparent'
285
+ btn.style.color = 'inherit'
286
+ btn.style.cursor = 'pointer'
287
+ btn.style.borderRadius = '4px'
288
+ btn.style.marginLeft = '2px'
289
+ actions.appendChild(btn)
290
+ }
291
+ }
292
+
293
+ function schedule() {
294
+ if (scheduled !== null) return
295
+ scheduled = requestAnimationFrame(() => {
296
+ scheduled = null
297
+ insertIcon()
298
+ injectRowButtons()
299
+ })
300
+ }
301
+
302
+ const observer = new MutationObserver(schedule)
303
+ observer.observe(document.body, { childList: true, subtree: true })
304
+ insertIcon()
305
+
306
+ // Fetch temp entries and inject row buttons once on boot (and refresh via the
307
+ // observer/5s debounce once the sidebar re-renders).
308
+ void fetchTempEntries().then(() => injectRowButtons())
309
+
310
+ // ── boot confirm popup ───────────────────────────────────────────────────
311
+ // Ask the host once whether any temp workspace is held for confirmation. If
312
+ // yes (and the setting is on), render a Modal with Delete / Keep. This keeps
313
+ // the marker durable: an unanswered dialog leaves the workspaces intact for
314
+ // the next boot.
315
+ const disposers = []
316
+
317
+ let confirmOpen = false
318
+ let bootAnswered = false
319
+
320
+ async function confirmPending() {
321
+ // Once the user has answered the boot prompt (keep/delete/permanent-keep),
322
+ // never re-open it for the rest of this page session.
323
+ if (bootAnswered) return
324
+ try {
325
+ const res = await fetch('/temp-workspace/api/pending', { method: 'GET' })
326
+ const parsed = (await res.json().catch(() => null)) || {}
327
+ if (!res.ok || parsed.ok !== true) throw new Error(parsed?.error?.message || `HTTP ${res.status}`)
328
+ // The route wraps the handler result as { ok, value }; some host builds
329
+ // nested it one level deeper, so unwrap defensively.
330
+ const value = unwrapValue(parsed)
331
+ if (!value || value.pendingCount <= 0) return
332
+ if (value.confirmBeforeDelete !== true) return
333
+
334
+ // Wait for the delayed deletion window before prompting, so the user
335
+ // still has the configured grace period to avert the popup.
336
+ const waitMs = Math.max(0, (value.deleteAt ?? 0) - Date.now())
337
+ if (waitMs > 0) {
338
+ setTimeout(() => { void confirmPending() }, Math.min(waitMs, 60_000))
339
+ return
340
+ }
341
+ // Only one confirm overlay at a time — the boot retries can otherwise
342
+ // stack a second dialog next to the first.
343
+ if (confirmOpen) return
344
+ confirmOpen = true
345
+ renderConfirm(value)
346
+ } catch (error) {
347
+ console.error('[temp-workspace] confirmPending failed', error)
348
+ }
349
+ }
350
+
351
+ function renderConfirm(value) {
352
+ const count = value?.pendingCount ?? value?.pending?.length ?? 0
353
+ const names = (value?.pending ?? [])
354
+ .map((p) => (p.path ? String(p.path).split(/[/\\]/).filter(Boolean).pop() : p.workspaceId))
355
+ .filter(Boolean)
356
+
357
+ const body = names.length > 0
358
+ ? react.createElement('ul', { style: { margin: '0 0 0 18px', padding: 0 } },
359
+ names.map((n) => react.createElement('li', { key: n, style: { fontSize: 12, lineHeight: 1.7 } }, n)))
360
+ : null
361
+
362
+ const dialog = mountDialog({
363
+ title: localeText('confirmTitle', 'Temporary workspaces pending'),
364
+ description: localeText('confirmDesc', `${count} temporary workspace(s) will be deleted together with their conversations unless you keep them.`, { n: count }),
365
+ onMask: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/keep') },
366
+ onClose: () => { confirmOpen = false },
367
+ buttons: [
368
+ { label: localeText('tmpKeep', 'Keep temporarily'), onClick: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/keep') } },
369
+ { label: localeText('delete', 'Delete'), primary: true, onClick: () => { bootAnswered = true; dialog.close(); void post('/temp-workspace/api/confirm') } },
370
+ ],
371
+ children: body,
372
+ }, { autoFocusFirst: true })
373
+ }
374
+
375
+ // Permanent keep for one temp workspace: pick the destination folder, then ask
376
+ // the host to move that workspace's files in and de-temp it.
377
+ const doPermanentKeep = async (workspaceId) => {
378
+ if (!workspaces || typeof workspaces.pickDirectory !== 'function') {
379
+ console.error('[temp-workspace] pickDirectory unavailable')
380
+ return
381
+ }
382
+ try {
383
+ const target = await workspaces.pickDirectory()
384
+ if (target === null || target === undefined || target === '') return
385
+ const payload = workspaceId !== undefined ? { target, workspaceId } : { target }
386
+ const ok = await post('/temp-workspace/api/permanentKeep', payload)
387
+ if (ok) { mountRestartNotice(); return }
388
+ console.error('[temp-workspace] permanent keep failed')
389
+ } catch (error) {
390
+ console.error('[temp-workspace] permanent keep failed', error)
391
+ }
392
+ }
393
+
394
+ // A short success dialog after a permanent keep, suggesting a restart.
395
+ function mountRestartNotice() {
396
+ // Non-dismissible: the user must restart for the migration to take effect,
397
+ // so the only action is "Restart now" — a real host restart.
398
+ mountDialog({
399
+ title: localeText('movedTitle', 'Workspace moved'),
400
+ description: localeText('movedDesc', 'The workspace(s) were moved out of the temporary area and are now permanent. For the migrated conversations to appear under the new workspace, restart the Harness now.'),
401
+ dismissible: false,
402
+ buttons: [
403
+ {
404
+ label: localeText('restartNow', 'Restart now'),
405
+ primary: true,
406
+ onClick: () => {
407
+ // Real host-level restart (detached helper respawns the DSH entry),
408
+ // not just a page reload — so the registry re-indexes.
409
+ void fetch('/temp-workspace/api/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
410
+ .catch((error) => console.error('[temp-workspace] restart failed', error))
411
+ },
412
+ },
413
+ ],
414
+ })
415
+ }
416
+
417
+ async function post(url, payload) {
418
+ try {
419
+ const res = await fetch(url, {
420
+ method: 'POST',
421
+ headers: { 'content-type': 'application/json' },
422
+ body: JSON.stringify(payload ?? {}),
423
+ })
424
+ const parsed = (await res.json().catch(() => null)) || {}
425
+ return res.ok && parsed.ok === true
426
+ } catch (error) {
427
+ console.error('[temp-workspace] ' + url + ' failed', error)
428
+ return false
429
+ }
430
+ }
431
+
432
+ void confirmPending()
433
+
434
+ // Re-check a few times after the first tick in case the host registers the
435
+ // pending set asynchronously (settings registration awaits the loader import
436
+ // and can outlive the client's first poll). confirmPending re-shows the popup
437
+ // only once, so an early empty read is harmless; these retries close the gap.
438
+ const RETRY_DELAYS = [1500, 3000, 6000, 12000]
439
+ const retries = RETRY_DELAYS.map((delay) => setTimeout(() => { void confirmPending() }, delay))
440
+ disposers.push(() => { for (const t of retries) clearTimeout(t) })
441
+
442
+ // ── Settings -> Plugins card ────────────────────────────────────────────
443
+ // Self-contained React card, keyed by the settings namespace, reading and
444
+ // writing its own settings via the host route (identical to the auto-approval
445
+ // card). It manages state directly rather than through the tab's inject face.
446
+ const SETTINGS_LIMIT = 24 * 3600 // max delay seconds (24h)
447
+
448
+ function SettingsCard() {
449
+ const [open, setOpen] = react.useState(false)
450
+ const [state, setState] = react.useState({
451
+ deleteMode: 'immediate',
452
+ deleteDelay: 3600,
453
+ confirmBeforeDelete: true,
454
+ loading: true,
455
+ saving: false,
456
+ status: '',
457
+ })
458
+
459
+ react.useEffect(() => {
460
+ let alive = true
461
+ fetch('/temp-workspace/api/config', { method: 'GET' })
462
+ .then((response) => response.json())
463
+ .then((data) => {
464
+ if (!alive) return
465
+ const v = unwrapValue(data)
466
+ setState((current) => ({
467
+ ...current,
468
+ loading: false,
469
+ deleteMode: v?.deleteMode ?? current.deleteMode,
470
+ deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : current.deleteDelay,
471
+ confirmBeforeDelete: v?.confirmBeforeDelete ?? current.confirmBeforeDelete,
472
+ status: data && data.ok === true ? '' : localeText('loadFail', 'Load failed'),
473
+ }))
474
+ })
475
+ .catch(() => { if (alive) setState((current) => ({ ...current, loading: false, status: localeText('loadFail', 'Load failed') })) })
476
+
477
+ const off = locale && typeof locale.subscribe === 'function'
478
+ ? locale.subscribe(() => setState((current) => ({ ...current, revision: (current.revision || 0) + 1 })))
479
+ : null
480
+ return () => { alive = false; if (off) off() }
481
+ }, [])
482
+
483
+ const busy = state.loading || state.saving
484
+ const valid = state.deleteMode === 'immediate' || state.deleteMode === 'delayed'
485
+ const delayText = String(state.deleteDelay)
486
+
487
+ const update = (patch, successText) => {
488
+ setState((current) => ({ ...current, saving: true, status: localeText('saving', 'Saving…') }))
489
+ fetch('/temp-workspace/api/config', {
490
+ method: 'POST',
491
+ headers: { 'content-type': 'application/json' },
492
+ body: JSON.stringify(patch),
493
+ })
494
+ .then((response) => response.json())
495
+ .then((data) => {
496
+ const v = unwrapValue(data)
497
+ setState((current) => ({
498
+ ...current,
499
+ saving: false,
500
+ status: data && data.ok === true ? successText : ((data && data.error) || localeText('saveFail', 'Save failed')),
501
+ deleteMode: v?.deleteMode ?? current.deleteMode,
502
+ deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : current.deleteDelay,
503
+ confirmBeforeDelete: v?.confirmBeforeDelete ?? current.confirmBeforeDelete,
504
+ }))
505
+ })
506
+ .catch(() => setState((current) => ({ ...current, saving: false, status: localeText('saveFail', 'Save failed') })))
507
+ }
508
+
509
+ const reset = () => updateAnyReset()
510
+
511
+ const updateAnyReset = () => {
512
+ setState((current) => ({ ...current, saving: true, status: localeText('saving', 'Saving…') }))
513
+ fetch('/temp-workspace/api/configReset', {
514
+ method: 'POST',
515
+ headers: { 'content-type': 'application/json' },
516
+ body: '{}',
517
+ })
518
+ .then((response) => response.json())
519
+ .then((data) => {
520
+ const v = unwrapValue(data)
521
+ setState((current) => ({
522
+ ...current,
523
+ saving: false,
524
+ status: data && data.ok === true ? localeText('restored', 'Defaults restored') : ((data && data.error) || localeText('saveFail', 'Save failed')),
525
+ deleteMode: v?.deleteMode ?? 'immediate',
526
+ deleteDelay: Number.isFinite(Number(v?.deleteDelay)) ? Number(v.deleteDelay) : 3600,
527
+ confirmBeforeDelete: v?.confirmBeforeDelete ?? true,
528
+ }))
529
+ })
530
+ .catch(() => setState((current) => ({ ...current, saving: false, status: localeText('saveFail', 'Save failed') })))
531
+ }
532
+
533
+ return react.createElement('div', { className: cardCss },
534
+ react.createElement('button', { type: 'button', className: headCss, onClick: () => setOpen((value) => !value) },
535
+ react.createElement('span', { className: headTextCss },
536
+ react.createElement('span', { className: nameCss }, localeText('name', 'Temporary Workspace')),
537
+ react.createElement('span', { className: descCss }, localeText('desc', 'When and whether temp workspaces are deleted after a restart'))),
538
+ react.createElement('span', { className: chevronCss + (open ? ' ' + chevronOpenCss : '') }, '▾')),
539
+ open ? react.createElement('div', { className: bodyCss },
540
+ react.createElement('fieldset', { className: fieldsetCss },
541
+ react.createElement('legend', { className: labelCss }, localeText('deleteModeLabel', 'Delete after restart')),
542
+ react.createElement('label', { className: radioCss },
543
+ react.createElement('input', { type: 'radio', name: 'tw-delete-mode', value: 'immediate', checked: state.deleteMode === 'immediate', disabled: busy, onChange: () => setState((current) => ({ ...current, deleteMode: 'immediate', status: '' })) }),
544
+ react.createElement('span', null, localeText('modeImmediate', 'Delete immediately'))),
545
+ react.createElement('label', { className: radioCss },
546
+ react.createElement('input', { type: 'radio', name: 'tw-delete-mode', value: 'delayed', checked: state.deleteMode === 'delayed', disabled: busy, onChange: () => setState((current) => ({ ...current, deleteMode: 'delayed', status: '' })) }),
547
+ react.createElement('span', null, localeText('modeDelayed', 'Delete after a delay'))),
548
+ state.deleteMode === 'delayed'
549
+ ? react.createElement('label', { className: delayCss },
550
+ react.createElement('span', null, localeText('delayLabel', 'Delay (seconds)')),
551
+ react.createElement('input', {
552
+ type: 'number',
553
+ min: 0,
554
+ max: SETTINGS_LIMIT,
555
+ value: delayText,
556
+ disabled: busy,
557
+ onChange: (event) => setState((current) => ({ ...current, deleteDelay: Number(event.target.value) || 0, status: '' })),
558
+ }))
559
+ : null),
560
+ react.createElement('label', { className: toggleCss },
561
+ react.createElement('input', { type: 'checkbox', checked: state.confirmBeforeDelete, disabled: busy, onChange: (event) => setState((current) => ({ ...current, confirmBeforeDelete: event.target.checked, status: '' })) }),
562
+ react.createElement('span', null, localeText('confirmLabel', 'Confirm before deleting'))),
563
+ react.createElement('div', { className: helpCss }, localeText('confirmHelp', 'When on, a dialog asks before a held temp workspace is removed on the next restart.')),
564
+ react.createElement('div', { className: actionsCss },
565
+ react.createElement('button', { type: 'button', className: btnCss, disabled: busy || !valid, onClick: () => update({ deleteMode: state.deleteMode, deleteDelay: state.deleteDelay, confirmBeforeDelete: state.confirmBeforeDelete }, localeText('saved', 'Saved')) }, localeText('save', 'Save')),
566
+ react.createElement('button', { type: 'button', className: btnCss, disabled: busy, onClick: reset }, localeText('reset', 'Restore defaults'))),
567
+ state.status ? react.createElement('div', { className: statusCss }, state.status) : null)
568
+ : null)
569
+ }
570
+
571
+ if (slots) {
572
+ ctx.effect(() => slots.inject('settings.plugin.item', () => slots.register({
573
+ name: 'settings.plugin.item',
574
+ key: SETTINGS_NS,
575
+ id: 'dsh-temp-workspace-settings',
576
+ label: localeText('name', 'Temporary Workspace'),
577
+ }, SettingsCard)), 'dsh-temp-workspace: settings card')
578
+ }
579
+
580
+ // ── cleanup on plugin unload ────────────────────────────────────────────
581
+ return () => {
582
+ observer.disconnect()
583
+ if (scheduled !== null) cancelAnimationFrame(scheduled)
584
+ for (const dispose of disposers) { try { dispose() } catch { /* noop */ } }
585
+ const node = document.querySelector('.' + BUTTON_CLASS)
586
+ if (node !== null) node.remove()
587
+ }
588
+ }
589
+
590
+ // ── lightweight inline-styled dialog (no dependency on primitives CSS) ──────
591
+ // Renders a fixed, centered overlay + dialog box through its own react-dom root
592
+ // so it shows regardless of the app's CSS-module scope. Returns `{ close }`.
593
+ function mountDialog(options, settings) {
594
+ const host = document.createElement('div')
595
+ host.style.position = 'fixed'
596
+ host.style.inset = '0'
597
+ host.style.zIndex = '2147483000'
598
+ host.style.display = 'flex'
599
+ host.style.alignItems = 'center'
600
+ host.style.justifyContent = 'center'
601
+ host.style.padding = '24px'
602
+ host.style.fontFamily = 'var(--dsw-font-family, sans-serif)'
603
+ document.body.appendChild(host)
604
+
605
+ const root = reactDOMClient.createRoot(host)
606
+ const dismissible = options.dismissible !== false
607
+ let closed = false
608
+ const close = () => {
609
+ if (closed) return
610
+ closed = true
611
+ // Remove the Escape listener once closed.
612
+ if (onKey) window.removeEventListener('keydown', onKey)
613
+ root.unmount()
614
+ host.remove()
615
+ if (options.onClose) { try { options.onClose() } catch { /* noop */ } }
616
+ }
617
+ // Non-dismissible dialogs only close via an explicit button (onClick). Mask
618
+ // click and Escape are ignored so the user must act.
619
+ const onKey = (e) => { if (e.key === 'Escape' && dismissible) close() }
620
+ window.addEventListener('keydown', onKey)
621
+
622
+ const maskStyle = {
623
+ position: 'absolute', inset: '0',
624
+ background: 'rgba(0,0,0,0.5)',
625
+ backdropFilter: 'blur(2px)',
626
+ }
627
+ const panelStyle = {
628
+ position: 'relative',
629
+ maxWidth: '440px',
630
+ width: '100%',
631
+ maxHeight: '80vh',
632
+ overflow: 'auto',
633
+ background: 'var(--dsw-alias-bg-layer-3, #1f1f1f)',
634
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))',
635
+ borderRadius: '14px',
636
+ boxShadow: '0 16px 48px rgba(0,0,0,0.4)',
637
+ padding: '18px 20px',
638
+ color: 'var(--dsw-alias-label-primary, #eee)',
639
+ }
640
+ const btnBase = {
641
+ height: '32px',
642
+ border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.3))',
643
+ borderRadius: '8px',
644
+ background: 'transparent',
645
+ color: 'var(--dsw-alias-label-secondary, #ccc)',
646
+ fontSize: '13px',
647
+ padding: '0 14px',
648
+ cursor: 'pointer',
649
+ }
650
+ const primaryBtn = { ...btnBase, background: 'var(--dsw-alias-state-business-primary, #3d9be9)', color: '#fff', border: 'none' }
651
+
652
+ root.render(
653
+ react.createElement('div', null,
654
+ react.createElement('div', { style: maskStyle, onClick: (ev) => { if (ev.target === ev.currentTarget && dismissible && options.onMask) options.onMask() } }),
655
+ react.createElement('div', { role: 'dialog', 'aria-modal': 'true', style: panelStyle },
656
+ options.title ? react.createElement('div', { style: { fontSize: 16, fontWeight: 600, marginBottom: 8 } }, options.title) : null,
657
+ options.description ? react.createElement('div', { style: { fontSize: 13, lineHeight: 1.6, color: 'var(--dsw-alias-label-tertiary, #aaa)' } }, options.description) : null,
658
+ options.children ? react.createElement('div', { style: { marginTop: 10 } }, options.children) : null,
659
+ react.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 16, flexWrap: 'wrap' } },
660
+ (options.buttons || []).map((button, i) =>
661
+ react.createElement('button', {
662
+ key: i,
663
+ type: 'button',
664
+ autoFocus: !!(settings?.autoFocusFirst && i === 0),
665
+ disabled: !!button.disabled,
666
+ style: button.primary ? primaryBtn : { ...btnBase, ...(button.disabled ? { opacity: 0.45, cursor: 'default' } : {}) },
667
+ onClick: () => { if (button.onClick) button.onClick() },
668
+ }, button.label))),
669
+ ),
670
+ )
671
+ )
672
+
673
+ return { close }
674
+ }
675
+
676
+ // Unwrap the wire envelope: { ok: true, value: X } or the double-nested
677
+ // { ok: true, value: { ok: true, value: X } } some host builds returned.
678
+ function unwrapValue(data) {
679
+ if (!data || data.ok !== true) return null
680
+ let value = data.value
681
+ if (value && typeof value === 'object' && value.ok === true && 'value' in value) value = value.value
682
+ return value
683
+ }
684
+
685
+ // ── CSS injection (card + modal) ────────────────────────────────────────────
686
+ let styleInjected = false
687
+ function injectStyle() {
688
+ if (styleInjected) return
689
+ styleInjected = true
690
+ const style = document.createElement('style')
691
+ style.dataset.plugin = SETTINGS_NS
692
+ style.textContent = [
693
+ // settings card, matching the framework's dsw-alias design tokens
694
+ '.dsh-temp-workspace-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;overflow:hidden}',
695
+ '.dsh-temp-workspace-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:transparent;border:0;display:flex;align-items:center;gap:12px;padding:14px 16px}',
696
+ '.dsh-temp-workspace-head-text{display:flex;flex:1;min-width:0;flex-direction:column;gap:4px}',
697
+ '.dsh-temp-workspace-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
698
+ '.dsh-temp-workspace-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
699
+ '.dsh-temp-workspace-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}',
700
+ '.dsh-temp-workspace-chevron-open{transform:rotate(180deg)}',
701
+ '.dsh-temp-workspace-body{border-top:1px solid var(--dsw-alias-border-l2);padding:14px 16px;display:flex;flex-direction:column;gap:14px}',
702
+ '.dsh-temp-workspace-fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:8px}',
703
+ '.dsh-temp-workspace-label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}',
704
+ '.dsh-temp-workspace-radio{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}',
705
+ '.dsh-temp-workspace-delay{display:flex;align-items:center;gap:10px;color:var(--dsw-alias-label-secondary);font-size:12px}',
706
+ '.dsh-temp-workspace-delay input{width:110px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-primary);font:inherit;padding:6px 10px}',
707
+ '.dsh-temp-workspace-toggle{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}',
708
+ '.dsh-temp-workspace-help{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5}',
709
+ '.dsh-temp-workspace-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
710
+ '.dsh-temp-workspace-btn{height:30px;border:1px solid var(--dsw-alias-border-l2);border-radius:7px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;padding:0 12px;cursor:pointer}',
711
+ '.dsh-temp-workspace-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
712
+ '.dsh-temp-workspace-btn:disabled{opacity:.45;cursor:default}',
713
+ '.dsh-temp-workspace-status{color:var(--dsw-alias-label-secondary);font-size:11px}',
714
+ ].join('')
715
+ document.head.appendChild(style)
716
+ }
717
+
718
+ // ── locale helper (zh/en dictionaries; follows the active locale) ───────────
719
+ // The active locale comes from ctx.locale (getLocale().active); kept in a
720
+ // module-level var that a locale subscription refreshes, so every UI text
721
+ // rendered through localeText follows the user's language. English is the
722
+ // fallback when the active-locale key is missing.
723
+ let activeLocale = 'zh' // 'zh' | 'en'; synced from ctx.locale
724
+
725
+ function activeLang(localeRef) {
726
+ try {
727
+ const id = (localeRef !== undefined ? localeRef.getLocale().active : activeLocale)
728
+ return id === 'en' ? 'en' : 'zh'
729
+ } catch { return 'zh' }
730
+ }
731
+
732
+ function localeText(key, fallbackEn, vars, localeRef) {
733
+ const lang = activeLang(localeRef)
734
+ const dict = lang === 'en' ? enLocale : zhLocale
735
+ // Fall back to English (the dictionary consulted after the active locale
736
+ // misses a key), then to the caller's fallback.
737
+ let text = dict[key] || enLocale[key] || fallbackEn
738
+ if (vars) for (const [name, value] of Object.entries(vars)) {
739
+ text = text.replace(new RegExp('\\{' + name + '\\}', 'g'), String(value))
740
+ }
741
+ return text
742
+ }
743
+
744
+ const zhLocale = {
745
+ name: '临时工作区',
746
+ desc: '重启后何时、以及是否先确认再删除临时工作区',
747
+ deleteModeLabel: '重启后何时删除',
748
+ modeImmediate: '立即删除',
749
+ modeDelayed: '延迟一段时间后删除',
750
+ delayLabel: '延迟(秒)',
751
+ confirmLabel: '删除前确认',
752
+ confirmHelp: '开启后,下次重启会先弹窗询问,确认后才删除临时工作区。',
753
+ save: '保存',
754
+ saved: '已保存',
755
+ reset: '恢复默认',
756
+ restored: '已恢复默认',
757
+ saving: '保存中…',
758
+ saveFail: '保存失败',
759
+ loadFail: '读取失败',
760
+ confirmTitle: '临时工作区待处理',
761
+ confirmDesc: '有 {n} 个临时工作区:若删除,其中的对话也会一并删除。',
762
+ delete: '删除',
763
+ keep: '保留',
764
+ tmpKeep: '临时保留',
765
+ close: '关闭',
766
+ movedTitle: '工作区已移动',
767
+ movedDesc: '这些工作区已移出临时区域并转为永久工作区。为了让迁移的对话在新工作区下显示,请立即重启 Harness。',
768
+ restartNow: '立即重启',
769
+ iconTitle: '临时工作区',
770
+ keepTitle: '永久保留(移出临时区)',
771
+ keepAria: '永久保留',
772
+ }
773
+
774
+ const enLocale = {
775
+ name: 'Temporary Workspace',
776
+ desc: 'When and whether temp workspaces are deleted after a restart',
777
+ deleteModeLabel: 'Delete after a restart',
778
+ modeImmediate: 'Delete immediately',
779
+ modeDelayed: 'Delete after a delay',
780
+ delayLabel: 'Delay (seconds)',
781
+ confirmLabel: 'Confirm before deleting',
782
+ confirmHelp: 'When on, a dialog asks before a held temp workspace is removed on the next restart.',
783
+ save: 'Save',
784
+ saved: 'Saved',
785
+ reset: 'Restore defaults',
786
+ restored: 'Defaults restored',
787
+ saving: 'Saving…',
788
+ saveFail: 'Save failed',
789
+ loadFail: 'Load failed',
790
+ confirmTitle: 'Temporary workspaces pending',
791
+ confirmDesc: '{n} temporary workspace(s) will be deleted together with their conversations unless you keep them.',
792
+ delete: 'Delete',
793
+ keep: 'Keep',
794
+ tmpKeep: 'Keep temporarily',
795
+ close: 'Close',
796
+ movedTitle: 'Workspace moved',
797
+ movedDesc: 'The workspace(s) were moved out of the temporary area and are now permanent. For the migrated conversations to appear under the new workspace, restart the Harness now.',
798
+ restartNow: 'Restart now',
799
+ iconTitle: 'Temporary workspace',
800
+ keepTitle: 'Keep permanently (move out of temp)',
801
+ keepAria: 'Keep permanently',
802
+ }
803
+
804
+ // ── inline CSS (class strings for the card, keyed off the sidebars' css vars) ──
805
+ const cardCss = 'dsh-temp-workspace-card'
806
+ const headCss = 'dsh-temp-workspace-head'
807
+ const headTextCss = 'dsh-temp-workspace-head-text'
808
+ const nameCss = 'dsh-temp-workspace-name'
809
+ const descCss = 'dsh-temp-workspace-desc'
810
+ const chevronCss = 'dsh-temp-workspace-chevron'
811
+ const chevronOpenCss = 'dsh-temp-workspace-chevron-open'
812
+ const bodyCss = 'dsh-temp-workspace-body'
813
+ const fieldsetCss = 'dsh-temp-workspace-fieldset'
814
+ const labelCss = 'dsh-temp-workspace-label'
815
+ const radioCss = 'dsh-temp-workspace-radio'
816
+ const delayCss = 'dsh-temp-workspace-delay'
817
+ const toggleCss = 'dsh-temp-workspace-toggle'
818
+ const helpCss = 'dsh-temp-workspace-help'
819
+ const actionsCss = 'dsh-temp-workspace-actions'
820
+ const btnCss = 'dsh-temp-workspace-btn'
821
+ const statusCss = 'dsh-temp-workspace-status'
822
+
823
+ module.exports = { inject, apply }
824
+
825
+ return module.exports;
826
+ },
827
+ })