heyquilt 0.3.138 → 0.3.140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/RELEASES.md +6 -0
  2. package/bin/quilt.js +7 -143
  3. package/package.json +1 -1
  4. package/src/account.js +0 -29
  5. package/src/api/memory-store.js +0 -29
  6. package/src/api/routes/teams.js +0 -23
  7. package/src/api/server.js +5 -19
  8. package/src/api/supabase-store.js +0 -52
  9. package/src/branch-view.js +6 -131
  10. package/src/control.js +2 -33
  11. package/src/duties.js +1 -1
  12. package/src/github-commit.js +3 -315
  13. package/src/mcp.js +8 -157
  14. package/src/relay-ask.js +3 -66
  15. package/src/relay-cluster.js +3 -5
  16. package/src/relay-mcp.js +6 -418
  17. package/src/relay-prune.js +6 -145
  18. package/src/relay-turn-sync.js +1 -1
  19. package/src/server.js +33 -1331
  20. package/src/session.js +52 -3526
  21. package/src/status.js +2 -58
  22. package/src/turn-sync.js +15 -2
  23. package/src/ui/changes.js +5 -14
  24. package/src/ui/chat.js +0 -1
  25. package/src/ui/common.js +0 -2
  26. package/src/ui/git-rule.js +2 -30
  27. package/src/ui/home.js +3 -76
  28. package/src/ui/session.js +34 -528
  29. package/src/ui/tree.js +2 -8
  30. package/src/ui/workspaces.js +2 -122
  31. package/src/ui-server.js +1 -38
  32. package/src/api/routes/github.js +0 -380
  33. package/src/branches.js +0 -194
  34. package/src/clash.js +0 -128
  35. package/src/commit-request.js +0 -59
  36. package/src/commit.js +0 -213
  37. package/src/git-hook.js +0 -48
  38. package/src/gitignore.js +0 -49
  39. package/src/gitstate.js +0 -1069
  40. package/src/relay-ci.js +0 -111
  41. package/src/relay-clash.js +0 -214
  42. package/src/relay-commit.js +0 -923
  43. package/src/relay-filekey.js +0 -46
  44. package/src/relay-git.js +0 -274
  45. package/src/relay-githttp.js +0 -166
  46. package/src/relay-github.js +0 -109
  47. package/src/relay-outbound.js +0 -1295
  48. package/src/relay-repo-moves.js +0 -43
  49. package/src/relay-sync.js +0 -795
  50. package/src/relay-upstream.js +0 -889
  51. package/src/sync-words.js +0 -42
  52. package/src/upstream.js +0 -192
package/src/ui/session.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // AI conversations at once), one person's AI chat, a shared file or the task board;
3
3
  // the team chat on the right.
4
4
  import { mountSecrets } from './secrets.js'
5
- import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor, toast, api, ask, remember, recall, AGENT_COMMIT_WORDS, toolsOf, busyPeople, NO_POSTING, ACCOUNT_KEY, loadAccessTypes, typeOptions, accessLine, loadingHtml, loadingNote } from './common.js'
5
+ import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor, toast, api, ask, remember, recall, toolsOf, NO_POSTING, ACCOUNT_KEY, loadAccessTypes, typeOptions, accessLine, loadingHtml, loadingNote } from './common.js'
6
6
  import { openInvite, renderTabs, markRead, go } from './app.js'
7
7
  import { renderFeed } from './feed.js'
8
8
  import { renderLoom, resetLoom, focusFile, showsLoom } from './loom.js'
@@ -16,10 +16,9 @@ import { renderFileView, renderFileTabs } from './fileview.js'
16
16
  import { rolledOff, diffLines } from './diffview.js'
17
17
  import { searchMarkup, searchTabMarkup, liveByPerson, fileReadsNeeded, SEARCH_ICON } from './quiltview.js'
18
18
  import { changesMarkup, bindChanges, unbindChanges, changesChanged, fileChanges, loadChanges, changedCount, unseenChanges, markChangesSeen } from './changes.js'
19
- import { chipCounts } from './changes-seen.js'
20
19
  import { quiltMark } from './mark.js'
21
20
  import { openSettings } from './home.js'
22
- import { fileCardHref, renderable, textHtml, mentionAt, mentionCandidates, completeMention, ALL_AGENTS, RELAY_NAME, isRelay, relayPrompts, typingNames, typingHtml, TYPING_MS, foldPersonas, aiOwners, aiName, shownName, ownAiChatter, authorOf } from './chat.js'
21
+ import { fileCardHref, renderable, textHtml, mentionAt, mentionCandidates, completeMention, ALL_AGENTS, RELAY_NAME, isRelay, relayPrompts, typingNames, typingHtml, TYPING_MS, foldPersonas, aiOwners, aiName, shownName, ownAiChatter } from './chat.js'
23
22
  import { toolLogo } from './tool-logo.js'
24
23
  import { renderBoard, taskNotesModalHtml, openTaskMenu, foldPlan, matchesSearch } from './board.js'
25
24
  import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFailed } from './access-form.js'
@@ -27,14 +26,11 @@ import { workspaceFilePicker } from './files.js'
27
26
  import { sessionAgentsHtml } from './agent-place.js'
28
27
  import { renderCatchUp, bindCatchUp } from './catchup.js'
29
28
  import { boxSummary, boxesOf, unplaced, boxesSig, boxListHtml, boxFoldHtml } from './boxes.js'
30
- import { gitControl, gitStatusParts, gitSegHtml } from './git-rule.js'
31
29
  import { inSeam, makeQuiltWords, seamChip, squareCard, squarePanelHtml, seamSettingsHtml, openSquareButton, squaresSideHtml, SEAM_SIDE_HINT } from './seam-model.js'
32
30
  import { callButtonMarkup, callPinMarkup, screenStageMarkup, mountCall, unmountCall, renderCall, callCards } from './voice.js'
33
31
 
34
32
  let current = null // session id being shown
35
33
  let timers = []
36
- let holdNoteTimer = null // shows the "git is busy" note once a hold has lasted HOLD_NOTE_MS
37
- const HOLD_NOTE_MS = 1000 // a `git add` holds for a moment: no note flashing by for it
38
34
  let grantLoad = grantsLoading() // this session's grants (the owner's view, from the API), for the Access sections
39
35
  let wsAgents = { id: null, agents: [] } // the agents this session's workspace invites (workspaces on), for its People
40
36
  let mounted = null // AbortController for document-level listeners of this mount
