pinokiod 3.271.0 → 3.272.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.
Files changed (55) hide show
  1. package/kernel/ansi_stream_tracker.js +115 -0
  2. package/kernel/api/app/index.js +422 -0
  3. package/kernel/api/htmlmodal/index.js +94 -0
  4. package/kernel/app_launcher/index.js +115 -0
  5. package/kernel/app_launcher/platform/base.js +276 -0
  6. package/kernel/app_launcher/platform/linux.js +229 -0
  7. package/kernel/app_launcher/platform/macos.js +163 -0
  8. package/kernel/app_launcher/platform/unsupported.js +34 -0
  9. package/kernel/app_launcher/platform/windows.js +247 -0
  10. package/kernel/bin/conda-meta.js +93 -0
  11. package/kernel/bin/conda.js +2 -4
  12. package/kernel/bin/index.js +2 -4
  13. package/kernel/index.js +7 -0
  14. package/kernel/shell.js +212 -1
  15. package/package.json +1 -1
  16. package/server/index.js +491 -6
  17. package/server/public/common.js +224 -741
  18. package/server/public/create-launcher.js +754 -0
  19. package/server/public/htmlmodal.js +292 -0
  20. package/server/public/logs.js +715 -0
  21. package/server/public/resizeSync.js +117 -0
  22. package/server/public/style.css +651 -6
  23. package/server/public/tab-idle-notifier.js +34 -59
  24. package/server/public/tab-link-popover.js +7 -10
  25. package/server/public/terminal-settings.js +723 -9
  26. package/server/public/terminal_input_utils.js +72 -0
  27. package/server/public/terminal_key_caption.js +187 -0
  28. package/server/public/urldropdown.css +120 -3
  29. package/server/public/xterm-inline-bridge.js +116 -0
  30. package/server/socket.js +29 -0
  31. package/server/views/agents.ejs +1 -2
  32. package/server/views/app.ejs +55 -28
  33. package/server/views/bookmarklet.ejs +1 -1
  34. package/server/views/bootstrap.ejs +1 -0
  35. package/server/views/connect.ejs +1 -2
  36. package/server/views/create.ejs +63 -0
  37. package/server/views/editor.ejs +36 -4
  38. package/server/views/index.ejs +1 -2
  39. package/server/views/index2.ejs +1 -2
  40. package/server/views/init/index.ejs +36 -28
  41. package/server/views/install.ejs +20 -22
  42. package/server/views/layout.ejs +2 -8
  43. package/server/views/logs.ejs +155 -0
  44. package/server/views/mini.ejs +0 -18
  45. package/server/views/net.ejs +2 -2
  46. package/server/views/network.ejs +1 -2
  47. package/server/views/network2.ejs +1 -2
  48. package/server/views/old_network.ejs +1 -2
  49. package/server/views/pro.ejs +26 -23
  50. package/server/views/prototype/index.ejs +30 -34
  51. package/server/views/screenshots.ejs +1 -2
  52. package/server/views/settings.ejs +1 -20
  53. package/server/views/shell.ejs +59 -66
  54. package/server/views/terminal.ejs +118 -73
  55. package/server/views/tools.ejs +1 -2
