@oddessentials/agent-guild 0.25.0 → 0.27.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 (56) hide show
  1. package/README.md +44 -10
  2. package/bin/agent-guild.mjs +2 -0
  3. package/node_modules/qrcode-generator/README.md +101 -0
  4. package/node_modules/qrcode-generator/dist/qrcode.d.ts +57 -0
  5. package/node_modules/qrcode-generator/dist/qrcode.js +2297 -0
  6. package/node_modules/qrcode-generator/dist/qrcode.mjs +2237 -0
  7. package/node_modules/qrcode-generator/dist/qrcode_SJIS.js +30 -0
  8. package/node_modules/qrcode-generator/dist/qrcode_SJIS.mjs +23 -0
  9. package/node_modules/qrcode-generator/dist/qrcode_UTF8.js +26 -0
  10. package/node_modules/qrcode-generator/dist/qrcode_UTF8.mjs +50 -0
  11. package/node_modules/qrcode-generator/experiment/vt/package-lock.json +6184 -0
  12. package/node_modules/qrcode-generator/experiment/vt/package.json +19 -0
  13. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode.ts +2311 -0
  14. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_SJIS.ts +25 -0
  15. package/node_modules/qrcode-generator/experiment/vt/src/main/qrcode_UTF8.ts +50 -0
  16. package/node_modules/qrcode-generator/experiment/vt/src/main/types.ts +80 -0
  17. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode-test-impl.js +353 -0
  18. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode.test.mjs +5 -0
  19. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_SJIS.test.mjs +10 -0
  20. package/node_modules/qrcode-generator/experiment/vt/src/test/qrcode_UTF8.test.mjs +9 -0
  21. package/node_modules/qrcode-generator/experiment/vt/tsconfig.json +25 -0
  22. package/node_modules/qrcode-generator/experiment/vt/vite.config.js +31 -0
  23. package/node_modules/qrcode-generator/experiment/vt/web-test-runner.config.mjs +7 -0
  24. package/node_modules/qrcode-generator/package.json +37 -0
  25. package/node_modules/qrcode-generator/test/qrcode-test-impl.js +353 -0
  26. package/node_modules/qrcode-generator/test/qrcode.test.html +18 -0
  27. package/node_modules/qrcode-generator/test/qrcode.test.mjs +5 -0
  28. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.html +21 -0
  29. package/node_modules/qrcode-generator/test/qrcode_SJIS.test.mjs +10 -0
  30. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.html +19 -0
  31. package/node_modules/qrcode-generator/test/qrcode_UTF8.test.mjs +9 -0
  32. package/node_modules/qrcode-generator/web-test-runner.config.mjs +8 -0
  33. package/package.json +3 -2
  34. package/src/manager/access-policy.mjs +39 -0
  35. package/src/manager/config.mjs +1 -0
  36. package/src/manager/folder-opener.mjs +96 -0
  37. package/src/manager/folder-picker.mjs +116 -0
  38. package/src/manager/folder-picker.ps1 +84 -0
  39. package/src/manager/github-views.mjs +171 -0
  40. package/src/manager/github.mjs +56 -1
  41. package/src/manager/main.mjs +11 -6
  42. package/src/manager/remote-access.mjs +286 -0
  43. package/src/manager/server.mjs +105 -18
  44. package/src/manager/tailscale.mjs +201 -0
  45. package/web/activity-favicon.js +71 -0
  46. package/web/app.js +1154 -98
  47. package/web/index.html +205 -92
  48. package/web/layout.js +75 -0
  49. package/web/remote-access.js +236 -0
  50. package/web/repo-search.js +91 -0
  51. package/web/skins/gnomeland/skin.css +1 -1
  52. package/web/skins/goblinville/skin.css +2 -2
  53. package/web/skins/grove/skin.css +1 -1
  54. package/web/skins/guild/skin.css +3 -3
  55. package/web/skins/orbital/skin.css +1 -1
  56. package/web/styles.css +269 -38
package/web/app.js CHANGED
@@ -3,6 +3,10 @@
3
3
 
4
4
  import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
5
5
  import { TerminalCopy } from './terminal-copy.js';
6
+ import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
7
+ import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
8
+ import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
9
+ import { createRemoteAccessUI } from './remote-access.js';
6
10
 
7
11
  const TOKEN_KEY = 'agentGuild.token';
8
12
  const CWD_KEY = 'agentGuild.cwd';
@@ -15,15 +19,23 @@ const NEWS_FILTER_KEY = 'agentGuild.newsFilter';
15
19
  const CHANGELOG_SEEN_KEY = 'agentGuild.changelogSeen';
16
20
  const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
17
21
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
22
+ const GITHUB_REPO_KEY = 'agentGuild.githubRepo';
23
+ const GITHUB_RECENT_KEY = 'agentGuild.githubRecent';
24
+ const GITHUB_VIEW_KEY = 'agentGuild.githubView';
18
25
  const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
19
26
  const SOUND_KEY = 'agentGuild.sound';
20
27
  const VOICE_KEY = 'agentGuild.voice';
21
28
  const NOTES_KEY = 'agentGuild.notes';
29
+ const DOCK_KEY = 'agentGuild.dock';
30
+ const DOCK_WIDTH_KEY = 'agentGuild.dockWidth';
31
+ const PANES_KEY = 'agentGuild.panes';
32
+ const SPLIT_RATIO_KEY = 'agentGuild.splitRatio';
22
33
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
23
34
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
24
35
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
25
36
  const finePointer = window.matchMedia('(hover: hover) and (pointer: fine)');
26
37
  const coarsePointer = window.matchMedia('(pointer: coarse)');
38
+ const activityFavicon = createActivityFavicon({ link: document.querySelector('link[rel="icon"]'), reducedMotion });
27
39
 
28
40
  const $ = (id) => document.getElementById(id);
