mrmd-editor 0.6.0 → 0.7.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.
@@ -2423,6 +2423,60 @@ ${ansiStyles}
2423
2423
  .cm-stdin-widget[data-password="true"] .cm-stdin-content {
2424
2424
  letter-spacing: 0.2em;
2425
2425
  }
2426
+
2427
+ /* ==========================================================================
2428
+ MOBILE RESPONSIVE
2429
+
2430
+ Output blocks need to be readable on narrow screens.
2431
+ Horizontal scroll for wide output, larger text for readability.
2432
+ ========================================================================== */
2433
+
2434
+ @media (max-width: 768px) {
2435
+ .cm-output-widget {
2436
+ font-size: max(var(--output-font-size, 0.7em), 12px);
2437
+ left: 0; /* No inset on mobile — use full width */
2438
+ right: 0;
2439
+ padding: 8px 12px;
2440
+ }
2441
+
2442
+ /* Output content lines: ensure they don't overflow */
2443
+ .cm-output-content-line {
2444
+ font-size: max(var(--output-font-size, 0.8em), 12px);
2445
+ }
2446
+
2447
+ /* Rich output (HTML renders, plots): full width */
2448
+ .cm-output-rich-widget {
2449
+ max-width: 100%;
2450
+ }
2451
+
2452
+ .cm-output-rich-widget iframe {
2453
+ max-width: 100%;
2454
+ }
2455
+
2456
+ /* Stdin widget: bigger input on mobile */
2457
+ .cm-stdin-widget .cm-stdin-input {
2458
+ font-size: 16px; /* Prevents iOS zoom */
2459
+ padding: 10px;
2460
+ }
2461
+
2462
+ .cm-stdin-widget .cm-stdin-prompt {
2463
+ font-size: 14px;
2464
+ }
2465
+ }
2466
+
2467
+ @media (pointer: coarse) {
2468
+ /* Output container: larger tap target for focus/interaction */
2469
+ .cm-output-widget {
2470
+ padding: 10px 14px;
2471
+ }
2472
+
2473
+ /* Collapsed output: easy to tap to expand */
2474
+ .cm-output-collapsed {
2475
+ min-height: 44px;
2476
+ display: flex;
2477
+ align-items: center;
2478
+ }
2479
+ }
2426
2480
  `;
2427
2481
 
2428
2482
  // #endregion STYLES
@@ -11,9 +11,9 @@
11
11
  * const extensions = createRuntimeCodeLensExtensions({
12
12
  * projectName: 'my-project',
13
13
  * getSessionStatus: (name) => shellState.get(`runtimes.sessions.${name}`),
14
- * onStart: async (runtime) => { await electronAPI.session.start(runtime); },
15
- * onStop: async (name) => { await electronAPI.session.stop(name); },
16
- * onRestart: async (name) => { await electronAPI.session.restart(name); },
14
+ * onStart: async (runtime) => { await electronAPI.runtime.start(runtime); },
15
+ * onStop: async (name) => { await electronAPI.runtime.stop(name); },
16
+ * onRestart: async (name) => { await electronAPI.runtime.restart(name); },
17
17
  * onRestartAll: async () => { ... },
18
18
  * });
19
19
  * ```
