heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
package/src/ui/session.js CHANGED
@@ -6,7 +6,7 @@ import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor,
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'
9
- import { buildLoom, foldAiLanes } from './loom-model.js'
9
+ import { buildLoom, foldAiLanes, aiWorking } from './loom-model.js'
10
10
  import { railModel } from './rail-model.js'
11
11
  import { renderRail, openLocks, closeLocks } from './rail.js'
12
12
  import { feedbackDockHtml, bindFeedback } from './feedback.js'
@@ -14,6 +14,7 @@ import { conversations } from './feed-convs.js'
14
14
  import { closeTreeMenu } from './tree.js'
15
15
  import { renderFileView, renderFileTabs } from './fileview.js'
16
16
  import { rolledOff, diffLines } from './diffview.js'
17
+ import { searchMarkup, searchTabMarkup, liveByPerson, fileReadsNeeded, SEARCH_ICON } from './quiltview.js'
17
18
  import { changesMarkup, bindChanges, unbindChanges, changesChanged, fileChanges, loadChanges, changedCount, unseenChanges, markChangesSeen } from './changes.js'
18
19
  import { chipCounts } from './changes-seen.js'
19
20
  import { quiltMark } from './mark.js'
@@ -25,10 +26,10 @@ import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFa
25
26
  import { workspaceFilePicker } from './files.js'
26
27
  import { sessionAgentsHtml } from './agent-place.js'
27
28
  import { renderCatchUp, bindCatchUp } from './catchup.js'
28
- import { branchMenuHtml, upstreamText, needsHand } from './branches.js'
29
29
  import { boxSummary, boxesOf, unplaced, boxesSig, boxListHtml, boxFoldHtml } from './boxes.js'
30
30
  import { gitControl, gitStatusParts, gitSegHtml } from './git-rule.js'
31
- import { callButtonMarkup, callPinMarkup, mountCall, unmountCall, renderCall, callCards } from './voice.js'
31
+ import { inSeam, makeQuiltWords, seamChip, squareCard, squarePanelHtml, seamSettingsHtml, openSquareButton, squaresSideHtml, SEAM_SIDE_HINT } from './seam-model.js'
32
+ import { callButtonMarkup, callPinMarkup, screenStageMarkup, mountCall, unmountCall, renderCall, callCards } from './voice.js'
32
33
 
33
34
  let current = null // session id being shown
34
35
  let timers = []