29
41
  const state = {
@@ -39,6 +51,8 @@ const state = {
39
51
  github: null,
40
52
  sessions: new Map(),
41
53
  views: new Map(),
54
+ panes: [],
55
+ focusedPane: 0,
42
56
  activeId: null,
43
57
  eventsSocket: null,
44
58
  eventsRetry: 0,
@@ -46,6 +60,10 @@ const state = {
46
60
  managerUnavailable: false,
47
61
  /** True while the events socket is open. */
48
62
  connected: false,
63
+ folderOpener: null,
64
+ folderOpening: false,
65
+ folderPicker: null,
66
+ folderPicking: false,
49
67
  /** The manager's own version check, from `hello` and `manager.upgrade`. */
50
68
  upgrade: null,
51
69
  /** The running manager's version and pid, from `hello`. */
@@ -92,7 +110,7 @@ function toast(message, ms = 5000, action = null) {
92
110
 
93
111
  /**
94
112
  * Handles a click, but not the second click of a double-click, which lands on whatever the first one
95
- * opened or uncovered: Close over Notes, or Stop manager under a panel's Close.
113
+ * opened, closed or uncovered.
96
114
  */
97
115
  const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
98
116
 
@@ -100,8 +118,13 @@ function setConnection(kind, label) {
100
118
  const el = $('connection');
101
119
  el.className = `connection ${kind}`;
102
120
  el.querySelector('.label').textContent = label;
121
+ el.title = label;
103
122
  // The manager can only be stopped, restarted or upgraded while the page can reach it.
104
123
  state.connected = kind === 'ok';
124
+ state.remoteAccessUI?.connectionChanged();
125
+ $('manager').hidden = !state.connected;
126
+ if (!state.connected) closeMenu($('manager-menu'));
127
+ renderFolderTools();
105
128
  $('stop-manager').hidden = !state.connected;
106
129
  $('restart-manager').hidden = !state.connected || !state.restartable;
107
130
  renderUpgrade();
@@ -164,7 +187,7 @@ function unreadRelease() {
164
187
  /** theme.js applied the saved or system theme before the first paint; this keeps the menu in step. */
165
188
  function applyTheme(theme) {
166
189
  document.documentElement.dataset.theme = theme;
167
- document.querySelector(`#appearance-menu input[name="theme"][value="${theme}"]`).checked = true;
190
+ document.querySelector(`#settings-menu input[name="theme"][value="${theme}"]`).checked = true;
168
191
  }
169
192
 
170
193
  function currentTheme() {
@@ -294,11 +317,10 @@ function managerGone() {
294
317
  () => state.managerInstance === instance && !state.connected, [`unavailable.${instance}`]);
295
318
  }
296
319
 
297
- /** Places the open menu under its button, right-aligned with it and kept on screen. */
298
- function placeAppearanceMenu() {
299
- const menu = $('appearance-menu');
300
- if (!menu.matches(':popover-open')) return;
301
- const box = $('appearance').getBoundingClientRect();
320
+ /** Places an open menu under its button, right-aligned with it and kept on screen. */
321
+ function placeMenu(menu, button) {
322
+ if (!menu.matches(':popover-open') || !button) return;
323
+ const box = button.getBoundingClientRect();
302
324
  const top = Math.round(box.bottom + 6);
303
325
  menu.style.top = `${top}px`;
304
326
  menu.style.maxHeight = `${innerHeight - top - 8}px`;
@@ -310,6 +332,29 @@ function placeAppearanceMenu() {
310
332
  }
311
333
  }
312
334
 
335
+ function closeMenu(menu) {
336
+ if (menu.matches(':popover-open')) menu.hidePopover();
337
+ }
338
+
339
+ function menuItems(menu) {
340
+ return [...menu.querySelectorAll('.menu-item')].filter((item) => !item.hidden && !item.disabled);
341
+ }
342
+
343
+ /** Arrow keys, Home and End move between the items; Tab leaves the menu and closes it. */
344
+ function moveInMenu(e) {
345
+ if (e.key === 'Tab') return closeMenu(e.currentTarget);
346
+ const items = menuItems(e.currentTarget);
347
+ const at = items.indexOf(document.activeElement);
348
+ const next = e.key === 'ArrowDown' ? items[(at + 1) % items.length]
349
+ : e.key === 'ArrowUp' ? items[(at - 1 + items.length) % items.length]
350
+ : e.key === 'Home' ? items[0]
351
+ : e.key === 'End' ? items[items.length - 1]
352
+ : null;
353
+ if (!next) return;
354
+ e.preventDefault();
355
+ next.focus();
356
+ }
357
+
313
358
  // ---- notes ----------------------------------------------------------------
314
359
 
315
360
  /** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
@@ -322,9 +367,8 @@ const NOTES_STATUS = {
322
367
  /**
323
368
  * `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
324
369
  * panel is saved, too `long` or `refused`; `shown`: the status the line under the title shows.
325
- * `pressedOutside`: the last press on the panel was on its backdrop.
326
370
  */
327
- const notesView = { saved: '', status: 'saved', shown: 'saved', pressedOutside: false };
371
+ const notesView = { saved: '', status: 'saved', shown: 'saved' };
328
372
 
329
373
  /** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
330
374
  function refreshNotes() {
@@ -366,41 +410,91 @@ function notesStored(e) {
366
410
  if (e.key === NOTES_KEY || e.key === null) refreshNotes();
367
411
  }
368
412
 
369
- function openNotes() {
413
+ function openNotes({ focus = true } = {}) {
370
414
  hideTip();
371
415
  refreshNotes();
372
416
  // Saving an empty note changes nothing, but shows at once when the browser keeps nothing.
373
417
  if (notesView.status === 'saved' && !$('notes-text').value) saveNotes();
374
- const dialog = $('notes');
375
- if (!dialog.open) dialog.showModal();
376
- $('notes-text').focus();
418
+ showDock('notes');
419
+ if (focus) $('notes-text').focus();
377
420
  }
378
421
 
379
- function closeNotes() {
380
- if ($('notes').open) $('notes').close();
422
+ function toggleNotes() {
423
+ if (dockShows('notes')) closeDock();
424
+ else openNotes();
381
425
  }
382
426
 
383
- /** Focus goes back to the Notes button in every browser, never to the page itself. */
384
- function notesClosed() {
385
- $('notes-open').focus();
427
+ // ---- dock -----------------------------------------------------------------
428
+
429
+ const DOCK_PANELS = ['github', 'notes'];
430
+ const dockView = { panel: null, opener: null, width: clampDockWidth(Number.parseFloat(load(DOCK_WIDTH_KEY)), innerWidth) };
431
+
432
+ function dockShows(panel) {
433
+ return dockView.panel === panel;
386
434
  }
387
435
 
388
- /** The second press of a double-click on Notes lands in the panel, where it must not take the focus from the text. */
389
- const keepFocus = (e) => { if (e.detail > 1 && e.target !== $('notes-text')) e.preventDefault(); };
436
+ function applyDockLayout() {
437
+ const root = document.documentElement;
438
+ const mode = dockMode(innerWidth);
439
+ const width = clampDockWidth(dockView.width, innerWidth);
440
+ root.classList.toggle('dock-push', mode === 'push');
441
+ root.classList.toggle('dock-full', mode === 'full');
442
+ root.style.setProperty('--dock-w', `${width}px`);
443
+ root.style.setProperty('--dock-space', dockView.panel && mode === 'push' ? `${width}px` : '0px');
444
+ root.style.setProperty('--dock-stage-space', dockView.panel && stageBesideDock(innerWidth, width) ? `${width}px` : '0px');
445
+ const splitter = $('dock-splitter');
446
+ splitter.setAttribute('aria-valuemin', String(DOCK_MIN));
447
+ splitter.setAttribute('aria-valuemax', String(clampDockWidth(Infinity, innerWidth)));
448
+ splitter.setAttribute('aria-valuenow', String(width));
449
+ }
390
450
 
391
- /** Whether a press or click on the panel was on its backdrop, outside the sheet. */
392
- function isOutsideNotes(e) {
393
- const box = e.currentTarget.getBoundingClientRect();
394
- return e.target === e.currentTarget && (e.clientX < box.left || e.clientX >= box.right || e.clientY < box.top || e.clientY >= box.bottom);
451
+ function renderDock() {
452
+ $('dock').hidden = !dockView.panel;
453
+ for (const name of DOCK_PANELS) {
454
+ const shown = dockView.panel === name;
455
+ $(name).hidden = !shown;
456
+ $(`${name}-title`).setAttribute('aria-selected', String(shown));
457
+ $(`${name}-title`).tabIndex = shown || !dockView.panel ? 0 : -1;
458
+ }
459
+ $('github-toggle').setAttribute('aria-pressed', String(dockShows('github')));
460
+ $('notes-open').setAttribute('aria-pressed', String(dockShows('notes')));
461
+ applyDockLayout();
462
+ scheduleRuns();
463
+ }
464
+
465
+ function showDock(panel) {
466
+ if (!dockView.panel) dockView.opener = document.activeElement;
467
+ dockView.panel = panel;
468
+ save(DOCK_KEY, panel);
469
+ if ($('topbar-menu').matches(':popover-open')) $('topbar-menu').hidePopover();
470
+ renderDock();
471
+ }
472
+
473
+ function closeDock({ focusOpener = true } = {}) {
474
+ if (!dockView.panel) return;
475
+ const hadFocus = $('dock').contains(document.activeElement);
476
+ const fallback = dockShows('github') ? $('github-toggle') : $('notes-open');
477
+ dockView.panel = null;
478
+ save(DOCK_KEY, null);
479
+ renderDock();
480
+ if (focusOpener && hadFocus) {
481
+ [dockView.opener, fallback, $('menu-toggle')].find((el) => el?.isConnected && el.checkVisibility?.())?.focus();
482
+ }
483
+ dockView.opener = null;
395
484
  }
396
485
 
397
- function notesPressed(e) {
398
- notesView.pressedOutside = isOutsideNotes(e);
486
+ function dockMakesWayForTerminal() {
487
+ if (dockView.panel && !stageBesideDock(innerWidth, clampDockWidth(dockView.width, innerWidth))) closeDock({ focusOpener: false });
399
488
  }
400
489
 
401
- /** Only a press and a release both outside close the notes: a drag into or out of them selects text. */
402
- function notesClicked(e) {
403
- if (notesView.pressedOutside && isOutsideNotes(e)) closeNotes();
490
+ function moveDockTab(e) {
491
+ const tabs = DOCK_PANELS.map((name) => $(`${name}-title`));
492
+ const at = tabs.indexOf(e.target);
493
+ if (at === -1 || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
494
+ e.preventDefault();
495
+ const next = tabs[(at + (e.key === 'ArrowRight' ? 1 : tabs.length - 1)) % tabs.length];
496
+ next.click();
497
+ next.focus();
404
498
  }
405
499
 
406
500
  // ---- upgrading the manager ------------------------------------------------
@@ -420,6 +514,7 @@ function renderUpgrade() {
420
514
  const restart = $('restart-manager');
421
515
  const pending = u?.pendingVersion;
422
516
  restart.classList.toggle('pending', Boolean(pending));
517
+ $('manager').classList.toggle('pending', Boolean(pending) && state.restartable);
423
518
  restart.textContent = pending ? `Restart to use v${pending}` : 'Restart manager';
424
519
  restart.title = pending
425
520
  ? `Agent Guild ${pending} is installed, but this manager is still ${u.version}. Restarting ends every session and starts the new version; this page reconnects by itself.`
@@ -631,6 +726,60 @@ async function api(method, path, body) {
631
726
  return data;
632
727
  }
633
728
 
729
+ function renderFolderTools() {
730
+ const paint = (id, feature, busy, label, text) => {
731
+ const button = $(id);
732
+ button.disabled = !state.connected || !feature?.available || busy;
733
+ button.setAttribute('aria-label', label);
734
+ button.setAttribute('aria-busy', String(busy));
735
+ button.title = !state.connected ? text.offline
736
+ : busy ? text.busy
737
+ : !feature?.available ? feature?.reason || text.missing
738
+ : label;
739
+ };
740
+ paint('cwd-pick', state.folderPicker, state.folderPicking, 'Choose a folder…', {
741
+ offline: 'Connect to the session manager to choose folders',
742
+ busy: 'Choosing a folder…',
743
+ missing: 'Choosing folders is unavailable with this manager',
744
+ });
745
+ paint('cwd-open', state.folderOpener, state.folderOpening, `Open working folder in ${state.folderOpener?.label || 'file manager'}`, {
746
+ offline: 'Connect to the session manager to open folders',
747
+ busy: 'Opening working folder…',
748
+ missing: 'Opening folders is unavailable with this manager',
749
+ });
750
+ }
751
+
752
+ async function pickWorkingFolder() {
753
+ if (!state.connected || !state.folderPicker?.available || state.folderPicking) return;
754
+ state.folderPicking = true;
755
+ renderFolderTools();
756
+ try {
757
+ const { path } = await api('POST', '/pick-folder', { cwd: $('cwd').value.trim() });
758
+ if (path) useFolder(path);
759
+ } catch (err) {
760
+ if (err instanceof AuthError) showAuth(err.message);
761
+ else toast(err.message || 'Could not choose a folder.');
762
+ } finally {
763
+ state.folderPicking = false;
764
+ renderFolderTools();
765
+ }
766
+ }
767
+
768
+ async function openWorkingFolder() {
769
+ if (!state.connected || !state.folderOpener?.available || state.folderOpening) return;
770
+ state.folderOpening = true;
771
+ renderFolderTools();
772
+ try {
773
+ await api('POST', '/open-folder', { cwd: $('cwd').value.trim() });
774
+ } catch (err) {
775
+ if (err instanceof AuthError) showAuth(err.message);
776
+ else toast(err.message || 'Could not open the working folder.');
777
+ } finally {
778
+ state.folderOpening = false;
779
+ renderFolderTools();
780
+ }
781
+ }
782
+
634
783
  function wsUrl(path) {
635
784
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
636
785
  return `${proto}://${location.host}/api/v1${path}?token=${encodeURIComponent(state.token)}`;
@@ -2308,10 +2457,10 @@ function resumeById(event) {
2308
2457
  // ---- GitHub ---------------------------------------------------------------
2309
2458
 
2310
2459
  const GITHUB_SCOPES = {
2311
- repo: 'Read and write access to all your repositories, private ones included. GitHub offers apps no read-only choice; Agent Guild only lists them.',
2460
+ repo: 'Read and write access to your repositories, private ones included, so you can browse repositories and create or edit issues.',
2312
2461
  'write:public_key': 'Add the SSH key Agent Guild creates for this account.',
2313
2462
  };
2314
- const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, opener: null, card: null, started: new Set(), announced: new Set() };
2463
+ const githubView = { accountId: null, repos: null, reposFor: null, loading: null, error: null, parentError: null, card: null, started: new Set(), announced: new Set() };
2315
2464
  let githubLoading = null;
2316
2465
 
2317
2466
  function el(tag, className, ...children) {
@@ -2385,7 +2534,8 @@ function setGitHub(github) {
2385
2534
  ? `You are already signed in as @${account.login}; that sign-in was renewed. To add another account, switch to it on github.com first.`
2386
2535
  : `Signed in to GitHub as @${account.login}.`, done.again ? 10000 : 4000);
2387
2536
  }
2388
- if (!$('github').open) return;
2537
+ if (!dockShows('github')) return;
2538
+ ensureAllRepos();
2389
2539
  renderGitHub();
2390
2540
  ensureRepos();
2391
2541
  }
@@ -2479,39 +2629,42 @@ async function signOutGitHub(account) {
2479
2629
  }
2480
2630
  }
2481
2631
 
2482
- function openGitHub() {
2483
- const dialog = $('github');
2632
+ function openGitHub({ focus = true } = {}) {
2484
2633
  $('github-parent').value = load(CLONE_PARENT_KEY) ?? $('cwd').value.trim();
2485
2634
  $('github-filter').value = '';
2486
2635
  githubView.card = null;
2487
- if (!dialog.open) {
2488
- githubView.opener = document.activeElement;
2489
- dialog.showModal();
2490
- }
2636
+ githubPick.followed = null;
2637
+ githubPick.origins.clear();
2638
+ showDock('github');
2491
2639
  renderGitHub();
2492
2640
  if (githubAccount() && !githubAccount().needsSignIn) loadRepos();
2493
2641
  loadGitHub();
2494
- ($('github-card').querySelector('.btn.primary') ?? $('github-close')).focus();
2642
+ if (githubPick.repo) {
2643
+ loadView('actions');
2644
+ if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
2645
+ }
2646
+ followTerminal();
2647
+ if (focus) ($('github-card').querySelector('.btn.primary') ?? $('dock-close')).focus();
2495
2648
  }
2496
2649
 
2497
2650
  function closeGitHub({ focusOpener = true } = {}) {
2498
- if (!$('github').open) return;
2499
- if (!focusOpener) githubView.opener = false;
2500
- $('github').close();
2651
+ if (dockShows('github')) closeDock({ focusOpener });
2652
+ }
2653
+
2654
+ function toggleGitHub() {
2655
+ if (dockShows('github')) closeDock();
2656
+ else openGitHub();
2501
2657
  }
2502
2658
 
2503
2659
  function renderGitHub() {
2504
2660
  const github = state.github;
2505
2661
  const account = githubAccount();
2506
2662
  const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
2507
- const sub = !github ? 'Loading…'
2508
- : !account ? 'Clone your repositories over SSH'
2509
- : [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
2510
- $('github-sub').textContent = sub;
2511
2663
  renderGitHubChips(github);
2512
2664
  renderGitHubCard(github, account);
2513
2665
  renderGitHubStatus(github, account);
2514
2666
  renderGitHubRepos(github, account, repos);
2667
+ renderGitHubViews();
2515
2668
  }
2516
2669
 
2517
2670
  function githubAvatar(account) {
@@ -2528,7 +2681,6 @@ function githubAvatar(account) {
2528
2681
 
2529
2682
  function renderGitHubChips(github) {
2530
2683
  const accounts = github?.accounts ?? [];
2531
- $('github-accounts').hidden = accounts.length === 0;
2532
2684
  const host = $('github-chips');
2533
2685
  const focused = document.activeElement?.closest?.('#github-chips .account-chip')?.dataset.account;
2534
2686
  host.replaceChildren(...accounts.map((account) => {
@@ -2702,7 +2854,6 @@ function buildRepoRow(fullName) {
2702
2854
  function repoAction(repo, control) {
2703
2855
  const running = runningClone(repo.target);
2704
2856
  if (running) {
2705
- closeGitHub({ focusOpener: false });
2706
2857
  openPanel(running.id);
2707
2858
  } else if (repo.local === 'cloned') {
2708
2859
  useFolder(repo.target);
@@ -2743,7 +2894,6 @@ function updateRepoRow(node, repo, blocker) {
2743
2894
  function renderGitHubRepos(github, account, repos) {
2744
2895
  const ready = Boolean(github && account && !account.needsSignIn);
2745
2896
  $('github-tools').hidden = !ready;
2746
- $('github-parent').closest('.github-form').hidden = !ready;
2747
2897
  const list = $('github-list');
2748
2898
  const filter = $('github-filter').value.trim().toLowerCase();
2749
2899
  const shown = ready && repos ? repos.repos.filter((repo) => !filter || `${repo.fullName}\n${repo.description ?? ''}`.toLowerCase().includes(filter)) : [];
@@ -2778,7 +2928,6 @@ async function startClone(account, fullName) {
2778
2928
  const { session } = await api('POST', '/github/clone', { account: account.id, repo: fullName, parent: cloneParent() });
2779
2929
  githubView.started.add(session.id);
2780
2930
  upsertSession(session);
2781
- closeGitHub({ focusOpener: false });
2782
2931
  openPanel(session.id);
2783
2932
  }
2784
2933
 
@@ -2884,7 +3033,8 @@ function noticeClone(s) {
2884
3033
  if (s.task !== 'clone' || s.status !== 'exited' || githubView.announced.has(s.id)) return;
2885
3034
  githubView.announced.add(s.id);
2886
3035
  githubView.reposFor = null;
2887
- if ($('github').open) loadRepos();
3036
+ githubPick.origins.clear();
3037
+ if (dockShows('github')) loadRepos();
2888
3038
  if (!githubView.started.has(s.id)) return;
2889
3039
  if (clonedPath(s)) toast(`Cloned ${s.clone.repo} into ${s.clone.path}.`, 12000, { label: 'Use folder', run: () => useFolder(s.clone.path) });
2890
3040
  else toast(`Cloning ${s.clone?.repo ?? 'the repository'} did not finish. Its session shows why.`, 8000);
@@ -2893,10 +3043,641 @@ function noticeClone(s) {
2893
3043
  function useFolder(dir) {
2894
3044
  $('cwd').value = dir;
2895
3045
  save(CWD_KEY, dir);
2896
- if ($('github').open) renderGitHub();
3046
+ if (dockShows('github')) renderGitHub();
2897
3047
  toast(`New sessions start in ${dir}.`, 4000);
2898
3048
  }
2899
3049
 
3050
+ // ---- GitHub repository views ------------------------------------------------
3051
+
3052
+ const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls'];
3053
+ const RUNS_POLL_MS = 6000;
3054
+ const RUNS_IDLE_POLL_MS = 30000;
3055
+ const BODY_LIMIT = 48000;
3056
+ const GITHUB_REQUEST_LIMIT = 64 * 1024;
3057
+ const PICKER_LIMIT = 200;
3058
+ /**
3059
+ * `list`: every account's repositories from /github/repos; `repo`: the picked one. `data[view]` is
3060
+ * `{ stamp, value, error, loading }` for the picked repository. `followed`: the session whose folder last picked the repository.
3061
+ */
3062
+ const githubPick = {
3063
+ list: null, listFor: null, loadingFor: null, error: null,
3064
+ repo: null, query: '', active: 0, open: false, recent: [],
3065
+ view: 'repos', issueState: 'open', editing: null, data: {}, followed: null, origins: new Map(),
3066
+ };
3067
+ let runsTimer = 0;
3068
+ let reposRequest = null;
3069
+
3070
+ function usableGitHubAccounts() {
3071
+ return (state.github?.accounts ?? []).filter((a) => !a.needsSignIn);
3072
+ }
3073
+
3074
+ function githubAccountsStamp() {
3075
+ return usableGitHubAccounts().map((a) => a.id).join(',');
3076
+ }
3077
+
3078
+ /** The view on screen: Repositories while no account can be used or a sign-in needs the card. */
3079
+ function githubShownView() {
3080
+ const signIn = state.github?.signIn;
3081
+ if (!usableGitHubAccounts().length || (signIn && signIn.status !== 'done')) return 'repos';
3082
+ return githubPick.view;
3083
+ }
3084
+
3085
+ function repoPath(repo) {
3086
+ return `/github/accounts/${repo.accountId}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`;
3087
+ }
3088
+
3089
+ function issueAccountError(repo) {
3090
+ const account = state.github?.accounts.find((a) => a.id === repo.accountId);
3091
+ return account && !account.needsSignIn ? null
3092
+ : `Sign in as @${account?.login ?? repo.login} in Repositories to change issues in ${repo.fullName}.`;
3093
+ }
3094
+
3095
+ function ensureAllRepos() {
3096
+ const stamp = githubAccountsStamp();
3097
+ if (!stamp) {
3098
+ reposRequest = null;
3099
+ Object.assign(githubPick, { list: null, listFor: null, loadingFor: null });
3100
+ return;
3101
+ }
3102
+ if (githubPick.listFor !== stamp && githubPick.loadingFor !== stamp) loadAllRepos();
3103
+ }
3104
+
3105
+ async function loadAllRepos({ refresh = false } = {}) {
3106
+ const stamp = githubAccountsStamp();
3107
+ const request = reposRequest = {};
3108
+ githubPick.loadingFor = stamp;
3109
+ renderGitHubViews();
3110
+ let list = null;
3111
+ let error = null;
3112
+ try {
3113
+ list = await api('GET', `/github/repos${refresh ? '?refresh=1' : ''}`);
3114
+ } catch (err) {
3115
+ if (err instanceof AuthError) return showAuth(err.message);
3116
+ error = err.message;
3117
+ }
3118
+ if (reposRequest !== request || githubAccountsStamp() !== stamp) return;
3119
+ Object.assign(githubPick, { loadingFor: null, error, listFor: stamp });
3120
+ if (list) githubPick.list = list;
3121
+ restorePick();
3122
+ renderGitHubViews();
3123
+ followTerminal();
3124
+ }
3125
+
3126
+ /** Keeps the picked repository in step with the list, or picks the one from the last visit. */
3127
+ function restorePick() {
3128
+ const repos = githubPick.list?.repos ?? [];
3129
+ const wanted = githubPick.repo ? repoKey(githubPick.repo) : load(GITHUB_REPO_KEY);
3130
+ const found = repos.find((repo) => repoKey(repo) === wanted) ?? null;
3131
+ if (found) pickRepo(found);
3132
+ // A failed or expired account must not discard its draft or switch its identity.
3133
+ else if (githubPick.repo && githubPick.list && !githubPick.editing && !issueAccountError(githubPick.repo)
3134
+ && !githubPick.list.errors?.some((error) => error.accountId === githubPick.repo.accountId)) {
3135
+ Object.assign(githubPick, { repo: null, data: {} });
3136
+ }
3137
+ }
3138
+
3139
+ function pickRepo(repo, { chosen = false } = {}) {
3140
+ const same = githubPick.repo && repoKey(githubPick.repo) === repoKey(repo);
3141
+ githubPick.repo = repo;
3142
+ save(GITHUB_REPO_KEY, repoKey(repo));
3143
+ if (chosen) {
3144
+ githubPick.recent = remember(githubPick.recent, repo);
3145
+ save(GITHUB_RECENT_KEY, JSON.stringify(githubPick.recent));
3146
+ }
3147
+ if (!same) Object.assign(githubPick, { data: {}, editing: null });
3148
+ if ((!same || chosen) && githubView.accountId !== repo.accountId && state.github?.accounts.some((a) => a.id === repo.accountId)) {
3149
+ selectGitHubAccount(repo.accountId);
3150
+ githubView.card = null;
3151
+ if (dockShows('github')) {
3152
+ renderGitHub();
3153
+ ensureRepos();
3154
+ }
3155
+ }
3156
+ if (same) return renderGitHubViews();
3157
+ renderGitHubViews();
3158
+ if (!dockShows('github')) return;
3159
+ loadView('actions');
3160
+ if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
3161
+ }
3162
+
3163
+ /** Picks the repository whose clone holds the focused terminal's folder, once for each terminal focused. */
3164
+ async function followTerminal() {
3165
+ const s = state.sessions.get(state.activeId);
3166
+ if (!s?.cwd || !dockShows('github') || !githubPick.list || githubPick.followed === s.id) return;
3167
+ githubPick.followed = s.id;
3168
+ let origin = githubPick.origins.get(s.cwd);
3169
+ if (origin === undefined) {
3170
+ try {
3171
+ origin = (await api('GET', `/github/origin?cwd=${encodeURIComponent(s.cwd)}`)).repo;
3172
+ } catch (err) {
3173
+ if (err instanceof AuthError) return showAuth(err.message);
3174
+ origin = null;
3175
+ }
3176
+ githubPick.origins.set(s.cwd, origin);
3177
+ }
3178
+ const repo = repoForOrigin(githubPick.list?.repos ?? [], origin, githubPick.recent);
3179
+ if (repo && state.activeId === s.id && githubPick.followed === s.id) pickRepo(repo);
3180
+ }
3181
+
3182
+ async function loadView(view) {
3183
+ const repo = githubPick.repo;
3184
+ if (!repo || view === 'repos') return;
3185
+ const query = view === 'issues' ? `?state=${githubPick.issueState}` : '';
3186
+ const key = repoKey(repo);
3187
+ const stamp = `${key}${query}`;
3188
+ const before = githubPick.data[view];
3189
+ const request = { key, stamp, value: before?.stamp === stamp ? before.value : null, error: null, loading: true };
3190
+ githubPick.data[view] = request;
3191
+ renderGitHubViews();
3192
+ let value = null;
3193
+ let error = null;
3194
+ try {
3195
+ value = await api('GET', `${repoPath(repo)}/${view}${query}`);
3196
+ } catch (err) {
3197
+ if (err instanceof AuthError) return showAuth(err.message);
3198
+ error = err.message;
3199
+ }
3200
+ const slot = githubPick.data[view];
3201
+ if (slot !== request) return;
3202
+ githubPick.data[view] = { key, stamp, value: value ?? slot.value, error, loading: false };
3203
+ renderGitHubViews();
3204
+ if (view === 'actions') scheduleRuns();
3205
+ }
3206
+
3207
+ function viewData(view) {
3208
+ const slot = githubPick.data[view];
3209
+ const repo = githubPick.repo;
3210
+ return slot && repo && slot.key === repoKey(repo) ? slot : null;
3211
+ }
3212
+
3213
+ /** Runs are polled only while their tab is on screen in a visible page, faster while one is going. */
3214
+ function scheduleRuns() {
3215
+ clearTimeout(runsTimer);
3216
+ if (!dockShows('github') || githubShownView() !== 'actions' || !githubPick.repo || document.visibilityState !== 'visible') return;
3217
+ runsTimer = setTimeout(() => loadView('actions'), viewData('actions')?.value?.running ? RUNS_POLL_MS : RUNS_IDLE_POLL_MS);
3218
+ }
3219
+
3220
+ function switchGitHubView(view, { focus = false } = {}) {
3221
+ if (!GITHUB_VIEWS.includes(view)) return;
3222
+ githubPick.view = view;
3223
+ save(GITHUB_VIEW_KEY, view);
3224
+ renderGitHubViews();
3225
+ if (focus) $(`github-view-${view}`).focus();
3226
+ if (view === 'repos') ensureRepos();
3227
+ else loadView(view);
3228
+ scheduleRuns();
3229
+ }
3230
+
3231
+ function moveGitHubView(e) {
3232
+ const at = GITHUB_VIEWS.indexOf(e.target.dataset?.view);
3233
+ if (at === -1) return;
3234
+ const next = e.key === 'ArrowRight' ? (at + 1) % GITHUB_VIEWS.length
3235
+ : e.key === 'ArrowLeft' ? (at - 1 + GITHUB_VIEWS.length) % GITHUB_VIEWS.length
3236
+ : e.key === 'Home' ? 0 : e.key === 'End' ? GITHUB_VIEWS.length - 1 : -1;
3237
+ if (next === -1) return;
3238
+ e.preventDefault();
3239
+ switchGitHubView(GITHUB_VIEWS[next], { focus: true });
3240
+ }
3241
+
3242
+ // The picker: a combobox over every account's repositories.
3243
+
3244
+ function pickerOptions() {
3245
+ const accounts = new Set(usableGitHubAccounts().map((a) => a.id));
3246
+ const repos = (githubPick.list?.repos ?? []).filter((repo) => accounts.has(repo.accountId));
3247
+ const ranked = rankRepos(repos, githubPick.query);
3248
+ const options = githubPick.query.trim() ? ranked : recentFirst(ranked, githubPick.recent);
3249
+ return { options: options.slice(0, PICKER_LIMIT), truncated: options.length > PICKER_LIMIT };
3250
+ }
3251
+
3252
+ function setPickerOpen(open) {
3253
+ githubPick.open = open;
3254
+ const input = $('github-repo');
3255
+ input.setAttribute('aria-expanded', String(open));
3256
+ $('github-repo-list').hidden = !open;
3257
+ if (!open) input.removeAttribute('aria-activedescendant');
3258
+ else renderPickerList();
3259
+ }
3260
+
3261
+ function highlighted(text, query) {
3262
+ return highlightParts(text, query).map((part) => (part.match ? el('mark', null, part.text) : part.text));
3263
+ }
3264
+
3265
+ function renderPickerList() {
3266
+ if (!githubPick.open) return;
3267
+ const list = $('github-repo-list');
3268
+ const input = $('github-repo');
3269
+ const { options, truncated } = pickerOptions();
3270
+ githubPick.active = Math.min(githubPick.active, Math.max(0, options.length - 1));
3271
+ const accounts = new Map((state.github?.accounts ?? []).map((a) => [a.id, a]));
3272
+ const several = accounts.size > 1;
3273
+ const items = options.map((repo, index) => {
3274
+ const account = accounts.get(repo.accountId);
3275
+ const meta = [several && `@${repo.login}`, repo.description, repo.language, repo.pushedAt && `pushed ${relativeTime(repo.pushedAt)}`].filter(Boolean).join(' · ');
3276
+ const item = el('li', 'github-option',
3277
+ account ? githubAvatar(account) : null,
3278
+ el('span', 'github-option-text', el('span', 'github-option-name', ...highlighted(repo.fullName, githubPick.query)), meta && el('span', 'github-option-meta', meta)),
3279
+ repo.private ? badge('', 'Private', 'Only people with access can see it') : null,
3280
+ repo.archived ? badge('', 'Archived', 'Read-only on GitHub') : null);
3281
+ item.id = `github-repo-option-${index}`;
3282
+ item.setAttribute('role', 'option');
3283
+ item.setAttribute('aria-selected', String(index === githubPick.active));
3284
+ item.addEventListener('pointerdown', (e) => e.preventDefault());
3285
+ item.addEventListener('click', () => choosePickerRepo(repo));
3286
+ return item;
3287
+ });
3288
+ if (!items.length) {
3289
+ const empty = el('li', 'github-option-empty', githubPick.list ? 'No repository matches.' : 'Loading repositories…');
3290
+ empty.setAttribute('role', 'presentation');
3291
+ items.push(empty);
3292
+ }
3293
+ if (truncated) {
3294
+ const hint = el('li', 'github-option-empty', `Showing ${PICKER_LIMIT} matches; refine your search.`);
3295
+ hint.setAttribute('role', 'presentation');
3296
+ items.push(hint);
3297
+ }
3298
+ list.replaceChildren(...items);
3299
+ const active = items[githubPick.active];
3300
+ if (active?.id) {
3301
+ input.setAttribute('aria-activedescendant', active.id);
3302
+ active.scrollIntoView({ block: 'nearest' });
3303
+ } else input.removeAttribute('aria-activedescendant');
3304
+ }
3305
+
3306
+ function choosePickerRepo(repo) {
3307
+ githubPick.query = '';
3308
+ githubPick.followed = state.activeId;
3309
+ setPickerOpen(false);
3310
+ pickRepo(repo, { chosen: true });
3311
+ $('github-repo').value = repo.fullName;
3312
+ }
3313
+
3314
+ function pickerKey(e) {
3315
+ const { options } = pickerOptions();
3316
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
3317
+ e.preventDefault();
3318
+ if (!githubPick.open) {
3319
+ githubPick.active = 0;
3320
+ return setPickerOpen(true);
3321
+ }
3322
+ if (!options.length) return;
3323
+ githubPick.active = (githubPick.active + (e.key === 'ArrowDown' ? 1 : options.length - 1)) % options.length;
3324
+ renderPickerList();
3325
+ } else if (e.key === 'Enter' && githubPick.open) {
3326
+ e.preventDefault();
3327
+ if (options[githubPick.active]) choosePickerRepo(options[githubPick.active]);
3328
+ } else if (e.key === 'Escape' && (githubPick.open || githubPick.query)) {
3329
+ e.preventDefault();
3330
+ githubPick.query = '';
3331
+ setPickerOpen(false);
3332
+ e.target.value = githubPick.repo?.fullName ?? '';
3333
+ e.target.select();
3334
+ }
3335
+ }
3336
+
3337
+ // Rendering the views.
3338
+
3339
+ function renderPickerNote() {
3340
+ const note = $('github-picker-note');
3341
+ const lines = [];
3342
+ if (githubPick.loadingFor && !githubPick.list) lines.push('Loading repositories…');
3343
+ if (githubPick.error) lines.push(`Repositories could not be loaded: ${githubPick.error}`);
3344
+ for (const failed of githubPick.list?.errors ?? []) lines.push(`@${failed.login}: ${failed.message}`);
3345
+ if (githubPick.list?.truncated) lines.push('An account has more repositories than the 1,000 most recently pushed shown here.');
3346
+ if (githubPick.list && !githubPick.repo && !lines.length) lines.push(githubPick.list.repos.length ? 'Pick a repository to see its issues, Actions runs and pull requests.' : 'Your accounts have no repositories yet.');
3347
+ note.replaceChildren(...lines.map((line) => el('span', null, line)));
3348
+ note.hidden = !lines.length;
3349
+ note.classList.toggle('warn', Boolean(githubPick.error || githubPick.list?.errors?.length));
3350
+ }
3351
+
3352
+ function renderGitHubViews() {
3353
+ const usable = usableGitHubAccounts().length > 0;
3354
+ const shown = githubShownView();
3355
+ const account = githubAccount();
3356
+ const repo = githubPick.repo;
3357
+ const repos = githubView.repos?.accountId === account?.id ? githubView.repos : null;
3358
+ $('github-accounts').hidden = shown !== 'repos' || !state.github?.accounts.length;
3359
+ $('github-sub').textContent = shown !== 'repos'
3360
+ ? (repo ? `${repo.fullName} · Acting as @${state.github?.accounts.find((a) => a.id === repo.accountId)?.login ?? repo.login}` : 'Repositories from all your accounts')
3361
+ : !state.github ? 'Loading…' : !account ? 'Sign in to browse your repositories'
3362
+ : [`@${account.login}`, account.name, repos && `${repos.repos.length} ${repos.repos.length === 1 ? 'repository' : 'repositories'}`].filter(Boolean).join(' · ');
3363
+ $('github-picker').hidden = !usable;
3364
+ $('github-views').hidden = !usable;
3365
+ const input = $('github-repo');
3366
+ if (document.activeElement !== input) input.value = githubPick.repo?.fullName ?? '';
3367
+ $('github-repo-reload').disabled = Boolean(githubPick.loadingFor);
3368
+ if (usable) renderPickerNote();
3369
+ else $('github-picker-note').hidden = true;
3370
+ for (const view of GITHUB_VIEWS) {
3371
+ const tab = $(`github-view-${view}`);
3372
+ tab.setAttribute('aria-selected', String(view === shown));
3373
+ tab.tabIndex = view === shown ? 0 : -1;
3374
+ }
3375
+ $('github-repos').hidden = shown !== 'repos';
3376
+ $('github-issues').hidden = shown !== 'issues';
3377
+ $('github-runs').hidden = shown !== 'actions';
3378
+ $('github-pulls').hidden = shown !== 'pulls';
3379
+ const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
3380
+ $('github-clone-into').hidden = !ready || shown !== 'repos';
3381
+ const running = Boolean(viewData('actions')?.value?.running);
3382
+ $('github-running').hidden = !running;
3383
+ $('github-view-actions').setAttribute('aria-label', running ? 'Actions, a workflow is running' : 'Actions');
3384
+ renderPickerList();
3385
+ if (shown === 'issues') renderIssues();
3386
+ else if (shown === 'actions') renderRuns();
3387
+ else if (shown === 'pulls') renderPulls();
3388
+ }
3389
+
3390
+ /** Rebuilds a panel and puts the focus back on the control with the same `data-key`. */
3391
+ function redraw(panel, children) {
3392
+ const key = panel.contains(document.activeElement) ? document.activeElement.dataset.key : null;
3393
+ panel.replaceChildren(...children.filter(Boolean));
3394
+ if (key) panel.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus({ preventScroll: true });
3395
+ }
3396
+
3397
+ function keyed(node, key) {
3398
+ node.dataset.key = key;
3399
+ return node;
3400
+ }
3401
+
3402
+ function viewTools(view, ...controls) {
3403
+ const slot = viewData(view);
3404
+ const refresh = keyed(button(slot?.loading ? 'Refreshing…' : 'Refresh', () => loadView(view)), 'refresh');
3405
+ refresh.disabled = Boolean(slot?.loading);
3406
+ const href = slot?.value?.url ?? `https://github.com/${githubPick.repo.fullName}/${view === 'pulls' ? 'pulls' : view}`;
3407
+ const open = keyed(externalLink('GitHub', href, 'btn github-out'), 'out');
3408
+ open.title = `Open ${githubPick.repo.fullName} on GitHub`;
3409
+ return el('div', 'github-view-tools', ...controls, el('span', 'github-spacer'), refresh, open);
3410
+ }
3411
+
3412
+ function viewStatus(view, empty) {
3413
+ const slot = viewData(view);
3414
+ if (slot?.error) {
3415
+ const line = el('p', 'github-error', slot.error);
3416
+ line.setAttribute('role', 'alert');
3417
+ return line;
3418
+ }
3419
+ if (!slot?.value) return el('p', 'github-view-note', 'Loading…');
3420
+ if (empty) return el('p', 'github-view-note', empty);
3421
+ return null;
3422
+ }
3423
+
3424
+ function noRepoNote(what) {
3425
+ return el('p', 'github-view-note', githubPick.list ? `Pick a repository above to see its ${what}.` : 'Loading repositories…');
3426
+ }
3427
+
3428
+ function recordRow(key, title, meta, side, href) {
3429
+ const head = href ? keyed(externalLink(title, href, 'github-record-title'), `${key}:title`) : title;
3430
+ return el('article', 'history-row github-record', el('div', 'history-main', el('div', 'history-title', head), el('div', 'history-meta', meta)), side);
3431
+ }
3432
+
3433
+ function renderIssues() {
3434
+ const panel = $('github-issues');
3435
+ const repo = githubPick.repo;
3436
+ if (!repo) return redraw(panel, [noRepoNote('issues')]);
3437
+ if (githubPick.editing !== null) return renderIssueEditor(panel, repo);
3438
+ const slot = viewData('issues');
3439
+ const blocked = issueAccountError(repo);
3440
+ const issues = slot?.value?.issues ?? [];
3441
+ const filter = el('div', 'github-segment', ...['open', 'closed'].map((value) => {
3442
+ const choice = keyed(button(value === 'open' ? 'Open' : 'Closed', () => {
3443
+ if (githubPick.issueState === value) return;
3444
+ githubPick.issueState = value;
3445
+ loadView('issues');
3446
+ }), `state:${value}`);
3447
+ choice.setAttribute('aria-pressed', String(githubPick.issueState === value));
3448
+ return choice;
3449
+ }));
3450
+ filter.setAttribute('role', 'group');
3451
+ filter.setAttribute('aria-label', 'Show issues');
3452
+ const create = keyed(button('New issue', () => editIssue('new'), 'btn primary'), 'new');
3453
+ create.disabled = Boolean(blocked);
3454
+ const rows = issues.map((issue) => {
3455
+ const edit = keyed(button('Edit', () => editIssue(issue.number)), `edit:${issue.number}`);
3456
+ edit.disabled = Boolean(blocked);
3457
+ edit.setAttribute('aria-label', `Edit #${issue.number} ${issue.title}`);
3458
+ const meta = [issue.user, issue.updatedAt && `updated ${relativeTime(issue.updatedAt)}`, issue.comments && `${issue.comments} comment${issue.comments === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
3459
+ return recordRow(`issue:${issue.number}`, `#${issue.number} ${issue.title}`, meta, edit, issue.url);
3460
+ });
3461
+ redraw(panel, [
3462
+ viewTools('issues', filter, create),
3463
+ blocked && el('p', 'github-error', blocked),
3464
+ viewStatus('issues', slot?.value && !issues.length ? `No ${githubPick.issueState} issues.` : null),
3465
+ rows.length ? el('div', 'history-list', ...rows) : null,
3466
+ slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 most recently updated.') : null,
3467
+ ]);
3468
+ }
3469
+
3470
+ function editIssue(target) {
3471
+ if (!githubPick.repo || issueAccountError(githubPick.repo)) return;
3472
+ githubPick.editing = { target };
3473
+ renderGitHubViews();
3474
+ $('github-issues').querySelector('input')?.focus();
3475
+ }
3476
+
3477
+ function leaveIssueEditor() {
3478
+ if (githubPick.editing?.pending) return;
3479
+ githubPick.editing = null;
3480
+ renderGitHubViews();
3481
+ $('github-issues').querySelector('[data-key="new"]')?.focus();
3482
+ }
3483
+
3484
+ function issuePayloadError(payload) {
3485
+ if (payload.title !== undefined) {
3486
+ if (!payload.title.trim()) return 'Enter a title for the issue.';
3487
+ if (payload.title.trim().length > 256) return 'Keep the title to 256 characters or fewer.';
3488
+ }
3489
+ if (payload.body?.length > BODY_LIMIT) return 'Keep the description to 48,000 characters or fewer, or edit it on GitHub.';
3490
+ if (new TextEncoder().encode(JSON.stringify(payload)).length > GITHUB_REQUEST_LIMIT) {
3491
+ return 'This issue is too large to send here. Shorten the description or edit it on GitHub.';
3492
+ }
3493
+ return null;
3494
+ }
3495
+
3496
+ function renderIssueEditor(panel, repo) {
3497
+ const editor = githubPick.editing;
3498
+ if (editor.form && panel.contains(editor.form)) return editor.update();
3499
+ const issue = editor.target === 'new' ? null : viewData('issues')?.value?.issues.find((i) => i.number === editor.target) ?? null;
3500
+ if (editor.target !== 'new' && !issue) {
3501
+ githubPick.editing = null;
3502
+ return renderIssues();
3503
+ }
3504
+ const key = repoKey(repo);
3505
+ const title = el('input');
3506
+ Object.assign(title, { type: 'text', required: true, value: issue?.title ?? '', spellcheck: true, autocomplete: 'off' });
3507
+ const body = el('textarea', 'github-issue-body');
3508
+ Object.assign(body, { value: issue?.body ?? '', placeholder: 'Describe the issue (Markdown)' });
3509
+ // Compare the browser's initialized values: textareas normalize CRLF to LF.
3510
+ const initial = { title: title.value, body: body.value };
3511
+ const longBody = Boolean(issue && issuePayloadError({ body: initial.body }));
3512
+ const context = el('p', 'github-small');
3513
+ const accountError = el('p', 'github-error');
3514
+ accountError.setAttribute('role', 'alert');
3515
+ const error = el('p', 'github-error');
3516
+ error.setAttribute('role', 'alert');
3517
+ error.hidden = true;
3518
+ const form = el('form', 'github-card github-issue-form',
3519
+ el('h3', null, issue ? `Edit #${issue.number} in ${repo.fullName}` : `New issue in ${repo.fullName}`),
3520
+ context,
3521
+ el('div', 'github-fields', el('label', 'wide', el('span', null, 'Title'), title), el('label', 'wide', el('span', null, 'Description'), body)),
3522
+ longBody && el('p', 'github-small', 'This description is too long to edit here. Title and state changes keep it intact. ', externalLink('Edit on GitHub', issue.url)),
3523
+ accountError, error);
3524
+ form.noValidate = true;
3525
+ editor.form = form;
3526
+ const changes = () => {
3527
+ const payload = {};
3528
+ if (!issue || title.value !== initial.title) payload.title = title.value;
3529
+ if (!issue || (!longBody && body.value !== initial.body)) payload.body = body.value;
3530
+ return payload;
3531
+ };
3532
+ const dirty = () => title.value !== initial.title || (!longBody && body.value !== initial.body);
3533
+ const current = () => githubPick.editing === editor && githubPick.repo && repoKey(githubPick.repo) === key;
3534
+ const visible = () => dockShows('github') && githubShownView() === 'issues';
3535
+ const fail = (message) => {
3536
+ error.textContent = message;
3537
+ error.hidden = false;
3538
+ };
3539
+ const send = async (control, nextState) => {
3540
+ if (editor.pending || !current()) return;
3541
+ const payload = changes();
3542
+ if (nextState) payload.state = nextState;
3543
+ if (!Object.keys(payload).length) return;
3544
+ const invalid = issueAccountError(repo) || issuePayloadError(payload);
3545
+ if (invalid) return fail(invalid);
3546
+ const login = state.github.accounts.find((a) => a.id === repo.accountId).login;
3547
+ const ownedFocus = form.contains(document.activeElement);
3548
+ editor.pending = true;
3549
+ editor.update();
3550
+ const pendingFocus = document.activeElement;
3551
+ const mayFocus = () => visible() && (form.contains(document.activeElement) || (ownedFocus && document.activeElement === pendingFocus));
3552
+ error.hidden = true;
3553
+ try {
3554
+ const result = issue
3555
+ ? await api('PATCH', `${repoPath(repo)}/issues/${issue.number}`, payload)
3556
+ : await api('POST', `${repoPath(repo)}/issues`, payload);
3557
+ const verb = nextState === 'closed' ? 'Closed' : nextState === 'open' ? 'Reopened' : issue ? 'Saved' : 'Created';
3558
+ toast(`${verb} #${result.issue.number} in ${repo.fullName} as @${login}.`, 4000);
3559
+ if (!current()) return;
3560
+ const focus = mayFocus();
3561
+ githubPick.editing = null;
3562
+ if (!issue) githubPick.issueState = 'open';
3563
+ delete githubPick.data.issues;
3564
+ if (!visible()) return;
3565
+ // A new editor may open while the refresh is pending. Focus only the list we drew.
3566
+ renderGitHubViews();
3567
+ const newButton = panel.querySelector('[data-key="new"]');
3568
+ const refreshFocus = document.activeElement;
3569
+ await loadView('issues');
3570
+ if (focus && visible() && !githubPick.editing && githubPick.repo && repoKey(githubPick.repo) === key
3571
+ && (document.activeElement === refreshFocus || document.activeElement === newButton)) {
3572
+ panel.querySelector('[data-key="new"]')?.focus();
3573
+ }
3574
+ } catch (err) {
3575
+ if (err instanceof AuthError) return showAuth(err.message);
3576
+ const message = err.code === 'too_large' ? 'This issue is too large to send here. Shorten the description or edit it on GitHub.' : err.message;
3577
+ if (current()) {
3578
+ fail(message);
3579
+ const focus = mayFocus();
3580
+ editor.pending = false;
3581
+ editor.update();
3582
+ if (focus) control.focus();
3583
+ } else toast(`${repo.fullName} · @${login}: ${message}`, 8000);
3584
+ } finally {
3585
+ editor.pending = false;
3586
+ if (current()) editor.update();
3587
+ }
3588
+ };
3589
+ const submit = el('button', 'btn primary', issue ? 'Save' : 'Create issue');
3590
+ submit.type = 'submit';
3591
+ const actions = el('div', 'github-actions', submit);
3592
+ let toggle;
3593
+ const closing = issue?.state === 'open';
3594
+ if (issue) {
3595
+ toggle = button('', () => send(toggle, closing ? 'closed' : 'open'), closing ? 'btn danger' : 'btn');
3596
+ actions.append(toggle);
3597
+ }
3598
+ actions.append(button('Cancel', leaveIssueEditor));
3599
+ if (issue) actions.append(externalLink('Open on GitHub', issue.url, 'btn github-out'));
3600
+ form.append(actions);
3601
+ editor.update = () => {
3602
+ const blocked = issueAccountError(repo);
3603
+ context.textContent = `Acting as @${state.github?.accounts.find((a) => a.id === repo.accountId)?.login ?? repo.login}`;
3604
+ if (accountError.textContent !== (blocked ?? '')) accountError.textContent = blocked ?? '';
3605
+ accountError.hidden = !blocked;
3606
+ for (const b of form.querySelectorAll('button')) b.disabled = Boolean(editor.pending);
3607
+ submit.disabled = Boolean(editor.pending || blocked || (issue && !dirty()));
3608
+ submit.textContent = editor.pending ? 'Saving…' : issue ? 'Save' : 'Create issue';
3609
+ title.readOnly = Boolean(editor.pending);
3610
+ body.readOnly = Boolean(editor.pending || longBody);
3611
+ form.setAttribute('aria-busy', String(Boolean(editor.pending)));
3612
+ if (toggle) {
3613
+ toggle.disabled = Boolean(editor.pending || blocked);
3614
+ toggle.textContent = dirty() ? (closing ? 'Save and close' : 'Save and reopen') : closing ? 'Close issue' : 'Reopen issue';
3615
+ }
3616
+ };
3617
+ form.addEventListener('input', () => {
3618
+ error.hidden = true;
3619
+ editor.update();
3620
+ });
3621
+ form.addEventListener('submit', (e) => {
3622
+ e.preventDefault();
3623
+ send(submit);
3624
+ });
3625
+ form.addEventListener('keydown', (e) => {
3626
+ if (e.key !== 'Escape') return;
3627
+ e.preventDefault();
3628
+ if (!editor.pending && !dirty()) leaveIssueEditor();
3629
+ });
3630
+ editor.update();
3631
+ panel.replaceChildren(form);
3632
+ }
3633
+
3634
+ function runTone(run) {
3635
+ if (run.status !== 'completed') return 'run';
3636
+ if (run.conclusion === 'success' || run.conclusion === 'skipped' || run.conclusion === 'neutral') return 'ok';
3637
+ if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'bad';
3638
+ return '';
3639
+ }
3640
+
3641
+ function runLabel(run) {
3642
+ return String(run.status === 'completed' && run.conclusion ? run.conclusion : run.status).replaceAll('_', ' ');
3643
+ }
3644
+
3645
+ function renderRuns() {
3646
+ const panel = $('github-runs');
3647
+ if (!githubPick.repo) return redraw(panel, [noRepoNote('Actions runs')]);
3648
+ const slot = viewData('actions');
3649
+ const runs = slot?.value?.runs ?? [];
3650
+ const banner = slot?.value ? el('p', `github-running-note${slot.value.running ? ' on' : ''}`, slot.value.running ? 'A workflow is running. This list refreshes every few seconds.' : 'No workflow is running.') : null;
3651
+ const rows = runs.map((run) => {
3652
+ const meta = [run.name !== run.title && run.name, run.branch && `on ${run.branch}`, run.event, run.runNumber && `#${run.runNumber}`, run.updatedAt && relativeTime(run.updatedAt)].filter(Boolean).join(' · ');
3653
+ return recordRow(`run:${run.id}`, run.title, meta, badge(runTone(run), runLabel(run), `Status: ${runLabel(run)}`), run.url);
3654
+ });
3655
+ redraw(panel, [
3656
+ viewTools('actions'),
3657
+ banner,
3658
+ viewStatus('actions', slot?.value && !runs.length ? 'No workflow runs yet.' : null),
3659
+ rows.length ? el('div', 'history-list', ...rows) : null,
3660
+ slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 latest runs.') : null,
3661
+ ]);
3662
+ }
3663
+
3664
+ function renderPulls() {
3665
+ const panel = $('github-pulls');
3666
+ if (!githubPick.repo) return redraw(panel, [noRepoNote('pull requests')]);
3667
+ const slot = viewData('pulls');
3668
+ const pulls = slot?.value?.pulls ?? [];
3669
+ const rows = pulls.map((pull) => {
3670
+ const meta = [pull.user, pull.head && pull.base && `${pull.head} → ${pull.base}`, pull.updatedAt && `updated ${relativeTime(pull.updatedAt)}`].filter(Boolean).join(' · ');
3671
+ return recordRow(`pull:${pull.number}`, `#${pull.number} ${pull.title}`, meta, pull.draft ? badge('', 'Draft', 'Not ready for review') : null, pull.url);
3672
+ });
3673
+ redraw(panel, [
3674
+ viewTools('pulls'),
3675
+ viewStatus('pulls', slot?.value && !pulls.length ? 'No open pull requests.' : null),
3676
+ rows.length ? el('div', 'history-list', ...rows) : null,
3677
+ slot?.value?.truncated ? el('p', 'github-view-note', 'Showing the 30 most recently updated.') : null,
3678
+ ]);
3679
+ }
3680
+
2900
3681
  // ---- session cards --------------------------------------------------------
2901
3682
 
2902
3683
  const cards = new Map();
@@ -2929,15 +3710,15 @@ function statusText(s) {
2929
3710
  if (s.signal) return `Exited (${s.signal})`;
2930
3711
  return s.exitCode === 0 || s.exitCode === null ? 'Exited' : `Exited (${s.exitCode})`;
2931
3712
  }
2932
- return s.activity === 'active' ? 'Working' : 'Running';
3713
+ return isSessionWorking(s) ? 'Working' : 'Running';
2933
3714
  }
2934
3715
 
2935
3716
  function buildCard(session) {
2936
3717
  const node = $('session-template').content.firstElementChild.cloneNode(true);
2937
3718
  node.dataset.id = session.id;
2938
- const open = () => openPanel(session.id);
2939
- node.addEventListener('click', (e) => { if (!e.target.closest('button')) open(); });
2940
- node.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === node) open(); });
3719
+ const open = (e) => openPanel(session.id, { beside: e.ctrlKey || e.metaKey });
3720
+ node.addEventListener('click', (e) => { if (!e.target.closest('button')) open(e); });
3721
+ node.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target === node) open(e); });
2941
3722
  node.querySelector('.open').addEventListener('click', open);
2942
3723
  node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
2943
3724
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
@@ -3017,7 +3798,7 @@ function updateCard(node, s) {
3017
3798
  paintIdButton(node.querySelector('.session-id'), id);
3018
3799
  const pill = node.querySelector('.status-pill');
3019
3800
  pill.textContent = statusText(s);
3020
- pill.className = `status-pill ${s.status === 'exited' ? 'exited' : s.activity}`;
3801
+ pill.className = `status-pill ${s.status === 'exited' ? 'exited' : isSessionWorking(s) ? 'active' : 'quiet'}`;
3021
3802
  const model = node.querySelector('.model-pill');
3022
3803
  model.hidden = !s.model;
3023
3804
  model.textContent = modelText(s);
@@ -3055,6 +3836,7 @@ function updateCard(node, s) {
3055
3836
  function renderSessions() {
3056
3837
  const grid = $('sessions');
3057
3838
  const sessions = orderSessions(state.sessions.values(), sessionOrder);
3839
+ activityFavicon.setWorking(sessions.some(isSessionWorking));
3058
3840
  for (const [id, node] of cards) {
3059
3841
  if (state.sessions.has(id)) continue;
3060
3842
  if (drag?.id === id) releaseDrag();
@@ -3111,8 +3893,9 @@ function upsertSession(session) {
3111
3893
  function dropSession(id) {
3112
3894
  state.sessions.delete(id);
3113
3895
  const view = state.views.get(id);
3896
+ const pane = state.panes.indexOf(id);
3897
+ if (pane !== -1) closePane(pane, { focusTerminal: paneNodes[pane].contains(document.activeElement) });
3114
3898
  if (view) { view.dispose(); state.views.delete(id); }
3115
- if (state.activeId === id) closePanel();
3116
3899
  renderSessions();
3117
3900
  }
3118
3901
 
@@ -3476,13 +4259,13 @@ class TerminalView {
3476
4259
  }
3477
4260
 
3478
4261
  connect() {
3479
- if (this.disposed) return;
4262
+ if (this.disposed || state.remoteRevoked) return;
3480
4263
  const ws = new WebSocket(wsUrl(`/sessions/${this.id}/terminal`));
3481
4264
  this.ws = ws;
3482
4265
  ws.onopen = () => { this.retry = 0; this.sent = { cols: 0, rows: 0 }; this.sendSize(); };
3483
4266
  ws.onmessage = (event) => this.onMessage(JSON.parse(event.data));
3484
4267
  ws.onclose = (event) => {
3485
- if (this.disposed || event.code === 4404 || event.code === 4410) return;
4268
+ if (this.disposed || event.code === 4403 || event.code === 4404 || event.code === 4410) return;
3486
4269
  const delay = Math.min(5000, 300 * 2 ** this.retry++);
3487
4270
  setTimeout(() => this.connect(), delay);
3488
4271
  };
@@ -3513,17 +4296,17 @@ class TerminalView {
3513
4296
 
3514
4297
  sendSize() {
3515
4298
  const { cols, rows } = this.term;
3516
- if (!this.el.isConnected || (cols === this.sent.cols && rows === this.sent.rows)) return;
4299
+ if (terminalSizesHeld || !this.el.isConnected || (cols === this.sent.cols && rows === this.sent.rows)) return;
3517
4300
  this.sent = { cols, rows };
3518
4301
  this.send({ type: 'resize', cols, rows });
3519
4302
  }
3520
4303
 
3521
- mount(host) {
4304
+ mount(host, { focus = true } = {}) {
3522
4305
  host.replaceChildren(this.el);
3523
4306
  if (!this.opened) { this.term.open(this.el); this.enableTouchScroll(); this.opened = true; }
3524
4307
  this.resizeObserver.observe(host);
3525
4308
  this.refit();
3526
- this.term.focus();
4309
+ if (focus) this.term.focus();
3527
4310
  }
3528
4311
 
3529
4312
  /**
@@ -3601,26 +4384,162 @@ const terminalCopy = new TerminalCopy({
3601
4384
  },
3602
4385
  });
3603
4386
 
3604
- function openPanel(id) {
4387
+ const paneNodes = [...document.querySelectorAll('.terminal-pane')];
4388
+ const sessionMenu = { mode: 'switch', invoker: null };
4389
+ let terminalSizesHeld = false;
4390
+ let panesRestored = false;
4391
+ let splitRatio = clampRatio(Number.parseFloat(load(SPLIT_RATIO_KEY)));
4392
+
4393
+ /**
4394
+ * `beside`: into the other pane instead of the focused one. Two panes never show the same session, whose terminal has one size.
4395
+ * `restoring`: brought back from the last visit, under a dock that was also brought back and stays on top.
4396
+ */
4397
+ function openPanel(id, { beside = false, restoring = false } = {}) {
3605
4398
  if (!state.sessions.has(id)) return;
4399
+ let index = state.panes.indexOf(id);
4400
+ if (index === -1) {
4401
+ index = !state.panes.length ? 0 : !beside ? state.focusedPane : state.panes.length < 2 ? 1 : 1 - state.focusedPane;
4402
+ const replaced = state.panes[index];
4403
+ if (replaced) state.views.get(replaced)?.unmount();
4404
+ state.panes[index] = id;
4405
+ if (!state.views.has(id)) state.views.set(id, new TerminalView(id));
4406
+ }
4407
+ $('terminal-panel').hidden = false;
4408
+ if (!restoring) dockMakesWayForTerminal();
4409
+ focusPane(index, { focusTerminal: !restoring || !dockView.panel });
4410
+ }
4411
+
4412
+ function focusPane(index, { focusTerminal = true } = {}) {
4413
+ const id = state.panes[index];
4414
+ if (!id) return;
3606
4415
  if (state.activeId !== id) terminalCopy.close();
3607
4416
  if (dictation && dictation.id !== id) stopDictation();
3608
- if (state.activeId && state.activeId !== id) state.views.get(state.activeId)?.unmount();
4417
+ state.focusedPane = index;
3609
4418
  state.activeId = id;
3610
- let view = state.views.get(id);
3611
- if (!view) { view = new TerminalView(id); state.views.set(id, view); }
3612
- $('terminal-panel').hidden = false;
4419
+ layoutPanes();
3613
4420
  updatePanel();
3614
- view.mount($('terminal-host'));
4421
+ if (focusTerminal) state.views.get(id)?.term.focus();
4422
+ savePanes();
4423
+ followTerminal();
4424
+ }
4425
+
4426
+ function closePane(index, { focusTerminal = true } = {}) {
4427
+ const id = state.panes[index];
4428
+ if (!id) return;
4429
+ if (state.panes.length < 2) return closePanel();
4430
+ state.views.get(id)?.unmount();
4431
+ state.panes.splice(index, 1);
4432
+ focusPane(0, { focusTerminal });
3615
4433
  }
3616
4434
 
3617
4435
  function closePanel() {
3618
4436
  terminalCopy.close();
3619
4437
  stopDictation();
3620
- if (state.activeId) state.views.get(state.activeId)?.unmount();
4438
+ for (const id of state.panes) state.views.get(id)?.unmount();
4439
+ state.panes = [];
4440
+ state.focusedPane = 0;
3621
4441
  state.activeId = null;
3622
4442
  $('terminal-panel').hidden = true;
4443
+ closeMenu($('panel-sessions'));
3623
4444
  renderVoice();
4445
+ savePanes();
4446
+ }
4447
+
4448
+ function stageSplit() {
4449
+ const box = $('terminal-panel').getBoundingClientRect();
4450
+ return splitMode(box.width, box.height);
4451
+ }
4452
+
4453
+ function layoutPanes() {
4454
+ if ($('terminal-panel').hidden) return;
4455
+ const split = state.panes.length === 2 ? stageSplit() : null;
4456
+ const panes = $('terminal-panes');
4457
+ if (split) panes.dataset.split = split;
4458
+ else delete panes.dataset.split;
4459
+ panes.style.setProperty('--ratio', String(splitRatio));
4460
+ const splitter = $('pane-splitter');
4461
+ splitter.hidden = !split;
4462
+ splitter.setAttribute('aria-orientation', split === 'rows' ? 'horizontal' : 'vertical');
4463
+ splitter.setAttribute('aria-valuemin', String(Math.round(SPLIT_RATIO_MIN * 100)));
4464
+ splitter.setAttribute('aria-valuemax', String(Math.round((1 - SPLIT_RATIO_MIN) * 100)));
4465
+ splitter.setAttribute('aria-valuenow', String(Math.round(splitRatio * 100)));
4466
+ paneNodes.forEach((pane, index) => {
4467
+ const id = state.panes[index];
4468
+ const shown = Boolean(id) && (split !== null || index === state.focusedPane);
4469
+ pane.hidden = !shown;
4470
+ pane.classList.toggle('focused', index === state.focusedPane);
4471
+ const view = id ? state.views.get(id) : null;
4472
+ const host = pane.querySelector('.terminal-host');
4473
+ if (shown && view && view.el.parentNode !== host) view.mount(host, { focus: false });
4474
+ else if (!shown && view && view.el.parentNode === host) view.unmount();
4475
+ });
4476
+ renderPaneLabels();
4477
+ }
4478
+
4479
+ function renderPaneLabels() {
4480
+ const split = $('terminal-panes').dataset.split;
4481
+ paneNodes.forEach((pane, index) => {
4482
+ const s = state.sessions.get(state.panes[index]);
4483
+ pane.querySelector('.pane-name').textContent = s ? `${s.name} · ${statusText(s)}` : '';
4484
+ pane.setAttribute('aria-label', s ? s.name : '');
4485
+ });
4486
+ const others = [...state.sessions.keys()].some((id) => !state.panes.includes(id));
4487
+ $('panel-split').hidden = state.panes.length !== 1 || !others || stageSplit() === null;
4488
+ const swap = $('panel-swap');
4489
+ const other = state.panes.length === 2 && !split ? state.sessions.get(state.panes[1 - state.focusedPane]) : null;
4490
+ swap.hidden = !other;
4491
+ swap.textContent = other ? `Show ${other.name}` : '';
4492
+ swap.title = other ? `Switch to ${other.name}, the other open terminal` : '';
4493
+ }
4494
+
4495
+ /** The button the session menu opened from, or the switcher once that button is gone, as Split is after a split. */
4496
+ function sessionInvoker() {
4497
+ return sessionMenu.invoker?.checkVisibility?.() ? sessionMenu.invoker : $('panel-switch');
4498
+ }
4499
+
4500
+ function renderSessionMenu() {
4501
+ const menu = $('panel-sessions');
4502
+ const beside = sessionMenu.mode === 'beside';
4503
+ const sessions = orderSessions(state.sessions.values(), sessionOrder).filter((s) => !beside || !state.panes.includes(s.id));
4504
+ const items = sessions.map((s) => {
4505
+ const item = el('button', 'menu-item', s.name, el('span', 'menu-note', [statusText(s), folderName(s.cwd)].filter(Boolean).join(' · ')));
4506
+ item.type = 'button';
4507
+ item.setAttribute('role', 'menuitem');
4508
+ if (s.id === state.activeId) item.setAttribute('aria-current', 'true');
4509
+ item.addEventListener('click', () => {
4510
+ closeMenu(menu);
4511
+ openPanel(s.id, { beside });
4512
+ });
4513
+ return item;
4514
+ });
4515
+ menu.replaceChildren(...(items.length ? items : [el('p', 'menu-empty', 'No other sessions')]));
4516
+ }
4517
+
4518
+ function holdTerminalSizes() {
4519
+ terminalSizesHeld = true;
4520
+ document.documentElement.classList.add('resizing');
4521
+ }
4522
+
4523
+ function releaseTerminalSizes() {
4524
+ terminalSizesHeld = false;
4525
+ document.documentElement.classList.remove('resizing');
4526
+ for (const id of state.panes) state.views.get(id)?.refit();
4527
+ }
4528
+
4529
+ function savePanes() {
4530
+ save(PANES_KEY, state.panes.length ? JSON.stringify({ panes: state.panes, focused: state.focusedPane }) : null);
4531
+ }
4532
+
4533
+ function restorePanes() {
4534
+ if (panesRestored) return;
4535
+ panesRestored = true;
4536
+ let saved;
4537
+ try { saved = JSON.parse(load(PANES_KEY)); } catch { return; }
4538
+ const ids = Array.isArray(saved?.panes) ? saved.panes.filter((id) => typeof id === 'string' && state.sessions.has(id)).slice(0, 2) : [];
4539
+ if (!ids.length || state.panes.length) return;
4540
+ openPanel(ids[0], { restoring: true });
4541
+ if (ids[1]) openPanel(ids[1], { beside: true, restoring: true });
4542
+ if (saved.focused === 0 && ids[1]) focusPane(0, { focusTerminal: !dockView.panel });
3624
4543
  }
3625
4544
 
3626
4545
  function updatePanel() {
@@ -3635,6 +4554,7 @@ function updatePanel() {
3635
4554
  renderAgents($('panel-agents'), s.agents, s.shells || []);
3636
4555
  const stop = $('panel-stop');
3637
4556
  stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
4557
+ renderPaneLabels();
3638
4558
  }
3639
4559
 
3640
4560
  // ---- voice input ----------------------------------------------------------
@@ -3942,7 +4862,7 @@ function showManagerUnavailable() {
3942
4862
  }
3943
4863
 
3944
4864
  function connectEvents() {
3945
- if (state.pageAway) return;
4865
+ if (state.pageAway || state.remoteRevoked) return;
3946
4866
  const ws = new WebSocket(wsUrl('/events'));
3947
4867
  state.eventsSocket = ws;
3948
4868
  ws.onopen = () => {
@@ -3961,22 +4881,29 @@ function connectEvents() {
3961
4881
  state.pid = msg.pid || null;
3962
4882
  state.restartable = typeof msg.pid === 'number';
3963
4883
  state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
4884
+ state.folderOpener = msg.folderOpener || null;
4885
+ state.folderPicker = msg.folderPicker || null;
4886
+ state.remoteAccessUI?.setAvailable(msg.remoteAccess);
3964
4887
  renderVersion();
3965
4888
  setConnection('ok', 'Connected to session manager');
3966
4889
  state.sessions = new Map(msg.sessions.map((s) => [s.id, s]));
3967
4890
  for (const id of [...state.views.keys()]) if (!state.sessions.has(id)) dropSession(id);
3968
4891
  managerConnected(msg);
3969
4892
  renderSessions();
4893
+ if (!sessionsShown && load(DOCK_KEY) === 'github' && !dockView.panel) openGitHub({ focus: false });
4894
+ restorePanes();
3970
4895
  sessionsShown = true;
3971
4896
  setUpgrade(msg.upgrade, true);
3972
4897
  loadNews();
3973
4898
  // A changelog.updated sent while the socket was down is lost; catch up the open panel.
3974
4899
  if ($('changelog').open) loadChangelog();
3975
- if ($('github').open) loadGitHub();
4900
+ if (dockShows('github')) loadGitHub();
4901
+ } else if (msg.type === 'remote-access.updated') {
4902
+ state.remoteAccessUI?.updated();
3976
4903
  } else if (msg.type === 'news.updated') {
3977
4904
  loadNews();
3978
4905
  } else if (msg.type === 'github.updated') {
3979
- if ($('github').open) loadGitHub();
4906
+ if (dockShows('github')) loadGitHub();
3980
4907
  } else if (msg.type === 'changelog.updated') {
3981
4908
  if ($('changelog').open) loadChangelog();
3982
4909
  } else if (msg.type === 'manager.upgrade') {
@@ -3999,8 +4926,15 @@ function connectEvents() {
3999
4926
  scheduleStats();
4000
4927
  }
4001
4928
  };
4002
- ws.onclose = () => {
4929
+ ws.onclose = (event) => {
4003
4930
  if (state.pageAway || state.eventsSocket !== ws) return;
4931
+ if (event?.code === 4403) {
4932
+ state.remoteRevoked = true;
4933
+ managerLoss.cancel();
4934
+ state.remoteAccessUI?.close();
4935
+ setConnection('down', 'Remote access changed. Your terminals are still running. Reopen an enabled address to reconnect.');
4936
+ return;
4937
+ }
4004
4938
  if (state.managerUnavailable) {
4005
4939
  showManagerUnavailable();
4006
4940
  } else if (state.stopping && state.stopRemaining !== null) {
@@ -4042,6 +4976,7 @@ let statsInterval;
4042
4976
  let newsTimer;
4043
4977
 
4044
4978
  function showAuth(message = '') {
4979
+ state.remoteAccessUI?.setAvailable(null);
4045
4980
  managerLoss.cancel();
4046
4981
  closePanel();
4047
4982
  closeModels();
@@ -4076,6 +5011,7 @@ async function boot() {
4076
5011
  }
4077
5012
  save(TOKEN_KEY, state.token);
4078
5013
  $('app').hidden = false;
5014
+ if (load(DOCK_KEY) === 'notes' && !dockView.panel) openNotes({ focus: false });
4079
5015
  connectEvents();
4080
5016
  loadUsage();
4081
5017
  clearInterval(usageTimer);
@@ -4095,6 +5031,32 @@ $('auth-form').addEventListener('submit', (e) => {
4095
5031
  boot();
4096
5032
  });
4097
5033
  $('panel-close').addEventListener('click', closePanel);
5034
+ paneNodes.forEach((pane, index) => {
5035
+ pane.querySelector('.pane-close').addEventListener('click', () => closePane(index));
5036
+ pane.addEventListener('focusin', () => { if (state.focusedPane !== index) focusPane(index, { focusTerminal: false }); });
5037
+ pane.addEventListener('pointerdown', () => { if (state.focusedPane !== index) focusPane(index, { focusTerminal: false }); });
5038
+ });
5039
+ const splitAxis = () => ($('terminal-panes').dataset.split === 'rows' ? 'y' : 'x');
5040
+ const setSplitRatio = (ratio) => {
5041
+ splitRatio = clampRatio(ratio);
5042
+ layoutPanes();
5043
+ };
5044
+ bindSplitter($('pane-splitter'), {
5045
+ axis: splitAxis,
5046
+ start: () => splitRatio,
5047
+ move: (delta, from) => {
5048
+ holdTerminalSizes();
5049
+ const box = $('terminal-panes').getBoundingClientRect();
5050
+ setSplitRatio(from + delta / Math.max(1, (splitAxis() === 'x' ? box.width : box.height) - 8));
5051
+ },
5052
+ end: () => {
5053
+ save(SPLIT_RATIO_KEY, String(splitRatio));
5054
+ releaseTerminalSizes();
5055
+ },
5056
+ home: () => setSplitRatio(SPLIT_RATIO_MIN),
5057
+ endKey: () => setSplitRatio(1 - SPLIT_RATIO_MIN),
5058
+ });
5059
+ new ResizeObserver(layoutPanes).observe($('terminal-panel'));
4098
5060
  $('panel-voice').addEventListener('click', () => {
4099
5061
  if (dictation) {
4100
5062
  const { id } = dictation;
@@ -4114,12 +5076,30 @@ $('history').addEventListener('close', () => {
4114
5076
  historyOpener = null;
4115
5077
  });
4116
5078
  $('history-filter').addEventListener('input', renderHistory);
4117
- $('github-open').addEventListener('click', openGitHub);
4118
- $('github-close').addEventListener('click', () => closeGitHub());
4119
- $('github').addEventListener('click', (e) => { if (e.target === $('github')) closeGitHub(); });
4120
- $('github').addEventListener('close', () => {
4121
- if (githubView.opener !== false) (githubView.opener?.isConnected && !githubView.opener.closest('[hidden]') ? githubView.opener : $('github-open')).focus();
4122
- githubView.opener = null;
5079
+ $('github-open').addEventListener('click', () => openGitHub());
5080
+ $('github-toggle').addEventListener('click', firstClick(toggleGitHub));
5081
+ $('github-title').addEventListener('click', () => { if (!dockShows('github')) openGitHub(); });
5082
+ $('notes-title').addEventListener('click', () => { if (!dockShows('notes')) openNotes(); });
5083
+ $('dock').querySelector('.dock-tabs').addEventListener('keydown', moveDockTab);
5084
+ $('dock-close').addEventListener('click', () => closeDock());
5085
+ $('dock').addEventListener('keydown', (e) => {
5086
+ if (e.key !== 'Escape' || e.defaultPrevented || (e.target.type === 'search' && e.target.value)) return;
5087
+ e.preventDefault();
5088
+ closeDock();
5089
+ });
5090
+ bindSplitter($('dock-splitter'), {
5091
+ start: () => clampDockWidth(dockView.width, innerWidth),
5092
+ move: (delta, from) => {
5093
+ holdTerminalSizes();
5094
+ dockView.width = clampDockWidth(from - delta, innerWidth);
5095
+ applyDockLayout();
5096
+ },
5097
+ end: () => {
5098
+ save(DOCK_WIDTH_KEY, String(dockView.width));
5099
+ releaseTerminalSizes();
5100
+ },
5101
+ home: () => { dockView.width = DOCK_MIN; applyDockLayout(); },
5102
+ endKey: () => { dockView.width = clampDockWidth(Infinity, innerWidth); applyDockLayout(); },
4123
5103
  });
4124
5104
  $('github-add').addEventListener('click', startGitHubSignIn);
4125
5105
  $('github-filter').addEventListener('input', () => renderGitHub());
@@ -4134,6 +5114,37 @@ $('github-create').addEventListener('input', () => {
4134
5114
  $('github-create-error').hidden = true;
4135
5115
  renderCreate();
4136
5116
  });
5117
+ const openPicker = (e) => {
5118
+ if (githubPick.open) return;
5119
+ githubPick.query = '';
5120
+ githubPick.active = 0;
5121
+ setPickerOpen(true);
5122
+ e.target.select();
5123
+ };
5124
+ $('github-repo').addEventListener('focus', openPicker);
5125
+ $('github-repo').addEventListener('click', openPicker);
5126
+ // Releasing a press would put the caret where it was let go; the press that opens the list keeps the name selected.
5127
+ let pickerPressOpens = false;
5128
+ $('github-repo').addEventListener('mousedown', () => { pickerPressOpens = !githubPick.open; });
5129
+ $('github-repo').addEventListener('mouseup', (e) => { if (pickerPressOpens) e.preventDefault(); });
5130
+ $('github-repo').addEventListener('input', (e) => {
5131
+ githubPick.query = e.target.value;
5132
+ githubPick.active = 0;
5133
+ if (githubPick.open) renderPickerList();
5134
+ else setPickerOpen(true);
5135
+ });
5136
+ $('github-repo').addEventListener('keydown', pickerKey);
5137
+ $('github-repo').addEventListener('blur', (e) => {
5138
+ setPickerOpen(false);
5139
+ githubPick.query = '';
5140
+ e.target.value = githubPick.repo?.fullName ?? '';
5141
+ });
5142
+ $('github-repo-reload').addEventListener('click', () => loadAllRepos({ refresh: true }));
5143
+ $('github-views').addEventListener('click', (e) => {
5144
+ const view = e.target.closest?.('[data-view]')?.dataset.view;
5145
+ if (view) switchGitHubView(view);
5146
+ });
5147
+ $('github-views').addEventListener('keydown', moveGitHubView);
4137
5148
  $('github-parent').addEventListener('change', () => {
4138
5149
  save(CLONE_PARENT_KEY, cloneParent());
4139
5150
  loadRepos();
@@ -4171,12 +5182,7 @@ document.addEventListener('keydown', (e) => {
4171
5182
  }, true);
4172
5183
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
4173
5184
  addEventListener('resize', () => { if (tipFor) hideTip(); });
4174
- $('notes-open').addEventListener('click', firstClick(openNotes));
4175
- $('notes-close').addEventListener('click', firstClick(closeNotes));
4176
- $('notes').addEventListener('mousedown', keepFocus);
4177
- $('notes').addEventListener('pointerdown', notesPressed);
4178
- $('notes').addEventListener('click', notesClicked);
4179
- $('notes').addEventListener('close', notesClosed);
5185
+ $('notes-open').addEventListener('click', firstClick(toggleNotes));
4180
5186
  $('notes-text').addEventListener('input', saveNotes);
4181
5187
  addEventListener('storage', notesStored);
4182
5188
  // On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
@@ -4196,9 +5202,12 @@ $('news').addEventListener('close', () => {
4196
5202
  document.addEventListener('visibilitychange', () => {
4197
5203
  if (document.visibilityState !== 'visible') stopDictation();
4198
5204
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
5205
+ if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
5206
+ else scheduleRuns();
4199
5207
  });
4200
5208
  addEventListener('pagehide', () => {
4201
5209
  state.pageAway = true;
5210
+ activityFavicon.setPaused(true);
4202
5211
  terminalCopy.close();
4203
5212
  stopDictation();
4204
5213
  managerLoss.cancel();
@@ -4207,6 +5216,7 @@ addEventListener('pagehide', () => {
4207
5216
  addEventListener('pageshow', (event) => {
4208
5217
  if (!event.persisted) return;
4209
5218
  state.pageAway = false;
5219
+ activityFavicon.setPaused(false);
4210
5220
  connectEvents();
4211
5221
  });
4212
5222
  $('version').addEventListener('click', openChangelog);
@@ -4267,22 +5277,61 @@ $('models-more').addEventListener('click', () => {
4267
5277
  renderModels();
4268
5278
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
4269
5279
  });
4270
- $('stop-manager').addEventListener('click', firstClick(() => stopManager()));
4271
- $('restart-manager').addEventListener('click', firstClick(() => stopManager({ restart: true })));
5280
+ $('stop-manager').addEventListener('click', firstClick(() => { closeMenu($('manager-menu')); stopManager(); }));
5281
+ $('restart-manager').addEventListener('click', firstClick(() => { closeMenu($('manager-menu')); stopManager({ restart: true }); }));
4272
5282
  $('copy-command').addEventListener('click', copyCommand);
4273
5283
  $('upgrade').addEventListener('click', upgradeManager);
4274
- $('appearance-menu').addEventListener('change', (e) => {
5284
+ $('settings-menu').addEventListener('change', (e) => {
4275
5285
  if (e.target.name === 'skin') changeSkin(e.target);
4276
5286
  else if (e.target.name === 'theme') changeTheme(e.target);
4277
5287
  else if (e.target.name === 'sound') changeSound(e.target);
4278
5288
  else if (e.target.name === 'voice') changeVoice(e.target);
4279
5289
  });
4280
- $('appearance-menu').addEventListener('toggle', (e) => {
4281
- if (e.newState !== 'open') return;
4282
- placeAppearanceMenu();
4283
- e.currentTarget.querySelector('input:checked')?.focus();
5290
+ /**
5291
+ * Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
5292
+ * that closes and opens again within a task, so this follows beforetoggle, which always fires.
5293
+ */
5294
+ function onMenu(menu, opened, closed = () => {}) {
5295
+ menu.addEventListener('beforetoggle', (e) => {
5296
+ const opening = e.newState === 'open';
5297
+ requestAnimationFrame(() => {
5298
+ if (menu.matches(':popover-open') === opening) (opening ? opened : closed)(menu);
5299
+ });
5300
+ });
5301
+ }
5302
+ /** A menu closed with its focus inside, or with nothing focused after it, hands the focus back to its button. */
5303
+ function returnFocus(menu, invoker) {
5304
+ const at = document.activeElement;
5305
+ if ((!at || at === document.body || menu.contains(at)) && invoker?.checkVisibility?.()) invoker.focus();
5306
+ }
5307
+ onMenu($('settings-menu'), (menu) => {
5308
+ placeMenu(menu, $('settings'));
5309
+ menu.querySelector('input:checked')?.focus();
5310
+ }, (menu) => returnFocus(menu, $('settings')));
5311
+ onMenu($('manager-menu'), (menu) => {
5312
+ placeMenu(menu, $('manager'));
5313
+ menuItems(menu)[0]?.focus();
5314
+ }, (menu) => returnFocus(menu, $('manager')));
5315
+ $('panel-sessions').addEventListener('beforetoggle', (e) => { if (e.newState === 'open') renderSessionMenu(); });
5316
+ onMenu($('panel-sessions'), (menu) => {
5317
+ placeMenu(menu, sessionInvoker());
5318
+ (menu.querySelector('.menu-item[aria-current="true"]') ?? menuItems(menu)[0])?.focus();
5319
+ }, (menu) => returnFocus(menu, sessionInvoker()));
5320
+ for (const menu of [$('manager-menu'), $('panel-sessions')]) menu.addEventListener('keydown', moveInMenu);
5321
+ // Recorded only on the click that opens the menu: a click that closes it leaves what the open menu showed.
5322
+ const sessionMenuFrom = (mode, invoker) => () => {
5323
+ if (!$('panel-sessions').matches(':popover-open')) Object.assign(sessionMenu, { mode, invoker });
5324
+ };
5325
+ $('panel-switch').addEventListener('click', sessionMenuFrom('switch', $('panel-switch')));
5326
+ $('panel-split').addEventListener('click', sessionMenuFrom('beside', $('panel-split')));
5327
+ $('panel-swap').addEventListener('click', () => focusPane(1 - state.focusedPane));
5328
+ window.addEventListener('resize', () => {
5329
+ if (topbarInline(innerWidth)) closeMenu($('topbar-menu'));
5330
+ applyDockLayout();
5331
+ placeMenu($('settings-menu'), $('settings'));
5332
+ placeMenu($('manager-menu'), $('manager'));
5333
+ placeMenu($('panel-sessions'), sessionInvoker());
4284
5334
  });
4285
- window.addEventListener('resize', placeAppearanceMenu);
4286
5335
  $('providers').addEventListener('animationend', (e) => {
4287
5336
  if (e.target === e.currentTarget.lastElementChild) e.currentTarget.classList.remove('deal');
4288
5337
  });
@@ -4322,21 +5371,28 @@ $('panel-stop').addEventListener('click', () => {
4322
5371
  else removeSession(s.id);
4323
5372
  });
4324
5373
  $('cwd').value = load(CWD_KEY) || '';
5374
+ $('cwd-pick').addEventListener('click', firstClick(pickWorkingFolder));
5375
+ $('cwd-open').addEventListener('click', firstClick(openWorkingFolder));
4325
5376
  try { state.accounts = JSON.parse(load(ACCOUNTS_KEY)) || {}; } catch { state.accounts = {}; }
4326
5377
  try { state.shellPicks = JSON.parse(load(SHELLS_KEY)) || {}; } catch { state.shellPicks = {}; }
4327
5378
  githubView.accountId = Number(load(GITHUB_ACCOUNT_KEY)) || null;
5379
+ try { githubPick.recent = JSON.parse(load(GITHUB_RECENT_KEY)) || []; } catch { githubPick.recent = []; }
5380
+ if (!Array.isArray(githubPick.recent)) githubPick.recent = [];
5381
+ if (GITHUB_VIEWS.includes(load(GITHUB_VIEW_KEY))) githubPick.view = load(GITHUB_VIEW_KEY);
4328
5382
  if (typeof state.accounts !== 'object' || Array.isArray(state.accounts)) state.accounts = {};
4329
5383
  if (typeof state.shellPicks !== 'object' || Array.isArray(state.shellPicks)) state.shellPicks = {};
4330
5384
  setInterval(renderSessions, 30000);
4331
5385
  setInterval(tickNews, 30000);
4332
- setInterval(() => { if ($('github').open && state.github) renderGitHub(); }, 30000);
5386
+ setInterval(() => { if (dockShows('github') && state.github) renderGitHub(); }, 30000);
4333
5387
 
4334
- // The terminal panel sits below the top bar, which wraps onto two rows on
4335
- // narrow screens; publish its height so the panel never covers its controls.
5388
+ // The terminal panel and the dock sit below the top bar; publish where it ends so they never cover its controls.
4336
5389
  const topbar = document.querySelector('.topbar');
4337
- const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${topbar.offsetHeight}px`);
5390
+ const publishTopbarHeight = () => document.documentElement.style.setProperty('--topbar-h', `${Math.max(0, Math.round(topbar.getBoundingClientRect().bottom))}px`);
4338
5391
  new ResizeObserver(publishTopbarHeight).observe(topbar);
5392
+ addEventListener('scroll', publishTopbarHeight, { passive: true });
4339
5393
  publishTopbarHeight();
5394
+ applyDockLayout();
4340
5395
 
5396
+ state.remoteAccessUI = createRemoteAccessUI({ api, getToken: () => state.token, isConnected: () => state.connected, onAuthError: showAuth });
4341
5397
  state.token = readTokenFromHash() || load(TOKEN_KEY);
4342
5398
  boot();