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