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