@edryslabs/edrys-dev 0.3.2 → 0.3.4

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/views/panel.js CHANGED
@@ -10,10 +10,15 @@
10
10
  }
11
11
  return
12
12
  }
13
- // the chat only with an agent to answer it (edrys-dev --mcp)
14
- if (!document.currentScript || !document.currentScript.hasAttribute('data-chat')) return
13
+ // the chat only with an agent to answer it (edrys-dev --mcp), the students
14
+ // view only with a tutor (--tutor)
15
+ const script = document.currentScript
16
+ const hasChat = !!script && script.hasAttribute('data-chat')
17
+ const hasTutor = !!script && script.hasAttribute('data-tutor')
18
+ if (!hasChat && !hasTutor) return
15
19
 
16
20
  const OPEN_KEY = 'edrys-dev:chat-open'
21
+ const VIEW_KEY = 'edrys-dev:chat-view'
17
22
 
18
23
  const css = `
19
24
  :host { all: initial; }
@@ -53,6 +58,46 @@
53
58
  font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px;
54
59
  }
55
60
  header small { font-weight: 400; color: var(--muted); white-space: nowrap; }
61
+ .tabs { display: flex; gap: 2px; }
62
+ .tabs button {
63
+ font: inherit; font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
64
+ border: 1px solid transparent; background: transparent; color: var(--muted);
65
+ }
66
+ .tabs button[aria-selected="true"] { border-color: var(--line); color: var(--fg); font-weight: 600; }
67
+ .tabs .count { display: inline-block; min-width: 16px; padding: 0 4px; margin-left: 2px; border-radius: 8px;
68
+ background: #e0452b; color: #fff; font-size: 11px; font-weight: 600; }
69
+ .tabs .count:empty { display: none; }
70
+ .spacer { flex: 1; }
71
+ .view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
72
+ .view[hidden] { display: none; }
73
+ .share { padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 13px; }
74
+ .share[hidden] { display: none; }
75
+ .share .row { display: flex; gap: 6px; align-items: center; }
76
+ .share .row[hidden] { display: none; }
77
+ .share .row span { font-weight: 600; white-space: nowrap; }
78
+ .share input {
79
+ flex: 1; min-width: 0; font: 12px ui-monospace, monospace; padding: 4px 6px; border-radius: 6px;
80
+ border: 1px solid var(--line); background: var(--bg); color: var(--fg);
81
+ }
82
+ .share button {
83
+ font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer;
84
+ border: 1px solid var(--accent); background: transparent; color: var(--accent);
85
+ }
86
+ .share .note { margin-top: 2px; color: var(--muted); font-size: 12px; }
87
+ .share .note:empty { display: none; }
88
+ .switch { padding: 8px 12px; border-bottom: 1px solid var(--line); font-size: 13px; }
89
+ .switch label { display: flex; gap: 6px; align-items: center; font-weight: 600; }
90
+ .switch .note { margin-top: 2px; color: var(--muted); font-size: 12px; }
91
+ .threads { flex: 1; overflow-y: auto; padding: 8px 12px; display: flex; flex-direction: column; gap: 8px; }
92
+ .threads .empty { color: var(--muted); font-size: 13px; }
93
+ .thread { border: 1px solid var(--line); border-radius: 8px; }
94
+ .thread summary { padding: 6px 10px; cursor: pointer; font-size: 13px; font-weight: 600; }
95
+ .thread summary small { font-weight: 400; color: var(--muted); }
96
+ .thread .entries { padding: 0 8px 8px; display: flex; flex-direction: column; gap: 6px; }
97
+ .msg.student { background: var(--me); color: var(--me-fg); align-self: flex-end; white-space: pre-wrap; }
98
+ .msg.tutor { background: var(--ai); align-self: flex-start; }
99
+ .msg .meta { display: block; font-size: 11px; opacity: .75; margin-bottom: 2px; white-space: normal; }
100
+ .msg.failed { align-self: stretch; font-size: 12px; border: 1px solid #e0452b; background: rgba(224,69,43,.1); }
56
101
  header select {
57
102
  flex: 1; min-width: 0; font-size: 13px; padding: 3px 4px; border-radius: 6px;
58
103
  border: 1px solid var(--line); background: var(--bg); color: var(--fg);
@@ -101,25 +146,46 @@
101
146
  root.innerHTML = `
