@oddessentials/agent-guild 0.31.0 → 0.33.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
@@ -4,7 +4,7 @@
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
6
  import { TerminalControls, bindTerminalViewport } from './terminal-controls.js';
7
- import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
7
+ import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, bindVisibleViewport, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
8
8
  import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
9
9
  import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
10
10
  import { createRemoteAccessUI } from './remote-access.js';
@@ -299,6 +299,69 @@ function alertSound(name, key, at = Date.now(), fresh = () => true, related = []
299
299
  });
300
300
  }
301
301
 
302
+ const AUTOSTART_NOTE = 'Starts the session manager in the background when you sign in to the computer running Agent Guild. The page does not open.';
303
+ let autostartRequest = 0;
304
+ let autostartChanging = false;
305
+
306
+ /** What the entry did at the last sign-in it ran at, or '' while it is off. */
307
+ function autostartRun(autostart) {
308
+ if (!autostart?.available || !autostart.enabled) return '';
309
+ const run = autostart.lastRun;
310
+ if (!run) return 'Has not run at a sign-in yet.';
311
+ const at = new Date(run.at).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
312
+ if (run.outcome === 'started') return `Last ran at sign-in on ${at} and started the session manager.`;
313
+ if (run.outcome === 'running') return `Last ran at sign-in on ${at}; the session manager was already running.`;
314
+ if (run.outcome === 'starting') return `Starting the session manager for the sign-in on ${at}…`;
315
+ return `Last ran at sign-in on ${at}, but the session manager did not start.${autostart.log ? ` See ${autostart.log}.` : ''}`;
316
+ }
317
+
318
+ /** The manager's sign-in setting; hidden when the manager has none. */
319
+ function renderAutostart(autostart) {
320
+ $('autostart-choice').hidden = !autostart;
321
+ $('autostart').checked = Boolean(autostart?.enabled);
322
+ $('autostart').disabled = !autostart?.available;
323
+ $('autostart-note').textContent = autostart?.reason || autostart?.note || AUTOSTART_NOTE;
324
+ const run = autostartRun(autostart);
325
+ $('autostart-run').textContent = run;
326
+ $('autostart-run').hidden = !run;
327
+ $('autostart-run').dataset.outcome = autostart?.enabled ? autostart.lastRun?.outcome ?? 'none' : '';
328
+ }
329
+
330
+ async function loadAutostart({ afterChange = false } = {}) {
331
+ if (!state.connected || (autostartChanging && !afterChange)) return;
332
+ const request = ++autostartRequest;
333
+ $('autostart').disabled = true;
334
+ try {
335
+ const { autostart } = await api('GET', '/autostart');
336
+ if (request === autostartRequest) renderAutostart(autostart);
337
+ } catch (err) {
338
+ if (request !== autostartRequest) return;
339
+ if (err instanceof AuthError) showAuth(err.message);
340
+ else if (err.code === 'not_found') renderAutostart(null);
341
+ else renderAutostart({ available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` });
342
+ }
343
+ }
344
+
345
+ async function changeAutostart(input) {
346
+ if (autostartChanging) return;
347
+ autostartChanging = true;
348
+ ++autostartRequest;
349
+ const enabled = input.checked;
350
+ input.disabled = true;
351
+ try {
352
+ renderAutostart((await api('PUT', '/autostart', { enabled })).autostart);
353
+ } catch (err) {
354
+ if (err instanceof AuthError) showAuth(err.message);
355
+ else {
356
+ toast(err.message);
357
+ // An OS operation can partly succeed. Read back what actually happened.
358
+ await loadAutostart({ afterChange: true });
359
+ }
360
+ } finally {
361
+ autostartChanging = false;
362
+ }
363
+ }
364
+
302
365
  function changeSound(input) {
303
366
  save(SOUND_KEY, input.checked ? 'on' : null);
304
367
  prepareSounds();
@@ -2967,6 +3030,13 @@ function loadGitHub() {
2967
3030
  function setGitHub(github) {
2968
3031
  const before = state.github?.signIn;
2969
3032
  state.github = github;
3033
+ const branches = githubPick.data.branches;
3034
+ if (branches && !github.accounts.some((a) => a.id === branches.repo.accountId && !a.needsSignIn)) {
3035
+ // Replace the generation even while the dock is closed; signing in again must
3036
+ // never make an earlier account's pending answer current.
3037
+ githubPick.data.branches = { ...branches, value: null, loading: false, nextPage: null,
3038
+ error: `Sign in as @${branches.repo.login} in Repositories to see its branches.` };
3039
+ }
2970
3040
  const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
2971
3041
  if (done) {
2972
3042
  selectGitHubAccount(done.accountId);
@@ -3086,7 +3156,7 @@ function openGitHub({ focus = true } = {}) {
3086
3156
  loadGitHub();
3087
3157
  if (githubPick.repo) {
3088
3158
  loadView('actions');
3089
- if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
3159
+ if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
3090
3160
  }
3091
3161
  followTerminal();
3092
3162
  if (focus) ($('github-card').querySelector('.btn.primary') ?? $('dock-close')).focus();
@@ -3497,7 +3567,7 @@ function useFolder(dir) {
3497
3567
 
3498
3568
  // ---- GitHub repository views ------------------------------------------------
3499
3569
 
3500
- const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls'];
3570
+ const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls', 'branches'];
3501
3571
  const RUNS_POLL_MS = 6000;
3502
3572
  const RUNS_IDLE_POLL_MS = 30000;
3503
3573
  const BODY_LIMIT = 48000;
@@ -3592,7 +3662,11 @@ function pickRepo(repo, { chosen = false } = {}) {
3592
3662
  githubPick.recent = remember(githubPick.recent, repo);
3593
3663
  save(GITHUB_RECENT_KEY, JSON.stringify(githubPick.recent));
3594
3664
  }
3595
- if (!same) Object.assign(githubPick, { data: {}, editing: null });
3665
+ if (!same) {
3666
+ Object.assign(githubPick, { data: {}, editing: null });
3667
+ $('github-branches-filter').value = '';
3668
+ $('github-branches').scrollTop = 0;
3669
+ }
3596
3670
  if ((!same || chosen) && githubView.accountId !== repo.accountId && state.github?.accounts.some((a) => a.id === repo.accountId)) {
3597
3671
  selectGitHubAccount(repo.accountId);
3598
3672
  githubView.card = null;
@@ -3605,7 +3679,7 @@ function pickRepo(repo, { chosen = false } = {}) {
3605
3679
  renderGitHubViews();
3606
3680
  if (!dockShows('github')) return;
3607
3681
  loadView('actions');
3608
- if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
3682
+ if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
3609
3683
  }
3610
3684
 
3611
3685
  /** Picks the repository whose clone holds the focused terminal's folder, once for each terminal focused. */
@@ -3628,6 +3702,7 @@ async function followTerminal() {
3628
3702
  }
3629
3703
 
3630
3704
  async function loadView(view) {
3705
+ if (view === 'branches') return loadBranches();
3631
3706
  const repo = githubPick.repo;
3632
3707
  if (!repo || view === 'repos') return;
3633
3708
  const query = view === 'issues' ? `?state=${githubPick.issueState}` : '';
@@ -3652,6 +3727,175 @@ async function loadView(view) {
3652
3727
  if (view === 'actions') scheduleRuns();
3653
3728
  }
3654
3729
 
3730
+ // Each object is a listing generation, scoped to the selected account/repository.
3731
+ // A retry continues that generation; Refresh replaces it and starts at page one.
3732
+ function branchesCurrent(request) {
3733
+ return githubPick.data.branches === request && githubPick.repo && repoKey(githubPick.repo) === request.key
3734
+ && state.github?.accounts.some((a) => a.id === request.repo.accountId && !a.needsSignIn);
3735
+ }
3736
+
3737
+ function branchesVisible() {
3738
+ return dockShows('github') && githubShownView() === 'branches' && document.visibilityState === 'visible';
3739
+ }
3740
+
3741
+ async function loadBranches({ resume = false } = {}) {
3742
+ const repo = githubPick.repo;
3743
+ if (!repo || !state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn)) return;
3744
+ const before = viewData('branches');
3745
+ if (resume && (!before || before.loading || before.nextPage === null)) return;
3746
+ const request = resume ? before : {
3747
+ key: repoKey(repo), repo, value: before?.value ?? null, loading: false, error: null,
3748
+ nextPage: 1, received: false, rows: new Map(), defaultBranch: null, metadataError: null,
3749
+ };
3750
+ githubPick.data.branches = request;
3751
+ request.loading = true;
3752
+ request.error = null;
3753
+ renderGitHubViews();
3754
+ try {
3755
+ while (request.nextPage !== null && branchesVisible() && branchesCurrent(request)) {
3756
+ const page = request.nextPage;
3757
+ const result = await api('GET', `${repoPath(repo)}/branches?page=${page}`);
3758
+ if (!branchesCurrent(request)) return;
3759
+ // A malformed response must not masquerade as a complete, empty list.
3760
+ if (!Array.isArray(result.branches) || (result.nextPage !== null
3761
+ && (!Number.isSafeInteger(result.nextPage) || result.nextPage <= page))) {
3762
+ throw new Error('Could not read the next page of branches. Try again.');
3763
+ }
3764
+ if (page === 1) {
3765
+ request.defaultBranch = result.defaultBranch;
3766
+ request.metadataError = result.metadataError;
3767
+ }
3768
+ for (const branch of result.branches) request.rows.set(branch.name, branch);
3769
+ request.nextPage = result.nextPage;
3770
+ // First loads display each page. A refresh keeps the previous list until
3771
+ // every page arrives, so later-page rows and the reading position survive.
3772
+ // `received` means the displayed value belongs to this listing generation.
3773
+ if (!request.value || request.received || request.nextPage === null) {
3774
+ request.received = true;
3775
+ request.value = { ...result, branches: [...request.rows.values()],
3776
+ defaultBranch: request.defaultBranch, metadataError: request.metadataError };
3777
+ }
3778
+ renderGitHubViews();
3779
+ }
3780
+ } catch (err) {
3781
+ if (!branchesCurrent(request)) return;
3782
+ if (err instanceof AuthError) { showAuth(err.message); return; }
3783
+ request.error = err.message;
3784
+ } finally {
3785
+ if (branchesCurrent(request)) {
3786
+ request.loading = false;
3787
+ renderGitHubViews();
3788
+ }
3789
+ }
3790
+ }
3791
+
3792
+ /** Keep the first visible branch at the same pixel offset when earlier rows arrive. */
3793
+ function branchAnchor(panel, list) {
3794
+ const { top, bottom } = panel.getBoundingClientRect();
3795
+ const row = [...list.children].find((node) => {
3796
+ const rect = node.getBoundingClientRect();
3797
+ return rect.bottom > top && rect.top < bottom;
3798
+ });
3799
+ return row ? { name: row.dataset.branch, offset: row.getBoundingClientRect().top - top } : null;
3800
+ }
3801
+
3802
+ async function copyBranch(name, control) {
3803
+ const request = viewData('branches');
3804
+ control.disabled = true;
3805
+ let message;
3806
+ try {
3807
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
3808
+ await navigator.clipboard.writeText(name);
3809
+ message = `Copied branch name: ${name}`;
3810
+ } catch {
3811
+ message = 'Could not copy. Select the branch name and copy it using your browser.';
3812
+ } finally {
3813
+ control.disabled = false;
3814
+ }
3815
+ if (request === viewData('branches') && branchesVisible() && control.isConnected) toast(message);
3816
+ }
3817
+
3818
+ function renderBranches({ preserveAnchor = true } = {}) {
3819
+ const panel = $('github-branches');
3820
+ const list = $('github-branches-list');
3821
+ const repo = githubPick.repo;
3822
+ const key = repo ? repoKey(repo) : '';
3823
+ const same = panel.dataset.repo === key;
3824
+ const anchor = same && preserveAnchor ? branchAnchor(panel, list) : null;
3825
+ const focused = list.contains(document.activeElement) ? document.activeElement : null;
3826
+ const oldScroll = same ? panel.scrollTop : 0;
3827
+ if (!same) { list.replaceChildren(); panel.dataset.repo = key; }
3828
+ $('github-branches-pick').hidden = Boolean(repo);
3829
+ $('github-branches-content').hidden = !repo;
3830
+ if (!repo) return;
3831
+ const slot = viewData('branches');
3832
+ const value = slot?.value;
3833
+ const query = $('github-branches-filter').value.trim().toLocaleLowerCase();
3834
+ $('github-branches-clear').disabled = !$('github-branches-filter').value;
3835
+ const refresh = $('github-branches-refresh');
3836
+ const usable = state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn);
3837
+ refresh.disabled = Boolean(slot?.loading) || !usable;
3838
+ refresh.textContent = slot?.loading ? (value ? 'Refreshing…' : 'Loading…') : 'Refresh';
3839
+ const out = $('github-branches-out');
3840
+ out.href = value?.url ?? `https://github.com/${repo.fullName}/branches`;
3841
+ out.title = `Open ${repo.fullName} branches on GitHub`;
3842
+ const branches = (value?.branches ?? []).filter((b) => b.name.toLocaleLowerCase().includes(query)).sort((a, b) =>
3843
+ Number(b.name === value.defaultBranch) - Number(a.name === value.defaultBranch)
3844
+ || a.name.localeCompare(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
3845
+ const complete = Boolean(slot?.received && value?.nextPage === null);
3846
+ const count = value?.branches.length ?? 0;
3847
+ let status = !value ? (slot?.error ? 'Branches could not be loaded.' : 'Loading branches…')
3848
+ : query ? `${branches.length} of ${count} loaded branches match.`
3849
+ : `${count} ${count === 1 ? 'branch' : 'branches'}${complete ? '' : ' loaded'}.`;
3850
+ if (value && !slot.received) status += slot.loading ? ' Refreshing…' : ' Showing previous results.';
3851
+ else if (!complete && value) status += slot.loading ? ' Loading more…' : ' Listing incomplete.';
3852
+ if (complete && !branches.length) status = query ? `No branches match “${$('github-branches-filter').value.trim()}”.` : 'No remote branches yet.';
3853
+ const statusNode = $('github-branches-status');
3854
+ if (statusNode.textContent !== status) statusNode.textContent = status;
3855
+ const failure = $('github-branches-error');
3856
+ failure.hidden = !slot?.error;
3857
+ failure.querySelector('span').textContent = slot?.error ?? '';
3858
+ $('github-branches-retry').disabled = Boolean(slot?.loading) || slot?.nextPage === null || !usable;
3859
+ const metadata = $('github-branches-metadata');
3860
+ metadata.hidden = !value?.metadataError;
3861
+ metadata.textContent = value?.metadataError ? 'Branches loaded, but the default branch could not be identified. Refresh to try again.' : '';
3862
+ const existing = new Map([...list.children].map((row) => [row.dataset.branch, row]));
3863
+ const wanted = new Set(branches.map((branch) => branch.name));
3864
+ let cursor = list.firstElementChild;
3865
+ for (const branch of branches) {
3866
+ let row = existing.get(branch.name);
3867
+ if (!row) {
3868
+ const copy = button('Copy name', () => copyBranch(branch.name, copy));
3869
+ copy.setAttribute('aria-label', `Copy branch name ${branch.name}`);
3870
+ row = recordRow(`branch:${branch.name}`, branch.name, '', copy, branch.url);
3871
+ row.classList.add('github-branch');
3872
+ row.dataset.branch = branch.name;
3873
+ row.setAttribute('role', 'listitem');
3874
+ row.querySelector('.history-title').append(el('span', 'github-branch-badges'));
3875
+ }
3876
+ const badges = row.querySelector('.github-branch-badges');
3877
+ const badgeState = `${branch.name === value.defaultBranch}:${branch.protected}`;
3878
+ if (badges.dataset.state !== badgeState) {
3879
+ badges.dataset.state = badgeState;
3880
+ badges.replaceChildren(...[
3881
+ branch.name === value.defaultBranch ? badge('', 'Default', 'Default branch') : null,
3882
+ branch.protected ? badge('', 'Protected', 'Protected by branch protection or rulesets') : null,
3883
+ ].filter(Boolean));
3884
+ }
3885
+ row.querySelector('.history-meta').textContent = branch.sha ? branch.sha.slice(0, 7) : '';
3886
+ row.querySelector('.history-meta').title = branch.sha ?? '';
3887
+ if (row === cursor) cursor = cursor.nextElementSibling;
3888
+ else list.insertBefore(row, cursor);
3889
+ }
3890
+ for (const row of [...list.children]) if (!wanted.has(row.dataset.branch)) row.remove();
3891
+ if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
3892
+ panel.scrollTop = oldScroll;
3893
+ if (anchor) {
3894
+ const row = [...list.children].find((node) => node.dataset.branch === anchor.name);
3895
+ if (row) panel.scrollTop += row.getBoundingClientRect().top - panel.getBoundingClientRect().top - anchor.offset;
3896
+ }
3897
+ }
3898
+
3655
3899
  function viewData(view) {
3656
3900
  const slot = githubPick.data[view];
3657
3901
  const repo = githubPick.repo;
@@ -3703,7 +3947,17 @@ function setPickerOpen(open) {
3703
3947
  input.setAttribute('aria-expanded', String(open));
3704
3948
  $('github-repo-list').hidden = !open;
3705
3949
  if (!open) input.removeAttribute('aria-activedescendant');
3706
- else renderPickerList();
3950
+ else {
3951
+ fitPickerList();
3952
+ renderPickerList();
3953
+ }
3954
+ }
3955
+
3956
+ // The list stays inside the visible part of the dock, so no option sits under an on-screen keyboard.
3957
+ function fitPickerList() {
3958
+ if (!githubPick.open) return;
3959
+ const room = $('dock').getBoundingClientRect().bottom - $('github-repo').getBoundingClientRect().bottom - 12;
3960
+ $('github-repo-list').style.setProperty('--picker-room', `${Math.max(120, Math.floor(room))}px`);
3707
3961
  }
3708
3962
 
3709
3963
  function highlighted(text, query) {
@@ -3824,6 +4078,7 @@ function renderGitHubViews() {
3824
4078
  $('github-issues').hidden = shown !== 'issues';
3825
4079
  $('github-runs').hidden = shown !== 'actions';
3826
4080
  $('github-pulls').hidden = shown !== 'pulls';
4081
+ $('github-branches').hidden = shown !== 'branches';
3827
4082
  const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
3828
4083
  $('github-clone-into').hidden = !ready || shown !== 'repos';
3829
4084
  const running = Boolean(viewData('actions')?.value?.running);
@@ -3833,6 +4088,7 @@ function renderGitHubViews() {
3833
4088
  if (shown === 'issues') renderIssues();
3834
4089
  else if (shown === 'actions') renderRuns();
3835
4090
  else if (shown === 'pulls') renderPulls();
4091
+ else if (shown === 'branches') renderBranches();
3836
4092
  }
3837
4093
 
3838
4094
  /** Rebuilds a panel and puts the focus back on the control with the same `data-key`. */
@@ -4863,6 +5119,7 @@ const terminalControls = new TerminalControls({
4863
5119
  },
4864
5120
  });
4865
5121
  bindTerminalViewport($('terminal-panel'), $('terminal-controls'));
5122
+ bindVisibleViewport($('dock'), 'dock', { fitted: fitPickerList });
4866
5123
 
4867
5124
  const terminalCopy = new TerminalCopy({
4868
5125
  opener: $('panel-copy'), dialog: $('terminal-copy'),
@@ -5650,6 +5907,18 @@ $('github-views').addEventListener('click', (e) => {
5650
5907
  if (view) switchGitHubView(view);
5651
5908
  });
5652
5909
  $('github-views').addEventListener('keydown', moveGitHubView);
5910
+ $('github-branches-refresh').addEventListener('click', () => loadView('branches'));
5911
+ $('github-branches-retry').addEventListener('click', () => loadBranches({ resume: true }));
5912
+ $('github-branches-filter').addEventListener('input', () => {
5913
+ $('github-branches').scrollTop = 0;
5914
+ renderBranches({ preserveAnchor: false });
5915
+ });
5916
+ $('github-branches-clear').addEventListener('click', () => {
5917
+ $('github-branches-filter').value = '';
5918
+ $('github-branches').scrollTop = 0;
5919
+ renderBranches({ preserveAnchor: false });
5920
+ $('github-branches-filter').focus();
5921
+ });
5653
5922
  $('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
5654
5923
  $('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
5655
5924
  $('history-here').addEventListener('change', renderHistory);
@@ -5710,6 +5979,7 @@ document.addEventListener('visibilitychange', () => {
5710
5979
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
5711
5980
  if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
5712
5981
  else scheduleRuns();
5982
+ if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
5713
5983
  });
5714
5984
  addEventListener('pagehide', () => {
5715
5985
  flushNotes();
@@ -5793,6 +6063,7 @@ $('settings-menu').addEventListener('change', (e) => {
5793
6063
  else if (e.target.name === 'theme') changeTheme(e.target);
5794
6064
  else if (e.target.name === 'sound') changeSound(e.target);
5795
6065
  else if (e.target.name === 'voice') changeVoice(e.target);
6066
+ else if (e.target.name === 'autostart') changeAutostart(e.target);
5796
6067
  });
5797
6068
  /**
5798
6069
  * Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
@@ -5812,6 +6083,7 @@ function returnFocus(menu, invoker) {
5812
6083
  if ((!at || at === document.body || menu.contains(at)) && invoker?.checkVisibility?.()) invoker.focus();
5813
6084
  }
5814
6085
  onMenu($('settings-menu'), (menu) => {
6086
+ loadAutostart();
5815
6087
  placeMenu(menu, $('settings'));
5816
6088
  menu.querySelector('input:checked')?.focus();
5817
6089
  }, (menu) => returnFocus(menu, $('settings')));
@@ -92,6 +92,8 @@ export function createEnvironmentUI({ api, onAuthError, isAuthError = () => fals
92
92
  $('environment-refresh').addEventListener('click', () => load(true));
93
93
  dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
94
94
  dialog.addEventListener('close', () => {
95
+ // A queued close from an earlier opening must not clear the current opener.
96
+ if (dialog.open) return;
95
97
  if (openerId) document.querySelector(`.provider[data-id="${openerId}"] .environment-open`)?.focus();
96
98
  openerId = null;
97
99
  });
package/web/index.html CHANGED
@@ -46,6 +46,12 @@
46
46
  <legend>Sound</legend>
47
47
  <label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
48
48
  </fieldset>
49
+ <fieldset id="autostart-choice" hidden>
50
+ <legend>Startup</legend>
51
+ <label class="choice"><input id="autostart" type="checkbox" name="autostart"><span class="autostart">Launch at sign-in</span></label>
52
+ <p id="autostart-note" class="voice-note"></p>
53
+ <p id="autostart-run" class="voice-note autostart-run" role="status" hidden></p>
54
+ </fieldset>
49
55
  <fieldset id="voice-choice" hidden>
50
56
  <legend>Voice</legend>
51
57
  <label class="choice"><input id="voice" type="checkbox" name="voice"><span class="voice-input">Voice input</span></label>
@@ -208,6 +214,7 @@
208
214
  <button id="github-view-issues" class="github-view" type="button" role="tab" aria-controls="github-issues" aria-selected="false" data-view="issues" tabindex="-1">Issues</button>
209
215
  <button id="github-view-actions" class="github-view" type="button" role="tab" aria-controls="github-runs" aria-selected="false" data-view="actions" tabindex="-1" aria-label="Actions">Actions<span id="github-running" class="github-running" hidden></span></button>
210
216
  <button id="github-view-pulls" class="github-view" type="button" role="tab" aria-controls="github-pulls" aria-selected="false" data-view="pulls" tabindex="-1" aria-label="Pull requests"><span class="long">Pull requests</span><span class="short" aria-hidden="true">PRs</span></button>
217
+ <button id="github-view-branches" class="github-view" type="button" role="tab" aria-controls="github-branches" aria-selected="false" data-view="branches" tabindex="-1">Branches</button>
211
218
  </div>
212
219
  <div id="github-repos" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-repos">
213
220
  <section id="github-card" class="github-card" aria-live="polite" hidden></section>
@@ -241,6 +248,27 @@
241
248
  <div id="github-issues" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-issues" hidden></div>
242
249
  <div id="github-runs" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-actions" hidden></div>
243
250
  <div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
251
+ <div id="github-branches" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-branches" hidden>
252
+ <p id="github-branches-pick" class="github-view-note">Pick a repository above to see its branches.</p>
253
+ <div id="github-branches-content" hidden>
254
+ <div class="github-view-tools">
255
+ <button id="github-branches-refresh" class="btn" type="button">Refresh</button>
256
+ <span class="github-spacer"></span>
257
+ <a id="github-branches-out" class="btn github-out" target="_blank" rel="noopener noreferrer" aria-description="Opens in a new tab">GitHub</a>
258
+ </div>
259
+ <div class="github-branch-filter">
260
+ <input id="github-branches-filter" type="search" placeholder="Filter branches" aria-label="Filter branches" spellcheck="false" autocomplete="off">
261
+ <button id="github-branches-clear" class="btn" type="button" disabled>Clear</button>
262
+ </div>
263
+ <p id="github-branches-status" class="github-view-note" role="status" aria-live="polite" aria-atomic="true"></p>
264
+ <div id="github-branches-error" class="github-error" hidden>
265
+ <span role="alert"></span>
266
+ <button id="github-branches-retry" class="btn" type="button">Retry</button>
267
+ </div>
268
+ <p id="github-branches-metadata" class="github-view-note" hidden></p>
269
+ <div id="github-branches-list" class="history-list" role="list" aria-label="Remote branches"></div>
270
+ </div>
271
+ </div>
244
272
  <div id="github-clone-into" class="history-form github-form">
245
273
  <label for="github-parent">Clone into</label>
246
274
  <div class="cwd-field single">
package/web/layout.js CHANGED
@@ -73,3 +73,39 @@ export function bindSplitter(handle, { axis = () => 'x', start, move, end, step
73
73
  end();
74
74
  });
75
75
  }
76
+
77
+ /**
78
+ * Publishes the part of the window an on-screen keyboard leaves visible as
79
+ * `--<name>-viewport-top`, `--<name>-viewport-bottom` and `--<name>-viewport-height`
80
+ * on `element` while `shown()` holds. iOS Safari keeps fixed elements at their full
81
+ * height under its keyboard and pans the page beneath them, so they must fit themselves.
82
+ * `fitted(height)` runs after each update, with null while the element is not shown.
83
+ */
84
+ export function bindVisibleViewport(element, name, { shown = () => !element.hidden, watch = [element], fitted = () => {} } = {}) {
85
+ const viewport = window.visualViewport;
86
+ const props = ['top', 'bottom', 'height'].map((edge) => `--${name}-viewport-${edge}`);
87
+ let frame;
88
+ const update = () => {
89
+ if (!shown()) {
90
+ for (const prop of props) element.style.removeProperty(prop);
91
+ return fitted(null);
92
+ }
93
+ // Magnification belongs to the browser; it must not resize the layout.
94
+ if (viewport && Math.abs(viewport.scale - 1) > 0.01) return;
95
+ const top = viewport?.offsetTop || 0;
96
+ const height = viewport?.height ?? innerHeight;
97
+ element.style.setProperty(props[0], `${top}px`);
98
+ element.style.setProperty(props[1], `${Math.max(0, innerHeight - top - height)}px`);
99
+ element.style.setProperty(props[2], `${height}px`);
100
+ fitted(height);
101
+ };
102
+ const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
103
+ viewport?.addEventListener('resize', schedule);
104
+ viewport?.addEventListener('scroll', schedule);
105
+ addEventListener('resize', schedule);
106
+ addEventListener('pageshow', schedule);
107
+ const observer = new MutationObserver(schedule);
108
+ for (const watched of watch) observer.observe(watched, { attributes: true, attributeFilter: ['hidden'] });
109
+ schedule();
110
+ return schedule;
111
+ }
package/web/styles.css CHANGED
@@ -23,6 +23,7 @@
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
25
  --icon-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='3' width='6' height='11' rx='3'/%3E%3Cpath d='M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21'/%3E%3C/svg%3E");
26
+ --icon-power: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M12 3v8.5'/%3E%3Cpath d='M7 6.2a7.5 7.5 0 1 0 10 0'/%3E%3C/svg%3E");
26
27
  --icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
27
28
  --icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
28
29
  --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
@@ -392,7 +393,10 @@ main { max-width: calc(1200px + var(--dock-space, 0px)); margin: 0 auto; padding
392
393
  .settings-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
393
394
  .settings-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
394
395
  .settings-menu .choice > .voice-input::before { mask-image: var(--icon-mic); }
396
+ .settings-menu .choice > .autostart::before { mask-image: var(--icon-power); }
395
397
  .voice-note { margin: 0 8px 6px; max-width: 26ch; color: var(--muted); font-size: 0.75rem; line-height: 1.35; }
398
+ .autostart-run { overflow-wrap: anywhere; }
399
+ .autostart-run[data-outcome="failed"] { color: var(--danger); }
396
400
  .notes-open::before { mask-image: var(--icon-notes); }
397
401
  .notes-open[aria-pressed="true"], .github-toggle[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
398
402
  .menu-button::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
@@ -872,7 +876,7 @@ a.news-link:visited { color: var(--muted); }
872
876
  .github-combo input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
873
877
  .github-combo input::placeholder { font-weight: 400; }
874
878
  .github-repo-list {
875
- position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px); max-height: min(26rem, 60dvh); overflow: auto;
879
+ position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px); max-height: min(26rem, 60dvh, var(--picker-room, 26rem)); overflow: auto;
876
880
  margin: 0; padding: 4px; list-style: none; background: var(--surface); color: var(--text);
877
881
  border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
878
882
  }
@@ -898,10 +902,27 @@ a.news-link:visited { color: var(--muted); }
898
902
  .github-view[aria-selected="true"] { color: var(--heading); background: var(--surface-2); border-color: var(--border); border-bottom-color: var(--surface-2); }
899
903
  .github-view:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
900
904
  .github-view .short { display: none; }
901
- @container (max-width: 340px) {
905
+ @container (max-width: 440px) {
902
906
  .github-view .long { display: none; }
903
907
  .github-view .short { display: inline; }
904
908
  }
909
+ #github-branches { overflow-anchor: none; }
910
+ .github-branch-filter { display: flex; align-items: center; gap: 6px; }
911
+ .github-branch-filter input {
912
+ flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius);
913
+ background: var(--surface); color: var(--text); font: inherit; font-size: 0.85rem;
914
+ }
915
+ .github-branch-filter input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
916
+ .github-branch .history-title { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
917
+ .github-branch .github-record-title { min-width: 0; overflow-wrap: anywhere; }
918
+ .github-branch-badges { display: inline-flex; flex-wrap: wrap; gap: 4px; }
919
+ .github-branch .history-meta { font-family: var(--font-mono, monospace); }
920
+ .github-branch > .btn { align-self: start; }
921
+ #github-branches-error .btn { margin-left: 6px; }
922
+ @media (pointer: coarse) {
923
+ .github-branch-filter input { font-size: 16px; }
924
+ .github-branch > .btn, #github-branches .github-view-tools .btn, .github-branch-filter .btn { min-height: 36px; }
925
+ }
905
926
  .github-running { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 3px color-mix(in srgb, var(--warn) 25%, transparent); animation: pulse 1.4s ease-in-out infinite; }
906
927
  .github-body[hidden] { display: none; }
907
928
  .github-view-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-bottom: 6px; }
@@ -944,7 +965,8 @@ a.btn.github-out::after {
944
965
  .toast .toast-action { margin-left: 12px; padding: 2px 10px; background: transparent; box-shadow: none; color: inherit; border-color: currentColor; }
945
966
 
946
967
  .dock {
947
- position: fixed; top: var(--topbar-h, 56px); right: 0; bottom: 0; z-index: 12; width: var(--dock-w, 420px); max-width: 100vw;
968
+ /* Above an on-screen keyboard, which the page publishes as --dock-viewport-*. */
969
+ position: fixed; top: max(var(--topbar-h, 56px), var(--dock-viewport-top, 0px)); right: 0; bottom: var(--dock-viewport-bottom, 0px); z-index: 12; width: var(--dock-w, 420px); max-width: 100vw;
948
970
  display: flex; flex-direction: column; background: var(--surface); color: var(--text);
949
971
  border-left: 1px solid var(--border); box-shadow: -16px 0 48px rgba(0, 0, 0, 0.25);
950
972
  }
@@ -1,3 +1,5 @@
1
+ import { bindVisibleViewport } from './layout.js';
2
+
1
3
  const ARROWS = { ArrowUp: 'A', ArrowDown: 'B', ArrowRight: 'C', ArrowLeft: 'D' };
2
4
 
3
5
  /** Use the same cursor mode as a physical keyboard, including inside full-screen tools. */
@@ -102,29 +104,9 @@ export class TerminalControls {
102
104
 
103
105
  /** Fit the touch panel to a docked keyboard without changing global page layout. */
104
106
  export function bindTerminalViewport(panel, controls) {
105
- const viewport = window.visualViewport;
106
- let frame;
107
- const update = () => {
108
- if (panel.hidden || controls.hidden) {
109
- panel.style.removeProperty('--terminal-viewport-top');
110
- panel.style.removeProperty('--terminal-viewport-bottom');
111
- delete panel.dataset.compact;
112
- return;
113
- }
114
- // Magnification belongs to the browser; it must not resize the shared PTY.
115
- if (viewport && Math.abs(viewport.scale - 1) > 0.01) return;
116
- const top = viewport?.offsetTop || 0;
117
- const height = viewport?.height ?? innerHeight;
118
- panel.style.setProperty('--terminal-viewport-top', `${top}px`);
119
- panel.style.setProperty('--terminal-viewport-bottom', `${Math.max(0, innerHeight - top - height)}px`);
120
- panel.toggleAttribute('data-compact', height < 420);
121
- };
122
- const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
123
- viewport?.addEventListener('resize', schedule);
124
- viewport?.addEventListener('scroll', schedule);
125
- addEventListener('resize', schedule);
126
- addEventListener('pageshow', schedule);
127
- const observer = new MutationObserver(schedule);
128
- for (const element of [panel, controls]) observer.observe(element, { attributes: true, attributeFilter: ['hidden'] });
129
- schedule();
107
+ bindVisibleViewport(panel, 'terminal', {
108
+ shown: () => !panel.hidden && !controls.hidden,
109
+ watch: [panel, controls],
110
+ fitted: (height) => panel.toggleAttribute('data-compact', height !== null && height < 420),
111
+ });
130
112
  }