@@ -0,0 +1,72 @@
1
+ (function (global) {
2
+ function processTerminalInputData(data, handlers) {
3
+ if (!data || typeof data !== 'string' || !handlers) {
4
+ return
5
+ }
6
+ const capture = typeof handlers.capture === 'function' ? handlers.capture : function () {}
7
+ const handleBackspace = typeof handlers.backspace === 'function' ? handlers.backspace : function () {}
8
+ const resetBuffer = typeof handlers.reset === 'function' ? handlers.reset : function () {}
9
+
10
+ let printable = ''
11
+ let lastWasCR = false
12
+ const flush = () => {
13
+ if (printable) {
14
+ capture(printable)
15
+ printable = ''
16
+ }
17
+ }
18
+
19
+ for (let i = 0; i < data.length; i++) {
20
+ const ch = data[i]
21
+ if (ch === '\u0008' || ch === '\u007f') { // backspace / DEL
22
+ flush()
23
+ handleBackspace()
24
+ lastWasCR = false
25
+ continue
26
+ }
27
+ if (ch === '\r') {
28
+ flush()
29
+ capture('\n')
30
+ lastWasCR = true
31
+ continue
32
+ }
33
+ if (ch === '\n') {
34
+ if (lastWasCR) {
35
+ lastWasCR = false
36
+ continue
37
+ }
38
+ flush()
39
+ capture('\n')
40
+ lastWasCR = false
41
+ continue
42
+ }
43
+ if (ch === '\t') {
44
+ flush()
45
+ capture('\t')
46
+ lastWasCR = false
47
+ continue
48
+ }
49
+ if (ch === '\u001b') { // ESC
50
+ flush()
51
+ if (data.length === 1) {
52
+ resetBuffer()
53
+ }
54
+ lastWasCR = false
55
+ continue
56
+ }
57
+ if (ch === '\u0015' || ch === '\u0017' || ch === '\u000b' || ch === '\u0003') {
58
+ flush()
59
+ resetBuffer()
60
+ lastWasCR = false
61
+ continue
62
+ }
63
+ if (ch >= ' ') {
64
+ printable += ch
65
+ }
66
+ lastWasCR = false
67
+ }
68
+ flush()
69
+ }
70
+
71
+ global.processTerminalInputData = processTerminalInputData
72
+ })(typeof window !== 'undefined' ? window : this)
@@ -0,0 +1,187 @@
1
+ (function (global) {
2
+ const DEFAULT_LIMIT = 512
3
+ const NAVIGATION_KEYS = new Set([
4
+ 'ArrowUp',
5
+ 'ArrowDown',
6
+ 'ArrowLeft',
7
+ 'ArrowRight',
8
+ 'Home',
9
+ 'End',
10
+ 'PageUp',
11
+ 'PageDown',
12
+ 'Insert'
13
+ ])
14
+
15
+ class TabCaptionHelper {
16
+ constructor(term, options = {}) {
17
+ this.term = term
18
+ this.tracker = options.tracker || null
19
+ this.limit = Number.isFinite(options.maxBuffer) && options.maxBuffer > 0 ? options.maxBuffer : DEFAULT_LIMIT
20
+ this.buffer = ''
21
+ this.pendingLine = null
22
+ this.keyListener = null
23
+ this.restoreSubmit = null
24
+ this.install()
25
+ }
26
+
27
+ install() {
28
+ this.attachKeyListener()
29
+ this.patchTracker()
30
+ }
31
+
32
+ attachKeyListener() {
33
+ if (!this.term || typeof this.term.onKey !== 'function') {
34
+ return
35
+ }
36
+ const disposable = this.term.onKey((event) => {
37
+ this.handleKeyEvent(event)
38
+ })
39
+ if (disposable && typeof disposable.dispose === 'function') {
40
+ this.keyListener = () => {
41
+ disposable.dispose()
42
+ }
43
+ }
44
+ }
45
+
46
+ patchTracker() {
47
+ if (!this.tracker || typeof this.tracker.submit !== 'function') {
48
+ return
49
+ }
50
+ const originalSubmit = this.tracker.submit
51
+ const helper = this
52
+ this.tracker.submit = function helperSubmit(line, meta) {
53
+ const override = helper.consumePendingLine()
54
+ if (typeof override === 'string') {
55
+ return originalSubmit.call(this, override, meta)
56
+ }
57
+ return originalSubmit.call(this, line, meta)
58
+ }
59
+ this.restoreSubmit = () => {
60
+ this.tracker.submit = originalSubmit
61
+ }
62
+ }
63
+
64
+ consumePendingLine() {
65
+ if (this.pendingLine !== null) {
66
+ const next = this.pendingLine
67
+ this.pendingLine = null
68
+ return next
69
+ }
70
+ return null
71
+ }
72
+
73
+ handleKeyEvent(event) {
74
+ if (!event || !event.domEvent) {
75
+ return
76
+ }
77
+ const domEvent = event.domEvent
78
+ const key = domEvent.key
79
+ if (!key) {
80
+ return
81
+ }
82
+ if (domEvent.isComposing) {
83
+ return
84
+ }
85
+ if (key === 'Enter') {
86
+ this.commitBuffer()
87
+ return
88
+ }
89
+ if (key === 'Backspace') {
90
+ this.applyBackspace()
91
+ return
92
+ }
93
+ if (key === 'Escape') {
94
+ this.resetBuffer()
95
+ return
96
+ }
97
+ if (domEvent.ctrlKey || domEvent.metaKey) {
98
+ this.handleModifierCombo(key)
99
+ return
100
+ }
101
+ if (NAVIGATION_KEYS.has(key) || key === 'Tab') {
102
+ this.resetBuffer()
103
+ return
104
+ }
105
+ if (this.isPrintable(domEvent)) {
106
+ this.appendCharacter(domEvent.key)
107
+ }
108
+ }
109
+
110
+ handleModifierCombo(key) {
111
+ const lower = typeof key === 'string' ? key.toLowerCase() : ''
112
+ if (!lower) {
113
+ return
114
+ }
115
+ if (lower === 'c' || lower === 'd' || lower === 'l') {
116
+ this.resetBuffer()
117
+ return
118
+ }
119
+ if (lower === 'u' || lower === 'w' || lower === 'k') {
120
+ this.resetBuffer()
121
+ }
122
+ }
123
+
124
+ isPrintable(domEvent) {
125
+ if (!domEvent || typeof domEvent.key !== 'string') {
126
+ return false
127
+ }
128
+ if (domEvent.ctrlKey || domEvent.metaKey) {
129
+ return false
130
+ }
131
+ if (domEvent.altKey && domEvent.key.length !== 1) {
132
+ return false
133
+ }
134
+ return domEvent.key.length === 1
135
+ }
136
+
137
+ appendCharacter(char) {
138
+ if (typeof char !== 'string' || char.length === 0) {
139
+ return
140
+ }
141
+ if (this.buffer.length >= this.limit) {
142
+ return
143
+ }
144
+ this.buffer += char
145
+ }
146
+
147
+ applyBackspace() {
148
+ if (!this.buffer) {
149
+ return
150
+ }
151
+ this.buffer = this.buffer.slice(0, -1)
152
+ }
153
+
154
+ commitBuffer() {
155
+ this.pendingLine = this.buffer
156
+ this.buffer = ''
157
+ }
158
+
159
+ resetBuffer() {
160
+ this.buffer = ''
161
+ this.pendingLine = null
162
+ }
163
+
164
+ dispose() {
165
+ if (this.keyListener) {
166
+ this.keyListener()
167
+ this.keyListener = null
168
+ }
169
+ if (this.restoreSubmit) {
170
+ this.restoreSubmit()
171
+ this.restoreSubmit = null
172
+ }
173
+ }
174
+ }
175
+
176
+ function attach(term, options) {
177
+ if (!term) {
178
+ return null
179
+ }
180
+ return new TabCaptionHelper(term, options || {})
181
+ }
182
+
183
+ global.PinokioTabCaptionHelper = {
184
+ attach,
185
+ Helper: TabCaptionHelper
186
+ }
187
+ })(typeof window !== 'undefined' ? window : this)
@@ -184,7 +184,8 @@ body.dark .url-dropdown-empty-description {
184
184
  -webkit-backdrop-filter: blur(14px);
185
185
  backdrop-filter: blur(14px);
186
186
  }