@@ -414,6 +414,76 @@ export const AI_MENU_STYLES = `
414
414
  color: #808080;
415
415
  font-style: italic;
416
416
  }
417
+
418
+ /* Mobile: AI menu becomes a bottom sheet */
419
+ @media (max-width: 768px) {
420
+ .ai-menu {
421
+ position: fixed !important;
422
+ left: 0 !important;
423
+ right: 0 !important;
424
+ bottom: 0 !important;
425
+ top: auto !important;
426
+ min-width: 100%;
427
+ max-width: 100%;
428
+ border-radius: 16px 16px 0 0;
429
+ border: none;
430
+ border-top: 1px solid #3c3c3c;
431
+ box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
432
+ animation: ai-menu-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
433
+ }
434
+
435
+ @keyframes ai-menu-slide-up {
436
+ from { transform: translateY(100%); }
437
+ to { transform: translateY(0); }
438
+ }
439
+
440
+ .ai-menu-header {
441
+ padding: 16px 20px;
442
+ position: relative;
443
+ }
444
+
445
+ .ai-menu-header::before {
446
+ content: '';
447
+ position: absolute;
448
+ top: 8px;
449
+ left: 50%;
450
+ transform: translateX(-50%);
451
+ width: 36px;
452
+ height: 4px;
453
+ background: #666;
454
+ border-radius: 2px;
455
+ opacity: 0.4;
456
+ }
457
+
458
+ .ai-menu-title {
459
+ font-size: 16px;
460
+ padding-top: 4px;
461
+ }
462
+
463
+ .ai-menu-juice {
464
+ font-size: 14px;
465
+ padding: 6px 12px;
466
+ border-radius: 8px;
467
+ }
468
+
469
+ .ai-menu-commands {
470
+ max-height: 50vh;
471
+ padding: 8px 0;
472
+ padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
473
+ }
474
+
475
+ .ai-menu-item {
476
+ padding: 14px 20px;
477
+ min-height: 48px;
478
+ font-size: 16px;
479
+ }
480
+
481
+ .ai-menu-item-icon {
482
+ width: 28px;
483
+ font-size: 18px;
484
+ margin-right: 12px;
485
+ }
486
+ }
417
487
  `;
418
488
 
