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/ACCEPTABLE-USE.md +28 -0
- package/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/client.js +690 -0
- package/cordis.patch.yml +9 -0
- package/docs/assets/icon-180.png +0 -0
- package/icon.svg +29 -0
- package/index.js +626 -0
- package/index.json +75 -0
- package/lib/engine.js +999 -0
- package/lib/parse.js +58 -0
- package/lib/png.js +175 -0
- package/lib/preview.js +141 -0
- package/lib/prompts.js +118 -0
- package/lib/rank.js +72 -0
- package/lib/render.js +148 -0
- package/lib/score-lane.js +38 -0
- package/lib/score.js +282 -0
- package/lib/util.js +85 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
- package/screenshots.json +6 -0
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
|
+
})
|
package/cordis.patch.yml
ADDED
|
@@ -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
|