@oddessentials/agent-guild 0.31.0 → 0.32.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/README.md CHANGED
@@ -195,8 +195,11 @@ of them from the box at its top; it starts on the repository the focused
195
195
  terminal's folder is a clone of. **Issues** lists, opens, edits and closes
196
196
  issues. **Actions** shows the latest workflow runs and whether one is still
197
197
  going, refreshing while it is on screen. **Pull requests** lists the open
198
- ones. **Repositories** lists and creates repositories and clones them over SSH
199
- with a key Agent Guild keeps for each account. On a wide window the side panel
198
+ ones. **Branches** lists every remote branch of the selected repository, with
199
+ its default and protected branches marked. Filter by name, copy a branch name,
200
+ or open it on GitHub. Larger lists load progressively; **Refresh** checks for
201
+ changes while keeping your place in the list. **Repositories** lists and creates
202
+ repositories and clones them over SSH with a key Agent Guild keeps for each account. On a wide window the side panel
200
203
  makes room for itself; on a smaller one it lies over the cards, and over the
201
204
  terminals when they would get too narrow. Drag its edge to resize it. Escape
202
205
  or **Close** puts it away.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,4 @@
1
- // Issues, Actions runs and open pull requests of one repository, read with a
1
+ // Branches, Issues, Actions runs and open pull requests of one repository, read with a
2
2
  // signed-in account. Reading and editing stay shallow: GitHub's page is the full view.
3
3
 
4
4
  import { nextLink, parseRepo } from './github.mjs';
@@ -114,6 +114,42 @@ export function createViews(github) {
114
114
  }
115
115
 
