heyquilt 0.3.90 → 0.3.107

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 (84) hide show
  1. package/README.md +14 -11
  2. package/RELEASES.md +135 -2
  3. package/bin/quilt.js +45 -12
  4. package/package.json +3 -3
  5. package/src/account.js +28 -0
  6. package/src/agents/claude-code.js +11 -4
  7. package/src/agents/cursor-transcripts.js +11 -4
  8. package/src/ai-vm-signin.js +14 -2
  9. package/src/ai-vm.js +150 -8
  10. package/src/api/agent-auth.js +9 -3
  11. package/src/api/fly-machines.js +72 -15
  12. package/src/api/memory-store.js +142 -6
  13. package/src/api/relay-cluster.js +337 -43
  14. package/src/api/relay-machines.js +35 -3
  15. package/src/api/releases.js +87 -0
  16. package/src/api/routes/agents.js +111 -16
  17. package/src/api/routes/ai-vm.js +146 -19
  18. package/src/api/routes/orgs.js +3 -1
  19. package/src/api/routes/secrets.js +448 -0
  20. package/src/api/routes/workspaces.js +8 -1
  21. package/src/api/sandbox-keys.js +57 -0
  22. package/src/api/secrets-access.js +93 -0
  23. package/src/api/server.js +40 -9
  24. package/src/api/supabase-store.js +121 -11
  25. package/src/autowork.js +3 -15
  26. package/src/catchup.js +1 -1
  27. package/src/clash.js +10 -22
  28. package/src/control.js +18 -15
  29. package/src/fsutil.js +11 -1
  30. package/src/gitstate.js +29 -5
  31. package/src/hooks.js +1 -1
  32. package/src/mcp-proxy.js +7 -2
  33. package/src/mcp.js +22 -25
  34. package/src/merge3.js +94 -12
  35. package/src/pass-source.js +9 -2
  36. package/src/pathrules.js +7 -0
  37. package/src/relay-clash.js +101 -484
  38. package/src/relay-githttp.js +36 -7
  39. package/src/relay-mcp.js +19 -7
  40. package/src/relay-metrics.js +66 -1
  41. package/src/relay-outbound.js +154 -25
  42. package/src/relay-sync.js +133 -136
  43. package/src/relay-turn-sync.js +250 -51
  44. package/src/relay-upstream.js +49 -179
  45. package/src/releases.js +16 -9
  46. package/src/runner.js +3 -2
  47. package/src/secret-import.js +55 -0
  48. package/src/secret-names.js +26 -0
  49. package/src/secret-run.js +80 -0
  50. package/src/secrets-client.js +56 -0
  51. package/src/server.js +71 -44
  52. package/src/session.js +347 -262
  53. package/src/status.js +3 -5
  54. package/src/sync-words.js +1 -1
  55. package/src/turn-sync-plan.js +13 -10
  56. package/src/turn-sync.js +32 -11
  57. package/src/ui/agent-guide.js +4 -4
  58. package/src/ui/app.css +208 -23
  59. package/src/ui/app.js +4 -0
  60. package/src/ui/cloud-ai-model.js +3 -2
  61. package/src/ui/common.js +2 -0
  62. package/src/ui/fileview.js +28 -1
  63. package/src/ui/home.js +32 -33
  64. package/src/ui/loom.css +18 -2
  65. package/src/ui/loom.js +41 -8
  66. package/src/ui/rail-model.js +6 -9
  67. package/src/ui/releases.js +1 -1
  68. package/src/ui/sandbox-keys-model.js +53 -0
  69. package/src/ui/sandbox-keys.js +132 -0
  70. package/src/ui/secret-rules.js +255 -0
  71. package/src/ui/secrets-view.js +162 -0
  72. package/src/ui/secrets.js +283 -0
  73. package/src/ui/session.js +47 -149
  74. package/src/ui/tree.js +23 -18
  75. package/src/ui/workspaces.js +5 -0
  76. package/src/ui-server.js +140 -28
  77. package/src/update-check.js +1 -1
  78. package/src/upstream.js +75 -38
  79. package/src/vault/crypto.js +96 -0
  80. package/src/vault/kms.js +61 -0
  81. package/src/vault/main.js +33 -0
  82. package/src/vault/server.js +279 -0
  83. package/src/vault/sigv4.js +38 -0
  84. package/src/ui/merges.js +0 -158
package/src/ui/session.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // The session workspace: file tree on the left; in the middle the Loom (everyone's
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
+ import { mountSecrets } from './secrets.js'
4
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
6
  import { openInvite, renderTabs, markRead, go } from './app.js'
6
7
  import { renderFeed } from './feed.js'
@@ -18,7 +19,6 @@ import { openSettings } from './home.js'
18
19
  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'
19
20
  import { renderBoard, taskNotesModalHtml, openTaskMenu, foldPlan, matchesSearch } from './board.js'
20
21
  import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFailed } from './access-form.js'
21
- import { bindMerges, renderMergeView } from './merges.js'
22
22
  import { workspaceFilePicker } from './files.js'
