dsh-remote-control-plugin 0.0.0-stage → 0.3.1

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,563 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-remote-control-plugin',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useCallback, useEffect, useMemo, useRef, useState } = React
7
+
8
+ const S = {
9
+ page: { display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, color: 'var(--dsw-alias-label-primary)' },
10
+ header: {
11
+ display: 'flex', alignItems: 'center', gap: '10px', padding: '14px 18px',
12
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
13
+ },
14
+ title: { fontSize: '15px', fontWeight: 600, margin: 0 },
15
+ subtitle: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' },
16
+ grow: { flex: 1 },
17
+ body: { display: 'flex', flex: 1, minHeight: 0 },
18
+ column: { display: 'flex', flexDirection: 'column', minHeight: 0 },
19
+ list: { flex: 1, overflowY: 'auto', padding: '10px' },
20
+ row: {
21
+ display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 12px',
22
+ borderRadius: '10px', border: '1px solid var(--dsw-alias-border-l1)',
23
+ background: 'var(--dsw-alias-bg-layer-1)', marginBottom: '8px',
24
+ },
25
+ rowButton: { cursor: 'pointer', textAlign: 'left', width: '100%' },
26
+ rowActive: { borderColor: 'var(--dsw-alias-brand-primary)' },
27
+ name: { fontSize: '13px', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
28
+ meta: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
29
+ chip: {
30
+ fontSize: '11px', padding: '2px 8px', borderRadius: '999px',
31
+ border: '1px solid var(--dsw-alias-border-l1)', color: 'var(--dsw-alias-label-secondary)',
32
+ whiteSpace: 'nowrap',
33
+ },
34
+ chipOk: { color: 'var(--dsw-alias-state-success-primary)', borderColor: 'var(--dsw-alias-state-success-primary)' },
35
+ chipWarn: { color: 'var(--dsw-alias-state-warn-primary)', borderColor: 'var(--dsw-alias-state-warn-primary)' },
36
+ chipBad: { color: 'var(--dsw-alias-state-error-primary)', borderColor: 'var(--dsw-alias-state-error-primary)' },
37
+ button: {
38
+ font: 'inherit', fontSize: '12px', padding: '6px 12px', borderRadius: '8px', cursor: 'pointer',
39
+ border: '1px solid var(--dsw-alias-border-l1)', background: 'var(--dsw-alias-bg-layer-2)',
40
+ color: 'var(--dsw-alias-label-primary)',
41
+ },
42
+ primary: { background: 'var(--dsw-alias-brand-primary)', borderColor: 'var(--dsw-alias-brand-primary)', color: '#fff' },
43
+ danger: { color: 'var(--dsw-alias-state-error-primary)', borderColor: 'var(--dsw-alias-state-error-primary)' },
44
+ notice: {
45
+ margin: '10px', padding: '10px 12px', borderRadius: '10px',
46
+ border: '1px solid var(--dsw-alias-state-warn-primary)',
47
+ background: 'var(--dsw-alias-bg-layer-1)', fontSize: '12px',
48
+ },
49
+ log: { flex: 1, overflowY: 'auto', padding: '14px 18px', display: 'flex', flexDirection: 'column', gap: '8px' },
50
+ bubble: {
51
+ maxWidth: '78%', padding: '8px 11px', borderRadius: '12px', fontSize: '13px',
52
+ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', border: '1px solid var(--dsw-alias-border-l1)',
53
+ },
54
+ userBubble: { alignSelf: 'flex-end', background: 'var(--dsw-alias-bg-layer-2)' },
55
+ assistantBubble: { alignSelf: 'flex-start', background: 'var(--dsw-alias-bg-layer-1)' },
56
+ toolRow: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', alignSelf: 'flex-start' },
57
+ note: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', alignSelf: 'center' },
58
+ composer: {
59
+ display: 'flex', gap: '8px', alignItems: 'flex-end', padding: '10px 14px',
60
+ borderTop: '1px solid var(--dsw-alias-border-l1)',
61
+ },
62
+ input: {
63
+ flex: 1, resize: 'none', font: 'inherit', fontSize: '13px', padding: '8px 10px', minHeight: '38px',
64
+ maxHeight: '140px', borderRadius: '10px', color: 'var(--dsw-alias-label-primary)',
65
+ background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l1)',
66
+ },
67
+ empty: { margin: 'auto', padding: '24px', textAlign: 'center', fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' },
68
+ pane: { width: '300px', borderRight: '1px solid var(--dsw-alias-border-l1)' },
69
+ field: {
70
+ font: 'inherit', fontSize: '12px', padding: '7px 9px', borderRadius: '8px', minWidth: '180px',
71
+ color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-2)',
72
+ border: '1px solid var(--dsw-alias-border-l1)',
73
+ },
74
+ error: { fontSize: '12px', color: 'var(--dsw-alias-state-error-primary)', padding: '8px 18px' },
75
+ }
76
+
77
+ async function api(path, body) {
78
+ const response = await fetch(path, {
79
+ method: body === undefined ? 'GET' : 'POST',
80
+ credentials: 'same-origin',
81
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
82
+ body: body === undefined ? undefined : JSON.stringify(body),
83
+ })
84
+ const data = await response.json().catch(() => ({}))
85
+ if (!response.ok) throw new Error(data.message || data.error || `HTTP ${response.status}`)
86
+ return data
87
+ }
88
+
89
+ function shortAccount(userId) {
90
+ if (!userId) return 'signed out'
91
+ return String(userId).slice(0, 8)
92
+ }
93
+
94
+ function relativeTime(value) {
95
+ const delta = Date.now() - Number(value || 0)
96
+ if (!Number.isFinite(delta)) return ''
97
+ const seconds = Math.round(delta / 1000)
98
+ if (seconds < 10) return 'now'
99
+ if (seconds < 60) return `${seconds}s ago`
100
+ const minutes = Math.round(seconds / 60)
101
+ if (minutes < 60) return `${minutes}m ago`
102
+ return `${Math.round(minutes / 60)}h ago`
103
+ }
104
+
105
+ function Icon({ size, active }) {
106
+ const edge = size || 20
107
+ return h('svg', {
108
+ viewBox: '0 0 24 24', width: edge, height: edge, fill: 'none',
109
+ stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round',
110
+ style: { opacity: active ? 1 : 0.72 },
111
+ },
112
+ h('rect', { x: 3, y: 4, width: 18, height: 12, rx: 2 }),
113
+ h('path', { d: 'M8 20h8M12 16v4' }),
114
+ h('path', { d: 'M7.5 10h2l1.5 2 1.5-4 1.5 2h2.5' }),
115
+ )
116
+ }
117
+
118
+ /* ---------------- chat ---------------- */
119
+
120
+ function Chat({ peerId, sessionId, title }) {
121
+ const [entries, setEntries] = useState([])
122
+ const [live, setLive] = useState('')
123
+ const [running, setRunning] = useState(false)
124
+ const [draft, setDraft] = useState('')
125
+ const [error, setError] = useState(null)
126
+ const seen = useRef(new Set())
127
+ const logRef = useRef(null)
128
+ const pinned = useRef(true)
129
+
130
+ useEffect(() => {
131
+ seen.current = new Set()
132
+ setEntries([])
133
+ setLive('')
134
+ setRunning(false)
135
+ setError(null)
136
+ if (!peerId || !sessionId) return undefined
137
+ const source = new EventSource(
138
+ `/remote-control/api/peer/${encodeURIComponent(peerId)}/stream?sessionId=${encodeURIComponent(sessionId)}`,
139
+ )
140
+ source.onmessage = (event) => {
141
+ let frame
142
+ try { frame = JSON.parse(event.data) } catch { return }
143
+ if (frame.type === 'snapshot') {
144
+ const batch = []
145
+ for (const record of frame.records || []) {
146
+ const entry = toEntry(record?.event, seen.current)
147
+ if (entry) batch.push(entry)
148
+ }
149
+ if (batch.length > 0) setEntries((current) => current.concat(batch))
150
+ return
151
+ }
152
+ if (frame.type === 'event') {
153
+ const entry = toEntry(frame.event, seen.current)
154
+ if (!entry) return
155
+ if (entry.kind === 'turn-start') { setRunning(true); return }
156
+ if (entry.kind === 'turn-end') { setRunning(false); return }
157
+ if (entry.kind === 'live-reset') { setLive(''); return }
158
+ if (entry.kind === 'assistant') setLive('')
159
+ setEntries((current) => current.concat([entry]))
160
+ return
161
+ }
162
+ if (frame.type === 'assistant-stream') {
163
+ const inner = frame.frame || {}
164
+ if (inner.type === 'start') { setLive(''); return }
165
+ if (inner.type === 'chunk' && inner.chunk?.type === 'text-delta') {
166
+ setLive((current) => current + String(inner.chunk.text ?? ''))
167
+ }
168
+ if (inner.type === 'end') {
169
+ setTimeout(() => setLive(''), 700)
170
+ }
171
+ return
172
+ }
173
+ if (frame.type === 'remote-error') setError(`${frame.code || 'error'}: ${frame.message || 'stream failed'}`)
174
+ }
175
+ source.onerror = () => { /* EventSource retries on its own */ }
176
+ return () => source.close()
177
+ }, [peerId, sessionId])
178
+
179
+ useEffect(() => {
180
+ if (!pinned.current) return
181
+ const node = logRef.current
182
+ if (node) node.scrollTop = node.scrollHeight
183
+ }, [entries, live])
184
+
185
+ const send = useCallback(async () => {
186
+ const text = draft.trim()
187
+ if (!text || !peerId || !sessionId) return
188
+ setDraft('')
189
+ setError(null)
190
+ try {
191
+ await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/prompt`, { sessionId, text })
192
+ } catch (failure) {
193
+ setDraft(text)
194
+ setError(failure.message)
195
+ }
196
+ }, [draft, peerId, sessionId])
197
+
198
+ const stop = useCallback(async () => {
199
+ try {
200
+ await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/cancel`, { sessionId })
201
+ } catch (failure) {
202
+ setError(failure.message)
203
+ }
204
+ }, [peerId, sessionId])
205
+
206
+ return h('div', { style: { ...S.column, flex: 1, minHeight: 0 } },
207
+ h('div', {
208
+ style: { padding: '10px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1)', fontSize: '13px', fontWeight: 600 },
209
+ }, title || sessionId),
210
+ error ? h('div', { style: S.error }, error) : null,
211
+ h('div', {
212
+ ref: logRef,
213
+ style: S.log,
214
+ onScroll: (event) => {
215
+ const node = event.currentTarget
216
+ pinned.current = node.scrollHeight - node.scrollTop - node.clientHeight < 60
217
+ },
218
+ },
219
+ entries.length === 0 && !live
220
+ ? h('div', { style: S.empty }, 'No messages yet. Send one below.')
221
+ : entries.map((entry) => renderEntry(entry)),
222
+ live ? h('div', { style: { ...S.bubble, ...S.assistantBubble, opacity: 0.85 } }, live) : null,
223
+ ),
224
+ h('div', { style: S.composer },
225
+ h('textarea', {
226
+ style: S.input,
227
+ rows: 1,
228
+ value: draft,
229
+ placeholder: 'Message this device…',
230
+ onChange: (event) => setDraft(event.target.value),
231
+ onKeyDown: (event) => {
232
+ if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() }
233
+ },
234
+ }),
235
+ running ? h('button', { type: 'button', style: { ...S.button, ...S.danger }, onClick: stop }, 'Stop') : null,
236
+ h('button', { type: 'button', style: { ...S.button, ...S.primary }, onClick: send, disabled: !draft.trim() }, 'Send'),
237
+ ),
238
+ )
239
+ }
240
+
241
+ function renderEntry(entry) {
242
+ if (entry.kind === 'user') return h('div', { key: entry.key, style: { ...S.bubble, ...S.userBubble } }, entry.text)
243
+ if (entry.kind === 'assistant') return h('div', { key: entry.key, style: { ...S.bubble, ...S.assistantBubble } }, entry.text)
244
+ if (entry.kind === 'tool') return h('div', { key: entry.key, style: S.toolRow }, `⚙ ${entry.text}`)
245
+ return h('div', { key: entry.key, style: S.note }, entry.text)
246
+ }
247
+
248
+ function blocksText(content) {
249
+ if (typeof content === 'string') return content
250
+ if (!Array.isArray(content)) return ''
251
+ return content.map((block) => (block && block.type === 'text' ? String(block.text ?? '') : '')).join('')
252
+ }
253
+
254
+ function toEntry(event, seen) {
255
+ if (!event || typeof event.seq !== 'number' || seen.has(event.seq)) return null
256
+ seen.add(event.seq)
257
+ const key = `e${event.seq}`
258
+ const data = event.data || {}
259
+ switch (event.type) {
260
+ case 'user/message': {
261
+ const kind = data.source?.kind
262
+ if (kind && kind !== 'user') return null
263
+ const text = blocksText(data.content ?? data.message?.content)
264
+ return text ? { key, kind: 'user', text } : null
265
+ }
266
+ case 'assistant/message': {
267
+ const text = blocksText(data.message?.content)
268
+ return text ? { key, kind: 'assistant', text } : null
269
+ }
270
+ case 'tool/call':
271
+ return { key, kind: 'tool', text: String(data.name || 'tool') }
272
+ case 'turn/start':
273
+ return { key, kind: 'turn-start' }
274
+ case 'turn/end':
275
+ return { key, kind: 'turn-end' }
276
+ case 'session/title':
277
+ return null
278
+ default:
279
+ return null
280
+ }
281
+ }
282
+
283
+ /* ---------------- devices page ---------------- */
284
+
285
+ function DevicesPage() {
286
+ const [state, setState] = useState(null)
287
+ const [error, setError] = useState(null)
288
+ const [busy, setBusy] = useState(null)
289
+ const [openPeer, setOpenPeer] = useState(null)
290
+ const [sessions, setSessions] = useState(null)
291
+ const [sessionId, setSessionId] = useState(null)
292
+ const [relayOpen, setRelayOpen] = useState(false)
293
+ const [relayUrl, setRelayUrl] = useState('')
294
+ const [relayKey, setRelayKey] = useState('')
295
+
296
+ const refresh = useCallback(async () => {
297
+ try {
298
+ const value = await api('/remote-control/api/state')
299
+ setState(value)
300
+ setError(null)
301
+ } catch (failure) {
302
+ setError(failure.message)
303
+ }
304
+ }, [])
305
+
306
+ useEffect(() => {
307
+ let alive = true
308
+ refresh()
309
+ const timer = setInterval(() => { if (alive) refresh() }, 3000)
310
+ return () => { alive = false; clearInterval(timer) }
311
+ }, [refresh])
312
+
313
+ const approve = useCallback(async (requestId) => {
314
+ setBusy(requestId)
315
+ try { await api('/remote-control/api/approve', { requestId }) } catch (failure) { setError(failure.message) }
316
+ setBusy(null)
317
+ refresh()
318
+ }, [refresh])
319
+
320
+ const deny = useCallback(async (requestId) => {
321
+ setBusy(requestId)
322
+ try { await api('/remote-control/api/deny', { requestId }) } catch (failure) { setError(failure.message) }
323
+ setBusy(null)
324
+ refresh()
325
+ }, [refresh])
326
+
327
+ const connect = useCallback(async (deviceId) => {
328
+ setBusy(deviceId)
329
+ try { await api('/remote-control/api/connect', { deviceId }) } catch (failure) { setError(failure.message) }
330
+ setBusy(null)
331
+ refresh()
332
+ }, [refresh])
333
+
334
+ const open = useCallback(async (peer) => {
335
+ setOpenPeer(peer)
336
+ setSessions(null)
337
+ setSessionId(null)
338
+ try {
339
+ const value = await api(`/remote-control/api/peer/${encodeURIComponent(peer.deviceId)}/sessions`)
340
+ setSessions(value.items || [])
341
+ } catch (failure) {
342
+ setError(failure.message)
343
+ setSessions([])
344
+ }
345
+ }, [])
346
+
347
+ const saveRelay = useCallback(async () => {
348
+ setBusy('relay')
349
+ setError(null)
350
+ const parts = relayUrl.trim().split(/\s+/)
351
+ const url = parts[0] || ''
352
+ const key = parts[1] || relayKey
353
+ try {
354
+ await api('/remote-control/api/relay', { url, key })
355
+ setRelayKey('')
356
+ setRelayOpen(false)
357
+ } catch (failure) {
358
+ setError(failure.message)
359
+ }
360
+ setBusy(null)
361
+ refresh()
362
+ }, [relayUrl, relayKey, refresh])
363
+
364
+ const hostRelay = useCallback(async () => {
365
+ setBusy('relay')
366
+ setError(null)
367
+ try {
368
+ await api('/remote-control/api/relay/host', { enabled: true })
369
+ } catch (failure) {
370
+ setError(failure.message)
371
+ }
372
+ setBusy(null)
373
+ refresh()
374
+ }, [refresh])
375
+
376
+ const stopHosting = useCallback(async () => {
377
+ setBusy('relay')
378
+ try { await api('/remote-control/api/relay/host', { enabled: false }) } catch (failure) { setError(failure.message) }
379
+ setBusy(null)
380
+ refresh()
381
+ }, [refresh])
382
+
383
+ const clearRelay = useCallback(async () => {
384
+ setBusy('relay')
385
+ try { await api('/remote-control/api/relay/clear', {}) } catch (failure) { setError(failure.message) }
386
+ setBusy(null)
387
+ refresh()
388
+ }, [refresh])
389
+
390
+ const peers = state?.peers ?? []
391
+ const requests = state?.requests ?? []
392
+ const self = state?.self
393
+ const relay = state?.relay
394
+
395
+ return h('div', { style: S.page },
396
+ h('div', { style: S.header },
397
+ h('div', null,
398
+ h('h2', { style: S.title }, 'Devices'),
399
+ h('div', { style: S.subtitle },
400
+ self ? `${self.deviceName} · ${shortAccount(self.userId)} · port ${self.port}` : 'Discovering…'),
401
+ ),
402
+ h('div', { style: S.grow }),
403
+ h('span', { style: { ...S.chip, ...(relay?.connected ? S.chipOk : {}) } },
404
+ relay?.connected ? `Relay · ${relay.peers}` : 'LAN only'),
405
+ h('button', { type: 'button', style: S.button, onClick: () => setRelayOpen((value) => !value) }, 'Relay'),
406
+ h('span', { style: S.chip }, `${peers.length} found`),
407
+ h('button', { type: 'button', style: S.button, onClick: refresh }, 'Refresh'),
408
+ ),
409
+ error ? h('div', { style: S.error }, error) : null,
410
+
411
+ relayOpen
412
+ ? h('div', { style: S.notice },
413
+ relay?.hosted
414
+ ? h('div', null,
415
+ h('div', { style: { ...S.meta, marginBottom: '6px' } },
416
+ 'This device is hosting the relay. Paste this invite into the other device’s Relay panel.'),
417
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
418
+ h('input', {
419
+ style: { ...S.field, flex: 1, minWidth: '260px' },
420
+ readOnly: true,
421
+ value: relay.invite || '',
422
+ onFocus: (event) => event.target.select(),
423
+ }),
424
+ h('button', {
425
+ type: 'button',
426
+ style: S.button,
427
+ onClick: () => { try { navigator.clipboard?.writeText(relay.invite || '') } catch { /* ignore */ } },
428
+ }, 'Copy'),
429
+ h('button', { type: 'button', style: S.button, disabled: busy === 'relay', onClick: stopHosting }, 'Stop hosting'),
430
+ ),
431
+ h('div', { style: { ...S.meta, marginTop: '6px' } },
432
+ `Reachable at ${(relay.urls || []).join(', ') || 'this machine'} — forward that port (or set publicUrl) so other networks can reach it.`),
433
+ )
434
+ : h('div', null,
435
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
436
+ h('input', {
437
+ style: S.field,
438
+ placeholder: 'relay URL or invite',
439
+ value: relayUrl,
440
+ onChange: (event) => setRelayUrl(event.target.value),
441
+ }),
442
+ h('input', {
443
+ style: S.field,
444
+ type: 'password',
445
+ placeholder: 'workspace key',
446
+ value: relayKey,
447
+ onChange: (event) => setRelayKey(event.target.value),
448
+ }),
449
+ h('button', {
450
+ type: 'button',
451
+ style: { ...S.button, ...S.primary },
452
+ disabled: busy === 'relay' || !relayUrl.trim(),
453
+ onClick: saveRelay,
454
+ }, 'Connect'),
455
+ h('button', { type: 'button', style: S.button, disabled: busy === 'relay', onClick: hostRelay }, 'Create relay here'),
456
+ relay?.connected
457
+ ? h('button', { type: 'button', style: S.button, disabled: busy === 'relay', onClick: clearRelay }, 'Disconnect')
458
+ : null,
459
+ ),
460
+ h('div', { style: { ...S.meta, marginTop: '6px' } },
461
+ 'Paste an invite (URL and key), or create a relay here and share it with your other devices.'),
462
+ ),
463
+ )
464
+ : null,
465
+
466
+ requests.length > 0
467
+ ? h('div', null, requests.map((request) => h('div', { key: request.requestId, style: S.notice },
468
+ h('div', { style: { marginBottom: '8px' } },
469
+ h('strong', null, request.deviceName),
470
+ ` wants to control this computer (account ${shortAccount(request.userId)}).`),
471
+ h('div', { style: { display: 'flex', gap: '8px' } },
472
+ h('button', {
473
+ type: 'button', style: { ...S.button, ...S.primary }, disabled: busy === request.requestId,
474
+ onClick: () => approve(request.requestId),
475
+ }, 'Allow'),
476
+ h('button', {
477
+ type: 'button', style: S.button, disabled: busy === request.requestId,
478
+ onClick: () => deny(request.requestId),
479
+ }, 'Deny'),
480
+ ),
481
+ )))
482
+ : null,
483
+
484
+ h('div', { style: S.body },
485
+ h('div', { style: { ...S.column, ...S.pane } },
486
+ h('div', { style: S.list },
487
+ peers.length === 0
488
+ ? h('div', { style: S.empty }, 'No other DSH device found on this network yet.')
489
+ : peers.map((peer) => h('div', {
490
+ key: peer.deviceId,
491
+ style: {
492
+ ...S.row,
493
+ ...(peer.approved ? S.rowButton : {}),
494
+ ...(openPeer?.deviceId === peer.deviceId ? S.rowActive : {}),
495
+ },
496
+ onClick: peer.approved ? () => open(peer) : undefined,
497
+ },
498
+ h('div', { style: { flex: 1, minWidth: 0 } },
499
+ h('div', { style: S.name }, peer.deviceName),
500
+ h('div', { style: S.meta }, `${peer.address}:${peer.port} · ${peer.userId ? shortAccount(peer.userId) : 'signed out'} · ${peer.transport === 'relay' ? 'relay' : 'LAN'} · ${relativeTime(peer.lastSeen)}`),
501
+ ),
502
+ peer.approved
503
+ ? h('span', { style: { ...S.chip, ...S.chipOk } }, 'Trusted')
504
+ : peer.connecting
505
+ ? h('span', { style: { ...S.chip, ...S.chipWarn } }, 'Waiting')
506
+ : h('button', {
507
+ type: 'button', style: S.button, disabled: busy === peer.deviceId,
508
+ onClick: (event) => { event.stopPropagation(); connect(peer.deviceId) },
509
+ }, 'Request'),
510
+ peer.userId && self?.userId && peer.userId !== self.userId
511
+ ? h('span', { style: { ...S.chip, ...S.chipBad } }, 'Other account')
512
+ : null,
513
+ )),
514
+ ),
515
+ ),
516
+
517
+ openPeer
518
+ ? h('div', { style: { ...S.column, flex: 1, minWidth: 0 } },
519
+ sessions === null
520
+ ? h('div', { style: S.empty }, 'Loading sessions…')
521
+ : sessions.length === 0
522
+ ? h('div', { style: S.empty }, 'This device has no sessions.')
523
+ : h('div', { style: { display: 'flex', flex: 1, minHeight: 0 } },
524
+ h('div', { style: { ...S.column, ...S.pane, width: '220px' } },
525
+ h('div', { style: S.list }, sessions.map((session) => h('div', {
526
+ key: session.id,
527
+ style: { ...S.row, ...S.rowButton, ...(sessionId === session.id ? S.rowActive : {}) },
528
+ onClick: () => setSessionId(session.id),
529
+ },
530
+ h('div', { style: { minWidth: 0 } },
531
+ h('div', { style: S.name }, session.title || session.cwd || session.id),
532
+ h('div', { style: S.meta },
533
+ [session.running ? 'running' : '', relativeTime(session.updatedAt)].filter(Boolean).join(' · ')),
534
+ ),
535
+ ))),
536
+ ),
537
+ sessionId
538
+ ? h(Chat, {
539
+ peerId: openPeer.deviceId,
540
+ sessionId,
541
+ title: sessions.find((session) => session.id === sessionId)?.title,
542
+ })
543
+ : h('div', { style: S.empty }, 'Pick a session to control it.'),
544
+ ),
545
+ )
546
+ : h('div', { style: S.empty }, 'Select a trusted device to see its sessions.'),
547
+ ),
548
+ )
549
+ }
550
+
551
+ return {
552
+ inject: ['slots'],
553
+ apply(ctx) {
554
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
555
+ name: 'sidebar.panellist', id: 'devices', order: 20, label: () => 'Devices',
556
+ }, Icon))
557
+ ctx.slots.inject('main', () => ctx.slots.register({
558
+ name: 'main', key: 'devices',
559
+ }, DevicesPage))
560
+ },
561
+ }
562
+ },
563
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: remote-control
3
+ name: 'dsh-remote-control-plugin'