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/web/app.js ADDED
@@ -0,0 +1,594 @@
1
+ const $ = (id) => document.getElementById(id)
2
+
3
+ const el = {
4
+ login: $('login'),
5
+ app: $('app'),
6
+ pairForm: $('pair-form'),
7
+ code: $('code'),
8
+ pairSubmit: $('pair-submit'),
9
+ pairError: $('pair-error'),
10
+ menu: $('menu'),
11
+ drawer: $('drawer'),
12
+ scrim: $('scrim'),
13
+ sessionList: $('session-list'),
14
+ newSession: $('new-session'),
15
+ logout: $('logout'),
16
+ title: $('session-title'),
17
+ meta: $('session-meta'),
18
+ status: $('status'),
19
+ log: $('log'),
20
+ empty: $('empty'),
21
+ composer: $('composer'),
22
+ input: $('input'),
23
+ send: $('send'),
24
+ stop: $('stop'),
25
+ }
26
+
27
+ const state = {
28
+ sessions: [],
29
+ sessionId: null,
30
+ source: null,
31
+ seen: new Set(),
32
+ lastSeq: 0,
33
+ running: false,
34
+ pinned: true,
35
+ live: null,
36
+ liveText: '',
37
+ tools: new Map(),
38
+ }
39
+
40
+ /* ---------------- transport ---------------- */
41
+
42
+ async function request(path, body) {
43
+ const response = await fetch(path, {
44
+ method: body === undefined ? 'GET' : 'POST',
45
+ credentials: 'same-origin',
46
+ headers: body === undefined ? undefined : { 'content-type': 'application/json' },
47
+ body: body === undefined ? undefined : JSON.stringify(body),
48
+ })
49
+ const data = await response.json().catch(() => ({}))
50
+ if (!response.ok) {
51
+ const error = new Error(data.message || data.error || `HTTP ${response.status}`)
52
+ error.status = response.status
53
+ throw error
54
+ }
55
+ return data
56
+ }
57
+
58
+ /* ---------------- boot ---------------- */
59
+
60
+ boot()
61
+
62
+ async function boot() {
63
+ try {
64
+ const me = await request('/api/me')
65
+ if (me.authenticated) startApp()
66
+ else showLogin()
67
+ } catch {
68
+ showLogin()
69
+ }
70
+ }
71
+
72
+ function showLogin() {
73
+ el.app.hidden = true
74
+ el.login.hidden = false
75
+ el.code.focus()
76
+ }
77
+
78
+ el.pairForm.addEventListener('submit', async (event) => {
79
+ event.preventDefault()
80
+ el.pairError.hidden = true
81
+ el.pairSubmit.disabled = true
82
+ try {
83
+ await request('/api/pair', { code: el.code.value.trim() })
84
+ location.reload()
85
+ } catch (error) {
86
+ el.pairError.textContent = error.message
87
+ el.pairError.hidden = false
88
+ el.code.select()
89
+ } finally {
90
+ el.pairSubmit.disabled = false
91
+ }
92
+ })
93
+
94
+ function startApp() {
95
+ el.login.hidden = true
96
+ el.app.hidden = false
97
+
98
+ el.menu.addEventListener('click', openDrawer)
99
+ el.scrim.addEventListener('click', closeDrawer)
100
+ el.newSession.addEventListener('click', createSession)
101
+ el.logout.addEventListener('click', async () => {
102
+ try { await request('/api/logout', {}) } catch { /* ignore */ }
103
+ location.reload()
104
+ })
105
+ el.composer.addEventListener('submit', send)
106
+ el.input.addEventListener('input', autosize)
107
+ el.input.addEventListener('keydown', (event) => {
108
+ if (event.key === 'Enter' && !event.shiftKey && !isTouch()) {
109
+ event.preventDefault()
110
+ send(event)
111
+ }
112
+ })
113
+ el.stop.addEventListener('click', stopTurn)
114
+ el.log.addEventListener('scroll', () => {
115
+ state.pinned = el.log.scrollHeight - el.log.scrollTop - el.log.clientHeight < 60
116
+ })
117
+
118
+ autosize()
119
+ loadSessions().catch((error) => addStrip(error.message))
120
+ }
121
+
122
+ function isTouch() {
123
+ return window.matchMedia('(pointer: coarse)').matches
124
+ }
125
+
126
+ /* ---------------- sessions ---------------- */
127
+
128
+ async function loadSessions() {
129
+ const value = await request('/api/sessions')
130
+ state.sessions = Array.isArray(value.items) ? value.items : []
131
+ renderSessions()
132
+ if (!state.sessionId && state.sessions.length > 0) {
133
+ const preferred = state.sessions.find((session) => session.running) || state.sessions[0]
134
+ selectSession(preferred.id)
135
+ } else if (state.sessionId) {
136
+ updateMeta()
137
+ }
138
+ }
139
+
140
+ function renderSessions() {
141
+ el.sessionList.textContent = ''
142
+ if (state.sessions.length === 0) {
143
+ const empty = document.createElement('p')
144
+ empty.className = 'muted tiny'
145
+ empty.style.padding = '10px 12px'
146
+ empty.textContent = 'No sessions yet.'
147
+ el.sessionList.append(empty)
148
+ return
149
+ }
150
+ for (const session of state.sessions) {
151
+ const button = document.createElement('button')
152
+ button.type = 'button'
153
+ button.className = 'session-item'
154
+ button.setAttribute('aria-current', String(session.id === state.sessionId))
155
+
156
+ const row1 = document.createElement('div')
157
+ row1.className = 'row1'
158
+ const name = document.createElement('span')
159
+ name.textContent = titleOf(session)
160
+ row1.append(name)
161
+ if (session.running) {
162
+ const dot = document.createElement('span')
163
+ dot.className = 'dot'
164
+ row1.append(dot)
165
+ }
166
+
167
+ const row2 = document.createElement('div')
168
+ row2.className = 'row2'
169
+ row2.textContent = subtitleOf(session)
170
+
171
+ button.append(row1, row2)
172
+ button.addEventListener('click', () => {
173
+ selectSession(session.id)
174
+ closeDrawer()
175
+ })
176
+ el.sessionList.append(button)
177
+ }
178
+ }
179
+
180
+ function titleOf(session) {
181
+ if (session.title) return session.title
182
+ if (session.cwd) return baseName(session.cwd)
183
+ return 'Untitled session'
184
+ }
185
+
186
+ function subtitleOf(session) {
187
+ const parts = []
188
+ if (session.cwd) parts.push(baseName(session.cwd))
189
+ if (session.updatedAt) parts.push(relativeTime(session.updatedAt))
190
+ if (session.running) parts.push('running')
191
+ return parts.join(' · ') || session.id
192
+ }
193
+
194
+ function baseName(value) {
195
+ const parts = String(value).split(/[\\/]/).filter(Boolean)
196
+ return parts[parts.length - 1] || String(value)
197
+ }
198
+
199
+ function relativeTime(value) {
200
+ const delta = Date.now() - Number(value)
201
+ if (!Number.isFinite(delta)) return ''
202
+ const minutes = Math.round(delta / 60000)
203
+ if (minutes < 1) return 'just now'
204
+ if (minutes < 60) return `${minutes}m ago`
205
+ const hours = Math.round(minutes / 60)
206
+ if (hours < 24) return `${hours}h ago`
207
+ const days = Math.round(hours / 24)
208
+ return `${days}d ago`
209
+ }
210
+
211
+ async function createSession() {
212
+ closeDrawer()
213
+ try {
214
+ const value = await request('/api/new-session', {})
215
+ if (value?.sessionId) {
216
+ await loadSessions()
217
+ selectSession(value.sessionId)
218
+ el.input.focus()
219
+ }
220
+ } catch (error) {
221
+ addStrip(`Could not create a session: ${error.message}`)
222
+ }
223
+ }
224
+
225
+ function selectSession(id) {
226
+ if (state.sessionId === id && state.source) return
227
+ state.sessionId = id
228
+ state.seen.clear()
229
+ state.lastSeq = 0
230
+ state.tools.clear()
231
+ state.live = null
232
+ state.liveText = ''
233
+ state.running = false
234
+ el.log.textContent = ''
235
+ el.log.append(el.empty)
236
+ el.empty.hidden = true
237
+ setRunning(false)
238
+ updateMeta()
239
+ for (const button of el.sessionList.children) {
240
+ button.setAttribute?.('aria-current', 'false')
241
+ }
242
+ renderSessions()
243
+ openStream()
244
+ }
245
+
246
+ function updateMeta() {
247
+ const session = state.sessions.find((entry) => entry.id === state.sessionId)
248
+ el.title.textContent = session ? titleOf(session) : 'No session'
249
+ el.meta.textContent = session ? subtitleOf(session) : ''
250
+ }
251
+
252
+ /* ---------------- stream ---------------- */
253
+
254
+ function openStream() {
255
+ state.source?.close()
256
+ setStatus('connecting')
257
+ const source = new EventSource(`/api/stream?sessionId=${encodeURIComponent(state.sessionId)}`)
258
+ state.source = source
259
+ source.onopen = () => setStatus('live')
260
+ source.onerror = () => setStatus('error')
261
+ source.onmessage = (event) => {
262
+ let frame
263
+ try { frame = JSON.parse(event.data) } catch { return }
264
+ handleFrame(frame)
265
+ }
266
+ }
267
+
268
+ function handleFrame(frame) {
269
+ if (!frame || typeof frame !== 'object') return
270
+ if (frame.type === 'snapshot') {
271
+ setStatus('live')
272
+ if (typeof frame.cursor === 'number') state.lastSeq = Math.max(state.lastSeq, frame.cursor)
273
+ for (const record of frame.records || []) {
274
+ if (record?.type === 'event' && record.event) appendEvent(record.event)
275
+ }
276
+ return
277
+ }
278
+ if (frame.type === 'event') {
279
+ appendEvent(frame.event)
280
+ return
281
+ }
282
+ if (frame.type === 'assistant-stream') {
283
+ handleLive(frame.frame)
284
+ return
285
+ }
286
+ if (frame.type === 'remote-error') {
287
+ addStrip(`${frame.code || 'error'}: ${frame.message || 'stream failed'}`)
288
+ setStatus('error')
289
+ }
290
+ }
291
+
292
+ function setStatus(value) {
293
+ const labels = { live: 'live', connecting: 'connecting', error: 'offline', idle: 'idle' }
294
+ el.status.dataset.state = value
295
+ el.status.lastElementChild.textContent = labels[value] || value
296
+ }
297
+
298
+ /* ---------------- rendering ---------------- */
299
+
300
+ function appendEvent(event) {
301
+ if (!event || typeof event.seq !== 'number' || state.seen.has(event.seq)) return
302
+ state.seen.add(event.seq)
303
+ state.lastSeq = Math.max(state.lastSeq, event.seq)
304
+
305
+ const data = event.data || {}
306
+ switch (event.type) {
307
+ case 'user/message': {
308
+ // Harness-injected context (runtime context, skill catalog, time context)
309
+ // is also stored as user/message; only genuine prompts belong in the chat.
310
+ const kind = data.source?.kind
311
+ if (kind && kind !== 'user') break
312
+ const text = blocksText(data.content ?? data.message?.content)
313
+ const images = Array.isArray(data.content)
314
+ ? data.content.filter((block) => block && block.type === 'image').length
315
+ : 0
316
+ if (text) addBubble('user', text)
317
+ else if (images > 0) addBubble('user', `[${images} image${images > 1 ? 's' : ''}]`)
318
+ break
319
+ }
320
+ case 'session/title': {
321
+ const title = typeof data.title === 'string' ? data.title.trim() : ''
322
+ if (title) {
323
+ el.title.textContent = title
324
+ const session = state.sessions.find((entry) => entry.id === state.sessionId)
325
+ if (session) session.title = title
326
+ }
327
+ break
328
+ }
329
+ case 'assistant/message': {
330
+ clearLive()
331
+ const text = blocksText(data.message?.content)
332
+ const reasoning = blocksReasoning(data.message?.content)
333
+ if (text || reasoning) addAssistant(text, reasoning, data.interrupted === true)
334
+ break
335
+ }
336
+ case 'assistant/attempt':
337
+ clearLive()
338
+ break
339
+ case 'tool/call':
340
+ addToolCall(data)
341
+ break
342
+ case 'tool/result':
343
+ addToolResult(data)
344
+ break
345
+ case 'turn/start':
346
+ setRunning(true)
347
+ break
348
+ case 'turn/end': {
349
+ setRunning(false)
350
+ const reason = data.reason || {}
351
+ if (reason.kind === 'error') {
352
+ addStrip(`Turn failed: ${reason.error?.message || reason.error?.code || 'unknown error'}`)
353
+ } else if (reason.kind === 'aborted') {
354
+ addNote('Stopped')
355
+ } else if (reason.kind === 'max-tokens') {
356
+ addNote('Stopped at the token limit')
357
+ }
358
+ break
359
+ }
360
+ default:
361
+ break
362
+ }
363
+ }
364
+
365
+ function blocksText(content) {
366
+ if (typeof content === 'string') return content
367
+ if (!Array.isArray(content)) return ''
368
+ return content
369
+ .map((block) => (block && block.type === 'text' ? String(block.text ?? '') : ''))
370
+ .join('')
371
+ }
372
+
373
+ function blocksReasoning(content) {
374
+ if (!Array.isArray(content)) return ''
375
+ return content
376
+ .map((block) => (block && block.type === 'reasoning' ? String(block.text ?? '') : ''))
377
+ .join('\n')
378
+ }
379
+
380
+ function addBubble(kind, text) {
381
+ const bubble = document.createElement('div')
382
+ bubble.className = `bubble ${kind}`
383
+ bubble.textContent = text
384
+ push(bubble)
385
+ return bubble
386
+ }
387
+
388
+ function addAssistant(text, reasoning, interrupted) {
389
+ const bubble = document.createElement('div')
390
+ bubble.className = 'bubble assistant'
391
+ if (interrupted) bubble.classList.add('interrupted')
392
+ if (reasoning) {
393
+ const details = document.createElement('details')
394
+ details.className = 'tool'
395
+ const summary = document.createElement('summary')
396
+ summary.textContent = 'Reasoning'
397
+ const pre = document.createElement('pre')
398
+ pre.textContent = reasoning
399
+ details.append(summary, pre)
400
+ bubble.append(details)
401
+ }
402
+ if (text) {
403
+ const body = document.createElement('div')
404
+ body.textContent = text
405
+ bubble.append(body)
406
+ }
407
+ if (interrupted) {
408
+ const note = document.createElement('span')
409
+ note.className = 'label'
410
+ note.textContent = 'interrupted'
411
+ bubble.append(note)
412
+ }
413
+ push(bubble)
414
+ }
415
+
416
+ function addToolCall(data) {
417
+ const name = String(data.name || 'tool')
418
+ let hint = ''
419
+ try {
420
+ const args = JSON.parse(data.arguments || '{}')
421
+ hint = args.command || args.path || args.query || args.description || args.file_path || ''
422
+ } catch {
423
+ hint = String(data.arguments || '')
424
+ }
425
+ const details = document.createElement('details')
426
+ details.className = 'tool'
427
+
428
+ const summary = document.createElement('summary')
429
+ const toolName = document.createElement('span')
430
+ toolName.className = 'tool-name'
431
+ toolName.textContent = name
432
+ const toolHint = document.createElement('span')
433
+ toolHint.className = 'tool-hint'
434
+ toolHint.textContent = String(hint).replace(/\s+/g, ' ').slice(0, 120)
435
+ summary.append('⚙', toolName, toolHint)
436
+
437
+ const pre = document.createElement('pre')
438
+ pre.textContent = prettyJson(data.arguments)
439
+ details.append(summary, pre)
440
+
441
+ if (data.callId) state.tools.set(data.callId, details)
442
+ push(details)
443
+ }
444
+
445
+ function addToolResult(data) {
446
+ const details = data.message?.toolCallId ? state.tools.get(data.message.toolCallId) : undefined
447
+ const output = blocksText(data.message?.content)
448
+ const failed = Boolean(data.error) || data.message?.isError === true
449
+ const target = details || document.createElement('details')
450
+
451
+ if (!details) {
452
+ target.className = 'tool'
453
+ const summary = document.createElement('summary')
454
+ summary.textContent = 'tool result'
455
+ target.append(summary)
456
+ push(target)
457
+ }
458
+ if (failed) target.classList.add('error')
459
+
460
+ const pre = document.createElement('pre')
461
+ if (failed && data.error) {
462
+ pre.textContent = `${data.error.name || 'error'}: ${data.error.reason || data.error.code || ''}`
463
+ if (output) pre.textContent += `\n\n${output}`
464
+ } else {
465
+ pre.textContent = output || '(no output)'
466
+ }
467
+ target.append(pre)
468
+ }
469
+
470
+ function addNote(text) {
471
+ const note = document.createElement('div')
472
+ note.className = 'note'
473
+ note.textContent = text
474
+ push(note)
475
+ }
476
+
477
+ function addStrip(text) {
478
+ const strip = document.createElement('div')
479
+ strip.className = 'strip'
480
+ strip.textContent = text
481
+ push(strip)
482
+ }
483
+
484
+ function push(node) {
485
+ el.empty.hidden = true
486
+ el.log.append(node)
487
+ if (state.pinned) {
488
+ requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
489
+ }
490
+ }
491
+
492
+ function prettyJson(value) {
493
+ if (typeof value !== 'string') return JSON.stringify(value, null, 2) ?? ''
494
+ try { return JSON.stringify(JSON.parse(value), null, 2) } catch { return value }
495
+ }
496
+
497
+ /* ---------------- live assistant stream ---------------- */
498
+
499
+ function handleLive(frame) {
500
+ if (!frame || typeof frame !== 'object') return
501
+ if (frame.type === 'start') {
502
+ clearLive()
503
+ state.live = document.createElement('div')
504
+ state.live.className = 'bubble assistant live'
505
+ state.liveText = ''
506
+ const body = document.createElement('div')
507
+ body.className = 'live-body'
508
+ state.live.append(body)
509
+ push(state.live)
510
+ return
511
+ }
512
+ if (frame.type === 'chunk') {
513
+ const chunk = frame.chunk || {}
514
+ if (!state.live) return
515
+ const body = state.live.querySelector('.live-body')
516
+ if (chunk.type === 'text-delta') {
517
+ state.liveText += String(chunk.text ?? '')
518
+ body.textContent = state.liveText
519
+ } else if (chunk.type === 'block-end' && chunk.block?.type === 'text') {
520
+ state.liveText = String(chunk.block.text ?? '')
521
+ body.textContent = state.liveText
522
+ }
523
+ if (state.pinned) requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
524
+ return
525
+ }
526
+ if (frame.type === 'end') {
527
+ const live = state.live
528
+ setTimeout(() => {
529
+ if (state.live === live) clearLive()
530
+ }, 800)
531
+ }
532
+ }
533
+
534
+ function clearLive() {
535
+ if (state.live) {
536
+ state.live.remove()
537
+ state.live = null
538
+ state.liveText = ''
539
+ }
540
+ }
541
+
542
+ /* ---------------- actions ---------------- */
543
+
544
+ function setRunning(value) {
545
+ state.running = value
546
+ el.stop.hidden = !value
547
+ el.send.disabled = false
548
+ }
549
+
550
+ async function send(event) {
551
+ event?.preventDefault()
552
+ const text = el.input.value.trim()
553
+ if (!text || !state.sessionId) return
554
+ el.input.value = ''
555
+ autosize()
556
+ try {
557
+ await request('/api/prompt', { sessionId: state.sessionId, text })
558
+ if (state.pinned) requestAnimationFrame(() => { el.log.scrollTop = el.log.scrollHeight })
559
+ } catch (error) {
560
+ el.input.value = text
561
+ autosize()
562
+ addStrip(`Could not send: ${error.message}`)
563
+ }
564
+ }
565
+
566
+ async function stopTurn() {
567
+ if (!state.sessionId) return
568
+ el.stop.disabled = true
569
+ try {
570
+ await request('/api/cancel', { sessionId: state.sessionId })
571
+ } catch (error) {
572
+ addStrip(`Could not stop: ${error.message}`)
573
+ } finally {
574
+ el.stop.disabled = false
575
+ }
576
+ }
577
+
578
+ function autosize() {
579
+ el.input.style.height = 'auto'
580
+ el.input.style.height = `${Math.min(el.input.scrollHeight, window.innerHeight * 0.4)}px`
581
+ }
582
+
583
+ /* ---------------- drawer ---------------- */
584
+
585
+ function openDrawer() {
586
+ el.drawer.hidden = false
587
+ el.scrim.hidden = false
588
+ loadSessions().catch(() => {})
589
+ }
590
+
591
+ function closeDrawer() {
592
+ el.drawer.hidden = true
593
+ el.scrim.hidden = true
594
+ }
package/web/icon.svg ADDED
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Remote Control">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#2f6bff"/>
5
+ <stop offset="1" stop-color="#7a3cff"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="url(#bg)"/>
9
+ <rect x="20" y="12" width="24" height="40" rx="6" fill="none" stroke="#ffffff" stroke-width="3.2"/>
10
+ <line x1="28" y1="17" x2="36" y2="17" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round"/>
11
+ <path d="M27 30l-4 5 4 5" fill="none" stroke="#dce6ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
12
+ <line x1="35" y1="29" x2="35" y2="41" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
13
+ <path d="M44 24c3 2.5 3 13.5 0 16" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.9"/>
14
+ <path d="M49 19c5.5 4.5 5.5 21.5 0 26" fill="none" stroke="#ffffff" stroke-width="2.6" stroke-linecap="round" opacity="0.55"/>
15
+ </svg>
package/web/index.html ADDED
@@ -0,0 +1,85 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <meta name="theme-color" content="#0b0d12" />
8
+ <title>DSH Remote Control</title>
9
+ <link rel="icon" href="/icon.svg" />
10
+ <link rel="stylesheet" href="/app.css" />
11
+ </head>
12
+ <body>
13
+ <section id="login" class="login" hidden>
14
+ <div class="login-card">
15
+ <img class="login-logo" src="/icon.svg" alt="" width="64" height="64" />
16
+ <h1>Remote Control</h1>
17
+ <p class="muted">
18
+ Enter the pairing code printed by the DeepSeek Harness Host. The code is
19
+ stored in <code>remote-control.json</code> inside your DSH home directory.
20
+ </p>
21
+ <form id="pair-form" autocomplete="off">
22
+ <label class="sr-only" for="code">Pairing code</label>
23
+ <input
24
+ id="code"
25
+ name="code"
26
+ inputmode="numeric"
27
+ pattern="[0-9]*"
28
+ maxlength="12"
29
+ placeholder="000000"
30
+ autocomplete="one-time-code"
31
+ spellcheck="false"
32
+ required
33
+ />
34
+ <button type="submit" id="pair-submit">Pair this device</button>
35
+ </form>
36
+ <p id="pair-error" class="error" role="alert" hidden></p>
37
+ <p class="muted tiny">
38
+ This page can send prompts and stop turns on the paired Host. Keep the URL
39
+ and code to yourself.
40
+ </p>
41
+ </div>
42
+ </section>
43
+
44
+ <section id="app" class="app" hidden>
45
+ <header class="bar">
46
+ <button id="menu" class="icon-button" type="button" aria-label="Sessions">
47
+ <svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
48
+ <path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
49
+ </svg>
50
+ </button>
51
+ <div class="bar-title">
52
+ <span id="session-title">No session</span>
53
+ <span id="session-meta" class="muted tiny"></span>
54
+ </div>
55
+ <span id="status" class="status" title="Stream status"><i></i><span>idle</span></span>
56
+ </header>
57
+
58
+ <div id="scrim" class="scrim" hidden></div>
59
+ <aside id="drawer" class="drawer" hidden>
60
+ <div class="drawer-head">
61
+ <strong>Sessions</strong>
62
+ <button id="new-session" class="ghost-button" type="button">New</button>
63
+ </div>
64
+ <div id="session-list" class="session-list"></div>
65
+ <div class="drawer-foot">
66
+ <button id="logout" class="link-button" type="button">Forget this device</button>
67
+ </div>
68
+ </aside>
69
+
70
+ <main id="log" class="log" aria-live="polite">
71
+ <div id="empty" class="empty">
72
+ <p>Pick a session from the list, or create a new one.</p>
73
+ </div>
74
+ </main>
75
+
76
+ <form id="composer" class="composer">
77
+ <textarea id="input" rows="1" placeholder="Message the agent…" enterkeyhint="send"></textarea>
78
+ <button id="stop" class="stop" type="button" hidden>Stop</button>
79
+ <button id="send" class="send" type="submit">Send</button>
80
+ </form>
81
+ </section>
82
+
83
+ <script type="module" src="/app.js"></script>
84
+ </body>
85
+ </html>