102
147
  <style>${css}</style>
103
148
  <div class="wrap">
104
- <button class="toggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="Agent chat">
149
+ <button class="toggle" type="button" aria-expanded="false" aria-controls="panel" aria-label="edrys-dev">
105
150
  🤖<span class="dot"></span>
106
151
  </button>
107
- <section class="panel" id="panel" aria-label="Agent chat">
152
+ <section class="panel" id="panel" aria-label="edrys-dev">
108
153
  <header>
109
- Agent
154
+ <div class="tabs" role="tablist">
155
+ <button type="button" role="tab" data-view="agent" aria-controls="agent">Agent</button>
156
+ <button type="button" role="tab" data-view="students" aria-controls="students">Students<span class="count"></span></button>
157
+ </div>
110
158
  <label for="sessions" hidden>Chats</label>
111
159
  <select id="sessions"></select>
160
+ <span class="spacer"></span>
112
161
  <small title="edrys-dev: not the class's chat">only you see this</small>
113
162
  </header>
114
- <div class="log" role="log" aria-live="polite"></div>
115
- <div class="problem" role="alert" hidden></div>
116
- <div class="status" aria-live="polite"></div>
117
- <div class="hint" aria-live="polite"></div>
118
- <form class="compose">
119
- <label for="text" hidden>Message to the agent</label>
120
- <textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
121
- <button type="submit">Send</button>
122
- </form>
163
+ <div class="share" hidden>
164
+ <div class="row" hidden>
165
+ <span>Link for students</span>
166
+ <input readonly aria-label="Link for your students">
167
+ <button type="button">Copy</button>
168
+ </div>
169
+ <div class="note" aria-live="polite"></div>
170
+ </div>
171
+ <div class="view" id="agent" role="tabpanel">
172
+ <div class="log" role="log" aria-live="polite"></div>
173
+ <div class="problem" role="alert" hidden></div>
174
+ <div class="status" aria-live="polite"></div>
175
+ <div class="hint" aria-live="polite"></div>
176
+ <form class="compose">
177
+ <label for="text" hidden>Message to the agent</label>
178
+ <textarea id="text" placeholder="Message to the agent (Enter sends)"></textarea>
179
+ <button type="submit">Send</button>
180
+ </form>
181
+ </div>
182
+ <div class="view" id="students" role="tabpanel" hidden>
183
+ <div class="switch">
184
+ <label><input type="checkbox" id="tutor-on"> Tutor answers students</label>
185
+ <div class="note" aria-live="polite"></div>
186
+ </div>
187
+ <div class="threads" role="log" aria-live="polite"></div>
188
+ </div>
123
189
  </section>