@@ -55,7 +56,9 @@ function ws (id) {
55
56
  convSel: {}, // person -> pinned conversation id (absent: follow the newest)
56
57
  fileTabs: [],
57
58
  fileSel: null,
58
- fileTab: {}, // file path -> 'changes' when its Changes tab is shown (absent: the file)
59
+ fileTab: {}, // file path -> 'changes' or 'blame' when that tab is shown (absent: the file)
60
+ searchTabs: [], // code searches open as tabs, in a session linked to a quilt (quiltview.js)
61
+ searchSel: null,
59
62
  expanded: {},
60
63
  boxesCollapsed: false, // the Boxes pane (who is working on what) starts open; folded, it is a rail of locks (rail.js)
61
64
  stale: {}, // file path -> changed while not visible
@@ -137,6 +140,7 @@ export function mountSession (id) {
137
140
  <button class="pop-item" role="menuitem" id="leave-btn">Leave this session</button>
138
141
  </div>
139
142
  </div>
143
+ <form class="qt-topsearch" id="qt-topsearch" role="search" hidden>${SEARCH_ICON}<input id="qt-topsearch-q" name="q" placeholder="Search the code on main" aria-label="Search the code on main" autocomplete="off" spellcheck="false"></form>
140
144
  <span class="spacer"></span>
141
145
  <span class="relay-problem" id="relay-problem" role="status" hidden></span>
142
146
  <span class="access-pill" id="access-pill" hidden></span>
@@ -183,12 +187,14 @@ export function mountSession (id) {
183
187
  </aside>
184
188
  <main class="ws-main">
185
189
  <div class="requests catchup" id="catchup" role="region" aria-label="While you were away" hidden></div>
190
+ <div class="requests square" id="square-card" role="status" aria-label="Seam" hidden></div>
186
191
  <div class="requests github" id="github-card" role="region" aria-label="GitHub" hidden></div>
187
192
  <div class="requests" id="requests" hidden></div>
188
193
  <div class="ws-mainbar" id="mainbar" hidden>
189
194
  <div class="ws-tabs" id="main-tabs" role="tablist"></div>
190
195
  </div>
191
196
  <div class="ws-content" id="main"></div>
197
+ ${screenStageMarkup()}
192
198
  </main>
193
199
  <aside class="ws-chat" id="chat-pane" aria-label="Chat">
194
200
  <div class="chat-resize" id="chat-resize" role="separator" aria-orientation="vertical" aria-label="Resize chat" title="Drag to resize · double-click to collapse" tabindex="0"></div>
@@ -228,6 +234,7 @@ export function mountSession (id) {
228
234
  // Report a bug / Suggest a feature at the bottom of the Boxes pane (feedback.js): onto this session's board.
229
235
  bindFeedback($('.ws-boxes'), { session: () => current, where: () => state.sessions.get(current)?.status?.sessionName || 'a session' })
230
236
  bindGithubCard()
237
+ bindSquareCard()
231
238
  bindMain()
232
239
  bindTreeEvents()
233
240
  applyTreeCollapsed()
@@ -390,6 +397,15 @@ function bindMenu (btn, menu, onPick) {
390
397
  }
391
398
 
392
399
  function bindTop () {
400
+ $('#qt-topsearch').onsubmit = (e) => {
401
+ e.preventDefault()
402
+ const box = $('#qt-topsearch-q')
403
+ const q = box.value.trim()
404
+ if (!q) return
405
+ box.value = ''
406
+ box.blur()
407
+ openSearch(q)
408
+ }
393
409
  $('#tasks-btn').onclick = () => {
394
410
  const w = ws(current)
395
411
  if (w.mode === 'tasks') w.mode = w.aiSel ? 'ai' : w.fileSel ? 'files' : 'ai'
@@ -420,6 +436,8 @@ function bindTop () {
420
436
  value: st.agentCommits || 'branches',
421
437
  rule: st.agentCommitsBy && st.agentCommitsBy.mode ? st.agentCommitsBy : null,
422
438
  words: st.gitForEveryone && st.gitForEveryone.words,
439
+ // In Seam the card says so instead of git's choice (git-rule.js thisSessionGitHtml).
440
+ ...(inSeam(st) ? { seam: st.quilt.slug || st.quilt.id } : {}),
423
441
  open: (tab) => openSessionSettings(tab)
424
442
  }
425
443
  })
@@ -478,7 +496,7 @@ function bindTop () {
478
496
  /** The Session settings window's controls: your AI chat's sharing, and (for the owner) who can get in and what they may do. */
479
497
  function bindSessionSettings (root) {
480
498
  root.addEventListener('change', (e) => { if (e.target.matches('[data-github-sync], [data-large-to-relay]')) gitSettingChange(e) })
481
- root.addEventListener('click', (e) => { if (e.target.closest('[data-gh-connect]')) connectGithub(); else if (e.target.closest('[data-agent-commits]')) 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) })
482
500
  root.addEventListener('change', async (e) => {
483
501
  if (e.target.matches('[data-share]')) {
484
502
  const on = e.target.checked
@@ -637,19 +655,99 @@ function agentLine (p) {
637
655
  // ------------------------------------------------------------- branches --
638
656
  let branchSyncing = false
639
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
+ let squareBusy = '' // 'commit' or 'merge' while the square panel's button runs (layout 3)
695
+
640
696
  function renderBranchMenu () {
641
697
  const st = sum().status
642
698
  const menu = $('#branch-menu')
643
- menu.hidden = !st.git && !(st.branches || []).length
644
- // Inside the git panel the branch list folds, so the changes below stay in reach; kept open across redraws.
645
- const open = !!menu.querySelector('details.br-all[open]')
646
- menu.innerHTML = branchMenuHtml({ git: st.git, branches: st.branches || [], me: st.me.name, syncing: branchSyncing, fold: true })
647
- if (open) menu.querySelector('details.br-all')?.setAttribute('open', '')
699
+ // In Seam: the square this folder follows, and committing and merging it. The branch documents kept
700
+ // for a move back are read-only, and git is set aside here: none of them is listed.
701
+ if (inSeam(st)) {
702
+ menu.hidden = false
703
+ const html = squarePanelHtml(st, { busy: squareBusy })
704
+ if (menu.dataset.html === html) return
705
+ // A commit message being typed survives the redraw.
706
+ const typing = menu.querySelector('[data-square-commit] input')
707
+ const kept = typing ? { value: typing.value, focused: document.activeElement === typing } : null
708
+ menu.dataset.html = html
709
+ menu.innerHTML = html
710
+ const box = kept && menu.querySelector('[data-square-commit] input')
711
+ if (box) { box.value = kept.value; if (kept.focused) box.focus() }
712
+ return
713
+ }
714
+ delete menu.dataset.html
715
+ menu.hidden = !st.git
716
+ menu.innerHTML = folderBranchHtml({ git: st.git, syncing: branchSyncing })
717
+ }
718
+
719
+ /** The square panel's Commit and Merge onto main (layout 3): Seam's own tools, as this member (control.js seamTool). */
720
+ async function squareAct (tool, message = '') {
721
+ const id = current
722
+ squareBusy = tool === 'seam_merge' ? 'merge' : tool === 'open_square' ? 'open' : 'commit'
723
+ renderBranchMenu()
724
+ renderSquareCard()
725
+ try {
726
+ const r = await api('POST', `/api/sessions/${id}/seam`, { tool, args: message ? { message } : {} })
727
+ toast(r.text, 8000)
728
+ return true
729
+ } catch (err) { toast(err.message, 8000); return false } finally {
730
+ squareBusy = ''
731
+ if (id === current && !$('#git-panel').hidden) renderBranchMenu()
732
+ if (id === current) renderSquareCard()
733
+ }
648
734
  }
649
735
 
650
736
  function bindBranchMenu () {
651
737
  const menu = $('#branch-menu')
738
+ menu.addEventListener('submit', async (e) => {
739
+ const form = e.target.closest('[data-square-commit]')
740
+ if (!form) return
741
+ e.preventDefault()
742
+ const input = form.querySelector('input')
743
+ const message = input.value.trim()
744
+ if (!message || squareBusy) return
745
+ if (await squareAct('seam_commit', message)) { input.value = ''; const now = menu.querySelector('[data-square-commit] input'); if (now) now.value = '' }
746
+ })
652
747
  menu.addEventListener('click', async (e) => {
748
+ // Merge commits what isn't committed yet first: with the words typed in the box, if any.
749
+ 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 }
653
751
  if (!e.target.closest('[data-branch-sync]') || branchSyncing) return
654
752
  branchSyncing = true
655
753
  renderBranchMenu()
@@ -665,9 +763,21 @@ function renderTop () {
665
763
  if (!current || !$('#people-btn')) return
666
764
  renderTabs()
667
765
  const st = sum().status
766
+ // Code search, only in a session linked to a quilt (the relay reads Seam for it).
767
+ const search = $('#qt-topsearch')
768
+ if (search) search.hidden = !st.quilt
668
769
  const g = st.git
669
770
  const label = $('#git-branch')
670
- if (label) {
771
+ 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
+ if (label && seam) {
775
+ // In Seam: the square this folder follows, by the name Seam gives it.
776
+ label.hidden = !seam.label
777
+ label.innerHTML = seam.label ? `<span class="branch-name">${esc(seam.label)}</span>` : ''
778
+ label.dataset.title = seam.title
779
+ if (!$('#git-panel').hidden) renderBranchMenu()
780
+ } else if (label) {
671
781
  label.hidden = !g
672
782
  if (g) {
673
783
  // Moving to another branch: said at once. Git busy: only once it has lasted a moment.
@@ -700,6 +810,7 @@ function renderTop () {
700
810
  renderCall()
701
811
  renderAccess()
702
812
  renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
813
+ renderSquareCard()
703
814
  renderGithubCard()
704
815
  renderGitChip()
705
816
  $('#sess-name').textContent = st.sessionName || basename(sum().dir)
@@ -868,12 +979,27 @@ function renderGitChip () {
868
979
  const dot = $('#git-dot')
869
980
  dot.hidden = !!say || !(live && live.dot)
870
981
  dot.className = `sd${live && live.dot ? ` ${live.dot}` : ''}`
982
+ const branch = $('#git-branch')
983
+ const seam = inSeam(st) ? seamChip(st) : null
984
+ // In Seam the session's own record of changes stopped at the switch (its branch document is kept read-only):
985
+ // the number is the square's work not committed yet, and the panel lists that instead.
986
+ const chg = $('#chg-panel')
987
+ if (chg) chg.hidden = !!seam
988
+ if (seam) {
989
+ $('#chg-count').textContent = seam.count
990
+ $('#git-chg').hidden = !seam.count
991
+ $('#git-sep').hidden = branch.hidden || !seam.count
992
+ chip.title = [seam.title, seam.countLine, 'Click for your square.'].filter(Boolean).join('\n')
993
+ chip.setAttribute('aria-label', seam.aria)
994
+ $('#git-panel').setAttribute('aria-label', 'Square')
995
+ if (!$('#git-panel').hidden) renderCommitPanel()
996
+ return
997
+ }
871
998
  // The number is your own work not committed (once that's known; before that, every file changed in
872
999
  // the session). What others changed since you last opened this panel goes in the tooltip, not the
873
1000
  // number, with the session's total.
874
1001
  const own = mineCount()
875
1002
  const { n, lines: counts } = chipCounts({ own, total: changedCount(), fresh: unseenChanges() })
876
- const branch = $('#git-branch')
877
1003
  $('#chg-count').textContent = n
878
1004
  $('#git-chg').hidden = !n
879
1005
  $('#git-sep').hidden = branch.hidden || !n
@@ -882,6 +1008,7 @@ function renderGitChip () {
882
1008
  ...counts,
883
1009
  open.length ? open.map((r) => `${shownName(authorOf(r), owners(sum()))}: ${r.message}`).join('\n') : live ? live.line : gh ? githubWords(gh) : ''
884
1010
  ].filter(Boolean)
1011
+ $('#git-panel').setAttribute('aria-label', 'Branch, GitHub and changes')
885
1012
  chip.title = [...lines, 'Click for branches, GitHub and every change.'].join('\n')
886
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}` : ''}`)
887
1014
  if (!$('#git-panel').hidden) renderCommitPanel()
@@ -934,6 +1061,7 @@ function myBranchOf (st) {
934
1061
 
935
1062
  /** Where this branch stands with GitHub (GitHub as a node: relay-sync.js syncStatus), in a few words, with how to read it. */
936
1063
  function syncWords (st) {
1064
+ if (inSeam(st)) return null
937
1065
  const b = myBranchOf(st)
938
1066
  const r = b && b.relay
939
1067
  const s = r && r.sync
@@ -1019,6 +1147,45 @@ function gitCardHtml (st) {
1019
1147
  }
1020
1148
 
1021
1149
  /** Session settings, for the owner: syncing with GitHub, large files, and git for this session. */
1150
+ /**
1151
+ * "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
1153
+ * into its squares at once; with one, it becomes a quilt with its history (makeQuiltWords).
1154
+ */
1155
+ function makeQuiltHtml (st) {
1156
+ if (inSeam(st)) return ''
1157
+ // Everyone hears that live sync stopped at the forced date; only the owner gets the button.
1158
+ 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 })
1160
+ return `<div class="pm-section"><div class="pm-title">Seam</div><div class="pm-card git-card">
1161
+ <div class="git-head"><h4>Make this a quilt</h4><span class="pill">Seam</span></div>
1162
+ ${words.note ? `<p class="git-line warn">${esc(words.note)}</p>` : ''}
1163
+ <p class="git-line">${esc(words.text)}</p>
1164
+ ${makeQuiltSteps ? `<div class="git-line"><p>${esc(makeQuiltSteps[0])}</p>${makeQuiltSteps.length > 1 ? `<ol>${makeQuiltSteps.slice(1).map((l) => `<li>${esc(l.replace(/^\d+\. /, ''))}</li>`).join('')}</ol>` : ''}</div>` : ''}
1165
+ <button type="button" class="btn primary" data-make-quilt ${words.disabled ? 'disabled' : ''}>${esc(words.button)}</button>
1166
+ </div></div>`
1167
+ }
1168
+
1169
+ /** The last answer's steps (lines), shown on the card: what the owner or the repository's GitHub owner does next. */
1170
+ let makeQuiltSteps = null
1171
+
1172
+ async function makeQuilt (btn) {
1173
+ makeQuiltSteps = null
1174
+ btn.disabled = true
1175
+ btn.textContent = 'Moving into Seam…'
1176
+ try {
1177
+ const r = await api('POST', `/api/sessions/${current}/quilt-make`, {})
1178
+ const lines = String(r.text || 'The session is a quilt now.').split('\n')
1179
+ // Steps for someone to take (the GitHub owner's ruleset) stay on the card until the next click; a plain answer is a toast.
1180
+ if (lines.length > 2 && r.quilt && r.quilt.stage) makeQuiltSteps = lines
1181
+ toast(lines[0])
1182
+ } catch (err) {
1183
+ const lines = String(err.message).split('\n')
1184
+ if (lines.length > 1) makeQuiltSteps = lines
1185
+ toast(lines[0])
1186
+ } finally { renderSessionSettings({ force: true }) }
1187
+ }
1188
+
1022
1189
  function githubSettingsHtml (st) {
1023
1190
  if (!st.access?.owner) return ''
1024
1191
  const sync = st.access.githubSync
@@ -1041,6 +1208,7 @@ async function openLiveSession (room) {
1041
1208
 
1042
1209
  /** The session's repository on GitHub and whether a member's connection lets agents commit there, from the relay's branch list. */
1043
1210
  function githubStateOf (st) {
1211
+ if (inSeam(st)) return null // GitHub only receives Seam's export of main: nothing to connect or sync here
1044
1212
  const list = st.branches || []
1045
1213
  const b = (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
1046
1214
  const r = b && b.relay
@@ -1066,6 +1234,22 @@ const GH_DISMISS_MS = 7 * 24 * 60 * 60 * 1000
1066
1234
  const ghDismissKey = (repo) => `quilt.github.notnow.${repo}`
1067
1235
  let ghConnecting = null // the repo a Connect click is waiting on (polling the account)
1068
1236
 
1237
+ /**
1238
+ * In a session that is a quilt at layout 3: one plain line on where this folder's work goes (the
1239
+ * square it follows, and how to commit it; session-follow.js), or why an edit reached nobody.
1240
+ */
1241
+ function renderSquareCard () {
1242
+ const el = $('#square-card')
1243
+ if (!el) return
1244
+ const st = sum().status
1245
+ const card = squareCard(st)
1246
+ el.hidden = !card
1247
+ const html = card ? `<div class="gh-card"><div class="gh-text"><b>${esc(card.title)}</b><span>${esc(card.text)}</span></div>${card.open && st.access?.role !== 'viewer' ? `<div class="gh-acts">${openSquareButton(st, squareBusy)}</div>` : ''}</div>` : ''
1248
+ if (el._html === html) return
1249
+ el._html = html
1250
+ el.innerHTML = html
1251
+ }
1252
+
1069
1253
  function renderGithubCard () {
1070
1254
  const el = $('#github-card')
1071
1255
  if (!el) return
@@ -1086,6 +1270,12 @@ function renderGithubCard () {
1086
1270
  el.innerHTML = html
1087
1271
  }
1088
1272
 
1273
+ /** The square card's button: a folder whose branch has no square opens one (layout 3). */
1274
+ function bindSquareCard () {
1275
+ const el = $('#square-card')
1276
+ if (el) el.addEventListener('click', (e) => { if (e.target.closest('[data-square-open]') && !squareBusy) squareAct('open_square') })
1277
+ }
1278
+
1089
1279
  function bindGithubCard () {
1090
1280
  const el = $('#github-card')
1091
1281
  if (!el) return
@@ -1472,7 +1662,7 @@ function peopleRows (st, { fold = false } = {}) {
1472
1662
  return person(`<div class="pm-row">
1473
1663
  <button class="pm-open" data-person="${esc(p.name)}" title="Open ${p.isMe ? 'your' : `${esc(p.name)}'s`} AI chat">${avatar(p.name, p.color, p.online)}
1474
1664
  <span class="pm-main">
1475
- <span class="pm-name">${esc(p.isMe ? `${p.name} (you)` : p.name)}${p.voice ? `<span class="tag call-tag" title="In the voice call${p.voice.muted ? ', muted' : ''}">${p.voice.muted ? I.micOff : I.mic}call</span>` : ''}${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}${toolsOf(p).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
1665
+ <span class="pm-name">${esc(p.isMe ? `${p.name} (you)` : p.name)}${p.voice ? `<span class="tag call-tag" title="In the voice call${p.voice.muted ? ', muted' : ''}${p.voice.sharing ? ', sharing a screen' : ''}">${p.voice.sharing ? I.screenShare : p.voice.muted ? I.micOff : I.mic}call</span>` : ''}${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}${toolsOf(p).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
1476
1666
  ${p.focus && !p.isMe ? `<span class="pm-sub pm-what">${esc(p.focus)}</span>` : ''}
1477
1667
  ${editing}
1478
1668
  ${agentLine(p) ? `<span class="pm-sub">${agentLine(p)}</span>` : ''}
@@ -1542,15 +1732,20 @@ function renderBoxesSide () {
1542
1732
  if (!el || !s) return
1543
1733
  const st = s.status
1544
1734
  const { selfRow, myAiRow, otherRows, awayRows } = peopleRows(st, { fold: true })
1545
- const n = boxView.data.boxes.length
1735
+ // In Seam there are no boxes (every edit is live in its square): the pane shows the squares and who is on each.
1736
+ const seam = inSeam(st)
1737
+ const title = el.closest('.ws-boxes')?.querySelector('.pane-title')
1738
+ if (title) title.textContent = seam ? 'Squares' : 'Boxes'
1739
+ el.closest('.ws-boxes')?.setAttribute('aria-label', seam ? 'Squares: who is on which, and what is not committed' : 'Boxes: who is working on what')
1740
+ const n = seam ? 0 : boxView.data.boxes.length
1546
1741
  const count = $('#box-count')
1547
1742
  if (count) { count.textContent = n ? String(n) : ''; count.title = n ? `${n} file${n === 1 ? '' : 's'} boxed: changed, not in everyone's folder yet` : '' }
1548
- const html = `<div class="pm-list bs-list">
1743
+ const html = `${seam ? squaresSideHtml(st) : ''}<div class="pm-list bs-list">
1549
1744
  ${selfRow}${myAiRow}
1550
1745
  ${otherRows}
1551
1746
  ${awayRows.length ? `<div class="pm-sep"></div><div class="bs-head">Away</div>${awayRows.join('')}` : ''}
1552
1747
  </div>
1553
- <p class="bs-hint">${boxView.data.on ? 'A box is a file someone is changing that isn\'t in everyone\'s folder yet. Click someone\'s boxes to see the files, and a file to see its change.' : 'What everyone is working on. Their unfinished files show here as boxes.'}</p>`
1748
+ <p class="bs-hint">${seam ? SEAM_SIDE_HINT : boxView.data.on ? 'A box is a file someone is changing that isn\'t in everyone\'s folder yet. Click someone\'s boxes to see the files, and a file to see its change.' : 'What everyone is working on. Their unfinished files show here as boxes.'}</p>`
1554
1749
  if (el.dataset.html === html) return
1555
1750
  const top = el.scrollTop
1556
1751
  el.innerHTML = html
@@ -1713,7 +1908,8 @@ function renderSessionSettings ({ force = false } = {}) {
1713
1908
  const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
1714
1909
  if (!force && active && box.querySelector('.pm-member.edit')) return
1715
1910
  const refocus = active && active.name ? [active.closest('.pm-member').dataset.key, active.name] : null
1716
- const vc = githubSettingsHtml(st) + githubTokenHtml(st.access)
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)
1717
1913
  box.innerHTML = ssTab === 'chat' ? sharingHtml(st)
1718
1914
  : ssTab === 'vc' ? (vc || '<div class="pm-empty">Only the session owner changes how this session works with GitHub.</div>')
1719
1915
  : membersHtml(st, ssTab)
@@ -1727,6 +1923,12 @@ function bindMain () {
1727
1923
  const tab = e.target.closest('[data-tab]')
1728
1924
  const w = ws(current)
1729
1925
  if (tab && tab.dataset.kind === 'loom') { openLoom(); return }
1926
+ if (close && close.dataset.kind === 'search') {
1927
+ e.stopPropagation()
1928
+ closeSearch(close.dataset.close)
1929
+ return
1930
+ }
1931
+ if (tab && tab.dataset.kind === 'search') { openSearch(tab.dataset.tab); return }
1730
1932
  if (close) {
1731
1933
  e.stopPropagation()
1732
1934
  const key = close.dataset.close
@@ -1763,10 +1965,27 @@ function bindMain () {
1763
1965
  if (c) pickConv(c.dataset.conv)
1764
1966
  const t = e.target.closest('[data-fvtab]')
1765
1967
  if (t) showFileTab(t.dataset.fvtab)
1968
+ // A file's History and Blame, and search hits, in a session linked to a quilt.
1969
+ const qp = e.target.closest('[data-qt-patch]')
1970
+ if (qp) togglePatch(ws(current).fileSel, qp.dataset.qtPatch)
1971
+ const qb = e.target.closest('[data-qt-blame]')
1972
+ if (qb) showPatch(ws(current).fileSel, qb.dataset.qtBlame)
1973
+ const qf = e.target.closest('[data-qt-file]')
1974
+ if (qf) openFile(qf.dataset.qtFile, { tab: 'changes' })
1975
+ const qh = e.target.closest('[data-qt-hit]')
1976
+ if (qh) openFile(qh.dataset.qtHit, { tab: 'file', line: Number(qh.dataset.line) || null })
1766
1977
  const f = e.target.closest('[data-fv]')
1767
1978
  if (f && f.dataset.fv === 'claim') claimPath(ws(current).fileSel, '')
1768
1979
  if (f && f.dataset.fv === 'release') releasePattern(f.dataset.pattern)
1769
1980
  })
1981
+ // Searching again from a search tab's own field.
1982
+ $('#main').addEventListener('submit', (e) => {
1983
+ const form = e.target.closest('[data-qt-search-form]')
1984
+ if (!form) return
1985
+ e.preventDefault()
1986
+ const q = String(new FormData(form).get('q') || '').trim()
1987
+ if (q) openSearch(q, { replace: ws(current).searchSel })
1988
+ })
1770
1989
  }
1771
1990
 
1772
1991
  function openLoom () {
@@ -1805,8 +2024,8 @@ function pickConv (conv) {
1805
2024
  renderMain()
1806
2025
  }
1807
2026
 
1808
- /** Opens a file tab; `tab: 'changes'` shows its Changes tab, otherwise the tab it was on. */
1809
- function openFile (path, { tab } = {}) {
2027
+ /** Opens a file tab; `tab: 'changes'` shows its Changes tab, otherwise the tab it was on. `line`: bring that line into view (a search hit). */
2028
+ function openFile (path, { tab, line = null } = {}) {
1810
2029
  const w = ws(current)
1811
2030
  if (!w.fileTabs.includes(path)) w.fileTabs.push(path)
1812
2031
  w.fileSel = path
@@ -1814,7 +2033,10 @@ function openFile (path, { tab } = {}) {
1814
2033
  if (!w.fileTab) w.fileTab = {}
1815
2034
  if (tab === 'changes') w.fileTab[path] = 'changes'
1816
2035
  else if (tab === 'file') delete w.fileTab[path]
2036
+ if (line) goLine.set(fileKey(path), line)
2037
+ else goLine.delete(fileKey(path))
1817
2038
  loadHistory(path)
2039
+ if (fileTabOf(path) === 'blame') loadBlame(path)
1818
2040
  const wasStale = w.stale[path]
1819
2041
  delete w.stale[path]
1820
2042
  saveWs(current)
@@ -1833,22 +2055,24 @@ function renderMainBar () {
1833
2055
  const fileOn = (path) => w.mode === 'files' && w.fileSel === path
1834
2056
  const loomOn = w.mode === 'ai' && !w.aiSel
1835
2057
  const st = sum().status
1836
- const anyWorking = [st.me, ...st.peers].some((p) => p?.agent && p.agent.sharing !== false && p.agent.status === 'working')
2058
+ const anyWorking = [st.me, ...st.peers].some((p) => !!p && aiWorking(p))
1837
2059
  const loomTab = `<div class="ws-tab loom-tab${loomOn ? ' on' : ''}" role="tab" aria-selected="${loomOn}" tabindex="0" data-kind="loom" data-tab="loom" title="Everyone's AI conversations, chat and task notes at once">
1838
2060
  <span class="ico">${I.users}</span><span class="nm">Everyone</span>${anyWorking ? '<span class="pulse" title="An AI is working"></span>' : ''}</div>`
1839
2061
  el.innerHTML = loomTab + (w.aiTabs.length ? '<span class="ws-tab-sep"></span>' : '') + w.aiTabs.map((name) => {
1840
2062
  const p = personInfo(name)
1841
- const working = p.agent && p.agent.sharing !== false && p.agent.status === 'working'
2063
+ const working = aiWorking(p)
1842
2064
  return `<div class="ws-tab${aiOn(name) ? ' on' : ''}" role="tab" aria-selected="${aiOn(name)}" tabindex="0" data-kind="ai" data-tab="${esc(name)}" title="${esc(p.isMe ? 'Your AI chat' : `${name}'s AI chat`)}">
1843
2065
  ${avatar(name, p.color, p.online)}<span class="nm">${esc(p.isMe ? 'Your AI' : `${name}'s AI`)}</span>${working ? '<span class="pulse" title="AI is working"></span>' : ''}
1844
2066
  <button class="x" data-kind="ai" data-close="${esc(name)}" aria-label="Close ${esc(name)}">${I.x}</button></div>`
1845
2067
  }).join('') + (w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
1846
2068
  w.fileTabs.map((path) => `<div class="ws-tab${fileOn(path) ? ' on' : ''}" role="tab" aria-selected="${fileOn(path)}" tabindex="0" data-kind="file" data-tab="${esc(path)}" title="${esc(path)}">
1847
2069
  <span class="ico">${I.file}</span><span class="nm">${esc(basename(path))}</span>${w.stale[path] ? '<span class="changed" title="Changed"></span>' : ''}
1848
- <button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('')
2070
+ <button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('') +
2071
+ ((w.searchTabs || []).length && !w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
2072
+ (w.searchTabs || []).map((q) => searchTabMarkup(q, w.mode === 'search' && w.searchSel === q)).join('')
1849
2073
  // On Everyone with nothing else open, the row would only name the view you are in: hide it until
1850
2074
  // there is a tab to switch to (or, on the Tasks board, Everyone to go back to).
1851
- $('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length
2075
+ $('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length && !(w.searchTabs || []).length
1852
2076
  }
1853
2077
 
1854
2078
  // A card being renamed, or a drag in progress, must not be rebuilt under the pointer.
@@ -2490,6 +2714,17 @@ function renderMain () {
2490
2714
  return
2491
2715
  }
2492
2716
  if (w.mode === 'merge') w.mode = 'ai' // a compare tab from before Quilt merged on its own
2717
+ if (w.mode === 'search' && !(w.searchSel && (w.searchTabs || []).includes(w.searchSel))) w.mode = w.fileSel ? 'files' : 'ai'
2718
+ if (w.mode === 'search') {
2719
+ if (!searches.has(`${current}\n${w.searchSel}`)) loadSearch(w.searchSel) // a tab put back after a reload
2720
+ const html = searchMarkup({ q: w.searchSel, result: searches.get(`${current}\n${w.searchSel}`) || null })
2721
+ // Status ticks redraw the main area: an unchanged search keeps its field, focus and scroll.
2722
+ if (el.firstElementChild && el.firstElementChild.classList.contains('qt-search') && el.dataset.qtSearch === html) return
2723
+ el.innerHTML = html
2724
+ el.dataset.qtSearch = html
2725
+ return
2726
+ }
2727
+ delete el.dataset.qtSearch
2493
2728
  if (w.mode === 'ai') {
2494
2729
  if (!w.aiSel) {
2495
2730
  const people = st.peers
@@ -2522,11 +2757,17 @@ function renderMain () {
2522
2757
  } else {
2523
2758
  if (!w.fileSel) {
2524
2759
  el.innerHTML = `<div class="main-empty"><div class="ill">${I.file}</div><h3>Open a file</h3>
2525
- <p class="hint">Open one from the git panel's changes, or a box's change from the Boxes list. Lines your partners change light up as they happen.</p></div>`
2760
+ <p class="hint">${inSeam(sum().status) ? 'Search the code at the top and open a file from there, or open one from the Boxes list.' : 'Open one from the git panel\'s changes, or a box\'s change from the Boxes list. Lines your partners change light up as they happen.'}</p></div>`
2526
2761
  return
2527
2762
  }
2528
2763
  const cached = state.files.get(fileKey(w.fileSel))
2529
2764
  if (!cached) loadFileOnce(w.fileSel) // a tab put back after a reload has nothing read yet
2765
+ // A tab put back after a reload, on any segment, reads what that segment shows.
2766
+ const fk = fileKey(w.fileSel)
2767
+ for (const need of fileReadsNeeded({ tab: fileTabOf(w.fileSel), linked: quiltLinked(), history: histories.get(fk), blame: blames.has(fk) ? blames.get(fk) : undefined, busy: historyLoads.has(fk) ? new Set(['history']) : undefined })) {
2768
+ if (need === 'history') loadHistory(w.fileSel)
2769
+ else loadBlame(w.fileSel)
2770
+ }
2530
2771
  renderFileView(el, {
2531
2772
  path: w.fileSel,
2532
2773
  file: cached ? cached.file : null,
@@ -2535,6 +2776,8 @@ function renderMain () {
2535
2776
  prevText: cached ? cached.prevText : null,
2536
2777
  tab: fileTabOf(w.fileSel),
2537
2778
  history: histories.get(fileKey(w.fileSel)) || null,
2779
+ quilt: quiltFileView(w.fileSel),
2780
+ line: takeLine(w.fileSel, cached),
2538
2781
  summary: fileChanges(w.fileSel),
2539
2782
  people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color },
2540
2783
  chatHref: treeMeta(w.fileSel)?.chat ? fileCardHref(current, treeMeta(w.fileSel).chat.id, TOKEN) : null,
@@ -2547,18 +2790,147 @@ function renderMain () {
2547
2790
  /** Links to a session's file bytes for the preview, or to download them (GET /api/sessions/<id>/raw). */
2548
2791
  const rawHrefFor = (id) => (path, download = false) => `/api/sessions/${encodeURIComponent(id)}/raw?path=${encodeURIComponent(path)}&t=${encodeURIComponent(TOKEN || '')}${download ? '&download=1' : ''}`
2549
2792
  const fileKey = (path) => `${current}\n${path}`
2550
- const fileTabOf = (path) => (ws(current).fileTab || {})[path] === 'changes' ? 'changes' : 'file'
2793
+ const fileTabOf = (path) => {
2794
+ const t = (ws(current).fileTab || {})[path]
2795
+ return t === 'changes' ? 'changes' : t === 'blame' && quiltLinked() ? 'blame' : 'file'
2796
+ }
2551
2797
 
2552
2798
  function showFileTab (tab) {
2553
2799
  const w = ws(current)
2554
2800
  if (!w.fileSel) return
2555
2801
  if (!w.fileTab) w.fileTab = {}
2556
- if (tab === 'changes') w.fileTab[w.fileSel] = 'changes'
2802
+ if (tab === 'changes' || tab === 'blame') w.fileTab[w.fileSel] = tab
2557
2803
  else delete w.fileTab[w.fileSel]
2558
2804
  saveWs(current)
2559
2805
  renderMain()
2560
2806
  $('#main .fv-tabs [aria-selected="true"]')?.focus()
2561
2807
  if (tab === 'changes') loadHistory(w.fileSel)
2808
+ if (tab === 'blame') loadBlame(w.fileSel)
2809
+ }
2810
+
2811
+ // ------------------------------------- History, Blame and search (a linked quilt) --
2812
+ // In a session linked to a quilt (Seam), the relay reads it for the app: a file's patches, who
2813
+ // wrote each line, and code search over main (src/server.js quiltBlame, quiltSearch, quiltShow).
2814
+ const quiltLinked = () => !!sum().status.quilt
2815
+ const patchLists = new Map() // fileKey -> { patches, quilt, note } (absent: the session isn't linked)
2816
+ const openPatches = new Map() // fileKey -> the History row opened: a commit, or live:<name>
2817
+ const patchData = new Map() // `${session}\n${commit}\n${path}` -> { files, diff } or { error }
2818
+ const blames = new Map() // fileKey -> the relay's blame answer
2819
+ const blameTimers = new Map()
2820
+ const searches = new Map() // `${session}\n${q}` -> the relay's search answer
2821
+ const goLine = new Map() // fileKey -> a line to bring into view once (a search hit)
2822
+
2823
+ /** What the file view shows of a linked file's History and Blame, or null when the session isn't linked. */
2824
+ function quiltFileView (path) {
2825
+ if (!quiltLinked()) return null
2826
+ const list = patchLists.get(fileKey(path)) || { patches: null, quilt: sum().status.quilt }
2827
+ const open = openPatches.get(fileKey(path)) || null
2828
+ const live = liveByPerson(histories.get(fileKey(path)) || [])
2829
+ return {
2830
+ ...list,
2831
+ open,
2832
+ shown: open && !open.startsWith('live:') ? patchData.get(`${current}\n${open}\n${path}`) || null : null,
2833
+ blame: blames.get(fileKey(path)) || null,
2834
+ liveBy: live.length ? live[0].by : (treeMeta(path)?.edited?.by || '')
2835
+ }
2836
+ }
2837
+
2838
+ /** The line a search hit opened the file at, given once its text is there. */
2839
+ function takeLine (path, cached) {
2840
+ const line = goLine.get(fileKey(path))
2841
+ if (!line || !cached || !cached.file || fileTabOf(path) !== 'file') return null
2842
+ goLine.delete(fileKey(path))
2843
+ return line
2844
+ }
2845
+
2846
+ /** Opens or closes a row of a file's History; a patch's diff is read the first time. */
2847
+ function togglePatch (path, which) {
2848
+ if (!path) return
2849
+ const key = fileKey(path)
2850
+ if (openPatches.get(key) === which) openPatches.delete(key)
2851
+ else openPatches.set(key, which)
2852
+ renderMain()
2853
+ if (openPatches.get(key) === which && !which.startsWith('live:')) loadPatch(path, which)
2854
+ }
2855
+
2856
+ /** Blame's patch number: that patch, opened in the file's History. */
2857
+ function showPatch (path, commit) {
2858
+ if (!path) return
2859
+ openPatches.set(fileKey(path), commit)
2860
+ showFileTab('changes')
2861
+ loadPatch(path, commit)
2862
+ requestAnimationFrame(() => $('#main .qt-patch.on')?.scrollIntoView({ block: 'nearest' }))
2863
+ }
2864
+
2865
+ async function loadPatch (path, commit) {
2866
+ const id = current
2867
+ const k = `${id}\n${commit}\n${path}`
2868
+ if (patchData.has(k)) return
2869
+ let shown
2870
+ try {
2871
+ const r = await api('GET', `/api/sessions/${id}/patch?commit=${encodeURIComponent(commit)}&path=${encodeURIComponent(path)}`)
2872
+ shown = r.patch ? r.patch : r.working ? null : { error: r.quilt === null ? 'This session is no longer linked to a quilt.' : 'Seam did not send this patch.' }
2873
+ if (!shown) { setTimeout(() => { if (id === current && openPatches.get(`${id}\n${path}`) === commit) loadPatch(path, commit) }, 2000); return }
2874
+ } catch (err) { shown = { error: err.message } }
2875
+ patchData.set(k, shown)
2876
+ if (id === current && ws(id).fileSel === path && fileTabOf(path) === 'changes') renderMain()
2877
+ }
2878
+
2879
+ /** Who wrote each line of a file: asked again every 2 s while Seam works it out the first time. */
2880
+ async function loadBlame (path, tries = 0) {
2881
+ const id = current
2882
+ const key = fileKey(path)
2883
+ clearTimeout(blameTimers.get(key))
2884
+ if (!blames.has(key)) blames.set(key, null) // being read
2885
+ let r
2886
+ try { r = await api('GET', `/api/sessions/${id}/blame?path=${encodeURIComponent(path)}`) } catch (err) { r = { error: err.message } }
2887
+ if (id !== current) return
2888
+ if (r.quilt === null) r = { error: 'This session is no longer linked to a quilt.' }
2889
+ blames.set(key, r)
2890
+ if (r.working && tries < 60) blameTimers.set(key, setTimeout(() => { if (id === current && ws(id).fileSel === path && fileTabOf(path) === 'blame') loadBlame(path, tries + 1) }, 2000))
2891
+ if (ws(id).fileSel === path && fileTabOf(path) === 'blame') renderMain()
2892
+ }
2893
+
2894
+ /** Opens a code search as a tab (in place of `replace`, a search searched again from its own field). */
2895
+ async function openSearch (q, { replace = null } = {}) {
2896
+ const w = ws(current)
2897
+ if (!w.searchTabs) w.searchTabs = []
2898
+ const at = replace ? w.searchTabs.indexOf(replace) : -1
2899
+ if (!w.searchTabs.includes(q)) { if (at >= 0) w.searchTabs.splice(at, 1, q); else w.searchTabs.push(q) } else if (at >= 0 && replace !== q) w.searchTabs.splice(at, 1)
2900
+ w.searchSel = q
2901
+ w.mode = 'search'
2902
+ saveWs(current)
2903
+ renderMainBar()
2904
+ renderMain()
2905
+ renderTreePane()
2906
+ loadSearch(q)
2907
+ }
2908
+
2909
+ async function loadSearch (q, tries = 0) {
2910
+ const id = current
2911
+ const k = `${id}\n${q}`
2912
+ if (!tries && searches.has(k) && !searches.get(k)?.error) return
2913
+ if (!tries) searches.set(k, null) // being read
2914
+ let r
2915
+ try { r = await api('GET', `/api/sessions/${id}/search?q=${encodeURIComponent(q)}`) } catch (err) { r = { error: err.message } }
2916
+ searches.set(k, r)
2917
+ if (r.working && tries < 30) setTimeout(() => { if (ws(id).searchTabs?.includes(q)) loadSearch(q, tries + 1) }, 2000)
2918
+ if (id === current && ws(id).mode === 'search' && ws(id).searchSel === q) renderMain()
2919
+ }
2920
+
2921
+ function closeSearch (q) {
2922
+ const w = ws(current)
2923
+ const i = (w.searchTabs || []).indexOf(q)
2924
+ if (i !== -1) w.searchTabs.splice(i, 1)
2925
+ searches.delete(`${current}\n${q}`)
2926
+ if (w.searchSel === q) {
2927
+ w.searchSel = w.searchTabs[Math.min(i, w.searchTabs.length - 1)] || null
2928
+ if (!w.searchSel && w.mode === 'search') w.mode = w.fileSel ? 'files' : 'ai'
2929
+ }
2930
+ saveWs(current)
2931
+ renderMainBar()
2932
+ renderMain()
2933
+ renderTreePane()
2562
2934
  }
2563
2935
 
2564
2936
  // ------------------------------------------------------- a file's changes --
@@ -2571,17 +2943,27 @@ function scheduleHistory (path) {
2571
2943
  historyTimers.set(path, setTimeout(() => { historyTimers.delete(path); loadHistory(path) }, 500))
2572
2944
  }
2573
2945
 
2946
+ const historyLoads = new Set() // fileKey -> its history is being read (or failed just now: asked again 5 s later)
2574
2947
  async function loadHistory (path) {
2575
2948
  const id = current
2949
+ const key = `${id}\n${path}`
2950
+ historyLoads.add(key)
2951
+ let read = false
2576
2952
  try {
2577
- const { entries } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
2953
+ const { entries, patches, quilt, note } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
2578
2954
  if (id !== current) return
2579
2955
  histories.set(`${id}\n${path}`, entries)
2956
+ read = true
2957
+ if (patches) patchLists.set(`${id}\n${path}`, { patches, quilt, ...(note ? { note } : {}) })
2958
+ else patchLists.delete(`${id}\n${path}`)
2580
2959
  const w = ws(id)
2581
2960
  if (w.mode !== 'files' || w.fileSel !== path) return
2582
- if (fileTabOf(path) === 'changes') renderMain()
2583
- else renderFileTabs($('#main'), { tab: 'file', history: entries, summary: fileChanges(path) }) // the count only: freshly changed lines stay lit
2584
- } catch {}
2961
+ if (fileTabOf(path) !== 'file') renderMain()
2962
+ else renderFileTabs($('#main'), { tab: 'file', history: entries, summary: fileChanges(path), quilt: quiltFileView(path) }) // the count only: freshly changed lines stay lit
2963
+ } catch {} finally {
2964
+ if (read) historyLoads.delete(key)
2965
+ else setTimeout(() => historyLoads.delete(key), 5000)
2966
+ }
2585
2967
  }
2586
2968
 
2587
2969
  function treeMeta (path) {
@@ -3201,7 +3583,7 @@ function bindMentions (input) {
3201
3583
  const update = () => {
3202
3584
  const s = sum()
3203
3585
  if (!s) return close()
3204
- const asks = relayPrompts(input.value, { owner: !!s.status.access?.owner })
3586
+ const asks = relayPrompts(input.value, { owner: !!s.status.access?.owner, seam: inSeam(s.status) })
3205
3587
  if (asks.length) { at = null; return show(asks.map((q) => ({ question: q.text }))) }
3206
3588
  const found = mentionAt(input.value, input.selectionStart)
3207
3589
  if (!found) return close()