419
489
  /**
@@ -55,8 +55,21 @@ export function createMenu(options) {
55
55
  }
56
56
  }
57
57
 
58
- // Position menu relative to anchor
58
+ // Detect mobile/narrow viewport
59
+ const isMobile = () => window.matchMedia('(max-width: 768px)').matches;
60
+
61
+ // Position menu relative to anchor (desktop) or as bottom sheet (mobile)
59
62
  function updatePosition() {
63
+ // On mobile, CSS handles the positioning (bottom sheet via @media queries).
64
+ // We just need to not set inline position styles that would conflict.
65
+ if (isMobile()) {
66
+ menu.style.top = '';
67
+ menu.style.left = '';
68
+ menu.style.right = '';
69
+ menu.style.bottom = '';
70
+ return;
71
+ }
72
+
60
73
  const anchorRect = anchor.getBoundingClientRect();
61
74
  const menuRect = menu.getBoundingClientRect();
62
75
 
@@ -96,10 +109,18 @@ export function createMenu(options) {
96
109
  menu.style.left = `${left}px`;
97
110
  }
98
111
 
112
+ // Mobile scrim backdrop (for bottom sheet pattern)
113
+ let scrim = null;
114
+
99
115
  // Close menu
100
116
  function close() {
101
117
  menu.remove();
118
+ if (scrim) {
119
+ scrim.remove();
120
+ scrim = null;
121
+ }
102
122
  document.removeEventListener('mousedown', handleOutsideClick);
123
+ document.removeEventListener('touchstart', handleOutsideClick);
103
124
  document.removeEventListener('keydown', handleKeydown);
104
125
  onClose?.();
105
126
  }
@@ -120,11 +141,28 @@ export function createMenu(options) {
120
141
 
121
142
  // Initialize
122
143
  render(items);
144
+
145
+ // On mobile, add a scrim backdrop behind the bottom sheet
146
+ if (isMobile()) {
147
+ scrim = document.createElement('div');
148
+ scrim.style.cssText = `
149
+ position: fixed;
150
+ inset: 0;
151
+ background: rgba(0, 0, 0, 0.4);
152
+ z-index: 999;
153
+ animation: mrmd-fade-in 0.15s ease;
154
+ `;
155
+ scrim.addEventListener('click', close);
156
+ scrim.addEventListener('touchstart', close);
157
+ document.body.appendChild(scrim);
158
+ }
159
+
123
160
  document.body.appendChild(menu);
124
161
  updatePosition();
125
162
 
126
163
  // Add event listeners
127
164
  document.addEventListener('mousedown', handleOutsideClick);
165
+ document.addEventListener('touchstart', handleOutsideClick);
128
166
  document.addEventListener('keydown', handleKeydown);
129
167
 
130
168
  return {
@@ -1402,7 +1402,7 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1402
1402
  let runtimeCount = 0;
1403
1403
  if (python?.running || python?.status === 'ready') runtimeCount++;
1404
1404
 
1405
- const sessions = shellState.getSessions();
1405
+ const sessions = shellState.getRuntimes();
1406
1406
  const dedicatedCount = sessions.filter(s => s.info?.dedicated).length;
1407
1407
  runtimeCount += dedicatedCount;
1408
1408
 
@@ -1650,7 +1650,7 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1650
1650
  items.push({ type: 'divider' });
1651
1651
  items.push({
1652
1652
  type: 'header',
1653
- label: `Active Sessions (${runtimes.sessions.length})`,
1653
+ label: `Active Runtime Attachments (${runtimes.sessions.length})`,
1654
1654
  });
1655
1655
 
1656
1656
  for (const session of runtimes.sessions) {
@@ -1671,16 +1671,16 @@ function createRuntimesSegment({ shellState, orchestratorClient, handlers, onCle
1671
1671
  });
1672
1672
  items.push({
1673
1673
  icon: '✖',
1674
- label: `Close "${session.doc}" session`,
1674
+ label: `Detach runtime from "${session.doc}"`,
1675
1675
  description: 'Stops monitor',
1676
1676
  onClick: async () => {
1677
1677
  try {
1678
- await orchestratorClient.destroySession(session.doc);
1678
+ await orchestratorClient.destroyRuntimeAttachment(session.doc);
1679
1679
  cachedRuntimes = null;
1680
1680
  lastFetchTime = 0;
1681
1681
  render();
1682
1682
  } catch (err) {
1683
- console.error('Failed to close session:', err);
1683
+ console.error('Failed to detach runtime attachment:', err);
1684
1684
  }
1685
1685
  },
1686
1686
  });
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { createDialog } from './base-dialog.js';
9
9
 
10
+ const FILE_PICKER_HISTORY_KEY = 'mrmd:file-picker-history:v1';
11
+ const FILE_PICKER_HISTORY_LIMIT = 400;
12
+
10
13
  // =============================================================================
11
14
  // FILE PICKER
12
15
  // =============================================================================
@@ -43,6 +46,7 @@ export function showFilePicker(options) {
43
46
  let entries = [];
44
47
  let selectedEntry = null;
45
48
  let isLoading = false;
49
+ let pickerHistory = loadFilePickerHistory();
46
50
 
47
51
  // Create content
48
52
  const content = document.createElement('div');
@@ -139,10 +143,20 @@ export function showFilePicker(options) {
139
143
  return;
140
144
  }
141
145
 
142
- // Sort: directories first, then files
146
+ // Sort: directories first, then recency/frequency score, then name
147
+ const nowMs = Date.now();
143
148
  const sorted = [...entries].sort((a, b) => {
144
149
  if (a.type === 'directory' && b.type !== 'directory') return -1;
145
150
  if (a.type !== 'directory' && b.type === 'directory') return 1;
151
+
152
+ const aScore = getHistoryScore(pickerHistory.get(a.path), nowMs);
153
+ const bScore = getHistoryScore(pickerHistory.get(b.path), nowMs);
154
+ if (aScore !== bScore) return bScore - aScore;
155
+
156
+ const aModified = Number(a.modified || 0);
157
+ const bModified = Number(b.modified || 0);
158
+ if (aModified !== bModified) return bModified - aModified;
159
+
146
160
  return a.name.localeCompare(b.name);
147
161
  });
148
162
 
@@ -164,11 +178,26 @@ export function showFilePicker(options) {
164
178
  name.textContent = entry.name;
165
179
  item.appendChild(name);
166
180
 
167
- if (entry.type === 'file' && entry.size !== undefined) {
168
- const info = document.createElement('span');
169
- info.className = 'mrmd-filepicker__item-info';
170
- info.textContent = formatSize(entry.size);
171
- item.appendChild(info);
181
+ if (entry.type === 'file') {
182
+ const infoParts = [];
183
+ if (entry.size !== undefined) {
184
+ infoParts.push(formatSize(entry.size));
185
+ }
186
+
187
+ const history = pickerHistory.get(entry.path);
188
+ if (history?.lastOpened) {
189
+ infoParts.push(formatTimeAgo(history.lastOpened));
190
+ }
191
+ if ((history?.count || 0) > 1) {
192
+ infoParts.push(`${Math.round(history.count)}x`);
193
+ }
194
+
195
+ if (infoParts.length > 0) {
196
+ const info = document.createElement('span');
197
+ info.className = 'mrmd-filepicker__item-info';
198
+ info.textContent = infoParts.join(' • ');
199
+ item.appendChild(info);
200
+ }
172
201
  }
173
202
 
174
203
  // Click handler
@@ -206,6 +235,13 @@ export function showFilePicker(options) {
206
235
  const result = await orchestratorClient.browse({ path, type: 'all' });
207
236
  currentPath = result.path;
208
237
  entries = result.entries;
238
+
239
+ // Track directory navigation with a lighter weight than file opens
240
+ pickerHistory = touchFilePickerHistory(pickerHistory, currentPath, {
241
+ timestamp: new Date().toISOString(),
242
+ weight: 0.25,
243
+ });
244
+ saveFilePickerHistory(pickerHistory);
209
245
  } catch (error) {
210
246
  console.error('Failed to browse:', error);
211
247
  entries = [];
@@ -218,6 +254,12 @@ export function showFilePicker(options) {
218
254
 
219
255
  // Select a file
220
256
  function selectFile(path) {
257
+ pickerHistory = touchFilePickerHistory(pickerHistory, path, {
258
+ timestamp: new Date().toISOString(),
259
+ weight: 1,
260
+ });
261
+ saveFilePickerHistory(pickerHistory);
262
+
221
263
  dialog.close();
222
264
  onSelect(path);
223
265
  }
@@ -449,3 +491,122 @@ function formatSize(bytes) {
449
491
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
450
492
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
451
493
  }
494
+
495
+ function formatTimeAgo(value) {
496
+ if (!value) return '';
497
+ const ts = Date.parse(value);
498
+ if (!Number.isFinite(ts)) return '';
499
+
500
+ const diffMs = Math.max(0, Date.now() - ts);
501
+ const mins = Math.floor(diffMs / 60000);
502
+ if (mins < 1) return 'just now';
503
+ if (mins < 60) return `${mins}m ago`;
504
+
505
+ const hours = Math.floor(mins / 60);
506
+ if (hours < 24) return `${hours}h ago`;
507
+
508
+ const days = Math.floor(hours / 24);
509
+ if (days < 30) return `${days}d ago`;
510
+
511
+ const months = Math.floor(days / 30);
512
+ return `${months}mo ago`;
513
+ }
514
+
515
+ function getStorage() {
516
+ if (typeof window === 'undefined') return null;
517
+ try {
518
+ return window.localStorage || null;
519
+ } catch {
520
+ return null;
521
+ }
522
+ }
523
+
524
+ function loadFilePickerHistory() {
525
+ const storage = getStorage();
526
+ if (!storage) return new Map();
527
+
528
+ try {
529
+ const raw = storage.getItem(FILE_PICKER_HISTORY_KEY);
530
+ if (!raw) return new Map();
531
+
532
+ const parsed = JSON.parse(raw);
533
+ if (!parsed || typeof parsed !== 'object') return new Map();
534
+
535
+ const byPath = new Map();
536
+ for (const [filePath, value] of Object.entries(parsed)) {
537
+ if (!filePath || !value || typeof value !== 'object') continue;
538
+
539
+ const count = Number(value.count || 0);
540
+ byPath.set(filePath, {
541
+ path: filePath,
542
+ lastOpened: typeof value.lastOpened === 'string' ? value.lastOpened : null,
543
+ count: Number.isFinite(count) && count > 0 ? count : 1,
544
+ });
545
+ }
546
+
547
+ return byPath;
548
+ } catch {
549
+ return new Map();
550
+ }
551
+ }
552
+
553
+ function saveFilePickerHistory(historyMap) {
554
+ const storage = getStorage();
555
+ if (!storage) return;
556
+
557
+ try {
558
+ const items = Array.from(historyMap.values())
559
+ .filter(item => item?.path)
560
+ .sort((a, b) => {
561
+ const aTs = Date.parse(a.lastOpened || 0) || 0;
562
+ const bTs = Date.parse(b.lastOpened || 0) || 0;
563
+ if (aTs !== bTs) return bTs - aTs;
564
+ return (b.count || 0) - (a.count || 0);
565
+ })
566
+ .slice(0, FILE_PICKER_HISTORY_LIMIT);
567
+
568
+ const serialized = {};
569
+ for (const item of items) {
570
+ serialized[item.path] = {
571
+ lastOpened: item.lastOpened || null,
572
+ count: item.count || 1,
573
+ };
574
+ }
575
+
576
+ storage.setItem(FILE_PICKER_HISTORY_KEY, JSON.stringify(serialized));
577
+ } catch {
578
+ // Best-effort persistence only
579
+ }
580
+ }
581
+
582
+ function touchFilePickerHistory(historyMap, filePath, { timestamp, weight = 1 } = {}) {
583
+ if (!filePath) return historyMap;
584
+
585
+ const next = new Map(historyMap);
586
+ const existing = next.get(filePath);
587
+ const nextCount = Math.max(0.1, Number(existing?.count || 0) + Math.max(0.1, Number(weight) || 0.1));
588
+
589
+ next.set(filePath, {
590
+ path: filePath,
591
+ lastOpened: timestamp || new Date().toISOString(),
592
+ count: nextCount,
593
+ });
594
+
595
+ return next;
596
+ }
597
+
598
+ function getHistoryScore(historyEntry, nowMs = Date.now()) {
599
+ if (!historyEntry) return 0;
600
+
601
+ const count = Math.max(1, Number(historyEntry.count) || 1);
602
+ const openedMs = Date.parse(historyEntry.lastOpened || '');
603
+
604
+ let recencyScore = 0;
605
+ if (Number.isFinite(openedMs)) {
606
+ const ageHours = Math.max(0, (nowMs - openedMs) / 3600000);
607
+ recencyScore = Math.max(0, 220 - Math.log2(ageHours + 1) * 36);
608
+ }
609
+
610
+ const frequencyScore = Math.min(180, Math.log2(count + 1) * 60);
611
+ return recencyScore + frequencyScore;
612
+ }
@@ -212,9 +212,9 @@ export async function createStudio(target, options = {}) {
212
212
  throw new Error('No sync URL returned from orchestrator');
213
213
  }
214
214
 
215
- // Register the shared session in shell state
215
+ // Register the shared runtime in shell state
216
216
  if (runtimeUrls.python) {
217
- shellState.registerSession('shared', {
217
+ shellState.registerRuntime('shared', {
218
218
  id: 'shared',
219
219
  url: runtimeUrls.python,
220
220
  status: 'ready',
@@ -329,7 +329,6 @@ export async function createStudio(target, options = {}) {
329
329
  ydoc: handle.ydoc,
330
330
  awareness: handle.awareness,
331
331
  runtimeUrl: monitorRuntimeUrl,
332
- session: docName,
333
332
  });
334
333
  }
335
334
 
@@ -519,11 +518,11 @@ export async function createStudio(target, options = {}) {
519
518
  editor = createEditorForDocument(handle, normalizedName);
520
519
  currentDocName = normalizedName;
521
520
 
522
- // Ensure session exists (starts monitor if needed)
521
+ // Ensure runtime attachment exists (starts monitor if needed)
523
522
  try {
524
- await orchestratorClient.createSession(normalizedName, 'shared');
523
+ await orchestratorClient.createRuntimeAttachment(normalizedName, 'shared');
525
524
  } catch (e) {
526
- console.warn('Failed to create session for', normalizedName, e);
525
+ console.warn('Failed to create runtime attachment for', normalizedName, e);
527
526
  }
528
527
 
529
528
  // Update shell state
@@ -558,11 +557,11 @@ export async function createStudio(target, options = {}) {
558
557
  editor = createEditorForDocument(handle, docToOpen);
559
558
  currentDocName = docToOpen;
560
559
 
561
- // Ensure session exists (starts monitor if needed)
560
+ // Ensure runtime attachment exists (starts monitor if needed)
562
561
  try {
563
- await orchestratorClient.createSession(docToOpen, 'shared');
562
+ await orchestratorClient.createRuntimeAttachment(docToOpen, 'shared');
564
563
  } catch (e) {
565
- console.warn('Failed to create session for initial doc:', e);
564
+ console.warn('Failed to create runtime attachment for initial doc:', e);
566
565
  }
567
566
  } catch (e) {
568
567
  console.error('Failed to open initial document:', e);
@@ -280,53 +280,95 @@ export class OrchestratorClient {
280
280
  }
281
281
 
282
282
  // ===========================================================================
283
- // Session Management
283
+ // Runtime Attachments
284
284
  // ===========================================================================
285
285
 
286
286
  /**
287
- * Create a session for a document
287
+ * Create a runtime attachment for a document
288
288
  * @param {string} doc - Document name
289
289
  * @param {'shared'|'dedicated'} python - Python runtime mode
290
290
  * @param {string} [venv] - Path to virtual environment (for dedicated runtimes)
291
291
  * @returns {Promise<Object>}
292
292
  */
