dsh-clone 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,690 @@
1
+ /**
2
+ * dsh-clone — Client half.
3
+ *
4
+ * Two surfaces, both drawn with the host's own React and theme tokens:
5
+ *
6
+ * 1. `conversation.view` — the Clone panel. The reference screenshot on the
7
+ * left, one live iframe per lane on the right, every lane adoptable with
8
+ * one click. The iframes point at this page's own origin (`/api/dsh-clone/
9
+ * preview/...`), so the panel needs no extra port and works over the same
10
+ * authenticated connection the page already has.
11
+ *
12
+ * 2. `conversation.composer.dock` — a quiet progress chip that appears only
13
+ * while builders are running.
14
+ *
15
+ * Lane documents are model-written HTML: they are served with a `sandbox` CSP
16
+ * (no `allow-same-origin`) and mounted with `sandbox="allow-scripts"`, so a
17
+ * clone can animate and run its own code but cannot read this page's cookies,
18
+ * storage or API.
19
+ *
20
+ * Bundle format: `window.__ModuleLoader__.load({ id, factory })` with only
21
+ * `react` required from the host module table. No build step, no JSX.
22
+ */
23
+
24
+ window.__ModuleLoader__.load({
25
+ id: 'dsh-clone',
26
+ factory: (require) => {
27
+ const module = { exports: {} }
28
+ const exports = module.exports
29
+
30
+ const React = require('react')
31
+ const h = React.createElement
32
+ const { useState, useEffect, useMemo, useRef } = React
33
+
34
+ // ── design tokens ───────────────────────────────────────────────────────
35
+ const T = {
36
+ bg: 'var(--dsw-alias-bg-base, #ffffff)',
37
+ layer: 'var(--dsw-alias-bg-layer-1, #ffffff)',
38
+ layer2: 'var(--dsw-alias-bg-layer-2, #f6f6f7)',
39
+ overlay: 'var(--dsw-alias-bg-overlay, #ffffff)',
40
+ borderL1: 'var(--dsw-alias-border-l1, rgba(0,0,0,0.07))',
41
+ borderL2: 'var(--dsw-alias-border-l2, rgba(0,0,0,0.14))',
42
+ brand: 'var(--dsw-alias-brand-primary, #2f6bff)',
43
+ label: 'var(--dsw-alias-label-primary, #1b2434)',
44
+ label2: 'var(--dsw-alias-label-secondary, #6b6b70)',
45
+ success: 'var(--dsw-alias-state-success-primary, #22c55e)',
46
+ warn: 'var(--dsw-alias-state-warn-primary, #f59e0b)',
47
+ error: 'var(--dsw-alias-state-error-primary, #ef4444)',
48
+ idle: 'var(--dsw-alias-state-idle-primary, #9aa0a6)',
49
+ }
50
+
51
+ const FONT = 'Inter, var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)'
52
+ const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace'
53
+
54
+ const STATE_URL = '/api/dsh-clone/state'
55
+ const ACTION_URL = '/api/dsh-clone/action'
56
+ const POLL_RUNNING_MS = 1000
57
+ const POLL_IDLE_MS = 5000
58
+
59
+ // ── locale helper ───────────────────────────────────────────────────────
60
+ const FALLBACK = {
61
+ 'panel.title': 'Clone',
62
+ 'panel.empty.title': 'Screenshot in, working UI out',
63
+ 'panel.empty.body': 'Paste a screenshot into the conversation, then run /clone 3. Three agents rebuild it in parallel, in their own directories, and you adopt the one you like.',
64
+ 'panel.empty.hint': 'Tip: /clone 4 for four attempts, /clone 2 dark variant to steer them.',
65
+ 'panel.reference': 'Reference',
66
+ 'panel.lanes': 'Lanes',
67
+ 'panel.lane': 'Lane',
68
+ 'panel.suggested': 'Suggested',
69
+ 'panel.adopt': 'Adopt this one',
70
+ 'panel.adopted': 'Adopted',
71
+ 'panel.cancel': 'Stop builders',
72
+ 'panel.discard': 'Discard scratch',
73
+ 'panel.running': 'building',
74
+ 'panel.settled': 'ready',
75
+ 'panel.scoring': 'scoring',
76
+ 'panel.emptyLane': 'no page',
77
+ 'panel.failed': 'failed',
78
+ 'panel.cancelled': 'cancelled',
79
+ 'panel.queued': 'queued',
80
+ 'panel.tools': 'tools',
81
+ 'panel.files': 'files',
82
+ 'panel.duration': 'time',
83
+ 'panel.chip': 'clone',
84
+ 'panel.loading': 'Loading clone state…',
85
+ 'panel.adoptedAt': 'Adopted into',
86
+ }
87
+
88
+ const translate = (props, key) => {
89
+ const t = props && props.t
90
+ if (typeof t === 'function') {
91
+ try {
92
+ const value = t(key)
93
+ if (typeof value === 'string' && value.length > 0 && value !== key) return value
94
+ } catch {
95
+ /* fall through to the built-in English copy */
96
+ }
97
+ }
98
+ return FALLBACK[key] ?? key
99
+ }
100
+
101
+ // ── one shared feed for every surface ───────────────────────────────────
102
+ // The panel and the chip read the same tiny JSON payload, so there is at
103
+ // most one request in flight and one store no matter how many surfaces the
104
+ // page mounts.
105
+ const store = { clones: [], loaded: false, error: null }
106
+ const subscribers = new Set()
107
+ let inflight = false
108
+ let timer = null
109
+ let sessionId = null
110
+
111
+ const publish = (next) => {
112
+ if (next.clones === store.clones && next.loaded === store.loaded && next.error === store.error) return
113
+ store.clones = next.clones
114
+ store.loaded = next.loaded
115
+ store.error = next.error
116
+ for (const fn of subscribers) {
117
+ try {
118
+ fn()
119
+ } catch {
120
+ /* one bad subscriber must not starve the others */
121
+ }
122
+ }
123
+ }
124
+
125
+ const isRunning = () => store.clones.some((clone) => clone.status === 'running')
126
+
127
+ const fetchState = () => {
128
+ if (inflight || typeof fetch !== 'function') return
129
+ inflight = true
130
+ const url = sessionId === null ? STATE_URL : `${STATE_URL}?sessionId=${encodeURIComponent(sessionId)}`
131
+ fetch(url, { cache: 'no-store', credentials: 'same-origin' })
132
+ .then((response) => (response.ok ? response.json() : null))
133
+ .then((data) => {
134
+ if (data === null || !Array.isArray(data.clones)) return
135
+ publish({ clones: data.clones, loaded: true, error: null })
136
+ })
137
+ .catch(() => {
138
+ /* host restarting or route absent: keep the last known state */
139
+ })
140
+ .then(() => {
141
+ inflight = false
142
+ schedule()
143
+ })
144
+ }
145
+
146
+ const schedule = () => {
147
+ if (typeof window === 'undefined') return
148
+ if (timer !== null) clearTimeout(timer)
149
+ timer = setTimeout(() => {
150
+ timer = null
151
+ fetchState()
152
+ }, isRunning() ? POLL_RUNNING_MS : POLL_IDLE_MS)
153
+ }
154
+
155
+ const subscribe = (fn) => {
156
+ subscribers.add(fn)
157
+ if (timer === null) fetchState()
158
+ return () => subscribers.delete(fn)
159
+ }
160
+
161
+ const refreshNow = () => {
162
+ if (timer !== null) {
163
+ clearTimeout(timer)
164
+ timer = null
165
+ }
166
+ fetchState()
167
+ }
168
+
169
+ const postAction = async (body) => {
170
+ const response = await fetch(ACTION_URL, {
171
+ method: 'POST',
172
+ cache: 'no-store',
173
+ credentials: 'same-origin',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify(body),
176
+ })
177
+ const data = await response.json().catch(() => null)
178
+ if (data === null || data.ok !== true) {
179
+ throw new Error(data?.error ?? `request failed (${response.status})`)
180
+ }
181
+ return data
182
+ }
183
+
184
+ /** Subscribe one component to the shared clone feed. */
185
+ function useClones(props) {
186
+ const [snapshot, setSnapshot] = useState({ ...store })
187
+ useEffect(() => {
188
+ const id = props && props.sessionId
189
+ if (typeof id === 'string' && id.length > 0) sessionId = id
190
+ const sync = () => setSnapshot({ ...store })
191
+ const unsubscribe = subscribe(sync)
192
+ sync()
193
+ return unsubscribe
194
+ }, [props && props.sessionId])
195
+ return snapshot
196
+ }
197
+
198
+ // ── small pieces ────────────────────────────────────────────────────────
199
+
200
+ const STATUS_COLOR = {
201
+ running: T.brand,
202
+ scoring: T.brand,
203
+ settled: T.success,
204
+ finished: T.success,
205
+ empty: T.warn,
206
+ failed: T.error,
207
+ cancelled: T.idle,
208
+ queued: T.idle,
209
+ }
210
+
211
+ function Dot({ status }) {
212
+ return h('span', {
213
+ 'aria-hidden': true,
214
+ style: {
215
+ width: 7,
216
+ height: 7,
217
+ borderRadius: '50%',
218
+ background: STATUS_COLOR[status] ?? T.idle,
219
+ display: 'inline-block',
220
+ flex: 'none',
221
+ },
222
+ })
223
+ }
224
+
225
+ function Pill({ children, tone }) {
226
+ return h('span', {
227
+ style: {
228
+ display: 'inline-flex',
229
+ alignItems: 'center',
230
+ gap: 5,
231
+ height: 20,
232
+ padding: '0 8px',
233
+ borderRadius: 999,
234
+ border: `0.5px solid ${T.borderL1}`,
235
+ background: tone === 'success' ? 'var(--dsw-alias-state-success-primary, #22c55e)' : T.layer2,
236
+ color: tone === 'success' ? '#ffffff' : T.label2,
237
+ fontFamily: FONT,
238
+ fontSize: 11,
239
+ fontWeight: 500,
240
+ lineHeight: '20px',
241
+ whiteSpace: 'nowrap',
242
+ flex: 'none',
243
+ },
244
+ }, children)
245
+ }
246
+
247
+ function Button({ children, onClick, disabled, primary, title }) {
248
+ return h('button', {
249
+ type: 'button',
250
+ onClick,
251
+ disabled: Boolean(disabled),
252
+ title,
253
+ style: {
254
+ appearance: 'none',
255
+ border: primary ? 'none' : `0.5px solid ${T.borderL2}`,
256
+ background: primary ? T.brand : 'transparent',
257
+ color: primary ? '#ffffff' : T.label,
258
+ fontFamily: FONT,
259
+ fontSize: 12,
260
+ fontWeight: primary ? 600 : 500,
261
+ height: 26,
262
+ padding: '0 10px',
263
+ borderRadius: 7,
264
+ cursor: disabled ? 'default' : 'pointer',
265
+ opacity: disabled ? 0.45 : 1,
266
+ whiteSpace: 'nowrap',
267
+ },
268
+ }, children)
269
+ }
270
+
271
+ const formatMs = (ms) => {
272
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '—'
273
+ if (ms < 1000) return `${Math.round(ms)}ms`
274
+ const seconds = ms / 1000
275
+ if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`
276
+ const minutes = Math.floor(seconds / 60)
277
+ return `${minutes}m ${String(Math.round(seconds - minutes * 60)).padStart(2, '0')}s`
278
+ }
279
+
280
+ const formatTokens = (tokens) => {
281
+ const total = (Number(tokens?.input) || 0) + (Number(tokens?.output) || 0) + (Number(tokens?.cacheRead) || 0)
282
+ if (total === 0) return null
283
+ if (total < 1000) return String(total)
284
+ if (total < 1_000_000) return `${(total / 1000).toFixed(total < 10_000 ? 1 : 0)}k`
285
+ return `${(total / 1_000_000).toFixed(1)}M`
286
+ }
287
+
288
+ /**
289
+ * Hover text for the similarity score: what was compared, and against what.
290
+ * The score is a coarse-grid picture comparison, not a correctness verdict.
291
+ */
292
+ const scoreTitle = (lane) => [
293
+ `${lane.score}% picture match with the reference`,
294
+ 'coarse-grid colour comparison of the rendered page, not a correctness check',
295
+ 'views: live page (above) · rendered snapshot',
296
+ ].join('\n')
297
+
298
+ // ── lane card ───────────────────────────────────────────────────────────
299
+
300
+ function LaneCard({ props, clone, lane, onAdopt, busy }) {
301
+ const ready = lane.status === 'settled' && lane.delivered
302
+ const adopted = clone.picked !== null && clone.picked.lane === lane.index
303
+ const suggested = clone.winner === lane.index
304
+ const showFrame = lane.status === 'settled' || lane.status === 'empty'
305
+
306
+ return h('div', {
307
+ style: {
308
+ display: 'flex',
309
+ flexDirection: 'column',
310
+ minWidth: 0,
311
+ border: `0.5px solid ${adopted ? T.brand : T.borderL1}`,
312
+ borderRadius: 10,
313
+ background: T.layer,
314
+ overflow: 'hidden',
315
+ },
316
+ }, [
317
+ h('div', {
318
+ key: 'head',
319
+ style: {
320
+ display: 'flex',
321
+ alignItems: 'center',
322
+ gap: 8,
323
+ padding: '7px 10px',
324
+ borderBottom: `0.5px solid ${T.borderL1}`,
325
+ background: T.layer2,
326
+ minHeight: 34,
327
+ },
328
+ }, [
329
+ h(Dot, { key: 'dot', status: lane.status }),
330
+ h('span', {
331
+ key: 'name',
332
+ style: { fontFamily: FONT, fontSize: 12, fontWeight: 600, color: T.label },
333
+ }, `${translate(props, 'panel.lane')} ${lane.index + 1}`),
334
+ h('span', {
335
+ key: 'strategy',
336
+ style: {
337
+ fontFamily: FONT, fontSize: 11, color: T.label2,
338
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0,
339
+ },
340
+ }, lane.strategyLabel),
341
+ h('span', { key: 'spacer', style: { flex: '1 1 auto' } }),
342
+ typeof lane.score === 'number'
343
+ ? h('span', {
344
+ key: 'score',
345
+ title: scoreTitle(lane),
346
+ style: {
347
+ fontFamily: MONO, fontSize: 10.5, fontWeight: 600,
348
+ color: lane.score >= 85 ? T.success : lane.score >= 60 ? T.warn : T.error,
349
+ fontVariantNumeric: 'tabular-nums', flex: 'none',
350
+ },
351
+ }, `${lane.score}%`)
352
+ : null,
353
+ suggested && ready
354
+ ? h(Pill, { key: 'sug', tone: 'success' }, translate(props, 'panel.suggested'))
355
+ : null,
356
+ ]),
357
+ h('div', {
358
+ key: 'body',
359
+ style: { position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#ffffff' },
360
+ }, showFrame
361
+ ? h('iframe', {
362
+ title: `clone lane ${lane.index + 1}`,
363
+ src: lane.previewUrl,
364
+ sandbox: 'allow-scripts',
365
+ style: {
366
+ position: 'absolute', inset: 0, width: '100%', height: '100%',
367
+ border: '0', background: '#ffffff', display: 'block',
368
+ },
369
+ })
370
+ : h('div', {
371
+ style: {
372
+ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
373
+ alignItems: 'center', justifyContent: 'center', gap: 6,
374
+ background: T.layer2, color: T.label2, fontFamily: FONT, fontSize: 12,
375
+ },
376
+ }, [
377
+ h('div', {
378
+ key: 'glyph',
379
+ 'aria-hidden': true,
380
+ style: {
381
+ width: 22, height: 22, borderRadius: 6,
382
+ border: `1.5px dashed ${lane.status === 'failed' ? T.error : T.borderL2}`,
383
+ opacity: lane.status === 'running' ? 0.9 : 0.6,
384
+ animation: lane.status === 'running' ? 'dshClonePulse 1.4s ease-in-out infinite' : 'none',
385
+ },
386
+ }),
387
+ h('span', { key: 'label' }, lane.status === 'running'
388
+ ? translate(props, 'panel.running')
389
+ : lane.status === 'failed'
390
+ ? (lane.error ?? translate(props, 'panel.failed'))
391
+ : translate(props, lane.status === 'cancelled' ? 'panel.cancelled' : 'panel.queued')),
392
+ ])),
393
+ h('div', {
394
+ key: 'foot',
395
+ style: {
396
+ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px',
397
+ borderTop: `0.5px solid ${T.borderL1}`, minHeight: 38,
398
+ },
399
+ }, [
400
+ h('span', {
401
+ key: 'stats',
402
+ style: {
403
+ fontFamily: MONO, fontSize: 10.5, color: T.label2,
404
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0,
405
+ },
406
+ }, [
407
+ formatMs(lane.durationMs),
408
+ ` · ${lane.tools} ${translate(props, 'panel.tools')}`,
409
+ lane.files > 0 ? ` · ${lane.files} ${translate(props, 'panel.files')}` : '',
410
+ formatTokens(lane.tokens) === null ? '' : ` · ${formatTokens(lane.tokens)} tok`,
411
+ ].join('')),
412
+ h('span', { key: 'spacer', style: { flex: '1 1 auto' } }),
413
+ adopted
414
+ ? h(Pill, { key: 'adopted', tone: 'success' }, translate(props, 'panel.adopted'))
415
+ : h(Button, {
416
+ key: 'adopt',
417
+ primary: ready,
418
+ disabled: !ready || busy,
419
+ onClick: () => onAdopt(lane.index + 1),
420
+ title: ready ? undefined : translate(props, 'panel.emptyLane'),
421
+ }, translate(props, 'panel.adopt')),
422
+ ]),
423
+ ])
424
+ }
425
+
426
+ // ── the panel ───────────────────────────────────────────────────────────
427
+
428
+ function ClonePanel(props) {
429
+ const { clones, loaded, error } = useClones(props)
430
+ const [busy, setBusy] = useState(false)
431
+ const [notice, setNotice] = useState(null)
432
+ const clone = clones.length === 0 ? null : clones[0]
433
+
434
+ const act = async (body, message) => {
435
+ setBusy(true)
436
+ setNotice(null)
437
+ try {
438
+ await postAction(body)
439
+ setNotice(message)
440
+ refreshNow()
441
+ } catch (failure) {
442
+ setNotice(String(failure?.message ?? failure))
443
+ } finally {
444
+ setBusy(false)
445
+ }
446
+ }
447
+
448
+ const header = h('div', {
449
+ key: 'header',
450
+ style: {
451
+ display: 'flex', alignItems: 'center', gap: 8, padding: '9px 14px',
452
+ borderBottom: `0.5px solid ${T.borderL1}`, background: T.bg, flex: 'none', minHeight: 40,
453
+ },
454
+ }, [
455
+ h('span', {
456
+ key: 'title',
457
+ style: { fontFamily: FONT, fontSize: 12.5, fontWeight: 600, color: T.label },
458
+ }, translate(props, 'panel.title')),
459
+ clone === null ? null : h('span', {
460
+ key: 'note',
461
+ style: {
462
+ fontFamily: FONT, fontSize: 11.5, color: T.label2, minWidth: 0,
463
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
464
+ },
465
+ }, clone.note.length > 0 ? `· ${clone.note}` : `· ${clone.id}`),
466
+ h('span', { key: 'spacer', style: { flex: '1 1 auto' } }),
467
+ clone === null || clone.status !== 'running' ? null : h(Button, {
468
+ key: 'cancel',
469
+ disabled: busy,
470
+ onClick: () => act({ cloneId: clone.id, action: 'cancel' }, 'Builders stopped.'),
471
+ }, translate(props, 'panel.cancel')),
472
+ clone === null ? null : h(Button, {
473
+ key: 'discard',
474
+ disabled: busy,
475
+ onClick: () => act({ cloneId: clone.id, action: 'discard' }, 'Scratch directories removed.'),
476
+ }, translate(props, 'panel.discard')),
477
+ ])
478
+
479
+ if (clone === null) {
480
+ return h('div', {
481
+ style: {
482
+ boxSizing: 'border-box', display: 'flex', flexDirection: 'column',
483
+ width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', background: T.bg,
484
+ },
485
+ }, [
486
+ header,
487
+ h('div', {
488
+ key: 'empty',
489
+ style: {
490
+ flex: '1 1 auto', display: 'flex', flexDirection: 'column',
491
+ alignItems: 'center', justifyContent: 'center', gap: 10, padding: 24, textAlign: 'center',
492
+ },
493
+ }, [
494
+ h('div', {
495
+ key: 'glyph',
496
+ 'aria-hidden': true,
497
+ style: {
498
+ width: 64, height: 44, borderRadius: 8, border: `1.5px solid ${T.borderL2}`,
499
+ background: `linear-gradient(135deg, ${T.layer2} 0%, ${T.bg} 100%)`,
500
+ position: 'relative',
501
+ },
502
+ }, h('span', {
503
+ style: {
504
+ position: 'absolute', inset: '8px 10px 8px 10px', borderRadius: 3,
505
+ background: `repeating-linear-gradient(90deg, ${T.borderL1} 0 10px, transparent 10px 18px)`,
506
+ },
507
+ })),
508
+ h('div', {
509
+ key: 'title',
510
+ style: { fontFamily: FONT, fontSize: 15, fontWeight: 600, color: T.label },
511
+ }, translate(props, 'panel.empty.title')),
512
+ h('div', {
513
+ key: 'body',
514
+ style: { fontFamily: FONT, fontSize: 12.5, lineHeight: '19px', color: T.label2, maxWidth: 460 },
515
+ }, translate(props, 'panel.empty.body')),
516
+ h('div', {
517
+ key: 'hint',
518
+ style: { fontFamily: MONO, fontSize: 11, color: T.label2, opacity: 0.8 },
519
+ }, translate(props, 'panel.empty.hint')),
520
+ error === null ? null : h('div', {
521
+ key: 'error',
522
+ style: { fontFamily: MONO, fontSize: 11, color: T.error },
523
+ }, String(error)),
524
+ loaded ? null : h('div', {
525
+ key: 'loading',
526
+ style: { fontFamily: FONT, fontSize: 11, color: T.label2 },
527
+ }, translate(props, 'panel.loading')),
528
+ ]),
529
+ ])
530
+ }
531
+
532
+ const reference = h('div', {
533
+ key: 'reference',
534
+ style: {
535
+ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: 6,
536
+ padding: '12px 14px', borderRight: `0.5px solid ${T.borderL1}`, minWidth: 0,
537
+ width: 'min(38%, 420px)',
538
+ },
539
+ }, [
540
+ h('div', {
541
+ key: 'label',
542
+ style: {
543
+ display: 'flex', alignItems: 'center', gap: 6,
544
+ fontFamily: FONT, fontSize: 11, fontWeight: 600, color: T.label2,
545
+ textTransform: 'uppercase', letterSpacing: '0.04em',
546
+ },
547
+ }, [
548
+ translate(props, 'panel.reference'),
549
+ h('span', { key: 'spacer', style: { flex: '1 1 auto' } }),
550
+ h('span', {
551
+ key: 'meta',
552
+ style: { fontFamily: MONO, fontSize: 10, textTransform: 'none', letterSpacing: 0, fontWeight: 400 },
553
+ }, `${clone.viewport} · ${formatMs(clone.durationMs)}`),
554
+ ]),
555
+ h('div', {
556
+ key: 'image',
557
+ style: {
558
+ border: `0.5px solid ${T.borderL2}`, borderRadius: 8, overflow: 'hidden',
559
+ background: T.layer2, minHeight: 0,
560
+ },
561
+ }, h('img', {
562
+ src: clone.reference.url,
563
+ alt: clone.reference.label,
564
+ style: { display: 'block', width: '100%', height: 'auto' },
565
+ })),
566
+ clone.picked === null ? null : h('div', {
567
+ key: 'adopted',
568
+ style: { fontFamily: MONO, fontSize: 10.5, color: T.label2, wordBreak: 'break-all' },
569
+ }, `${translate(props, 'panel.adoptedAt')}: ${clone.picked.path}`),
570
+ notice === null ? null : h('div', {
571
+ key: 'notice',
572
+ style: { fontFamily: MONO, fontSize: 10.5, color: T.label2, wordBreak: 'break-word' },
573
+ }, notice),
574
+ ])
575
+
576
+ const lanes = h('div', {
577
+ key: 'lanes',
578
+ style: {
579
+ flex: '1 1 auto', minWidth: 0, minHeight: 0, overflow: 'auto',
580
+ padding: 12, display: 'grid', gap: 12,
581
+ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
582
+ alignContent: 'start',
583
+ },
584
+ }, clone.lanes.map((lane) => h(LaneCard, {
585
+ key: lane.index,
586
+ props,
587
+ clone,
588
+ lane,
589
+ busy,
590
+ onAdopt: (laneNumber) => act(
591
+ { cloneId: clone.id, action: 'pick', lane: laneNumber },
592
+ `Lane ${laneNumber} adopted.`,
593
+ ),
594
+ })))
595
+
596
+ return h('div', {
597
+ style: {
598
+ boxSizing: 'border-box', display: 'flex', flexDirection: 'column',
599
+ width: '100%', height: '100%', minHeight: 0, overflow: 'hidden', background: T.bg,
600
+ },
601
+ }, [
602
+ header,
603
+ h('div', {
604
+ key: 'body',
605
+ style: { flex: '1 1 auto', display: 'flex', minHeight: 0, minWidth: 0 },
606
+ }, [reference, lanes]),
607
+ ])
608
+ }
609
+
610
+ // ── composer chip ───────────────────────────────────────────────────────
611
+
612
+ function CloneChip(props) {
613
+ const { clones } = useClones(props)
614
+ const clone = clones.length === 0 ? null : clones[0]
615
+ if (clone === null) return null
616
+ if (clone.status !== 'running' && clone.picked !== null) return null
617
+ const summary = clone.summary ?? { done: 0, total: clone.lanes.length, delivered: 0 }
618
+ const label = clone.status === 'running'
619
+ ? `${summary.done}/${summary.total}`
620
+ : `${summary.delivered} ${translate(props, 'panel.settled')}`
621
+ return h('div', {
622
+ style: {
623
+ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 2px 0',
624
+ fontFamily: FONT, fontSize: 11, color: T.label2,
625
+ },
626
+ title: clone.note.length > 0 ? clone.note : clone.id,
627
+ }, [
628
+ h(Dot, { key: 'dot', status: clone.status }),
629
+ h('span', { key: 'label' }, `${translate(props, 'panel.chip')} · ${label}`),
630
+ ])
631
+ }
632
+
633
+ // ── client plugin entry ─────────────────────────────────────────────────
634
+
635
+ const VIEW_SLOT = 'conversation.view'
636
+ const VIEW_ID = 'dsh-clone:panel'
637
+ const VIEW_ORDER = 46
638
+ const DOCK_SLOT = 'conversation.composer.dock'
639
+ const DOCK_ID = 'dsh-clone:chip'
640
+
641
+ function apply(ctx) {
642
+ // A keyframe for the pending lane placeholder; component-scoped so it
643
+ // disappears with the plugin.
644
+ if (typeof document !== 'undefined' && document.head) {
645
+ const style = document.createElement('style')
646
+ style.setAttribute('data-dsh-clone', 'animations')
647
+ style.textContent = '@keyframes dshClonePulse{0%,100%{opacity:.35}50%{opacity:1}}'
648
+ document.head.appendChild(style)
649
+ }
650
+
651
+ ctx.inject(['slots'], (scope) => {
652
+ scope.effect(() => {
653
+ const disposeView = scope.slots.inject(VIEW_SLOT, () => scope.slots.register({
654
+ name: VIEW_SLOT,
655
+ id: VIEW_ID,
656
+ order: VIEW_ORDER,
657
+ label: () => FALLBACK['panel.title'],
658
+ }, ClonePanel))
659
+
660
+ const disposeChip = scope.slots.inject(DOCK_SLOT, () => scope.slots.register({
661
+ name: DOCK_SLOT,
662
+ id: DOCK_ID,
663
+ order: 6,
664
+ }, CloneChip))
665
+
666
+ return () => {
667
+ for (const dispose of [disposeChip, disposeView]) {
668
+ try {
669
+ if (typeof dispose === 'function') dispose()
670
+ } catch {
671
+ /* one disposer must not strand the other */
672
+ }
673
+ }
674
+ }
675
+ }, 'dsh-clone: register panel and chip')
676
+ })
677
+ }
678
+
679
+ exports.apply = apply
680
+ exports.inject = []
681
+ exports.__test = {
682
+ getStore: () => store,
683
+ isRunning,
684
+ formatMs,
685
+ formatTokens,
686
+ }
687
+
688
+ return module.exports
689
+ },
690
+ })
@@ -0,0 +1,9 @@
1
+ # dsh-clone bundle patch.
2
+ #
3
+ # One row: the Host half owns the clone engine (reference image, isolated lane
4
+ # directories, parallel subagents, static preview routes). The `dsh.client`
5
+ # declaration in package.json makes the Web app load ./client.js as the
6
+ # browser half, which draws the live side-by-side clone panel.
7
+ - insert:
8
+ - id: dsh-clone
9
+ name: dsh-clone