124
190
  </div>`
125
191
  document.documentElement.appendChild(host)
@@ -133,6 +199,41 @@
133
199
  const compose = root.querySelector('.compose')
134
200
  const textarea = root.querySelector('textarea')
135
201
  const menu = root.querySelector('#sessions')
202
+ const tabs = [...root.querySelectorAll('.tabs button')]
203
+ const count = root.querySelector('.tabs .count')
204
+ const tutorBox = root.querySelector('#tutor-on')
205
+ const tutorNote = root.querySelector('.switch .note')
206
+ const threadList = root.querySelector('.threads')
207
+
208
+ // the views there are; the tabs only when there are two
209
+ let view = null
210
+ function showView(name) {
211
+ view = name
212
+ for (const tab of tabs) tab.setAttribute('aria-selected', String(tab.dataset.view === name))
213
+ root.querySelector('#agent').hidden = name !== 'agent'
214
+ root.querySelector('#students').hidden = name !== 'students'
215
+ menu.hidden = name !== 'agent'
216
+ // the menu fills the header; without it, the note goes right
217
+ root.querySelector('.spacer').hidden = name === 'agent'
218
+ if (name === 'students') count.textContent = ''
219
+ try {
220
+ sessionStorage.setItem(VIEW_KEY, name)
221
+ } catch {}
222
+ }
223
+ for (const tab of tabs) {
224
+ tab.hidden = !(hasChat && hasTutor) || (tab.dataset.view === 'agent' ? !hasChat : !hasTutor)
225
+ tab.addEventListener('click', () => showView(tab.dataset.view))
226
+ }
227
+ if (!(hasChat && hasTutor)) {
228
+ // one view: its name as the title
229
+ const title = root.querySelector('.tabs')
230
+ title.textContent = hasChat ? 'Agent' : 'Students'
231
+ }
232
+ let savedView = null
233
+ try {
234
+ savedView = sessionStorage.getItem(VIEW_KEY)
235
+ } catch {}
236
+ showView(!hasChat ? 'students' : !hasTutor ? 'agent' : savedView === 'students' ? 'students' : 'agent')
136
237
 
137
238
  let open = null // the ask id of the form the user can still answer
138
239
  let current = null // the chat shown, {id, title}
@@ -167,6 +268,7 @@
167
268
  panel.classList.toggle('open', isOpen)
168
269
  toggle.setAttribute('aria-expanded', String(isOpen))
169
270
  if (isOpen) toggle.classList.remove('unread')
271
+ if (isOpen && view === 'students') count.textContent = ''
170
272
  try {
171
273
  sessionStorage.setItem(OPEN_KEY, isOpen ? '1' : '')
172
274
  } catch {}
@@ -376,12 +478,184 @@
376
478
  }
377
479
  }
378
480
 
481
+ // --- the students' tutor threads ---
482
+
483
+ const threads = new Map() // thread id → {box, entries, when}
484
+ const time = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
485
+
486
+ function emptyNote() {
487
+ if (threads.size) return threadList.querySelector('.empty')?.remove()
488
+ const note = document.createElement('div')
489
+ note.className = 'empty'
490
+ note.textContent = 'No questions yet. Students ask with the 🤖 button in the class.'
491
+ threadList.replaceChildren(note)
492
+ }
493
+
494
+ // the thread's box, moved to the top: the one changed last first
495
+ function threadFor(id, name) {
496
+ let thread = threads.get(id)
497
+ if (!thread) {
498
+ const box = document.createElement('details')
499
+ box.className = 'thread'
500
+ box.open = true
501
+ const summary = document.createElement('summary')
502
+ const entries = document.createElement('div')
503
+ entries.className = 'entries'
504
+ box.append(summary, entries)
505
+ thread = { box, summary, entries }
506
+ threads.set(id, thread)
507
+ emptyNote()
508
+ }
509
+ // the name is the student's choice: text only
510
+ const when = document.createElement('small')
511
+ when.textContent = thread.last ? ' · ' + time(thread.last) : ''
512
+ thread.summary.replaceChildren(name, when)
513
+ threadList.prepend(thread.box)
514
+ return thread
515
+ }
516
+
517
+ function showTutorEntry(thread, entry) {
518
+ const div = document.createElement('div')
519
+ const meta = document.createElement('span')
520
+ meta.className = 'meta'
521
+ meta.textContent = time(entry.t) + (entry.room ? ' · ' + entry.room : '')
522
+ if (entry.from === 'student') {
523
+ div.className = 'msg student'
524
+ div.append(meta, entry.text)
525
+ } else {
526
+ div.className = 'msg tutor'
527
+ div.append(meta, render(entry.text))
528
+ }
529
+ thread.entries.appendChild(div)
530
+ thread.last = entry.t
531
+ }
532
+
533
+ function addTutorEntry(id, name, entry) {
534
+ const thread = threadFor(id, name)
535
+ showTutorEntry(thread, entry)
536
+ threadFor(id, name)
537
+ threadList.scrollTop = 0
538
+ if (entry.from !== 'student') return
539
+ if (view !== 'students' || !panel.classList.contains('open')) {
540
+ count.textContent = String(Number(count.textContent || 0) + 1)
541
+ if (!panel.classList.contains('open')) toggle.classList.add('unread')
542
+ }
543
+ }
544
+
545
+ function showSwitch(on) {
546
+ tutorBox.checked = on
547
+ tutorNote.textContent = on
548
+ ? 'Students see the 🤖 button while this tab is open.'
549
+ : 'Off: students do not see the button, and questions get "The tutor is off".'
550
+ }
551
+
552
+ tutorBox.addEventListener('change', async () => {
553
+ const on = tutorBox.checked
554
+ const response = await fetch('/edrys-dev/tutor', {
555
+ method: 'POST',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify({ on }),
558
+ }).catch(() => null)
559
+ if (response?.ok) return
560
+ showSwitch(!on)
561
+ tutorNote.textContent = 'Not switched: is edrys-dev still running? Try again.'
562
+ })
563
+
564
+ function connectTutor() {
565
+ const events = new EventSource('/edrys-dev/tutor/events')
566
+ events.onmessage = (event) => {
567
+ const message = JSON.parse(event.data)
568
+ switch (message.type) {
569
+ case 'threads':
570
+ threads.clear()
571
+ threadList.replaceChildren()
572
+ showSwitch(message.on)
573
+ // oldest first, so the newest ends up on top
574
+ for (const saved of [...message.threads].reverse()) {
575
+ const thread = threadFor(saved.id, saved.name)
576
+ if (saved.skipped) {
577
+ const note = document.createElement('div')
578
+ note.className = 'skipped'
579
+ note.textContent = `${saved.skipped} earlier messages not shown`
580
+ thread.entries.appendChild(note)
581
+ }
582
+ saved.entries.forEach((entry) => showTutorEntry(thread, entry))
583
+ threadFor(saved.id, saved.name)
584
+ }
585
+ emptyNote()
586
+ break
587
+ case 'entry':
588
+ addTutorEntry(message.id, message.name, message.entry)
589
+ break
590
+ case 'failed': {
591
+ // the reason is for the teacher; the student was told it failed
592
+ const thread = threadFor(message.id, message.name)
593
+ const div = document.createElement('div')
594
+ div.className = 'msg failed'
595
+ div.textContent = 'No answer: ' + message.error
596
+ thread.entries.appendChild(div)
597
+ break
598
+ }
599
+ case 'on':
600
+ showSwitch(message.on)
601
+ break
602
+ case 'limit':
603
+ tutorNote.textContent = `The class reached its limit of ${message.perHour} questions this hour: more get no answer for now. Start edrys-dev with --tutor-per-hour to change it.`
604
+ if (!panel.classList.contains('open')) toggle.classList.add('unread')
605
+ break
606
+ }
607
+ }
608
+ }
609
+
610
+ // --- the link for students: the class's share link would only work on this computer ---
611
+ const shareBox = root.querySelector('.share')
612
+ const shareRow = shareBox.querySelector('.row')
613
+ const shareInput = shareBox.querySelector('input')
614
+ const shareButton = shareBox.querySelector('button')
615
+ const shareNote = shareBox.querySelector('.note')
616
+
617
+ function showLink({ reachable, link, local_urls }) {
618
+ shareBox.hidden = false
619
+ shareRow.hidden = !link
620
+ shareInput.value = link || ''
621
+ const modules = local_urls.length
622
+ shareNote.textContent = !reachable
623
+ ? 'Students on other computers cannot join: edrys-dev runs with --local, which reaches this computer only.'
624
+ : !link
625
+ ? 'The link for students appears once the class is open.'
626
+ : modules
627
+ ? `${modules === 1 ? 'A module loads' : `${modules} modules load`} from this computer (localhost) and will not load for students: publish ${modules === 1 ? 'it' : 'them'} first.`
628
+ : ''
629
+ }
630
+
631
+ shareInput.addEventListener('focus', () => shareInput.select())
632
+ shareButton.addEventListener('click', async () => {
633
+ let copied = false
634
+ try {
635
+ await navigator.clipboard.writeText(shareInput.value)
636
+ copied = true
637
+ } catch {
638
+ shareInput.select()
639
+ try {
640
+ copied = document.execCommand('copy')
641
+ } catch {}
642
+ }
643
+ shareButton.textContent = copied ? 'Copied' : 'Select and copy it'
644
+ setTimeout(() => (shareButton.textContent = 'Copy'), 2000)
645
+ })
646
+
647
+ new EventSource('/edrys-dev/link/events').onmessage = (event) => showLink(JSON.parse(event.data))
648
+
379
649
  // the renderer first, then the messages that need it
380
650
  let loaded = 0
381
651
  for (const name of ['marked', 'purify']) {
382
652
  const script = document.createElement('script')
383
653
  script.src = `/edrys-dev/${name}.js`
384
- script.onload = () => ++loaded === 2 && connect()
654
+ script.onload = () => {
655
+ if (++loaded < 2) return
656
+ if (hasChat) connect()
657
+ if (hasTutor) connectTutor()
658
+ }
385
659
  document.head.appendChild(script)
386
660
  }
387
661
  })()