293
- async createSession(doc, python = 'shared', venv = null) {
293
+ async createRuntimeAttachment(doc, python = 'shared', venv = null) {
294
294
  const body = { doc, python };
295
295
  if (venv) {
296
296
  body.venv = venv;
297
297
  }
298
- return this._fetch('/api/sessions', {
299
- method: 'POST',
300
- body: JSON.stringify(body),
301
- });
298
+
299
+ try {
300
+ return await this._fetch('/api/runtimes', {
301
+ method: 'POST',
302
+ body: JSON.stringify(body),
303
+ });
304
+ } catch (err) {
305
+ // Legacy orchestrator compatibility
306
+ return this._fetch('/api/sessions', {
307
+ method: 'POST',
308
+ body: JSON.stringify(body),
309
+ });
310
+ }
302
311
  }
303
312
 
304
313
  /**
305
- * Get session info for a document
314
+ * Get runtime attachment info for a document
306
315
  * @param {string} doc - Document name
307
316
  * @returns {Promise<Object>}
308
317
  */
309
- async getSession(doc) {
310
- return this._fetch(`/api/sessions/${encodeURIComponent(doc)}`);
318
+ async getRuntimeAttachment(doc) {
319
+ const encoded = encodeURIComponent(doc);
320
+ try {
321
+ return await this._fetch(`/api/runtimes/${encoded}`);
322
+ } catch (err) {
323
+ return this._fetch(`/api/sessions/${encoded}`);
324
+ }
311
325
  }
312
326
 
313
327
  /**
314
- * Destroy a session
328
+ * Destroy a runtime attachment
315
329
  * @param {string} doc - Document name
316
330
  * @returns {Promise<{doc: string, status: string}>}
317
331
  */
318
- async destroySession(doc) {
319
- return this._fetch(`/api/sessions/${encodeURIComponent(doc)}`, {
320
- method: 'DELETE',
321
- });
332
+ async destroyRuntimeAttachment(doc) {
333
+ const encoded = encodeURIComponent(doc);
334
+ try {
335
+ return await this._fetch(`/api/runtimes/${encoded}`, {
336
+ method: 'DELETE',
337
+ });
338
+ } catch (err) {
339
+ return this._fetch(`/api/sessions/${encoded}`, {
340
+ method: 'DELETE',
341
+ });
342
+ }
322
343
  }
323
344
 
324
345
  /**
325
- * List all active sessions
326
- * @returns {Promise<{sessions: Array}>}
346
+ * List all runtime attachments
347
+ * @returns {Promise<{runtimes?: Array, sessions?: Array}>}
327
348
  */
349
+ async listRuntimeAttachments() {
350
+ try {
351
+ return await this._fetch('/api/runtimes');
352
+ } catch (err) {
353
+ return this._fetch('/api/sessions');
354
+ }
355
+ }
356
+
357
+ // Legacy aliases
358
+ async createSession(doc, python = 'shared', venv = null) {
359
+ return this.createRuntimeAttachment(doc, python, venv);
360
+ }
361
+
362
+ async getSession(doc) {
363
+ return this.getRuntimeAttachment(doc);
364
+ }
365
+
366
+ async destroySession(doc) {
367
+ return this.destroyRuntimeAttachment(doc);
368
+ }
369
+
328
370
  async listSessions() {
329
- return this._fetch('/api/sessions');
371
+ return this.listRuntimeAttachments();
330
372
  }
331
373
 
332
374
  // ===========================================================================