23
23
  import { sessionAgentsHtml } from './agent-place.js'
24
24
  import { renderCatchUp, bindCatchUp } from './catchup.js'
@@ -39,7 +39,7 @@ let boxView = { id: null, sig: null, at: 0, loading: false, data: { on: false, b
39
39
  const BOXES_FRESH_MS = 3000 // while the panel is open, a box's change is fetched again at most this often
40
40
 
41
41
  // ------------------------------------------------------------ layout state --
42
- // Per session: mode ('ai' | 'files' | 'merge'), open tabs per mode, expanded folders.
42
+ // Per session: mode ('ai' | 'files'), open tabs per mode, expanded folders.
43
43
  function ws (id) {
44
44
  if (!state.ws.has(id)) {
45
45
  let saved = null
@@ -52,7 +52,6 @@ function ws (id) {
52
52
  fileTabs: [],
53
53
  fileSel: null,
54
54
  fileTab: {}, // file path -> 'changes' when its Changes tab is shown (absent: the file)
55
- mergeSel: null, // merge id shown in the compare view
56
55
  expanded: {},
57
56
  treeCollapsed: true, // the Files pane starts folded into its rail (rail.js)
58
57
  stale: {}, // file path -> changed while not visible
@@ -160,7 +159,6 @@ export function mountSession (id) {
160
159
  <div class="pane-head">
161
160
  <button type="button" class="tree-collapse wide-tree" id="collapse-tree" title="Collapse files" aria-expanded="true" aria-controls="tree">${I.arrowLeft}</button>
162
161
  <span class="pane-title">Files</span><span class="hint" id="file-count"></span>
163
- <button type="button" class="merge-pill" id="merge-filter" aria-pressed="false" title="Show only the files to merge" hidden></button>
164
162
  </div>
165
163
  <div class="tree-rail" id="tree-rail" role="toolbar" aria-orientation="vertical" aria-label="Locks"></div>
166
164
  <div class="tree-scroll" id="tree"></div>
@@ -200,7 +198,6 @@ export function mountSession (id) {
200
198
 
201
199
  bindTop()
202
200
  bindAccess()
203
- bindMerges($('#main'), { sessionId: () => current, onCompare: openMerge, editors: mergeEditors })
204
201
  bindChanges(id, mounted.signal, { onOpen: (path) => openFile(path, { tab: 'changes' }), close: () => setGitOpen(false), onCount: renderGitChip })
205
202
  bindCatchUp($('#catchup'), { sessionId: () => current, onOpen: openFile })
206
203
  bindGithubCard()
@@ -232,8 +229,6 @@ export function mountSession (id) {
232
229
  setTimeout(() => cover.remove(), 300)
233
230
  })
234
231
  }
235
- const shown = ws(id).mode === 'merge' && shownMerge()
236
- if (shown && !shown.binary) refreshFile(shown.path, false) // the cached copy may be from before
237
232
  loadNewPeople(id)
238
233
  // Relative times ("4s ago") and recent-edit badges age out.
239
234
  timers.push(setInterval(() => { renderTreePane(); renderTop() }, 15000))
@@ -305,7 +300,6 @@ export function sessionFileChanged (id, { path }) {
305
300
  scheduleTree()
306
301
  changesChanged()
307
302
  const w = ws(id)
308
- if (w.mode === 'merge' && shownMerge()?.path === path) refreshFile(path, false)
309
303
  if (w.fileTabs.includes(path)) {
310
304
  if (w.mode === 'files' && w.fileSel === path) { refreshFile(path, true); scheduleHistory(path) } else { w.stale[path] = true; histories.delete(fileKey(path)); renderMainBar() }
311
305
  }
@@ -327,13 +321,6 @@ function editorsByPreference () {
327
321
  return mine ? [mine, ...all.filter((e) => e !== mine)] : all
328
322
  }
329
323
 
330
- /** The merge bar's apps: your AI on its own first when you turned that on (Settings → Your AI), then the apps. */
331
- function mergeEditors () {
332
- const a = state.profile?.auto
333
- const apps = editorsByPreference()
334
- return a && a.mode === 'auto' && a.supported ? [{ id: 'auto', name: 'your AI', auto: true }, ...apps] : apps
335
- }
336
-
337
324
  function openInMarkup () {
338
325
  const [first, ...rest] = editorsByPreference()
339
326
  if (!first) return ''
@@ -680,7 +667,6 @@ function renderTop () {
680
667
  renderAccess()
681
668
  renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
682
669
  renderGithubCard()
683
- renderMerges()
684
670
  renderGitChip()
685
671
  $('#rename-btn').hidden = !st.access?.owner
686
672
  $('#move-ws-btn').hidden = !(st.access?.owner && state.workspacesOn && (state.workspaces || []).some((w) => w.access === 'edit'))
@@ -1169,66 +1155,6 @@ async function moveToWorkspace () {
1169
1155
  } catch (err) { toast(err.message) }
1170
1156
  }
1171
1157
 
1172
- // ----------------------------------------------------------------- merges --
1173
- const isViewer = () => { const a = sum().status.access || {}; return !!(a.controlled && a.role === 'viewer') }
1174
- const shownMerge = () => (sum().status.merges || []).find((m) => m.id === ws(current).mergeSel) || null
1175
-
1176
- // Status carries merge records without their texts; the compare view fetches
1177
- // the full record once. Keyed by session, id and state: ours and base never
1178
- // change for a record, but "edit by hand" and settling change its state.
1179
- const mergeTexts = new Map() // `${session}\n${id}\n${state}` -> { ours, base } | 'loading'
1180
- const mergeKey = (sid, m) => `${sid}\n${m.id}\n${m.state}`
1181
-
1182
- /** The shown merge's ours and base, or undefined while they load. */
1183
- function fullMerge (m) {
1184
- const sid = current
1185
- const got = mergeTexts.get(mergeKey(sid, m))
1186
- if (got && got !== 'loading') return got
1187
- if (got) return undefined
1188
- mergeTexts.set(mergeKey(sid, m), 'loading')
1189
- api('GET', `/api/sessions/${sid}/merges`).then((r) => {
1190
- for (const full of r.merges || []) mergeTexts.set(mergeKey(sid, full), { ours: full.ours ?? null, base: full.base ?? null })
1191
- if (mergeTexts.get(mergeKey(sid, m)) === 'loading') mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // gone meanwhile
1192
- if (current === sid && ws(sid).mode === 'merge') renderMain()
1193
- }).catch(() => {
1194
- mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // shown as not available, not refetched on every status
1195
- if (current === sid && ws(sid).mode === 'merge') renderMain()
1196
- })
1197
- return undefined
1198
- }
1199
-
1200
- /** Files to merge, marked in the tree, and the compare view when it's showing (it only redraws on a change). */
1201
- let lastMergeSig = ''
1202
- function renderMerges () {
1203
- if (!current || !sum()) return
1204
- const merges = sum().status.merges || []
1205
- const w = ws(current)
1206
- // The tree marks the files to merge: drawn again only when they change.
1207
- const sig = JSON.stringify(merges.filter((m) => m.state !== 'done').map((m) => [m.id, m.path, m.heldBy || '']))
1208
- if (sig !== lastMergeSig) { lastMergeSig = sig; renderTreePane() }
1209
- if (w.mode !== 'merge' || !w.mergeSel) return
1210
- // The same file can conflict again after its record was settled: the compare
1211
- // tab then follows the new open record instead of showing the old one.
1212
- const shown = merges.find((m) => m.id === w.mergeSel)
1213
- if (shown && shown.state === 'done') {
1214
- const next = merges.find((m) => m.path === shown.path && m.state !== 'done')
1215
- if (next) { w.mergeSel = next.id; saveWs(current); renderMainBar() }
1216
- }
1217
- renderMain()
1218
- }
1219
-
1220
- function openMerge (id) {
1221
- const w = ws(current)
1222
- w.mergeSel = id
1223
- w.mode = 'merge'
1224
- saveWs(current)
1225
- renderMainBar()
1226
- renderMain()
1227
- renderTreePane()
1228
- // Always fetch: a cached copy may be from before the session moved on.
1229
- const m = shownMerge()
1230
- if (m && !m.binary) refreshFile(m.path, false)
1231
- }
1232
1158
 
1233
1159
  /** Is member `m` an agent (a chat link is one too)? */
1234
1160
  const isAgentMember = (m) => m.kind === 'agent' || !!m.chat
@@ -1560,15 +1486,20 @@ const SS_TABS = [
1560
1486
  { id: 'chat', label: 'Your AI chat', icon: 'chat' },
1561
1487
  { id: 'vc', label: 'Version control', icon: 'branch' },
1562
1488
  { id: 'agents', label: 'Agents', icon: 'bot' },
1563
- { id: 'people', label: 'People', icon: 'users' }
1489
+ { id: 'people', label: 'People', icon: 'users' },
1490
+ // Secrets (secrets.js): only once the accounts API has them switched on.
1491
+ { id: 'secrets', label: 'Secrets', icon: 'lock', on: () => state.secretsOn }
1564
1492
  ]
1493
+ const ssTabs = () => SS_TABS.filter((x) => !x.on || x.on())
1494
+ // The Secrets tab's card while it is drawn: kept across redraws of the window, so a typed name isn't lost.
1495
+ let ssSecrets = null
1565
1496
  let ssTab = 'chat'
1566
1497
 
1567
1498
  function openSessionSettings (tab) {
1568
1499
  document.querySelector('.session-settings-back')?.remove()
1569
1500
  $('#people-menu').hidden = true
1570
1501
  $('#people-btn').setAttribute('aria-expanded', 'false')
1571
- if (tab && SS_TABS.some((t) => t.id === tab)) ssTab = tab
1502
+ if (tab && ssTabs().some((t) => t.id === tab)) ssTab = tab
1572
1503
  const back = document.createElement('div')
1573
1504
  back.className = 'modal-back top session-settings-back'
1574
1505
  back.innerHTML = `<div class="card modal session-settings" role="dialog" aria-modal="true" aria-labelledby="ss-title">
@@ -1597,8 +1528,9 @@ function openSessionSettings (tab) {
1597
1528
  back.querySelector('.ss-tabs').addEventListener('keydown', (e) => {
1598
1529
  if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
1599
1530
  e.preventDefault()
1600
- const i = SS_TABS.findIndex((t) => t.id === ssTab)
1601
- ssTab = SS_TABS[(i + (e.key === 'ArrowDown' ? 1 : SS_TABS.length - 1)) % SS_TABS.length].id
1531
+ const i = ssTabs().findIndex((t) => t.id === ssTab)
1532
+ const list = ssTabs()
1533
+ ssTab = list[(i + (e.key === 'ArrowDown' ? 1 : list.length - 1)) % list.length].id
1602
1534
  renderSessionSettings({ force: true })
1603
1535
  back.querySelector(`[data-ss-tab="${ssTab}"]`)?.focus()
1604
1536
  })
@@ -1621,7 +1553,7 @@ function renderSessionSettings ({ force = false } = {}) {
1621
1553
  const st = sum().status
1622
1554
  // The list is drawn once (a redraw under a click would lose it); here only which is on, and the counts.
1623
1555
  const tabs = back.querySelector('.ss-tabs')
1624
- if (!tabs.children.length) tabs.innerHTML = SS_TABS.map((t) => `<button type="button" role="tab" class="ss-tab" data-ss-tab="${t.id}">${I[t.icon]}<span>${t.label}</span><span class="ss-count"></span></button>`).join('')
1556
+ if (!tabs.children.length) tabs.innerHTML = ssTabs().map((t) => `<button type="button" role="tab" class="ss-tab" data-ss-tab="${t.id}">${I[t.icon]}<span>${t.label}</span><span class="ss-count"></span></button>`).join('')
1625
1557
  for (const b of tabs.children) {
1626
1558
  const on = b.dataset.ssTab === ssTab
1627
1559
  b.classList.toggle('on', on)
@@ -1630,7 +1562,17 @@ function renderSessionSettings ({ force = false } = {}) {
1630
1562
  b.querySelector('.ss-count').textContent = ssCount(st, b.dataset.ssTab)
1631
1563
  }
1632
1564
  box.dataset.tab = ssTab
1633
- back.querySelector('.ss-name').textContent = SS_TABS.find((t) => t.id === ssTab).label
1565
+ back.querySelector('.ss-name').textContent = (ssTabs().find((t) => t.id === ssTab) || ssTabs()[0]).label
1566
+ if (ssTab === 'secrets') {
1567
+ const room = st.room
1568
+ const mounted = box.querySelector(`[data-secrets-room="${CSS.escape(String(room || ''))}"]`)
1569
+ if (mounted && !force) return
1570
+ ssSecrets?.destroy()
1571
+ box.innerHTML = `<div class="ss-secrets" data-secrets-room="${esc(room || '')}"></div>`
1572
+ ssSecrets = mountSecrets(box.firstElementChild, { type: 'session', id: room, importFrom: { sessionId: current } })
1573
+ return
1574
+ }
1575
+ if (ssSecrets) { ssSecrets.destroy(); ssSecrets = null }
1634
1576
  // Don't redraw under the owner while they change someone's access (unless fresh grants
1635
1577
  // arrived: then the form is redrawn from them, and keeps its focus).
1636
1578
  const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
@@ -1649,17 +1591,6 @@ function bindMain () {
1649
1591
  const close = e.target.closest('[data-close]')
1650
1592
  const tab = e.target.closest('[data-tab]')
1651
1593
  const w = ws(current)
1652
- if (close && close.dataset.kind === 'merge') {
1653
- e.stopPropagation()
1654
- w.mergeSel = null
1655
- if (w.mode === 'merge') w.mode = w.fileSel ? 'files' : 'ai'
1656
- saveWs(current)
1657
- renderMainBar()
1658
- renderMain()
1659
- renderTreePane()
1660
- return
1661
- }
1662
- if (tab && tab.dataset.kind === 'merge') { openMerge(tab.dataset.tab); return }
1663
1594
  if (tab && tab.dataset.kind === 'loom') { openLoom(); return }
1664
1595
  if (close) {
1665
1596
  e.stopPropagation()
@@ -1779,20 +1710,10 @@ function renderMainBar () {
1779
1710
  }).join('') + (w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
1780
1711
  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)}">
1781
1712
  <span class="ico">${I.file}</span><span class="nm">${esc(basename(path))}</span>${w.stale[path] ? '<span class="changed" title="Changed"></span>' : ''}
1782
- <button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('') +
1783
- (w.mergeSel ? mergeTabHtml(w) : '')
1713
+ <button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('')
1784
1714
  // On Everyone with nothing else open, the row would only name the view you are in: hide it until
1785
1715
  // there is a tab to switch to (or, on the Tasks board, Everyone to go back to).
1786
- $('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length && !w.mergeSel
1787
- }
1788
-
1789
- function mergeTabHtml (w) {
1790
- const m = shownMerge()
1791
- const on = w.mode === 'merge'
1792
- const name = m ? `Merge ${basename(m.path)}` : 'Merge'
1793
- return `<span class="ws-tab-sep"></span><div class="ws-tab${on ? ' on' : ''}" role="tab" aria-selected="${on}" tabindex="0" data-kind="merge" data-tab="${esc(w.mergeSel)}" title="${esc(m ? `Compare the two versions of ${m.path}` : 'Merge')}">
1794
- <span class="ico">${I.branch}</span><span class="nm">${esc(name)}</span>
1795
- <button class="x" data-kind="merge" data-close="${esc(w.mergeSel)}" aria-label="Close merge">${I.x}</button></div>`
1716
+ $('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length
1796
1717
  }
1797
1718
 
1798
1719
  // A card being renamed, or a drag in progress, must not be rebuilt under the pointer.
@@ -2420,17 +2341,7 @@ function renderMain () {
2420
2341
  </div>`
2421
2342
  return
2422
2343
  }
2423
- if (w.mode === 'merge' && !w.mergeSel) w.mode = 'ai'
2424
- if (w.mode === 'merge') {
2425
- const m = shownMerge()
2426
- const cached = m ? state.files.get(fileKey(m.path)) : null
2427
- const f = cached && cached.file
2428
- const theirs = !cached ? undefined : f.missing || f.binary ? null : f.text
2429
- const texts = m && !m.binary ? fullMerge(m) : { ours: null, base: null }
2430
- renderMergeView(el, { merge: m, texts, theirs, me: me(), editors: mergeEditors(), viewer: isViewer() })
2431
- if (m && !m.binary && !cached) refreshFile(m.path, false)
2432
- return
2433
- }
2344
+ if (w.mode === 'merge') w.mode = 'ai' // a compare tab from before Quilt merged on its own
2434
2345
  if (w.mode === 'ai') {
2435
2346
  if (!w.aiSel) {
2436
2347
  const people = st.peers
@@ -2467,6 +2378,7 @@ function renderMain () {
2467
2378
  return
2468
2379
  }
2469
2380
  const cached = state.files.get(fileKey(w.fileSel))
2381
+ if (!cached) loadFileOnce(w.fileSel) // a tab put back after a reload has nothing read yet
2470
2382
  renderFileView(el, {
2471
2383
  path: w.fileSel,
2472
2384
  file: cached ? cached.file : null,
@@ -2476,7 +2388,8 @@ function renderMain () {
2476
2388
  tab: fileTabOf(w.fileSel),
2477
2389
  history: histories.get(fileKey(w.fileSel)) || null,
2478
2390
  summary: fileChanges(w.fileSel),
2479
- people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color }
2391
+ people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color },
2392
+ chatHref: treeMeta(w.fileSel)?.chat ? fileCardHref(current, treeMeta(w.fileSel).chat.id, TOKEN) : null
2480
2393
  })
2481
2394
  if (cached && fileTabOf(w.fileSel) === 'file') cached.prevText = null // highlight once, on the File tab
2482
2395
  }
@@ -2528,6 +2441,14 @@ function treeMeta (path) {
2528
2441
  return { path, edited: null, claim: null }
2529
2442
  }
2530
2443
 
2444
+ const fileLoads = new Set() // fileKey -> being read for the first time
2445
+ function loadFileOnce (path) {
2446
+ const key = fileKey(path)
2447
+ if (fileLoads.has(key)) return
2448
+ fileLoads.add(key)
2449
+ refreshFile(path, false).finally(() => fileLoads.delete(key))
2450
+ }
2451
+
2531
2452
  async function refreshFile (path, highlight) {
2532
2453
  const id = current
2533
2454
  const key = fileKey(path)
@@ -2542,7 +2463,7 @@ async function refreshFile (path, highlight) {
2542
2463
  if (id !== current) return
2543
2464
  state.files.set(key, { file, prevText })
2544
2465
  const w = ws(current)
2545
- if ((w.mode === 'files' && w.fileSel === path) || (w.mode === 'merge' && shownMerge()?.path === path)) renderMain()
2466
+ if (w.mode === 'files' && w.fileSel === path) renderMain()
2546
2467
  }
2547
2468
 
2548
2469
  // ------------------------------------------------------------------ Loom --
@@ -2667,7 +2588,8 @@ async function loadTree () {
2667
2588
  if (w.mode === 'files' && w.fileSel) {
2668
2589
  // Keep the banner (editor, claim) fresh without re-highlighting.
2669
2590
  const cached = state.files.get(fileKey(w.fileSel))
2670
- if (cached) renderFileView($('#main'), { path: w.fileSel, file: cached.file, meta: treeMeta(w.fileSel), me: me(), bannerOnly: true })
2591
+ const meta = treeMeta(w.fileSel)
2592
+ if (cached) renderFileView($('#main'), { path: w.fileSel, file: cached.file, meta, me: me(), bannerOnly: true, chatHref: meta?.chat ? fileCardHref(id, meta.chat.id, TOKEN) : null })
2671
2593
  }
2672
2594
  } catch {}
2673
2595
  }
@@ -2678,20 +2600,10 @@ function renderTreePane () {
2678
2600
  const w = ws(current)
2679
2601
  const tree = state.trees.get(current)
2680
2602
  const scroll = el.scrollTop
2681
- const merges = (sum()?.status.merges || []).filter((m) => m.state !== 'done')
2682
- if (!merges.length) w.mergesOnly = false
2683
- const shown = w.mode === 'merge' ? merges.find((m) => m.id === w.mergeSel)?.path : null
2684
- renderTree(el, tree, { me: me(), expanded: w.expanded, selected: w.mode === 'files' ? w.fileSel : shown || null, merges, mergesOnly: !!w.mergesOnly })
2603
+ renderTree(el, tree, { me: me(), expanded: w.expanded, selected: w.mode === 'files' ? w.fileSel : null })
2685
2604
  el.scrollTop = scroll
2686
- $('#file-count').textContent = tree ? `${tree.files.length}` : ''
2687
- const pill = $('#merge-filter')
2688
- if (pill) {
2689
- pill.hidden = !merges.length
2690
- pill.innerHTML = `<span class="mp-n">${merges.length}</span><span class="mp-l">to merge</span>`
2691
- pill.setAttribute('aria-pressed', String(!!w.mergesOnly))
2692
- pill.title = w.mergesOnly ? 'Show every file' : 'Show only the files to merge'
2693
- }
2694
- renderRailPane(tree, merges)
2605
+ $('#file-count').textContent = tree ? `${tree.files.filter((f) => !f.chat).length}` : ''
2606
+ renderRailPane(tree)
2695
2607
  // The rows were replaced: keep the card on the one still under the pointer, with fresh numbers.
2696
2608
  if (cardPath) {
2697
2609
  const row = treeRow(cardPath)
@@ -2817,25 +2729,21 @@ function openTaskNotes (task, { instant = false } = {}) {
2817
2729
  if (instant) back.querySelector('.task-comment-text').focus()
2818
2730
  }
2819
2731
 
2820
- /** The folded Files pane: locks and who holds them (rail.js); a merge in a file you have open marks the merge pill. */
2821
- function renderRailPane (tree = state.trees.get(current), merges = sum()?.status.merges || []) {
2732
+ /** The folded Files pane: locks and who holds them (rail.js). */
2733
+ function renderRailPane (tree = state.trees.get(current)) {
2822
2734
  const el = $('#tree-rail')
2823
2735
  const s = sum()
2824
2736
  if (!el || !s) return
2825
2737
  const st = s.status
2826
- const w = ws(current)
2827
2738
  const model = railModel({
2828
2739
  claims: st.claims || [],
2829
- merges,
2830
2740
  me: st.me.name,
2831
2741
  owners: owners(s),
2832
2742
  aiName,
2833
2743
  files: tree ? tree.files.map((f) => f.path) : [],
2834
- open: [...w.fileTabs, ...(w.mergeSel ? [merges.find((m) => m.id === w.mergeSel)?.path] : [])].filter(Boolean),
2835
2744
  focusOf: (name) => (name === st.me.name ? st.me.focus : st.peers.find((p) => p.name === name)?.focus) || '',
2836
2745
  kindOf: (name) => personInfo(name).kind
2837
2746
  })
2838
- $('#merge-filter')?.classList.toggle('urgent', model.merges.urgent)
2839
2747
  renderRail(el, model, { me: st.me.name, colorOf: (name) => personInfo(name).color, onFile: (path) => openFile(path) })
2840
2748
  }
2841
2749
 
@@ -2958,15 +2866,6 @@ function bindChatPane () {
2958
2866
  function bindTreeEvents () {
2959
2867
  const collapse = $('#collapse-tree')
2960
2868
  if (collapse) collapse.onclick = () => toggleTreeCollapsed()
2961
- const filter = $('#merge-filter')
2962
- if (filter) {
2963
- filter.onclick = () => {
2964
- const w = ws(current)
2965
- if (w.treeCollapsed) { w.mergesOnly = true; toggleTreeCollapsed() } else w.mergesOnly = !w.mergesOnly
2966
- saveWs(current)
2967
- renderTreePane()
2968
- }
2969
- }
2970
2869
  const el = $('#tree')
2971
2870
  el.addEventListener('click', (e) => {
2972
2871
  const more = e.target.closest('[data-more]')
@@ -2984,8 +2883,7 @@ function bindTreeEvents () {
2984
2883
  return
2985
2884
  }
2986
2885
  const file = e.target.closest('[data-file]')
2987
- if (file && file.dataset.merge) openMerge(file.dataset.merge) // changed in two places: both versions, side by side
2988
- else if (file) openFile(file.dataset.file)
2886
+ if (file) openFile(file.dataset.file)
2989
2887
  })
2990
2888
  el.addEventListener('contextmenu', (e) => {
2991
2889
  const row = e.target.closest('[data-file],[data-dir]')
package/src/ui/tree.js CHANGED
@@ -1,6 +1,9 @@
1
1
  // The shared project's file tree: who edited what recently, what's claimed.
2
2
  import { esc, I, ago, colorFor, loadingNote } from './common.js'
3
3
 
4
+ /** Where each person's chat files are kept (CHAT_FILES in pathrules.js, which the page can't load). */
5
+ export const CHAT_FILES = 'Chat files'
6
+
4
7
  export const RECENT_MS = 2 * 60 * 1000
5
8
  /** How long a file brought in from GitHub keeps its GitHub badge (an edit in the session takes it away sooner). */
6
9
  export const GITHUB_BADGE_MS = 10 * 60 * 1000
@@ -76,15 +79,10 @@ function hasRecent (node, me) {
76
79
  * @param {{ files: object[], claims: object[] }} tree
77
80
  * @param {{ me: string, expanded: object, selected: string|null }} view
78
81
  */
79
- export function renderTree (el, tree, { me, expanded, selected, merges = [], mergesOnly = false }) {
82
+ export function renderTree (el, tree, { me, expanded, selected }) {
80
83
  if (!tree) { el.innerHTML = `<div class="empty-note" style="padding:12px 14px">${loadingNote('Loading files…')}</div>`; return }
81
- // Files to merge are marked where they are; one the session deleted is listed all the same.
82
- const mergeOf = new Map(merges.map((m) => [m.path, m]))
83
- const known = new Set(tree.files.map((f) => f.path))
84
- let files = [...tree.files, ...merges.filter((m) => !known.has(m.path)).map((m) => ({ path: m.path }))]
85
- if (mergesOnly) files = files.filter((f) => mergeOf.has(f.path))
84
+ const files = tree.files
86
85
  if (!files.length) { el.innerHTML = '<div class="empty-note" style="padding:12px 14px">No shared files yet.</div>'; return }
87
- const mergesIn = (dir) => merges.filter((m) => m.path.startsWith(`${dir}/`)).length
88
86
  const folderClaims = new Map()
89
87
  for (const c of tree.claims) folderClaims.set(claimFolder(c.pattern), c)
90
88
  const root = buildTree(files)
@@ -97,34 +95,41 @@ export function renderTree (el, tree, { me, expanded, selected, merges = [], mer
97
95
  // Who just edited a file: a button to its Changes tab. Its +/− shows in the row's change card.
98
96
  const editBadge = (f, byOther) => `<button type="button" class="t-badge ${byOther ? 'edit' : 'mine'}" data-changes="${esc(f.path)}" aria-label="See what changed in ${esc(f.path)}">${esc(f.edited.by === me ? 'you' : f.edited.by)} · ${shortAgo(f.edited.ts)}</button>`
99
97
  const walk = (node, depth) => {
100
- const dirs = [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))
98
+ const dirs = [...node.dirs.values()].sort((a, b) => (a.path === CHAT_FILES) - (b.path === CHAT_FILES) || a.name.localeCompare(b.name)) // chat files last
101
99
  for (const d of dirs) {
102
- const open = mergesOnly || (expanded[d.path] ?? depth === 0)
100
+ const open = expanded[d.path] ?? depth === 0
103
101
  const claim = folderClaims.get(d.path)
104
- const inside = open ? 0 : mergesIn(d.path)
102
+ // The chat files folder (session.js): each person's own copy of what was sent in chat, nothing to claim.
103
+ if (d.path === CHAT_FILES) {
104
+ rows.push(`<div class="t-row t-dir t-chat" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}" title="Files sent in this session's chat. Only people in the session get them; never synced or committed.">
105
+ <span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span><span class="t-badge chat">${I.lock}session only</span></div>`)
106
+ if (open) walk(d, depth + 1)
107
+ continue
108
+ }
105
109
  rows.push(`<div class="t-row t-dir" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}">
106
110
  <span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span>
107
- ${inside ? `<span class="t-badge merge" title="${inside} file${inside === 1 ? '' : 's'} to merge inside">${inside}</span>` : ''}
108
111
  ${!open && hasRecent(d, me) ? '<span class="t-dot" title="Recently edited inside"></span>' : ''}${claim ? claimBadge(claim) : !open ? insideBadge(claimsInside(tree.claims, d.path), me) : ''}
109
112
  <button class="t-more" data-more="${esc(d.path)}" data-kind="dir" aria-label="More for ${esc(d.name)}">${I.more}</button></div>`)
110
113
  if (open) walk(d, depth + 1)
111
114
  }
112
115
  for (const f of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
116
+ if (f.chat) {
117
+ rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(`Sent in chat by ${f.chat.by === me ? 'you' : f.chat.by}${f.chat.to ? ` to ${f.chat.to === me ? 'you' : f.chat.to}` : ''}`)}">
118
+ <span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span><span class="t-badge chat">${esc(f.chat.by === me ? 'you' : f.chat.by)}</span></div>`)
119
+ continue
120
+ }
113
121
  const recent = !!f.edited && Date.now() - f.edited.ts < RECENT_MS
114
122
  const byOther = recent && f.edited.by !== me
115
123
  const ownClaim = f.claim && claimFolder(f.claim.pattern) === f.path ? f.claim : null
116
124
  // The holder of a claim is the one editing it: their claim badge says so, one badge is enough on a narrow row.
117
- // A file to merge says so instead of who last edited it.
118
- const m = mergeOf.get(f.path)
119
- const mergeBadge = m ? `<span class="t-badge merge" title="${esc(m.heldBy && m.heldBy !== me ? `${m.heldBy} is merging it` : 'Changed in two places: open it to compare and settle')}">${m.heldBy && m.heldBy !== me ? esc(m.heldBy) : 'merge'}</span>` : ''
120
125
  // GitHub's version, not edited in the session since: GitHub's commit takes the "who edited" place, for ten
121
126
  // minutes after it came in (an older relay doesn't say when: then after the commit).
122
- const gh = !m && f.github && Date.now() - (f.github.came || f.github.at || 0) < GITHUB_BADGE_MS ? f.github : null
127
+ const gh = f.github && Date.now() - (f.github.came || f.github.at || 0) < GITHUB_BADGE_MS ? f.github : null
123
128
  const ghBadge = gh ? `<span class="t-badge gh" title="${esc(`From GitHub: ${gh.sha.slice(0, 7)}${gh.by ? ` by ${gh.by}` : ''}${gh.subject ? `: ${gh.subject}` : ''}. Not edited in the session since.`)}">${I.github}<span class="t-gh">${esc(gh.by || 'GitHub')}${gh.at ? ` · ${shortAgo(gh.at)}` : ''}</span></span>` : ''
124
129
  const ghost = ownClaim && ownClaim.box && !boxMine(ownClaim)
125
- rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}${ghost ? ' ghost' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}"${m ? ` data-merge="${esc(m.id)}"` : ''} style="--depth:${depth}" title="${esc(f.path)}">
126
- <span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span>${mergeBadge}${ghBadge}
127
- ${recent && !m && !gh && !(ownClaim && ownClaim.by === f.edited.by) ? editBadge(f, byOther) : ''}
130
+ rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}${ghost ? ' ghost' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(f.path)}">
131
+ <span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span>${ghBadge}
132
+ ${recent && !gh && !(ownClaim && ownClaim.by === f.edited.by) ? editBadge(f, byOther) : ''}
128
133
  ${claimBadge(ownClaim)}
129
134
  <button class="t-more" data-more="${esc(f.path)}" data-kind="file" aria-label="More for ${esc(f.name)}">${I.more}</button></div>`)
130
135
  }
@@ -1,6 +1,7 @@
1
1
  // Workspaces: the home grid of cards, the Add workspace card, and a workspace's page
2
2
  // (Sessions, Files, People & agents, Settings). Sessions stay in session.js; the file library
3
3
  // (the Files section, All files and uploads) is files.js.
4
+ import { mountSecrets } from './secrets.js'
4
5
  import { I, state, $, esc, basename, toast, api, ask, avatar, colorFor, ago, bytes, COLORS, loadingHtml, AGENT_COMMIT_WORDS } from './common.js'
5
6
  import { gitControl, gitSegHtml } from './git-rule.js'
6
7
  import { filesSectionHtml, bindFilesSection } from './files.js'
@@ -276,6 +277,7 @@ export function workspacePageHtml () {
276
277
  : `<p class="hint ws-empty">No sessions yet.${d.access.access === 'edit' ? ' Start one with New session: it opens a folder of yours here.' : ''}</p>`}
277
278
  </section>
278
279
  ${filesSectionHtml(d)}
280
+ ${state.secretsOn ? '<section class="sec ws-secrets" data-ws-secrets></section>' : ''}
279
281
  </div>
280
282
  </div>
281
283
  </div>`
@@ -375,6 +377,9 @@ export function bindWorkspacePage (root, { go, rerender, newSessionDialog, invit
375
377
  root.querySelector('[data-ws-settings]')?.addEventListener('click', () => settingsDialog(reload, go, dialog))
376
378
  root.querySelector('[data-ws-github]')?.addEventListener('click', () => githubDialog(id, dialog))
377
379
  bindFilesSection(root, { id, reload, go })
380
+ // Secrets: the workspace's, with its team's above (secrets.js); .env files come from its sessions on this computer.
381
+ const sec = root.querySelector('[data-ws-secrets]')
382
+ if (sec) mountSecrets(sec, { type: 'workspace', id, importFrom: { workspaceId: id } })
378
383
  }
379
384
 
380
385
  /** "1.2 GB of 5.0 GB used · 34 files", from the workspace's usage; nothing without one. */