@@ -135,7 +131,6 @@ export function mountSession (id) {
135
131
  <button class="pop-item" role="menuitem" id="move-ws-btn" hidden>Move to a workspace…</button>
136
132
  <button class="pop-item" role="menuitem" data-session-settings>Session settings…</button>
137
133
  <button class="pop-item" role="menuitem" data-gather-rules>One set of AI rules…</button>
138
- <button class="pop-item" role="menuitem" id="ask-commit">Ask for a commit…</button>
139
134
  <div class="pop-sep"></div>
140
135
  <button class="pop-item" role="menuitem" id="leave-btn">Leave this session</button>
141
136
  </div>
@@ -188,7 +183,6 @@ export function mountSession (id) {
188
183
  <main class="ws-main">
189
184
  <div class="requests catchup" id="catchup" role="region" aria-label="While you were away" hidden></div>
190
185
  <div class="requests square" id="square-card" role="status" aria-label="Seam" hidden></div>
191
- <div class="requests github" id="github-card" role="region" aria-label="GitHub" hidden></div>
192
186
  <div class="requests" id="requests" hidden></div>
193
187
  <div class="ws-mainbar" id="mainbar" hidden>
194
188
  <div class="ws-tabs" id="main-tabs" role="tablist"></div>
@@ -229,11 +223,9 @@ export function mountSession (id) {
229
223
  }, () => renderMessages())
230
224
  bindAccess()
231
225
  bindChanges(id, mounted.signal, { onOpen: (path) => openFile(path, { tab: 'changes' }), close: () => setGitOpen(false), onCount: renderGitChip })
232
- loadMine(true)
233
226
  bindCatchUp($('#catchup'), { sessionId: () => current, onOpen: openFile })
234
227
  // Report a bug / Suggest a feature at the bottom of the Boxes pane (feedback.js): onto this session's board.
235
228
  bindFeedback($('.ws-boxes'), { session: () => current, where: () => state.sessions.get(current)?.status?.sessionName || 'a session' })
236
- bindGithubCard()
237
229
  bindSquareCard()
238
230
  bindMain()
239
231
  bindTreeEvents()
@@ -272,7 +264,6 @@ export function sessionUnmount () {
272
264
  unmountCall()
273
265
  for (const t of timers) clearInterval(t)
274
266
  timers = []
275
- clearTimeout(holdNoteTimer)
276
267
  if (mounted) mounted.abort()
277
268
  mounted = null
278
269
  closeTreeMenu()
@@ -298,7 +289,6 @@ export function sessionUpdated (id) {
298
289
  if (ws(id).mode === 'tasks') paintBoard()
299
290
  scheduleTree()
300
291
  changesChanged()
301
- loadMine()
302
292
  }
303
293
 
304
294
  export function sessionMessage (id) {
@@ -335,7 +325,6 @@ export function sessionFileChanged (id, { path }) {
335
325
  if (id !== current) return
336
326
  scheduleTree()
337
327
  changesChanged()
338
- loadMine()
339
328
  const w = ws(id)
340
329
  if (w.fileTabs.includes(path)) {
341
330
  if (w.mode === 'files' && w.fileSel === path) { refreshFile(path, true); scheduleHistory(path) } else { w.stale[path] = true; histories.delete(fileKey(path)); renderMainBar() }
@@ -418,7 +407,6 @@ function bindTop () {
418
407
  }
419
408
  $('#invite-btn').onclick = () => openInvite(current)
420
409
  bindOpenIn()
421
- $('#ask-commit').onclick = askForCommit
422
410
  $('#rename-btn').onclick = renameSession
423
411
  $('#move-ws-btn').onclick = moveToWorkspace
424
412
  bindCommitChip()
@@ -495,8 +483,7 @@ function bindTop () {
495
483
 
496
484
  /** The Session settings window's controls: your AI chat's sharing, and (for the owner) who can get in and what they may do. */
497
485
  function bindSessionSettings (root) {
498
- root.addEventListener('change', (e) => { if (e.target.matches('[data-github-sync], [data-large-to-relay]')) gitSettingChange(e) })
499
- root.addEventListener('click', (e) => { if (e.target.closest('[data-gh-connect]')) connectGithub(); else if (e.target.closest('[data-make-quilt]')) makeQuilt(e.target.closest('[data-make-quilt]')); else if (e.target.closest('[data-agent-commits]')) gitSettingChange(e) })
486
+ root.addEventListener('click', (e) => { const b = e.target.closest('[data-make-quilt]'); if (b) makeQuilt(b) })
500
487
  root.addEventListener('change', async (e) => {
501
488
  if (e.target.matches('[data-share]')) {
502
489
  const on = e.target.checked
@@ -571,11 +558,6 @@ function bindSessionSettings (root) {
571
558
  loadSessionAgents()
572
559
  })
573
560
  root.addEventListener('click', async (e) => {
574
- if (e.target.closest('[data-gh-clear]')) {
575
- try { await api('POST', `/api/sessions/${current}/github-token`, { token: '' }); toast('The relay no longer has a GitHub token') } catch (err) { toast(err.message) }
576
- refreshPeople({ force: true })
577
- return
578
- }
579
561
  if (e.target.closest('[data-end-session]')) {
580
562
  if (!await ask({ title: 'End this session for everyone?', message: 'Everyone is disconnected, and the session and its stored files are deleted from the relay. Your own folder is not touched.', ok: 'End session', danger: true })) return
581
563
  try { await api('POST', `/api/sessions/${current}/end`); toast('Session ended') } catch (err) { toast(err.message) }
@@ -600,21 +582,7 @@ function bindSessionSettings (root) {
600
582
  refreshPeople()
601
583
  } catch (err) { toast(err.message) } finally { save.disabled = false }
602
584
  })
603
- root.addEventListener('submit', async (e) => {
604
- const f = e.target.closest('.pm-gh')
605
- if (!f) return
606
- e.preventDefault()
607
- const token = f.token.value.trim()
608
- if (!token) return toast('Paste a token first')
609
- const save = f.querySelector('[type=submit]')
610
- save.disabled = true
611
- try {
612
- await api('POST', `/api/sessions/${current}/github-token`, { token })
613
- f.token.value = ''
614
- toast('Token saved: the relay brings in commits while everyone\'s offline')
615
- refreshPeople({ force: true })
616
- } catch (err) { toast(err.message) } finally { save.disabled = false }
617
- })
585
+
618
586
  }
619
587
 
620
588
  function toggleDrawer (which) {
@@ -653,44 +621,6 @@ function agentLine (p) {
653
621
  }
654
622
 
655
623
  // ------------------------------------------------------------- branches --
656
- let branchSyncing = false
657
-
658
- /** One line on how a branch stands against its upstream ('' without one). */
659
- export function upstreamText (u) {
660
- if (!u || !u.name) return ''
661
- const n = (k, w) => `${k} ${w}${k === 1 ? '' : 's'}`
662
- // A failed fetch: what git last knew is stale, so never "up to date".
663
- if (u.fetchOk === false) return `can't fetch ${u.name}: ${u.fetchError || 'git fetch failed'}`
664
- const by = u.mergedBy ? `; being merged by ${u.clashMine ? 'your AI' : u.mergedBy}` : ''
665
- if (u.diverged) return `diverged from ${u.name}: ${n(u.ahead, 'commit')} here, ${n(u.behind, 'commit')} there${by}`
666
- const clash = Array.isArray(u.conflicts) ? u.conflicts.length : u.conflicts
667
- if (clash) return `${n(u.behind, 'commit')} behind ${u.name}; ${n(clash, 'file')} clash with the session's work${by}`
668
- if (u.waiting) return `${n(u.behind, 'commit')} behind ${u.name}; waiting: ${u.waiting}`
669
- if (u.behind) return `${n(u.behind, 'commit')} behind ${u.name}`
670
- if (u.ahead) return `${n(u.ahead, 'commit')} ahead of ${u.name}`
671
- return `up to date with ${u.name}`
672
- }
673
-
674
- /** Whether the label should draw the eye: this folder is behind and Quilt can't bring it in by itself. */
675
- export function needsHand (u) {
676
- return !!(u && (u.diverged || u.fetchOk === false || (Array.isArray(u.conflicts) ? u.conflicts.length : u.conflicts)))
677
- }
678
-
679
- /**
680
- * The git panel's head in a session still on git: this folder's branch, how it stands against its
681
- * upstream, and Check. The list of every branch (the branch menu, src/ui/branches.js) went in 16's R2:
682
- * which branch a folder is on is git's, and new sessions are quilts.
683
- */
684
- export function folderBranchHtml ({ git, syncing = false }) {
685
- if (!git) return ''
686
- const u = git.upstream
687
- return `<div class="br-here br-compact">
688
- <div class="br-line">${I.branch}<b class="br-name">${esc(git.key)}</b>${u ? `<span class="br-sub${needsHand(u) ? ' warn' : ''}" title="${esc(upstreamText(u))}">${esc(upstreamText(u))}</span>` : ''}${u ? `<button type="button" class="btn sm ghost br-sync" data-branch-sync ${syncing ? 'disabled' : ''} title="Look for new commits on ${esc(u.name || 'the upstream')}">${I.refresh}<span>${syncing ? 'Checking' : 'Check'}</span></button>` : ''}</div>
689
- ${u && Array.isArray(u.conflicts) && u.conflicts.length ? `<ul class="br-clash">${u.conflicts.slice(0, 6).map((c) => `<li><code>${esc(c.path)}</code> ${esc(c.why)}</li>`).join('')}</ul><div class="br-hint">Ask your AI to pull and resolve these: Quilt brings nothing in until they're settled.</div>` : ''}
690
- ${u && u.diverged ? '<div class="br-hint">Quilt never merges git history: ask your AI to pull or rebase, and the session takes in the result.</div>' : ''}
691
- </div>`
692
- }
693
-
694
624
  let squareBusy = '' // 'commit' or 'merge' while the square panel's button runs (layout 3)
695
625
 
696
626
  function renderBranchMenu () {
@@ -712,8 +642,8 @@ function renderBranchMenu () {
712
642
  return
713
643
  }
714
644
  delete menu.dataset.html
715
- menu.hidden = !st.git
716
- menu.innerHTML = folderBranchHtml({ git: st.git, syncing: branchSyncing })
645
+ menu.hidden = true
646
+ menu.innerHTML = ''
717
647
  }
718
648
 
719
649
  /** The square panel's Commit and Merge onto main (layout 3): Seam's own tools, as this member (control.js seamTool). */
@@ -747,15 +677,7 @@ function bindBranchMenu () {
747
677
  menu.addEventListener('click', async (e) => {
748
678
  // Merge commits what isn't committed yet first: with the words typed in the box, if any.
749
679
  if (e.target.closest('[data-square-open]')) { if (!squareBusy) squareAct('open_square'); return }
750
- if (e.target.closest('[data-square-merge]')) { if (!squareBusy) squareAct('seam_merge', (menu.querySelector('[data-square-commit] input')?.value || '').trim()); return }
751
- if (!e.target.closest('[data-branch-sync]') || branchSyncing) return
752
- branchSyncing = true
753
- renderBranchMenu()
754
- const id = current
755
- try { await api('POST', `/api/sessions/${id}/branches/sync`) } catch (err) { toast(err.message) } finally {
756
- branchSyncing = false
757
- if (id === current && !$('#git-panel').hidden) renderBranchMenu()
758
- }
680
+ if (e.target.closest('[data-square-merge]')) { if (!squareBusy) squareAct('seam_merge', (menu.querySelector('[data-square-commit] input')?.value || '').trim()) }
759
681
  })
760
682
  }
761
683
 
@@ -766,11 +688,8 @@ function renderTop () {
766
688
  // Code search, only in a session linked to a quilt (the relay reads Seam for it).
767
689
  const search = $('#qt-topsearch')
768
690
  if (search) search.hidden = !st.quilt
769
- const g = st.git
770
691
  const label = $('#git-branch')
771
692
  const seam = inSeam(st) ? seamChip(st) : null
772
- // In Seam nobody asks a person with git to commit: whoever is on a square commits it.
773
- $('#ask-commit').hidden = !!seam
774
693
  if (label && seam) {
775
694
  // In Seam: the square this folder follows, by the name Seam gives it.
776
695
  label.hidden = !seam.label
@@ -778,21 +697,8 @@ function renderTop () {
778
697
  label.dataset.title = seam.title
779
698
  if (!$('#git-panel').hidden) renderBranchMenu()
780
699
  } else if (label) {
781
- label.hidden = !g
782
- if (g) {
783
- // Moving to another branch: said at once. Git busy: only once it has lasted a moment.
784
- const held = g.hold ? Date.now() - g.hold.since : 0
785
- const note = g.hold && (g.hold.kind === 'switching' || held >= HOLD_NOTE_MS)
786
- clearTimeout(holdNoteTimer)
787
- if (g.hold && !note) holdNoteTimer = setTimeout(renderTop, HOLD_NOTE_MS - held + 20)
788
- const tag = note ? (g.hold.kind === 'switching' ? (g.hold.waiting === 'refused' ? `not syncing · ${esc(g.hold.to || '?')} isn't in the session` : g.hold.waiting === 'retrying' ? `waiting for ${esc(g.hold.to || '?')}…` : `moving to ${esc(g.hold.to || '?')}…`) : g.hold.conflict ? 'paused: resolve the git conflict' : 'syncing paused: git is busy') : ''
789
- const up = g.upstream
790
- const behind = !tag && up && (up.behind || up.diverged) ? `<span class="tag${needsHand(up) ? ' warn' : ''}">${needsHand(up) ? 'needs a pull' : `${up.behind} behind`}</span>` : ''
791
- const full = g.full ? '<span class="tag warn" title="This branch is over the session\'s size limit: new changes aren\'t saved. Start a fresh copy with git checkout -b.">full</span>' : ''
792
- label.innerHTML = `${I.branch}<span class="branch-name">${esc(g.key)}</span>${tag ? `<span class="tag" title="${tag}">${tag}</span>` : ''}${behind}${full}`
793
- label.dataset.title = g.hold ? (g.hold.kind === 'switching' ? (g.hold.waiting === 'refused' ? `The session can't take ${g.hold.to || 'this branch'}, so this folder isn't syncing. Check out ${g.key} again in git to keep syncing there.` : `You checked out ${g.hold.to || 'another branch'} in git: this folder is moving to that branch's work in the session. The branch it left keeps its own.`) : g.hold.conflict ? `git left a conflict in ${g.hold.conflict.join(', ')} on this computer. Resolve it and git add it; Quilt then shares your resolution.` : 'Quilt waits for git to finish, then catches up.') : `This folder is on ${g.key}${up ? `, ${upstreamText(up)}` : ''}${g.full ? '. Over the size limit: new changes aren\'t saved.' : ''}.`
794
- }
795
- if (!$('#git-panel').hidden) renderBranchMenu()
700
+ label.hidden = true
701
+ label.innerHTML = ''
796
702
  }
797
703
  const people = [st.me, ...peopleHere(st)]
798
704
  const shown = people.slice(0, 4)
@@ -811,7 +717,6 @@ function renderTop () {
811
717
  renderAccess()
812
718
  renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
813
719
  renderSquareCard()
814
- renderGithubCard()
815
720
  renderGitChip()
816
721
  $('#sess-name').textContent = st.sessionName || basename(sum().dir)
817
722
  const me = state.account || {}
@@ -824,161 +729,35 @@ function renderTop () {
824
729
  $('#chat-sub').textContent = st.peers.length ? `with ${peopleHere(st).map((p) => p.sessions && p.mine ? 'your AI' : p.name).join(', ')}` : 'just you so far'
825
730
  }
826
731
 
827
- // --------------------------------------------------------------- commits --
828
- // Agents get their own work into git (git from their folders, signed in through the session's GitHub
829
- // connection; quilt_commit for agents without a folder), within what the owner lets them: picked
830
- // here. When they can't, they ask (commit.js): in a folder with git each
831
- // request has a Commit button (exactly its files, then a push). "Not committed yet" shows the
832
- // uncommitted work here by who made it, so nothing finished sits unshipped unnoticed.
833
- let commitGithub = null // the owner's GitHub connection (GET /api/github), while the panel is open
834
- // Your own work not committed here (GET mine-uncommitted: yours and your AIs', each boxed or not),
835
- // for the git chip's count and the panel. Nobody else's: they commit their own.
836
- let mineView = { id: null, data: null, at: 0, loading: false, again: false, open: new Set(['boxed']) }
837
- const MINE_FRESH_MS = 3000 // fetched again at most this often as the session changes
732
+ // --------------------------------------------------------------- source control --
733
+ // The chip in the top bar: in Seam, the square this folder follows and its work not committed yet
734
+ // (the panel commits and merges it); otherwise how many files changed in the session.
838
735
 
839
- function loadMine (force = false) {
840
- const id = current
841
- if (!id) return
842
- if (mineView.id !== id) mineView = { id, data: null, at: 0, loading: false, again: false, open: new Set(['boxed']) }
843
- if (mineView.loading) { mineView.again = true; return }
844
- const wait = MINE_FRESH_MS - (Date.now() - mineView.at)
845
- if (!force && wait > 0) { clearTimeout(loadMine.timer); loadMine.timer = setTimeout(() => loadMine(true), wait); return }
846
- mineView.loading = true
847
- api('GET', `/api/sessions/${id}/mine-uncommitted`).then((r) => {
848
- if (mineView.id !== id) return
849
- mineView.data = r && Array.isArray(r.files) ? r : null
850
- mineView.at = Date.now()
851
- renderGitChip()
852
- }, () => {}).finally(() => {
853
- if (mineView.id !== id) return
854
- mineView.loading = false
855
- if (mineView.again) { mineView.again = false; loadMine() }
856
- })
857
- }
858
-
859
- /** How many of your own files aren't committed here (null before the first look, or without git). */
860
- const mineCount = () => (mineView.id === current && mineView.data ? mineView.data.files.length : null)
861
- /** Session settings: GitHub sync, large files through the relay, and git for this session. */
862
- async function gitSettingChange (e) {
863
- if (e.target.matches('[data-large-to-relay]')) {
864
- try {
865
- const r = await api('POST', `/api/sessions/${current}/large-to-relay`, { on: e.target.checked })
866
- toast(r.largeToRelay ? 'The relay can commit large files on branches that sync with GitHub now.' : 'Large files stay end-to-end encrypted: the relay commits none.')
867
- } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
868
- return
869
- }
870
- if (e.target.matches('[data-github-sync]')) {
871
- try {
872
- const r = await api('POST', `/api/sessions/${current}/github-sync`, { on: e.target.checked })
873
- toast(r.githubSync === false ? 'Sync with GitHub is off for this session.' : 'This session syncs with GitHub.')
874
- } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
875
- return
876
- }
877
- const seg = e.target.closest('[data-agent-commits]')
878
- if (!seg || seg.disabled || seg.classList.contains('on')) return
879
- seg.closest('.seg').querySelectorAll('button').forEach((b) => { b.classList.toggle('on', b === seg); b.setAttribute('aria-pressed', String(b === seg)) })
880
- try {
881
- const r = await api('POST', `/api/sessions/${current}/agent-commits`, { mode: seg.dataset.agentCommits })
882
- toast(AGENT_COMMIT_WORDS[r.agentCommits])
883
- } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
884
- }
885
-
886
- /** Opens or closes the git panel: this folder's branch, the session against GitHub, and what changed, in one place. */
736
+ /** Opens or closes the source control panel: the square, and what changed, in one place. */
887
737
  function setGitOpen (open) {
888
738
  const panel = $('#git-panel')
889
739
  if (!panel) return
890
740
  panel.hidden = !open
891
741
  $('#git-chip').setAttribute('aria-expanded', String(open))
892
- if (open) { markChangesSeen(); renderBranchMenu(); renderCommitPanel(); loadCommitWork(); loadChanges().then(() => { if (!panel.hidden) markChangesSeen() }); loadMine(true) }
742
+ if (open) { markChangesSeen(); renderBranchMenu(); renderCommitPanel(); loadChanges().then(() => { if (!panel.hidden) markChangesSeen() }) }
893
743
  }
894
744
 
895
745
  function bindCommitChip () {
896
746
  const wrap = $('#git-wrap')
897
747
  const chip = $('#git-chip')
898
- const panel = $('#commit-panel')
899
748
  chip.onclick = () => setGitOpen($('#git-panel').hidden)
900
749
  wrap.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setGitOpen(false); chip.focus() } })
901
750
  document.addEventListener('mousedown', (e) => { if (!wrap.contains(e.target) && !e.target.closest('.modal, .dialog')) setGitOpen(false) }, { signal: mounted.signal })
902
- panel.addEventListener('submit', async (e) => {
903
- const form = e.target.closest('[data-commit-session]')
904
- if (!form) return
905
- e.preventDefault()
906
- const btn = form.querySelector('button')
907
- const input = form.querySelector('input')
908
- btn.disabled = true
909
- btn.textContent = 'Committing…'
910
- try {
911
- const r = await api('POST', `/api/sessions/${current}/commit-session`, { message: input.value, mine: true })
912
- toast(r.hash ? `Committed ${r.hash.slice(0, 7)}${r.pushed ? ' and pushed' : `; not pushed: ${r.pushError || 'no upstream'}`}` : 'Already in git')
913
- input.value = ''
914
- loadMine(true)
915
- } catch (err) { toast(err.message) } finally {
916
- btn.disabled = false
917
- btn.textContent = 'Commit and push'
918
- }
919
- })
920
- // Which of your two lines (boxed, shared) is open stays so across redraws.
921
- panel.addEventListener('toggle', (e) => {
922
- const d = e.target.closest && e.target.closest('[data-mine-group]')
923
- if (d) d.open ? mineView.open.add(d.dataset.mineGroup) : mineView.open.delete(d.dataset.mineGroup)
924
- }, true)
925
- panel.addEventListener('click', async (e) => {
926
- if (e.target.closest('[data-gh-connect]')) { connectGithub(); return }
927
- const live = e.target.closest('[data-open-live]')
928
- if (live) { live.disabled = true; openLiveSession(live.dataset.openLive).catch((err) => { toast(err.message); live.disabled = false }); return }
929
- const b = e.target.closest('[data-done], [data-commit]')
930
- if (!b) return
931
- b.disabled = true
932
- try {
933
- if (b.dataset.done) {
934
- const id = b.dataset.done === 'all' ? null : b.dataset.done
935
- const r = await api('POST', `/api/sessions/${current}/commit-request/done`, id ? { id } : {})
936
- toast(r.done === 1 ? 'Closed' : `Closed ${r.done}`)
937
- } else if (b.dataset.commit) {
938
- b.textContent = 'Committing…'
939
- const r = await api('POST', `/api/sessions/${current}/commit-request/commit`, { id: b.dataset.commit })
940
- toast(r.hash ? `Committed ${r.hash.slice(0, 7)}${r.pushed ? ' and pushed' : `; not pushed: ${r.pushError || 'no upstream'}`}` : 'Already in git: closed')
941
- }
942
- loadCommitWork()
943
- } catch (err) { toast(err.message); b.disabled = false; loadCommitWork() }
944
- })
945
- }
946
-
947
- async function loadCommitWork () {
948
- if ($('#git-panel').hidden) return
949
- const st = sum().status
950
- if (st.access?.owner) { try { commitGithub = await api('GET', '/api/github') } catch { commitGithub = null } }
951
- renderCommitPanel()
952
751
  }
953
752
 
954
- /**
955
- * The git chip: this folder's branch, how many files changed in the session, and a dot (with a
956
- * word when something needs someone) for the session against GitHub. One control for all three,
957
- * so the top bar has one place for source control.
958
- */
753
+ /** The source control chip: the square this folder follows (in Seam) and how many files changed. */
959
754
  function renderGitChip () {
960
755
  const chip = $('#git-chip')
961
756
  if (!chip || !sum()) return
962
757
  const st = sum().status
963
- const open = (st.commits || []).filter((r) => r.state === 'open')
964
- const gh = githubStateOf(st)
965
- const busy = busyPeople(st)
966
- // A branch that syncs live already has what it needs (a connection, or the owner's token).
967
- const liveOn = !!(myBranchOf(st)?.relay?.live)
968
- const off = !!(myBranchOf(st)?.relay?.sync?.state === 'paused' && /owner turned (live )?sync( with GitHub)? off/.test(myBranchOf(st).relay.sync.why || ''))
969
- const needs = gh && gh.state && gh.state !== 'ok' && gh.state !== 'not-github' && !liveOn && !off
970
- const live = syncWords(st)
971
- const warn = !!(live && live.warn && !open.length)
972
- chip.className = `git-chip${open.length && !busy.length ? ' ready' : ''}${(needs && !open.length) || warn ? ' needs' : ''}${!open.length && !needs && !warn ? ' quiet' : ''}`
973
- // Words only when someone has to act; otherwise the dot says where the session stands.
974
- const say = open.length ? `${open.length === 1 ? 'Commit requested' : 'Commits requested'}${busy.length ? ` · ${busy.length === 1 ? 'someone' : 'people'} working` : ''}`
975
- : needs ? 'Connect GitHub' : warn ? live.chip : ''
976
- const sayEl = $('#git-say')
977
- sayEl.hidden = !say
978
- sayEl.textContent = say
979
- const dot = $('#git-dot')
980
- dot.hidden = !!say || !(live && live.dot)
981
- dot.className = `sd${live && live.dot ? ` ${live.dot}` : ''}`
758
+ chip.className = 'git-chip quiet'
759
+ $('#git-say').hidden = true
760
+ $('#git-dot').hidden = true
982
761
  const branch = $('#git-branch')
983
762
  const seam = inSeam(st) ? seamChip(st) : null
984
763
  // In Seam the session's own record of changes stopped at the switch (its branch document is kept read-only):
@@ -992,171 +771,37 @@ function renderGitChip () {
992
771
  chip.title = [seam.title, seam.countLine, 'Click for your square.'].filter(Boolean).join('\n')
993
772
  chip.setAttribute('aria-label', seam.aria)
994
773
  $('#git-panel').setAttribute('aria-label', 'Square')
995
- if (!$('#git-panel').hidden) renderCommitPanel()
996
774
  return
997
775
  }
998
- // The number is your own work not committed (once that's known; before that, every file changed in
999
- // the session). What others changed since you last opened this panel goes in the tooltip, not the
1000
- // number, with the session's total.
1001
- const own = mineCount()
1002
- const { n, lines: counts } = chipCounts({ own, total: changedCount(), fresh: unseenChanges() })
776
+ const n = changedCount()
777
+ const fresh = unseenChanges()
1003
778
  $('#chg-count').textContent = n
1004
779
  $('#git-chg').hidden = !n
1005
780
  $('#git-sep').hidden = branch.hidden || !n
1006
- const lines = [
1007
- branch.hidden ? '' : branch.dataset.title,
1008
- ...counts,
1009
- open.length ? open.map((r) => `${shownName(authorOf(r), owners(sum()))}: ${r.message}`).join('\n') : live ? live.line : gh ? githubWords(gh) : ''
1010
- ].filter(Boolean)
1011
- $('#git-panel').setAttribute('aria-label', 'Branch, GitHub and changes')
1012
- chip.title = [...lines, 'Click for branches, GitHub and every change.'].join('\n')
1013
- chip.setAttribute('aria-label', `Source control${branch.hidden ? '' : `: ${st.git.key}`}${n ? own === null ? `, ${n} changed` : `, ${n} of yours not committed` : ''}${say ? `, ${say}` : live ? `, ${live.chip}` : ''}`)
1014
- if (!$('#git-panel').hidden) renderCommitPanel()
1015
- }
1016
-
1017
- /** Open requests (Commit, Close), the uncommitted work here by who made it, and committing by itself. */
781
+ $('#git-panel').setAttribute('aria-label', 'Changes')
782
+ chip.title = [n ? `${n} changed in the session` : '', fresh ? `${fresh} new since you last looked` : '', 'Click for every change.'].filter(Boolean).join('\n')
783
+ chip.setAttribute('aria-label', `Source control${n ? `, ${n} changed` : ''}`)
784
+ }
785
+
786
+ /** Nothing of git's to show under the square or the changes. */
1018
787
  function renderCommitPanel () {
1019
788
  const panel = $('#commit-panel')
1020
789
  if (!panel) return
1021
- const st = sum().status
1022
- const open = (st.commits || []).filter((r) => r.state === 'open')
1023
- const fileList = (files) => `<details><summary>${files.length} file${files.length === 1 ? '' : 's'}</summary><ul class="commit-files">${files.map((f) => `<li>${esc(f)}</li>`).join('')}</ul></details>`
1024
- const req = (r) => {
1025
- const why = r.error ? `Last try: ${r.error}` : ''
1026
- return `<li><b>${esc(shownName(authorOf(r), owners(sum())))}</b>${r.task ? ` <span class="hint">· task</span>` : ''}<div>${esc(r.message)}</div>
1027
- ${Array.isArray(r.files) && r.files.length ? fileList(r.files) : ''}
1028
- ${why ? `<p class="commit-why">${esc(why)}</p>` : ''}
1029
- <div class="commit-acts">${st.canCommit ? `<button type="button" class="btn sm" data-commit="${esc(r.id)}">Commit and push</button>` : ''}<button type="button" class="btn sm ghost" data-done="${esc(r.id)}" title="Close it without a commit from Quilt (committed by hand, or not needed)">Close</button></div></li>`
1030
- }
1031
- // Only what needs a person: an agent that may not commit itself asked for one, the session's
1032
- // work not on GitHub yet (and the commit that sends it), or GitHub to connect. Settings
1033
- // (sync, large files, what agents may commit) live in Session settings.
1034
- const html = `
1035
- ${open.length ? `<h4>Asked to be committed</h4><ul class="commit-reqs">${open.map(req).join('')}</ul>` : ''}
1036
- ${mineHtml(st)}
1037
- ${gitLineHtml(st)}
1038
- ${connectHtml(st)}
1039
- ${open.length > 1 ? '<div class="commit-foot"><button type="button" class="btn sm ghost" data-done="all">Close all</button></div>' : ''}`.trim()
1040
- panel.hidden = !html
1041
- // Status comes in often: redrawn only when something in it changed, so an open menu or a
1042
- // file list someone expanded stays as it is.
1043
- if (panel.dataset.html === html) return
1044
- panel.dataset.html = html
1045
- // A commit message being typed survives the redraw.
1046
- const typing = panel.querySelector('[data-commit-session] input')
1047
- const kept = typing ? { value: typing.value, focused: document.activeElement === typing, at: typing.selectionStart } : null
1048
- panel.innerHTML = html
1049
- const box = kept && panel.querySelector('[data-commit-session] input')
1050
- if (box) {
1051
- box.value = kept.value
1052
- if (kept.focused) { box.focus(); box.setSelectionRange(kept.at, kept.at) }
1053
- }
1054
- }
1055
-
1056
- /** This folder's branch in the relay's branch list (or the default branch), or null. */
1057
- function myBranchOf (st) {
1058
- const list = st.branches || []
1059
- return (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
1060
- }
1061
-
1062
- /** Where this branch stands with GitHub (GitHub as a node: relay-sync.js syncStatus), in a few words, with how to read it. */
1063
- function syncWords (st) {
1064
- if (inSeam(st)) return null
1065
- const b = myBranchOf(st)
1066
- const r = b && b.relay
1067
- const s = r && r.sync
1068
- if (!s) return null
1069
- const name = b.name
1070
- const at = s.revision ? ` at ${s.revision.slice(0, 7)}` : ''
1071
- const t = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1072
- if (s.state === 'synced') return { chip: 'Committed', dot: 'ok', line: `${name} is in step with GitHub${at}: everything in the session is committed, and pushes and merges there come in within seconds.`, warn: false }
1073
- if (s.state === 'github-pending') return { chip: 'Uncommitted changes', dot: 'edit', line: `${name} has GitHub's latest${at}. The session has changes that aren't committed: everyone here has them, and GitHub gets them when someone commits.`, warn: false, pending: true }
1074
- if (s.state === 'conflict-held') return { chip: 'Merge held', line: `${s.why} on ${name}: the session and GitHub changed the same lines. The merge is on the board; everything else keeps syncing.`, warn: true }
1075
- if (s.state === 'pr-pending') return { chip: s.pr ? `Through pull request #${s.pr.number}` : 'Through a pull request', line: r.leased ? `${name} syncs with GitHub in another session, so this session's commits go through` : `${name} is protected on GitHub, so the session's commits go through`, pr: s.pr || { number: 0, url: '' }, warn: false, leased: !!r.leased, liveIn: r.liveIn || null }
1076
- if (s.state === 'offline') return { chip: 'GitHub unreachable', line: `GitHub unreachable since ${s.since ? t(s.since) : 'a moment ago'}: the session keeps working, and GitHub's changes and the commits asked for go when it answers.`, warn: true }
1077
- if (s.state === 'permission-held') return { chip: 'Sync paused', line: s.why, warn: true }
1078
- if (/owner turned (live )?sync( with GitHub)? off/.test(s.why || '')) return { chip: 'Not syncing', line: `Sync with GitHub is off for this session: files stay in the session, and people commit with git.`, warn: false, off: true }
1079
- return { chip: 'Not syncing', line: `${(s.why || `sync of ${name} with GitHub is off`).replace(/^./, (c) => c.toUpperCase())}.`, warn: false, off: true }
1080
- }
1081
-
1082
- /**
1083
- * Your own work not committed here, in two lines (mockup option B): what is boxed (in your folder
1084
- * only so far) and what everyone already has; each opens to its files. Then the commit, which
1085
- * takes only these. Nobody else's work is counted or listed.
1086
- */
1087
- function mineHtml (st) {
1088
- const files = mineView.id === current && mineView.data ? mineView.data.files : null
1089
- if (!files) return ''
1090
- if (!files.length) return '<div class="mw-head"><h4>Not committed</h4></div><p class="mw-none">Nothing of yours: your work here is all in git.</p>'
1091
- const boxed = files.filter((f) => f.boxed)
1092
- const shared = files.filter((f) => !f.boxed)
1093
- const group = (key, list, ico, label, hint) => list.length
1094
- ? `<details class="mw" data-mine-group="${key}"${mineView.open.has(key) ? ' open' : ''}><summary><span class="mw-ico">${ico}</span><span>${list.length} ${label}</span><span class="hint">${hint}</span></summary><ul>${list.map((f) => `<li><code title="${esc(f.path)}">${esc(f.path)}</code></li>`).join('')}</ul></details>`
1095
- : ''
1096
- return `<div class="mw-head"><h4>Not committed</h4></div>
1097
- ${group('boxed', boxed, I.box, 'boxed', 'only in your folder')}
1098
- ${group('shared', shared, '<span class="mw-dot"></span>', 'shared', 'everyone has them')}
1099
- ${st.canCommit ? `<form class="cp-commit" data-commit-session><input class="input" name="message" placeholder="Commit message" aria-label="Commit message" maxlength="500" required autocomplete="off"><button type="submit" class="btn sm primary">Commit and push</button></form>` : ''}`
1100
- }
1101
-
1102
- /** The session against GitHub in a line (the long words on hover), and the commit that sends what isn't there yet. */
1103
- function gitLineHtml (st) {
1104
- const w = syncWords(st)
1105
- if (!w || w.off) return ''
1106
- const b = myBranchOf(st)
1107
- // Work not on GitHub yet: what is yours shows above (mineHtml); everyone else's is theirs to commit.
1108
- if (w.pending) return ''
1109
- const pr = w.pr ? (w.pr.url ? ` · <a href="${esc(w.pr.url)}" target="_blank" rel="noreferrer">pull request #${w.pr.number}</a>` : '') : ''
1110
- const live = w.liveIn ? ` <button type="button" class="btn sm" data-open-live="${esc(w.liveIn)}">Open the live session</button>` : ''
1111
- return `<p class="cp-line${w.warn ? ' warn' : ''}" title="${esc(w.line)}">${w.dot ? `<span class="sd ${w.dot}"></span>` : ''}<span>${esc(w.chip)}</span>${pr}${live}</p>`
1112
- }
1113
-
1114
- /** Connect GitHub, while nobody here has (one click, by anyone who can write to the repository). */
1115
- function connectHtml (st) {
1116
- const gh = githubStateOf(st)
1117
- if (!gh || !gh.state || gh.state === 'ok' || gh.state === 'not-github' || myBranchOf(st)?.relay?.live) return ''
1118
- return `<p class="cp-line"><button type="button" class="btn sm" data-gh-connect>${gh.state === 'not-installed' ? 'Add it on GitHub' : 'Connect GitHub'}</button><span class="hint">so git is on for everyone here, agents included</span></p>`
1119
- }
1120
-
1121
- /** What else the git card says under the control: the read-only token, or why git isn't working yet. */
1122
- function agentCommitNote (st) {
1123
- const gh = githubStateOf(st)
1124
- if (st.access.githubToken) return 'Agents without a folder commit with the session\'s GitHub token.'
1125
- if (gh && gh.state && gh.state !== 'ok') return `${esc(githubWords(gh))} Connect GitHub from the branch button in the top bar.`
1126
- if (!gh) return 'This session doesn\'t follow a GitHub repository yet.'
1127
- return ''
790
+ panel.hidden = true
791
+ panel.innerHTML = ''
792
+ delete panel.dataset.html
1128
793
  }
1129
794
 
1130
- /**
1131
- * Git for this session (mockup option A): one segmented control that fills its card, the rule over the
1132
- * session as a pill (who set it), the choices it doesn't allow greyed with why, and who git works through.
1133
- */
1134
- function gitCardHtml (st) {
1135
- const by = st.agentCommitsBy
1136
- const m = gitControl({ value: st.agentCommits || 'branches', rule: by && by.mode ? by : null })
1137
- const g = st.gitForEveryone
1138
- const line = g && g.words ? gitStatusParts(g.words) : null
1139
- const note = agentCommitNote(st)
1140
- return `<div class="pm-card git-card">
1141
- <div class="git-head"><h4>This session's source control</h4>${m.rulePill ? `<span class="pill rule">${esc(m.rulePill)}</span>` : '<span class="pill">No rule above it</span>'}</div>
1142
- ${gitSegHtml(m, { attr: 'data-agent-commits', label: 'This session\'s source control' })}
1143
- ${line ? `<p class="git-line ${g.policy === 'off' ? 'off' : g.on ? 'ok' : ''}"><b>${esc(line.strong)}</b>${esc(line.rest)}</p>` : ''}
1144
- ${m.lockedHint ? `<span class="hint">${esc(m.lockedHint)}</span>` : ''}
1145
- ${note ? `<span class="hint">${note}</span>` : ''}
1146
- </div>`
1147
- }
1148
-
1149
- /** Session settings, for the owner: syncing with GitHub, large files, and git for this session. */
1150
795
  /**
1151
796
  * "Make this a quilt" (Seam's plans/quilt-source-control/16, flag 9), for the session's owner while its
1152
- * code is still on git: one button. With no git remote the relay makes the quilt and moves the files
797
+ * code is not in Seam yet: one button. With no git remote the relay makes the quilt and moves the files
1153
798
  * into its squares at once; with one, it becomes a quilt with its history (makeQuiltWords).
1154
799
  */
1155
800
  function makeQuiltHtml (st) {
1156
801
  if (inSeam(st)) return ''
1157
802
  // Everyone hears that live sync stopped at the forced date; only the owner gets the button.
1158
803
  if (!st.access?.owner) return st.force && st.force.stopped ? `<div class="pm-section"><div class="pm-title">Seam</div><div class="pm-card git-card"><p class="git-line warn">${esc(st.force.stopped.why || '')}</p></div></div>` : ''
1159
- const words = makeQuiltWords({ quilt: st.quilt || null, remote: !!githubStateOf(st) || !!(st.git && st.git.upstream), moving: !!st.moving, force: st.force || null, importing: st.importing || null, shadow: st.shadow || null })
804
+ const words = makeQuiltWords({ quilt: st.quilt || null, remote: !!(st.quilt && st.quilt.hasRemote) || !!(st.force && (st.force.remote || st.force.stopped)), moving: !!st.moving, force: st.force || null, importing: st.importing || null, shadow: st.shadow || null })
1160
805
  return `<div class="pm-section"><div class="pm-title">Seam</div><div class="pm-card git-card">
1161
806
  <div class="git-head"><h4>Make this a quilt</h4><span class="pill">Seam</span></div>
1162
807
  ${words.note ? `<p class="git-line warn">${esc(words.note)}</p>` : ''}
@@ -1186,54 +831,6 @@ async function makeQuilt (btn) {
1186
831
  } finally { renderSessionSettings({ force: true }) }
1187
832
  }
1188
833
 
1189
- function githubSettingsHtml (st) {
1190
- if (!st.access?.owner) return ''
1191
- const sync = st.access.githubSync
1192
- const rows = [
1193
- sync !== undefined ? `<label class="pm-switch"><span><b>Sync with GitHub</b><small>Pushes and merges on GitHub come into the session within seconds; nothing goes to GitHub until someone commits.</small></span><input type="checkbox" role="switch" data-github-sync ${sync !== false ? 'checked' : ''}></label>` : '',
1194
- sync !== undefined && sync !== false ? `<label class="pm-switch"><span><b>Let the relay commit large files</b><small>Large files are end-to-end encrypted. On branches that sync with GitHub this lets the relay read them, so a commit can include them.</small></span><input type="checkbox" role="switch" data-large-to-relay ${st.access.largeToRelay ? 'checked' : ''}></label>` : ''
1195
- ].filter(Boolean).join('')
1196
- return `<div class="pm-section"><div class="pm-title">GitHub</div>${gitCardHtml(st)}${rows ? `<div class="pm-card pm-settings">${rows}</div>` : ''}</div>`
1197
- }
1198
-
1199
- /** Opens session `room`: the one already open in this app, else a recent one on this computer. */
1200
- async function openLiveSession (room) {
1201
- for (const [id, s] of state.sessions) if (s && s.status && s.status.room === room) return go(id)
1202
- const r = (state.recent || []).find((x) => x.room === room)
1203
- if (!r) { toast('That session isn\'t on this computer: ask its owner for an invite.'); return }
1204
- const opened = await api('POST', '/api/sessions', { mode: 'rejoin', dir: r.dir })
1205
- state.sessions.set(opened.id, opened)
1206
- await go(opened.id)
1207
- }
1208
-
1209
- /** The session's repository on GitHub and whether a member's connection lets agents commit there, from the relay's branch list. */
1210
- function githubStateOf (st) {
1211
- if (inSeam(st)) return null // GitHub only receives Seam's export of main: nothing to connect or sync here
1212
- const list = st.branches || []
1213
- const b = (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
1214
- const r = b && b.relay
1215
- if (!r || !r.repo || !/^github\.com\//.test(r.repo)) return null
1216
- return { repo: r.repo.replace(/^github\.com\//, ''), state: r.github ? r.github.state : '', login: r.github ? r.github.login : '' }
1217
- }
1218
-
1219
- /** The connection, in a sentence. */
1220
- function githubWords (gh) {
1221
- if (!gh) return ''
1222
- if (gh.state === 'ok') return `Git is on for everyone here, through @${gh.login}'s connection: people and agents push to ${gh.repo} with git, nothing to set up (a short-lived token each), and pushes come in within seconds. To keep a branch safe for good, protect it on GitHub.`
1223
- if (gh.state === 'not-installed') return `Quilt isn't installed on ${gh.repo} on GitHub yet.`
1224
- if (gh.state === 'no-access') return `No connected GitHub account (@${gh.login}) may write to ${gh.repo}.`
1225
- if (gh.state === 'not-connected') return `Nobody here has connected GitHub for ${gh.repo} yet.`
1226
- return `Checking ${gh.repo}…`
1227
- }
1228
-
1229
- // --------------------------------------------------------- GitHub card --
1230
- // A session on a GitHub repository that no member has connected yet: one card at the top, for
1231
- // people, with the one click that lets agents commit and merge on their own. "Not now" keeps it
1232
- // away for a week; the commit chip still says "Connect GitHub".
1233
- const GH_DISMISS_MS = 7 * 24 * 60 * 60 * 1000
1234
- const ghDismissKey = (repo) => `quilt.github.notnow.${repo}`
1235
- let ghConnecting = null // the repo a Connect click is waiting on (polling the account)
1236
-
1237
834
  /**
1238
835
  * In a session that is a quilt at layout 3: one plain line on where this folder's work goes (the
1239
836
  * square it follows, and how to commit it; session-follow.js), or why an edit reached nobody.
@@ -1250,86 +847,12 @@ function renderSquareCard () {
1250
847
  el.innerHTML = html
1251
848
  }
1252
849
 
1253
- function renderGithubCard () {
1254
- const el = $('#github-card')
1255
- if (!el) return
1256
- const st = sum().status
1257
- const gh = githubStateOf(st)
1258
- const dismissed = gh && recall(ghDismissKey(gh.repo))
1259
- const show = gh && ['not-connected', 'not-installed', 'no-access'].includes(gh.state) && !(dismissed && Date.now() - Number(dismissed) < GH_DISMISS_MS)
1260
- el.hidden = !show
1261
- if (!show) { el._html = ''; return }
1262
- const action = gh.state === 'not-installed' ? 'Add it on GitHub' : 'Connect GitHub'
1263
- const html = `<div class="gh-card">
1264
- <div class="gh-text"><b>${esc(gh.state === 'not-installed' ? `Quilt isn't on ${gh.repo} yet` : gh.state === 'no-access' ? `@${gh.login} can't write to ${gh.repo}` : `This session follows ${gh.repo}`)}</b>
1265
- <span>${esc(gh.state === 'not-installed' ? 'Add the repository to Quilt on GitHub, and agents here commit and merge on their own; pushes come in within seconds.' : gh.state === 'no-access' ? 'Someone whose GitHub account can write to it connects once, and that covers everyone here, agents included.' : 'Connect GitHub once (one click, by anyone whose GitHub account can write to it): agents here then commit and merge on their own, and pushes come in within seconds. Nobody else has to connect.')}</span></div>
1266
- <div class="gh-acts">${ghConnecting === gh.repo ? `<span class="hint">${loadingNote('Finish on GitHub, then come back…')}</span>` : `<button type="button" class="btn sm primary" data-gh-connect>${action}</button>`}<button type="button" class="btn sm ghost" data-gh-later>Not now</button></div>
1267
- </div>`
1268
- if (el._html === html) return
1269
- el._html = html
1270
- el.innerHTML = html
1271
- }
1272
-
1273
850
  /** The square card's button: a folder whose branch has no square opens one (layout 3). */
1274
851
  function bindSquareCard () {
1275
852
  const el = $('#square-card')
1276
853
  if (el) el.addEventListener('click', (e) => { if (e.target.closest('[data-square-open]') && !squareBusy) squareAct('open_square') })
1277
854
  }
1278
855
 
1279
- function bindGithubCard () {
1280
- const el = $('#github-card')
1281
- if (!el) return
1282
- el.addEventListener('click', (e) => {
1283
- if (e.target.closest('[data-gh-connect]')) return connectGithub()
1284
- const later = e.target.closest('[data-gh-later]')
1285
- if (!later) return
1286
- const gh = githubStateOf(sum().status)
1287
- if (gh) remember(ghDismissKey(gh.repo), String(Date.now()))
1288
- renderGithubCard()
1289
- })
1290
- }
1291
-
1292
- /** Opens GitHub's install page for this session's repository, then watches for the connection and tells the relay to look again. */
1293
- async function connectGithub () {
1294
- const gh = githubStateOf(sum().status)
1295
- try {
1296
- const { url } = await api('POST', '/api/github/connect', gh ? { repo: gh.repo } : {})
1297
- window.open(url, '_blank', 'noopener')
1298
- toast('Finish on GitHub, then come back')
1299
- } catch (err) { toast(err.message); return }
1300
- if (!gh) return
1301
- ghConnecting = gh.repo
1302
- renderGithubCard()
1303
- const id = current
1304
- const until = Date.now() + 10 * 60 * 1000
1305
- let was = null
1306
- try { was = (await api('GET', '/api/github')).login || null } catch {}
1307
- while (Date.now() < until && current === id && ghConnecting === gh.repo) {
1308
- await new Promise((r) => setTimeout(r, 3000))
1309
- let now = null
1310
- try { now = (await api('GET', '/api/github')).login || null } catch {}
1311
- if (!now || now === was) continue
1312
- // Connected (or re-connected): the relay checks the repository again, and the card and chip follow.
1313
- try {
1314
- const r = await api('POST', `/api/sessions/${id}/github/recheck`)
1315
- const s = r && r.github && r.github.state
1316
- toast(s === 'ok' ? `Connected as @${now}: agents can commit here now` : s === 'not-installed' ? `Connected as @${now}, but ${gh.repo} wasn't picked: add it on GitHub` : s === 'no-access' ? `@${now} can't write to ${gh.repo}` : `Connected as @${now}`)
1317
- } catch (err) { toast(err.message) }
1318
- break
1319
- }
1320
- if (ghConnecting === gh.repo) ghConnecting = null
1321
- renderGithubCard()
1322
- }
1323
-
1324
- async function askForCommit () {
1325
- const message = await ask({ title: 'Ask for a commit', message: 'Of everything you changed that is not in git yet. A person with git on this branch commits it, and you are told the commit.', ok: 'Ask', input: { label: 'What is the commit for?', placeholder: 'Pricing page and download button' } })
1326
- if (!message) return
1327
- try {
1328
- const r = await api('POST', `/api/sessions/${current}/commit-request`, { message: message.trim() })
1329
- toast(`Asked for a commit of ${r.files.length} file${r.files.length === 1 ? '' : 's'}`)
1330
- } catch (err) { toast(err.message) }
1331
- }
1332
-
1333
856
  // ----------------------------------------------------------------- access --
1334
857
  const roleLabel = (r) => r === 'owner' ? 'Owner' : r === 'viewer' ? 'View only' : 'Can edit'
1335
858
  const scopesText = (scopes) => (scopes || []).join(', ')
@@ -1485,23 +1008,6 @@ function membersHtml (st, kind = 'people') {
1485
1008
  <div class="pm-foot"><button type="button" class="btn sm ghost danger" data-end-session>End session for everyone</button></div>`
1486
1009
  }
1487
1010
 
1488
- /**
1489
- * The owner's GitHub token: the relay brings commits in from a private repository with it while
1490
- * nobody's folder is online, and commits the work of agents without a folder with it (quilt_commit) when it can write.
1491
- * Never shown back: only whether one is set.
1492
- */
1493
- export function githubTokenHtml (acc) {
1494
- if (!acc || !acc.owner) return ''
1495
- const set = !!acc.githubToken
1496
- return `<div class="pm-section"><div class="pm-title">GitHub token</div>
1497
- <form class="pm-gh pm-admit" autocomplete="off">
1498
- <label for="pm-gh-token"><span>GitHub token for this session's repository</span></label>
1499
- <input class="input" id="pm-gh-token" type="password" name="token" autocomplete="off" spellcheck="false" placeholder="${set ? 'A token is set: paste another to replace it' : 'github_pat_… for this repository'}" aria-label="GitHub token">
1500
- <div class="pm-gh-row"><button type="submit" class="btn sm">Save token</button>${set ? '<button type="button" class="btn sm ghost" data-gh-clear>Remove token</button>' : ''}</div>
1501
- <div class="hint">With "Contents: Read" Quilt brings in commits while everyone's offline (private repositories need it). With "Contents: Read and write" (and "Pull requests: Read and write") agents can commit their own work, within what you let them above, without anyone online. It stays on the relay and is never shown to anyone, you included.</div>
1502
- </form></div>`
1503
- }
1504
-
1505
1011
  /** With workspaces on, the agents this session's workspace invites (and the ones not invited), for its People. */
1506
1012
  async function loadSessionAgents () {
1507
1013
  const id = current
@@ -1908,10 +1414,10 @@ function renderSessionSettings ({ force = false } = {}) {
1908
1414
  const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
1909
1415
  if (!force && active && box.querySelector('.pm-member.edit')) return
1910
1416
  const refocus = active && active.name ? [active.closest('.pm-member').dataset.key, active.name] : null
1911
- // In Seam the git and GitHub settings are put away (the relay keeps them for a move back): where the history is instead.
1912
- const vc = inSeam(st) ? seamSettingsHtml(st) : makeQuiltHtml(st) + githubSettingsHtml(st) + githubTokenHtml(st.access)
1417
+ // Source control is Seam's: where the history is (or, for the owner of a session not in Seam yet, the button that moves it there).
1418
+ const vc = inSeam(st) ? seamSettingsHtml(st) : makeQuiltHtml(st)
1913
1419
  box.innerHTML = ssTab === 'chat' ? sharingHtml(st)
1914
- : ssTab === 'vc' ? (vc || '<div class="pm-empty">Only the session owner changes how this session works with GitHub.</div>')
1420
+ : ssTab === 'vc' ? (vc || '<div class="pm-empty">Only the session owner sees where this session\'s history is.</div>')
1915
1421
  : membersHtml(st, ssTab)
1916
1422
  if (refocus) [...box.querySelectorAll('.pm-member')].find((f) => f.dataset.key === refocus[0])?.elements?.[refocus[1]]?.focus()
1917
1423
  }