187
- .create-launcher-modal {
187
+ .create-launcher-modal,
188
+ .create-launcher-page-card {
188
189
  background: rgba(255, 255, 255, 0.86);
189
190
  border: 1px solid rgba(15, 23, 42, 0.08);
190
191
  border-radius: 20px;
@@ -202,7 +203,8 @@ body.dark .url-dropdown-empty-description {
202
203
  backdrop-filter: blur(28px);
203
204
  position: relative;
204
205
  }
205
- body.dark .create-launcher-modal {
206
+ body.dark .create-launcher-modal,
207
+ body.dark .create-launcher-page-card {
206
208
  background: rgba(17, 24, 39, 0.82);
207
209
  border: 1px solid rgba(148, 163, 184, 0.22);
208
210
  color: rgba(226, 232, 240, 0.96);
@@ -267,7 +269,8 @@ body.dark .create-launcher-modal-close:hover {
267
269
  flex-direction: column;
268
270
  gap: 6px;
269
271
  }
270
- .create-launcher-modal h3 {
272
+ .create-launcher-modal h3,
273
+ .create-launcher-page-card h3 {
271
274
  margin: 0;
272
275
  font-size: 24px;
273
276
  font-weight: 600;
@@ -616,6 +619,9 @@ body.dark .create-launcher-modal-button.confirm {
616
619
  margin-top: 6px;
617
620
  font-size: 13px;
618
621
  }
622
+ .create-launcher-modal-links .create-launcher-modal-bookmarklet {
623
+ margin-left: 8px;
624
+ }
619
625
  .create-launcher-modal-advanced {
620
626
  display: inline-flex;
621
627
  align-items: center;
@@ -648,6 +654,27 @@ body.dark .create-launcher-modal-advanced.secondary {
648
654
  body.dark .create-launcher-modal-advanced.secondary:hover {
649
655
  color: rgba(226, 232, 240, 0.98);
650
656
  }
657
+ .create-launcher-modal-bookmarklet {
658
+ font-size: 13px;
659
+ font-weight: 600;
660
+ text-transform: uppercase;
661
+ letter-spacing: 0.08em;
662
+ color: rgba(127, 91, 243, 0.95);
663
+ text-decoration: underline;
664
+ transition: color 0.2s ease;
665
+ }
666
+ .create-launcher-modal-bookmarklet:hover,
667
+ .create-launcher-modal-bookmarklet:focus-visible {
668
+ color: rgba(84, 63, 196, 1);
669
+ text-decoration: underline;
670
+ }
671
+ body.dark .create-launcher-modal-bookmarklet {
672
+ color: rgba(183, 161, 255, 0.95);
673
+ }
674
+ body.dark .create-launcher-modal-bookmarklet:hover,
675
+ body.dark .create-launcher-modal-bookmarklet:focus-visible {
676
+ color: rgba(225, 213, 255, 0.98);
677
+ }
651
678
  @media (max-width: 640px) {
652
679
  .modal-overlay {
653
680
  padding: 16px;
@@ -686,6 +713,96 @@ body.dark .create-launcher-modal-advanced.secondary:hover {
686
713
  width: 100%;
687
714
  }
688
715
  }
716
+ .create-page {
717
+ max-width: 960px;
718
+ margin: 0 auto;
719
+ padding: 30px 20px 80px;
720
+ }
721
+
722
+ .create-page-hero {
723
+ border: 1px solid rgba(0, 0, 0, 0.05);
724
+ border-radius: 24px;
725
+ padding: 32px;
726
+ background: rgba(0, 0, 0, 0.01);
727
+ }
728
+
729
+ body.dark .create-page-hero {
730
+ background: rgba(255, 255, 255, 0.02);
731
+ border-color: rgba(255, 255, 255, 0.05);
732
+ }
733
+
734
+ .create-page-hero .eyebrow {
735
+ text-transform: uppercase;
736
+ letter-spacing: 0.08em;
737
+ font-size: 12px;
738
+ margin: 0 0 8px;
739
+ color: rgba(0, 0, 0, 0.6);
740
+ }
741
+
742
+ body.dark .create-page-hero .eyebrow {
743
+ color: rgba(255, 255, 255, 0.6);
744
+ }
745
+
746
+ .create-page-hero h1 {
747
+ margin: 0 0 12px;
748
+ font-size: 32px;
749
+ }
750
+
751
+ .create-page-hero .subtitle {
752
+ margin: 0;
753
+ font-size: 16px;
754
+ color: rgba(0, 0, 0, 0.7);
755
+ max-width: 640px;
756
+ }
757
+
758
+ body.dark .create-page-hero .subtitle {
759
+ color: rgba(255, 255, 255, 0.7);
760
+ }
761
+
762
+ .create-page-root {
763
+ margin-top: 28px;
764
+ }
765
+
766
+ .create-launcher-page-card {
767
+ width: min(720px, 100%);
768
+ margin: 0 auto;
769
+ max-height: none;
770
+ overflow: visible;
771
+ padding: 32px;
772
+ border-radius: 26px;
773
+ background: #fff;
774
+ border: 1px solid rgba(15, 23, 42, 0.06);
775
+ box-shadow: 0 20px 65px rgba(15, 15, 15, 0.08);
776
+ -webkit-backdrop-filter: none;
777
+ backdrop-filter: none;
778
+ }
779
+
780
+ body.dark .create-launcher-page-card {
781
+ background: rgba(17, 24, 39, 0.92);
782
+ border-color: rgba(148, 163, 184, 0.15);
783
+ box-shadow: 0 22px 55px rgba(0, 0, 0, 0.55);
784
+ }
785
+
786
+ .create-launcher-page-card .create-launcher-modal-links {
787
+ justify-content: space-between;
788
+ flex-wrap: wrap;
789
+ gap: 12px;
790
+ }
791
+
792
+ .create-launcher-page-card .create-launcher-modal-button.cancel {
793
+ display: none;
794
+ }
795
+
796
+ @media (max-width: 720px) {
797
+ .create-page {
798
+ padding: 20px 12px 60px;
799
+ }
800
+ .create-launcher-page-card {
801
+ padding: 24px;
802
+ border-radius: 18px;
803
+ width: 100%;
804
+ }
805
+ }
689
806
  .capture-modal-overlay {
690
807
  background: rgba(15, 23, 42, 0.45);
691
808
  -webkit-backdrop-filter: blur(16px);
@@ -0,0 +1,116 @@
1
+ (() => {
2
+ if (typeof window === 'undefined' || window.PinokioInlineBridgeInitialized) {
3
+ return;
4
+ }
5
+ const isStandaloneMobile = () => {
6
+ try {
7
+ if (window.top !== window.self) {
8
+ return false;
9
+ }
10
+ } catch (_) {
11
+ return false;
12
+ }
13
+ const ua = (navigator.userAgent || '').toLowerCase();
14
+ return /iphone|ipad|ipod|android|mobile/.test(ua);
15
+ };
16
+ if (!isStandaloneMobile()) {
17
+ return;
18
+ }
19
+ window.PinokioInlineBridgeInitialized = true;
20
+
21
+ const ensureFrameName = () => {
22
+ if (typeof window.name === 'string' && window.name.trim()) {
23
+ return window.name;
24
+ }
25
+ const generated = `inline-${Date.now()}`;
26
+ window.name = generated;
27
+ return generated;
28
+ };
29
+
30
+ const createFrameLink = (frameName) => {
31
+ const existing = document.querySelector('.frame-link.pinokio-inline');
32
+ if (existing) {
33
+ return existing;
34
+ }
35
+ const link = document.createElement('div');
36
+ link.className = 'frame-link pinokio-inline';
37
+ link.setAttribute('target', frameName);
38
+ link.dataset.canNotify = 'true';
39
+ link.style.display = 'none';
40
+ link.innerHTML = `
41
+ <div class="tab">
42
+ <div class="tab-main">
43
+ <div class="tab-details">
44
+ <div class="tab-updated">
45
+ <span class="indicator">
46
+ <span class="dot"></span>
47
+ <span class="label"></span>
48
+ </span>
49
+ </div>
50
+ <div class="tab-preview"></div>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ `;
55
+ document.body.appendChild(link);
56
+ return link;
57
+ };
58
+
59
+ const init = () => {
60
+ const frameName = ensureFrameName();
61
+ const link = createFrameLink(frameName);
62
+ if (!link) {
63
+ return;
64
+ }
65
+ window.PinokioInlineIdle = true;
66
+ const indicator = link.querySelector('.tab-updated');
67
+ const label = indicator ? indicator.querySelector('.label') : null;
68
+ const preview = link.querySelector('.tab-preview');
69
+ const updateIndicator = (text, hasContent) => {
70
+ if (preview) {
71
+ preview.textContent = hasContent ? text : '';
72
+ }
73
+ const now = Date.now();
74
+ indicator.dataset.timestamp = String(now);
75
+ indicator.classList.add('is-live');
76
+ if (label) {
77
+ label.textContent = 'live';
78
+ }
79
+ clearTimeout(updateIndicator._timer);
80
+ updateIndicator._timer = setTimeout(() => {
81
+ indicator.classList.remove('is-live');
82
+ indicator.dataset.timestamp = String(Date.now());
83
+ if (label) {
84
+ label.textContent = 'idle';
85
+ }
86
+ }, 1200);
87
+ };
88
+
89
+ const handleMessage = (event) => {
90
+ if (!event || typeof event.data !== 'object' || event.data === null) {
91
+ return;
92
+ }
93
+ const data = event.data;
94
+ if (data.type === 'terminal-input') {
95
+ const hasContent = typeof data.hasContent === 'boolean'
96
+ ? data.hasContent
97
+ : Boolean(data.line && data.line.length > 0);
98
+ updateIndicator(data.line || '', hasContent);
99
+ } else if (data.type === 'stream') {
100
+ updateIndicator('', true);
101
+ }
102
+ };
103
+
104
+ window.addEventListener('message', handleMessage, true);
105
+ const script = document.createElement('script');
106
+ script.src = '/tab-idle-notifier.js';
107
+ script.async = false;
108
+ document.head.appendChild(script);
109
+ };
110
+
111
+ if (document.readyState === 'loading') {
112
+ document.addEventListener('DOMContentLoaded', init, { once: true });
113
+ } else {
114
+ init();
115
+ }
116
+ })();
package/server/socket.js CHANGED
@@ -12,6 +12,7 @@ class Socket {
12
12
  this.sessions = {}
13
13
  this.connected = {}
14
14
  this.active_shell = {}
15
+ this.shell_to_path = {}
15
16
  this.parent = parent
16
17
  this.server = parent.server
17
18
  // this.kernel = parent.kernel
@@ -217,18 +218,45 @@ class Socket {
217
218
  }
218
219
  }
219
220
  } else if (req.emit) {
221
+ if (req.id) {
222
+ const shell = this.parent.kernel.shell.get(req.id)
223
+ if (shell) {
224
+ shell.setUserActive(true)
225
+ }
226
+ }
220
227
  this.parent.kernel.shell.emit(req)
221
228
  } else if (req.key && req.id) {
229
+ const shell = this.parent.kernel.shell.get(req.id)
230
+ if (shell) {
231
+ shell.setUserActive(true)
232
+ }
222
233
  this.parent.kernel.shell.emit({
223
234
  id: req.id,
224
235
  emit: req.key,
225
236
  paste: req.paste
226
237
  })
227
238
  } else if (req.resize && req.id) {
239
+ const targetId = this.shell_to_path[req.id] || req.id
228
240
  this.parent.kernel.shell.resize({
229
241
  id: req.id,
230
242
  resize: req.resize
231
243
  })
244
+ const subscribers = this.subscriptions.get(targetId)
245
+ if (subscribers && subscribers.size > 0) {
246
+ const payload = JSON.stringify({
247
+ type: 'resize',
248
+ data: {
249
+ id: req.id,
250
+ cols: req.resize.cols,
251
+ rows: req.resize.rows
252
+ }
253
+ })
254
+ subscribers.forEach((subscriber) => {
255
+ if (subscriber !== ws && subscriber.readyState === WebSocket.OPEN) {
256
+ subscriber.send(payload)
257
+ }
258
+ })
259
+ }
232
260
  }
233
261
  }
234
262
 
@@ -333,6 +361,7 @@ class Socket {
333
361
 
334
362
  if (e.data && e.data.shell_id) {
335
363
  this.active_shell[id] = e.data.shell_id
364
+ this.shell_to_path[e.data.shell_id] = id
336
365
  }
337
366
 
338
367
  // send to caller session
@@ -513,8 +513,7 @@ body.dark .plugin-option:hover {
513
513
  <% } %>
514
514
  <a href="/connect" class='tab'><i class="fa-solid fa-plug"></i><div class='caption'>Login</div></a>
515
515
  <a class='tab' href="<%=portal%>" target="_blank"><i class="fa-solid fa-question"></i><div class='caption'>Help</div></a>
516
- <a class='tab' id='genlog'><i class="fa-solid fa-laptop-code"></i><div class='caption'>Logs</div></a>
517
- <a id='downloadlogs' download class='hidden btn2' href="/pinokio/logs.zip"><i class="fa-solid fa-download"></i><div class='caption'>Download logs</div></a>
516
+ <a class='tab' id='genlog' href="/logs"><i class="fa-solid fa-laptop-code"></i><div class='caption'>Logs</div></a>
518
517
  <a class='tab' href="/screenshots"><i class="fa-solid fa-camera"></i><div class='caption'>Screenshots</div></a>
519
518
  <a class='tab' href="/tools"><i class="fa-solid fa-toolbox"></i><div class='caption'>Installed Tools</div></a>
520
519
  <a class='tab selected' href="/agents"><i class="fa-solid fa-robot"></i><div class='caption'>Agents</div></a>