116
116
  return {
117
+ async branches(accountId, owner, name, { page = '1' } = {}) {
118
+ if (!/^[1-9]\d*$/.test(String(page)) || !Number.isSafeInteger(Number(page))) {
119
+ throw refusal(400, 'bad_page', 'page must be a positive integer');
120
+ }
121
+ page = Number(page);
122
+ // One metadata read per listing, never a protection request per branch.
123
+ const [listing, metadata] = await Promise.all([
124
+ call(accountId, owner, name, `/branches?per_page=100&page=${page}`),
125
+ page === 1 ? call(accountId, owner, name, '').then(({ body }) => ({ body }), (error) => ({ error: error.message })) : null,
126
+ ]);
127
+ const { body, res, repo } = listing;
128
+ if (!Array.isArray(body)) throw refusal(502, 'github_error', 'GitHub did not return a branch list');
129
+ let nextPage = null;
130
+ const next = nextLink(res.headers.get('link'));
131
+ if (next) {
132
+ // Reconstruct our own route; never send credentials to a supplied Link URL.
133
+ try { nextPage = Number(new URL(next).searchParams.get('page')); } catch { /* rejected below */ }
134
+ if (!Number.isSafeInteger(nextPage) || nextPage <= page) {
135
+ throw refusal(502, 'github_error', 'GitHub returned an invalid next page for branches');
136
+ }
137
+ }
138
+ const branches = body.filter((item) => typeof item?.name === 'string' && item.name.length > 0).map((item) => ({
139
+ name: item.name,
140
+ sha: typeof item.commit?.sha === 'string' && /^[a-f0-9]{40,64}$/i.test(item.commit.sha) ? item.commit.sha : null,
141
+ protected: item.protected === true,
142
+ url: `${WEB}/${repo.fullName}/tree/${encodeURIComponent(item.name)}`,
143
+ }));
144
+ return {
145
+ branches, nextPage,
146
+ defaultBranch: typeof metadata?.body?.default_branch === 'string' ? metadata.body.default_branch : null,
147
+ metadataError: metadata?.error ?? null,
148
+ fetchedAt: new Date().toISOString(),
149
+ url: `${WEB}/${repo.fullName}/branches`,
150
+ };
151
+ },
152
+
117
153
  async issues(accountId, owner, name, { state = 'open' } = {}) {
118
154
  if (state !== 'open' && state !== 'closed' && state !== 'all') throw refusal(400, 'bad_state', 'state must be open, closed or all');
119
155
  const { body, res, repo } = await call(accountId, owner, name, `/issues?state=${state}&per_page=${PAGE}&sort=updated&direction=desc`);
@@ -458,7 +458,7 @@ export function createManagerServer({
458
458
  const folder = manager.resolveCwd(url.searchParams.get('cwd'));
459
459
  return sendJson(res, 200, { folder, repo: folderOrigin(folder) });
460
460
  }
461
- const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls)(?:\/([^/]+))?$/);
461
+ const view = route.match(/^\/github\/accounts\/([^/]+)\/repos\/([^/]+)\/([^/]+)\/(issues|actions|pulls|branches)(?:\/([^/]+))?$/);
462
462
  if (view) {
463
463
  let parts;
464
464
  try { parts = view.map((part) => (part === undefined ? part : decodeURIComponent(part))); } catch { throw new HttpError(400, 'repo must be a GitHub repository written as owner/name', 'bad_repo'); }
@@ -472,6 +472,7 @@ export function createManagerServer({
472
472
  if (kind === 'issues' && number !== undefined && method === 'PATCH') {
473
473
  return sendJson(res, 200, { issue: await views.updateIssue(id, owner, name, number, await readJsonBody(req)) });
474
474
  }
475
+ if (kind === 'branches' && number === undefined && method === 'GET') return sendJson(res, 200, await views.branches(id, owner, name, { page: url.searchParams.get('page') ?? '1' }));
475
476
  if (kind === 'actions' && number === undefined && method === 'GET') return sendJson(res, 200, await views.actions(id, owner, name));
476
477
  if (kind === 'pulls' && number === undefined && method === 'GET') return sendJson(res, 200, await views.pulls(id, owner, name));
477
478
  }
package/web/app.js CHANGED
@@ -2967,6 +2967,13 @@ function loadGitHub() {
2967
2967
  function setGitHub(github) {
2968
2968
  const before = state.github?.signIn;
2969
2969
  state.github = github;
2970
+ const branches = githubPick.data.branches;
2971
+ if (branches && !github.accounts.some((a) => a.id === branches.repo.accountId && !a.needsSignIn)) {
2972
+ // Replace the generation even while the dock is closed; signing in again must
2973
+ // never make an earlier account's pending answer current.
2974
+ githubPick.data.branches = { ...branches, value: null, loading: false, nextPage: null,
2975
+ error: `Sign in as @${branches.repo.login} in Repositories to see its branches.` };
2976
+ }
2970
2977
  const done = github.signIn?.status === 'done' && before?.status === 'pending' ? github.signIn : null;
2971
2978
  if (done) {
2972
2979
  selectGitHubAccount(done.accountId);
@@ -3086,7 +3093,7 @@ function openGitHub({ focus = true } = {}) {
3086
3093
  loadGitHub();
3087
3094
  if (githubPick.repo) {
3088
3095
  loadView('actions');
3089
- if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
3096
+ if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
3090
3097
  }
3091
3098
  followTerminal();
3092
3099
  if (focus) ($('github-card').querySelector('.btn.primary') ?? $('dock-close')).focus();
@@ -3497,7 +3504,7 @@ function useFolder(dir) {
3497
3504
 
3498
3505
  // ---- GitHub repository views ------------------------------------------------
3499
3506
 
3500
- const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls'];
3507
+ const GITHUB_VIEWS = ['repos', 'issues', 'actions', 'pulls', 'branches'];
3501
3508
  const RUNS_POLL_MS = 6000;
3502
3509
  const RUNS_IDLE_POLL_MS = 30000;
3503
3510
  const BODY_LIMIT = 48000;
@@ -3592,7 +3599,11 @@ function pickRepo(repo, { chosen = false } = {}) {
3592
3599
  githubPick.recent = remember(githubPick.recent, repo);
3593
3600
  save(GITHUB_RECENT_KEY, JSON.stringify(githubPick.recent));
3594
3601
  }
3595
- if (!same) Object.assign(githubPick, { data: {}, editing: null });
3602
+ if (!same) {
3603
+ Object.assign(githubPick, { data: {}, editing: null });
3604
+ $('github-branches-filter').value = '';
3605
+ $('github-branches').scrollTop = 0;
3606
+ }
3596
3607
  if ((!same || chosen) && githubView.accountId !== repo.accountId && state.github?.accounts.some((a) => a.id === repo.accountId)) {
3597
3608
  selectGitHubAccount(repo.accountId);
3598
3609
  githubView.card = null;
@@ -3605,7 +3616,7 @@ function pickRepo(repo, { chosen = false } = {}) {
3605
3616
  renderGitHubViews();
3606
3617
  if (!dockShows('github')) return;
3607
3618
  loadView('actions');
3608
- if (githubShownView() === 'issues' || githubShownView() === 'pulls') loadView(githubShownView());
3619
+ if (['issues', 'pulls', 'branches'].includes(githubShownView())) loadView(githubShownView());
3609
3620
  }
3610
3621
 
3611
3622
  /** Picks the repository whose clone holds the focused terminal's folder, once for each terminal focused. */
@@ -3628,6 +3639,7 @@ async function followTerminal() {
3628
3639
  }
3629
3640
 
3630
3641
  async function loadView(view) {
3642
+ if (view === 'branches') return loadBranches();
3631
3643
  const repo = githubPick.repo;
3632
3644
  if (!repo || view === 'repos') return;
3633
3645
  const query = view === 'issues' ? `?state=${githubPick.issueState}` : '';
@@ -3652,6 +3664,170 @@ async function loadView(view) {
3652
3664
  if (view === 'actions') scheduleRuns();
3653
3665
  }
3654
3666
 
3667
+ // Each object is a listing generation, scoped to the selected account/repository.
3668
+ // A retry continues that generation; Refresh replaces it and starts at page one.
3669
+ function branchesCurrent(request) {
3670
+ return githubPick.data.branches === request && githubPick.repo && repoKey(githubPick.repo) === request.key
3671
+ && state.github?.accounts.some((a) => a.id === request.repo.accountId && !a.needsSignIn);
3672
+ }
3673
+
3674
+ function branchesVisible() {
3675
+ return dockShows('github') && githubShownView() === 'branches' && document.visibilityState === 'visible';
3676
+ }
3677
+
3678
+ async function loadBranches({ resume = false } = {}) {
3679
+ const repo = githubPick.repo;
3680
+ if (!repo || !state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn)) return;
3681
+ const before = viewData('branches');
3682
+ if (resume && (!before || before.loading || before.nextPage === null)) return;
3683
+ const request = resume ? before : {
3684
+ key: repoKey(repo), repo, value: before?.value ?? null, loading: false, error: null,
3685
+ nextPage: 1, received: false, rows: new Map(), defaultBranch: null, metadataError: null,
3686
+ };
3687
+ githubPick.data.branches = request;
3688
+ request.loading = true;
3689
+ request.error = null;
3690
+ renderGitHubViews();
3691
+ try {
3692
+ while (request.nextPage !== null && branchesVisible() && branchesCurrent(request)) {
3693
+ const page = request.nextPage;
3694
+ const result = await api('GET', `${repoPath(repo)}/branches?page=${page}`);
3695
+ if (!branchesCurrent(request)) return;
3696
+ // A malformed response must not masquerade as a complete, empty list.
3697
+ if (!Array.isArray(result.branches) || (result.nextPage !== null
3698
+ && (!Number.isSafeInteger(result.nextPage) || result.nextPage <= page))) {
3699
+ throw new Error('Could not read the next page of branches. Try again.');
3700
+ }
3701
+ if (page === 1) {
3702
+ request.defaultBranch = result.defaultBranch;
3703
+ request.metadataError = result.metadataError;
3704
+ }
3705
+ for (const branch of result.branches) request.rows.set(branch.name, branch);
3706
+ request.nextPage = result.nextPage;
3707
+ request.received = true;
3708
+ request.value = { ...result, branches: [...request.rows.values()],
3709
+ defaultBranch: request.defaultBranch, metadataError: request.metadataError };
3710
+ renderGitHubViews();
3711
+ }
3712
+ } catch (err) {
3713
+ if (!branchesCurrent(request)) return;
3714
+ if (err instanceof AuthError) { showAuth(err.message); return; }
3715
+ request.error = err.message;
3716
+ } finally {
3717
+ if (branchesCurrent(request)) {
3718
+ request.loading = false;
3719
+ renderGitHubViews();
3720
+ }
3721
+ }
3722
+ }
3723
+
3724
+ /** Keep the first visible branch at the same pixel offset when earlier rows arrive. */
3725
+ function branchAnchor(panel, list) {
3726
+ const { top, bottom } = panel.getBoundingClientRect();
3727
+ const row = [...list.children].find((node) => {
3728
+ const rect = node.getBoundingClientRect();
3729
+ return rect.bottom > top && rect.top < bottom;
3730
+ });
3731
+ return row ? { name: row.dataset.branch, offset: row.getBoundingClientRect().top - top } : null;
3732
+ }
3733
+
3734
+ async function copyBranch(name, control) {
3735
+ const request = viewData('branches');
3736
+ control.disabled = true;
3737
+ let message;
3738
+ try {
3739
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
3740
+ await navigator.clipboard.writeText(name);
3741
+ message = `Copied branch name: ${name}`;
3742
+ } catch {
3743
+ message = 'Could not copy. Select the branch name and copy it using your browser.';
3744
+ } finally {
3745
+ control.disabled = false;
3746
+ }
3747
+ if (request === viewData('branches') && branchesVisible() && control.isConnected) toast(message);
3748
+ }
3749
+
3750
+ function renderBranches({ preserveAnchor = true } = {}) {
3751
+ const panel = $('github-branches');
3752
+ const list = $('github-branches-list');
3753
+ const repo = githubPick.repo;
3754
+ const key = repo ? repoKey(repo) : '';
3755
+ const same = panel.dataset.repo === key;
3756
+ const anchor = same && preserveAnchor ? branchAnchor(panel, list) : null;
3757
+ const focused = list.contains(document.activeElement) ? document.activeElement : null;
3758
+ const oldScroll = same ? panel.scrollTop : 0;
3759
+ if (!same) { list.replaceChildren(); panel.dataset.repo = key; }
3760
+ $('github-branches-pick').hidden = Boolean(repo);
3761
+ $('github-branches-content').hidden = !repo;
3762
+ if (!repo) return;
3763
+ const slot = viewData('branches');
3764
+ const value = slot?.value;
3765
+ const query = $('github-branches-filter').value.trim().toLocaleLowerCase();
3766
+ $('github-branches-clear').disabled = !$('github-branches-filter').value;
3767
+ const refresh = $('github-branches-refresh');
3768
+ const usable = state.github?.accounts.some((a) => a.id === repo.accountId && !a.needsSignIn);
3769
+ refresh.disabled = Boolean(slot?.loading) || !usable;
3770
+ refresh.textContent = slot?.loading ? (value ? 'Refreshing…' : 'Loading…') : 'Refresh';
3771
+ const out = $('github-branches-out');
3772
+ out.href = value?.url ?? `https://github.com/${repo.fullName}/branches`;
3773
+ out.title = `Open ${repo.fullName} branches on GitHub`;
3774
+ const branches = (value?.branches ?? []).filter((b) => b.name.toLocaleLowerCase().includes(query)).sort((a, b) =>
3775
+ Number(b.name === value.defaultBranch) - Number(a.name === value.defaultBranch)
3776
+ || a.name.localeCompare(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
3777
+ const complete = Boolean(value && value.nextPage === null && (!slot.error || slot.received));
3778
+ const count = value?.branches.length ?? 0;
3779
+ let status = !value ? (slot?.error ? 'Branches could not be loaded.' : 'Loading branches…')
3780
+ : query ? `${branches.length} of ${count} loaded branches match.`
3781
+ : `${count} ${count === 1 ? 'branch' : 'branches'}${complete ? '' : ' loaded'}.`;
3782
+ if (value && !slot.received) status += slot.loading ? ' Refreshing…' : ' Showing previous results.';
3783
+ else if (!complete && value) status += slot.loading ? ' Loading more…' : ' Listing incomplete.';
3784
+ if (complete && !branches.length) status = query ? `No branches match “${$('github-branches-filter').value.trim()}”.` : 'No remote branches yet.';
3785
+ const statusNode = $('github-branches-status');
3786
+ if (statusNode.textContent !== status) statusNode.textContent = status;
3787
+ const failure = $('github-branches-error');
3788
+ failure.hidden = !slot?.error;
3789
+ failure.querySelector('span').textContent = slot?.error ?? '';
3790
+ $('github-branches-retry').disabled = Boolean(slot?.loading) || slot?.nextPage === null || !usable;
3791
+ const metadata = $('github-branches-metadata');
3792
+ metadata.hidden = !value?.metadataError;
3793
+ metadata.textContent = value?.metadataError ? 'Branches loaded, but the default branch could not be identified. Refresh to try again.' : '';
3794
+ const existing = new Map([...list.children].map((row) => [row.dataset.branch, row]));
3795
+ const wanted = new Set(branches.map((branch) => branch.name));
3796
+ let cursor = list.firstElementChild;
3797
+ for (const branch of branches) {
3798
+ let row = existing.get(branch.name);
3799
+ if (!row) {
3800
+ const copy = button('Copy name', () => copyBranch(branch.name, copy));
3801
+ copy.setAttribute('aria-label', `Copy branch name ${branch.name}`);
3802
+ row = recordRow(`branch:${branch.name}`, branch.name, '', copy, branch.url);
3803
+ row.classList.add('github-branch');
3804
+ row.dataset.branch = branch.name;
3805
+ row.setAttribute('role', 'listitem');
3806
+ row.querySelector('.history-title').append(el('span', 'github-branch-badges'));
3807
+ }
3808
+ const badges = row.querySelector('.github-branch-badges');
3809
+ const badgeState = `${branch.name === value.defaultBranch}:${branch.protected}`;
3810
+ if (badges.dataset.state !== badgeState) {
3811
+ badges.dataset.state = badgeState;
3812
+ badges.replaceChildren(...[
3813
+ branch.name === value.defaultBranch ? badge('', 'Default', 'Default branch') : null,
3814
+ branch.protected ? badge('', 'Protected', 'Protected by branch protection or rulesets') : null,
3815
+ ].filter(Boolean));
3816
+ }
3817
+ row.querySelector('.history-meta').textContent = branch.sha ? branch.sha.slice(0, 7) : '';
3818
+ row.querySelector('.history-meta').title = branch.sha ?? '';
3819
+ if (row === cursor) cursor = cursor.nextElementSibling;
3820
+ else list.insertBefore(row, cursor);
3821
+ }
3822
+ for (const row of [...list.children]) if (!wanted.has(row.dataset.branch)) row.remove();
3823
+ if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
3824
+ panel.scrollTop = oldScroll;
3825
+ if (anchor) {
3826
+ const row = [...list.children].find((node) => node.dataset.branch === anchor.name);
3827
+ if (row) panel.scrollTop += row.getBoundingClientRect().top - panel.getBoundingClientRect().top - anchor.offset;
3828
+ }
3829
+ }
3830
+
3655
3831
  function viewData(view) {
3656
3832
  const slot = githubPick.data[view];
3657
3833
  const repo = githubPick.repo;
@@ -3824,6 +4000,7 @@ function renderGitHubViews() {
3824
4000
  $('github-issues').hidden = shown !== 'issues';
3825
4001
  $('github-runs').hidden = shown !== 'actions';
3826
4002
  $('github-pulls').hidden = shown !== 'pulls';
4003
+ $('github-branches').hidden = shown !== 'branches';
3827
4004
  const ready = Boolean(state.github && githubAccount() && !githubAccount().needsSignIn);
3828
4005
  $('github-clone-into').hidden = !ready || shown !== 'repos';
3829
4006
  const running = Boolean(viewData('actions')?.value?.running);
@@ -3833,6 +4010,7 @@ function renderGitHubViews() {
3833
4010
  if (shown === 'issues') renderIssues();
3834
4011
  else if (shown === 'actions') renderRuns();
3835
4012
  else if (shown === 'pulls') renderPulls();
4013
+ else if (shown === 'branches') renderBranches();
3836
4014
  }
3837
4015
 
3838
4016
  /** Rebuilds a panel and puts the focus back on the control with the same `data-key`. */
@@ -5650,6 +5828,18 @@ $('github-views').addEventListener('click', (e) => {
5650
5828
  if (view) switchGitHubView(view);
5651
5829
  });
5652
5830
  $('github-views').addEventListener('keydown', moveGitHubView);
5831
+ $('github-branches-refresh').addEventListener('click', () => loadView('branches'));
5832
+ $('github-branches-retry').addEventListener('click', () => loadBranches({ resume: true }));
5833
+ $('github-branches-filter').addEventListener('input', () => {
5834
+ $('github-branches').scrollTop = 0;
5835
+ renderBranches({ preserveAnchor: false });
5836
+ });
5837
+ $('github-branches-clear').addEventListener('click', () => {
5838
+ $('github-branches-filter').value = '';
5839
+ $('github-branches').scrollTop = 0;
5840
+ renderBranches({ preserveAnchor: false });
5841
+ $('github-branches-filter').focus();
5842
+ });
5653
5843
  $('github-parent').addEventListener('change', () => setCloneParent(cloneParent()));
5654
5844
  $('github-parent-pick').addEventListener('click', firstClick(() => chooseFolder('clone')));
5655
5845
  $('history-here').addEventListener('change', renderHistory);
@@ -5710,6 +5900,7 @@ document.addEventListener('visibilitychange', () => {
5710
5900
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
5711
5901
  if (document.visibilityState === 'visible' && dockShows('github') && githubShownView() === 'actions') loadView('actions');
5712
5902
  else scheduleRuns();
5903
+ if (branchesVisible() && !viewData('branches')?.error) loadBranches({ resume: true });
5713
5904
  });
5714
5905
  addEventListener('pagehide', () => {
5715
5906
  flushNotes();
@@ -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
@@ -208,6 +208,7 @@
208
208
  <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
209
  <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
210
  <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>
211
+ <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
212
  </div>
212
213
  <div id="github-repos" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-repos">
213
214
  <section id="github-card" class="github-card" aria-live="polite" hidden></section>
@@ -241,6 +242,27 @@
241
242
  <div id="github-issues" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-issues" hidden></div>
242
243
  <div id="github-runs" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-actions" hidden></div>
243
244
  <div id="github-pulls" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-pulls" hidden></div>
245
+ <div id="github-branches" class="models-body github-body" role="tabpanel" aria-labelledby="github-view-branches" hidden>
246
+ <p id="github-branches-pick" class="github-view-note">Pick a repository above to see its branches.</p>
247
+ <div id="github-branches-content" hidden>
248
+ <div class="github-view-tools">
249
+ <button id="github-branches-refresh" class="btn" type="button">Refresh</button>
250
+ <span class="github-spacer"></span>
251
+ <a id="github-branches-out" class="btn github-out" target="_blank" rel="noopener noreferrer" aria-description="Opens in a new tab">GitHub</a>
252
+ </div>
253
+ <div class="github-branch-filter">
254
+ <input id="github-branches-filter" type="search" placeholder="Filter branches" aria-label="Filter branches" spellcheck="false" autocomplete="off">
255
+ <button id="github-branches-clear" class="btn" type="button" disabled>Clear</button>
256
+ </div>
257
+ <p id="github-branches-status" class="github-view-note" role="status" aria-live="polite" aria-atomic="true"></p>
258
+ <div id="github-branches-error" class="github-error" hidden>
259
+ <span role="alert"></span>
260
+ <button id="github-branches-retry" class="btn" type="button">Retry</button>
261
+ </div>
262
+ <p id="github-branches-metadata" class="github-view-note" hidden></p>
263
+ <div id="github-branches-list" class="history-list" role="list" aria-label="Remote branches"></div>
264
+ </div>
265
+ </div>
244
266
  <div id="github-clone-into" class="history-form github-form">
245
267
  <label for="github-parent">Clone into</label>
246
268
  <div class="cwd-field single">
package/web/styles.css CHANGED
@@ -898,10 +898,27 @@ a.news-link:visited { color: var(--muted); }
898
898
  .github-view[aria-selected="true"] { color: var(--heading); background: var(--surface-2); border-color: var(--border); border-bottom-color: var(--surface-2); }
899
899
  .github-view:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
900
900
  .github-view .short { display: none; }
901
- @container (max-width: 340px) {
901
+ @container (max-width: 440px) {
902
902
  .github-view .long { display: none; }
903
903
  .github-view .short { display: inline; }
904
904
  }
905
+ #github-branches { overflow-anchor: none; }
906
+ .github-branch-filter { display: flex; align-items: center; gap: 6px; }
907
+ .github-branch-filter input {
908
+ flex: 1; min-width: 0; padding: 7px 10px; border: 1px solid var(--border); border-radius: var(--radius);
909
+ background: var(--surface); color: var(--text); font: inherit; font-size: 0.85rem;
910
+ }
911
+ .github-branch-filter input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
912
+ .github-branch .history-title { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
913
+ .github-branch .github-record-title { min-width: 0; overflow-wrap: anywhere; }
914
+ .github-branch-badges { display: inline-flex; flex-wrap: wrap; gap: 4px; }
915
+ .github-branch .history-meta { font-family: var(--font-mono, monospace); }
916
+ .github-branch > .btn { align-self: start; }
917
+ #github-branches-error .btn { margin-left: 6px; }
918
+ @media (pointer: coarse) {
919
+ .github-branch-filter input { font-size: 16px; }
920
+ .github-branch > .btn, #github-branches .github-view-tools .btn, .github-branch-filter .btn { min-height: 36px; }
921
+ }
905
922
  .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
923
  .github-body[hidden] { display: none; }
907
924
  .github-view-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-bottom: 6px; }