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
@@ -19,9 +19,11 @@ const MAX_LINES = 20000
19
19
  * @param {object} [o.people] { who, colorOf } for the Changes tab
20
20
  * @param {object|null} [o.summary] the file's totals in this session (changes.js fileChanges)
21
21
  */
22
- export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly = false, tab = 'file', history = null, people, summary = null }) {
22
+ export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly = false, tab = 'file', history = null, people, summary = null, chatHref = null }) {
23
23
  const scroller = el.querySelector('.fv-scroll')
24
24
  const keep = scroller && scroller.dataset.path === path ? scroller.scrollTop : 0
25
+ // The tree tells which files came from chat: one opened before it loaded is drawn again once it has.
26
+ if (meta && meta.chat) return bannerOnly && scroller && scroller.dataset.tab === 'chat' && scroller.dataset.path === path ? undefined : renderChatFile(el, { path, file, chat: meta.chat, me, href: chatHref, keep: scroller && scroller.dataset.path === path ? keep : 0 })
25
27
 
26
28
  const edited = meta && meta.edited
27
29
  const claim = meta && meta.claim
@@ -69,6 +71,31 @@ export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly
69
71
  }
70
72
  }
71
73
 
74
+ const IMAGE = /\.(png|jpe?g|gif|webp)$/i
75
+
76
+ /**
77
+ * A file sent in chat, from the chat files folder: who sent it, a download, and the file itself
78
+ * (text, or a picture). No claim and no changes: it is this person's copy, never synced.
79
+ */
80
+ function renderChatFile (el, { path, file, chat, me, href, keep }) {
81
+ const who = (n) => n === me ? 'you' : esc(n)
82
+ const banner = `<div class="fv-banner"><span class="fv-path mono">${esc(path)}</span><span class="fv-edited">Sent in chat by ${who(chat.by)}${chat.to ? ` to ${who(chat.to)}` : ''} ${agoLong(chat.ts)}</span>
83
+ <span class="hint">Only people in the session get it. Never synced or committed.</span><span class="spacer"></span>${href ? `<a class="btn sm" href="${href}" download>${I.down}<span>Download</span></a>` : ''}</div>`
84
+ let body
85
+ if (!file) body = `<div class="fv-note">${loadingNote()}</div>`
86
+ else if (file.missing) body = '<div class="fv-note">This file is no longer in your Chat files folder.</div>'
87
+ else if (file.binary && IMAGE.test(path) && href) body = `<div class="fv-image"><img src="${href}&amp;inline=1" alt="${esc(path.split('/').pop())}"></div>`
88
+ else if (file.binary) body = `<div class="fv-note">${I.file} ${bytes(file.size)}. It is in the Chat files folder in this session's folder.</div>`
89
+ else {
90
+ const lines = file.text.split('\n')
91
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
92
+ body = `<table class="fv-code"><tbody>${lines.slice(0, MAX_LINES).map((l, i) => `<tr><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>
93
+ ${lines.length > MAX_LINES ? `<div class="fv-note">Showing the first ${MAX_LINES.toLocaleString()} lines.</div>` : ''}`
94
+ }
95
+ el.innerHTML = `<div class="fv">${banner}<div class="fv-scroll" data-path="${esc(path)}" data-tab="chat">${body}</div></div>`
96
+ el.querySelector('.fv-scroll').scrollTop = keep
97
+ }
98
+
72
99
  const tabsMarkup = (tab, history, summary) => `<div class="fv-tabs segmented" role="tablist" aria-label="File or its changes">
73
100
  <button type="button" role="tab" data-fvtab="file" class="${tab === 'file' ? 'on' : ''}" aria-selected="${tab === 'file'}">File</button>
74
101
  <button type="button" role="tab" data-fvtab="changes" class="${tab === 'changes' ? 'on' : ''}" aria-selected="${tab === 'changes'}" title="Every change to this file, with who made it and what changed">Changes${history ? ` <span class="n">${changeCount(history, summary)}</span>` : ''}</button>
package/src/ui/home.js CHANGED
@@ -10,6 +10,7 @@ import { workspacesHtml, bindWorkspaces, workspacePageHtml, bindWorkspacePage, l
10
10
  import { allFilesHtml, bindAllFiles } from './files.js'
11
11
  import { invitesHtml, bindInvites } from './invites.js'
12
12
  import { agentCardHtml, bindPlacement, placeableWorkspaces, agentMiniHtml, openAgentMenu } from './agent-place.js'
13
+ import { bindSandboxKeys } from './sandbox-keys.js'
13
14
  import { SOUND_EVENTS, playSound } from './sounds.js'
14
15
  import { gitControl, gitSegHtml } from './git-rule.js'
15
16
  import { cloudAiCardHtml, bindCloudAi } from './cloud-ai.js'
@@ -729,44 +730,34 @@ function agentsPageHtml () {
729
730
  <label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label>
730
731
  <button class="btn primary" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button>
731
732
  </header>
732
- <p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card to choose where it works.</p>
733
+ <p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card for where it works and its keys.</p>
733
734
  <div id="agents-invite"><p class="error" id="agents-error"></p></div>
734
735
  <div id="agents-list"><p class="hint">${loadingNote()}</p></div>
735
736
  <div class="ag-pages" id="agents-pages" hidden></div>
737
+ <div class="ag-details" id="agent-details" hidden></div>
736
738
  <p class="hint ag-foot"><a href="https://heyquilt.com/docs/agents#kinds" target="_blank" rel="noopener">How agent kinds work</a> · Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a></p>
737
739
  </div>`
738
740
  }
739
741
 
740
742
  /**
741
- * Where an agent works, in a dialog: the card with Works in (all workspaces, chosen ones, or
742
- * only where added) and Invited to new sessions; each change saves. `onChange` is told after a save.
743
+ * An agent's details, in place under the cards (no dialog): with workspaces on, where it works
744
+ * (Works in and Invited to new sessions; each change saves), then its sandbox keys (sandbox-keys.js).
743
745
  */
744
- async function openPlacementDialog (a, { onChange = () => {} } = {}) {
746
+ async function showAgentDetails (root, a) {
747
+ const box = $('#agent-details', root)
748
+ box.hidden = false
749
+ box.dataset.agent = a.id
750
+ box.innerHTML = `${state.workspacesOn ? '<div data-ag-place-slot></div>' : ''}<div data-sk-slot></div>`
751
+ bindSandboxKeys($('[data-sk-slot]', box), a)
752
+ box.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' })
753
+ if (!state.workspacesOn) return
754
+ const slot = $('[data-ag-place-slot]', box)
745
755
  let place
746
- try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) { toast(err.message); return }
747
- if (!place) { toast('This agent has no placement yet.'); return }
748
- $('#agent-place-back')?.remove()
749
- const back = document.createElement('div')
750
- back.id = 'agent-place-back'
751
- back.className = 'modal-back settings-back'
752
- back.innerHTML = `
753
- <div class="settings-modal agent-modal" role="dialog" aria-modal="true" aria-labelledby="agent-place-title">
754
- <header class="settings-head">
755
- <h2 id="agent-place-title">${I.bot}<span>${esc(a.name)}</span></h2>
756
- <p>Where it works, and whether it is invited to new sessions. Changes save at once.</p>
757
- <button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
758
- </header>
759
- <div class="settings-body">${agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })}</div>
760
- </div>`
761
- document.body.appendChild(back)
762
- bindPlacement(back.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
763
- back.addEventListener('change', onChange)
764
- back.addEventListener('click', (e) => { if (e.target.closest('[data-placement-reach], [data-placement-sessions], [data-placement-ws]')) onChange() })
765
- const close = () => back.remove()
766
- back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
767
- back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
768
- back.querySelector('[data-settings-close]').onclick = close
769
- back.querySelector('[data-settings-close]').focus()
756
+ try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) { slot.innerHTML = `<p class="hint warn">${esc(err.message)}</p>`; return }
757
+ if (box.dataset.agent !== a.id) return
758
+ if (!place) { slot.innerHTML = '<p class="hint">This agent has no placement yet.</p>'; return }
759
+ slot.innerHTML = agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })
760
+ bindPlacement(slot.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
770
761
  }
771
762
 
772
763
  // Settings come in tabs: You (profile, appearance, sounds), Sessions (defaults for starting and
@@ -905,7 +896,8 @@ function settingsHtml ({ head = true } = {}) {
905
896
  <div id="agents-invite"><p class="error" id="agents-error"></p></div>
906
897
  <div id="agents-list"><p class="hint">${loadingNote()}</p></div>
907
898
  <div class="ag-pages" id="agents-pages" hidden></div>
908
- <div class="sec-actions"><span class="hint">${state.workspacesOn ? 'Open a card to choose where the agent works and whether it joins new sessions.' : 'An invite is pasted into the AI once; from then on it joins any session you send it.'} Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a>.</span>${state.workspacesOn ? `<button class="btn sm" type="button" data-view="agents">Open Agents</button>` : ''}</div>
899
+ <div class="ag-details" id="agent-details" hidden></div>
900
+ <div class="sec-actions"><span class="hint">${state.workspacesOn ? 'Open a card for where the agent works, whether it joins new sessions, and its keys.' : 'An invite is pasted into the AI once; from then on it joins any session you send it. Open a card for its keys.'} Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a>.</span>${state.workspacesOn ? `<button class="btn sm" type="button" data-view="agents">Open Agents</button>` : ''}</div>
909
901
  </div>
910
902
  </section>` : ''}
911
903
 
@@ -1017,6 +1009,12 @@ function bindAgentsGrid (root, list) {
1017
1009
  const search = $('#agents-search', root)
1018
1010
  let all = []
1019
1011
  let page = 0
1012
+ let open = '' // the agent whose details show under the cards
1013
+ const details = (a) => {
1014
+ open = a.id
1015
+ for (const c of list.querySelectorAll('[data-agent]')) c.setAttribute('aria-pressed', String(c.dataset.agent === a.id))
1016
+ showAgentDetails(root, a)
1017
+ }
1020
1018
  const matching = () => {
1021
1019
  const q = (search.value || '').trim().toLowerCase()
1022
1020
  return q ? all.filter((a) => [a.name, a.type, a.provider].some((x) => String(x || '').toLowerCase().includes(q))) : all
@@ -1028,7 +1026,7 @@ function bindAgentsGrid (root, list) {
1028
1026
  const from = page * AGENTS_PAGE
1029
1027
  const shown = hits.slice(from, from + AGENTS_PAGE)
1030
1028
  list.innerHTML = shown.length
1031
- ? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens: state.workspacesOn })).join('')}</div>`
1029
+ ? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens: true })).join('').replace(`data-agent="${esc(open)}"`, `data-agent="${esc(open)}" aria-pressed="true"`)}</div>`
1032
1030
  : `<p class="hint">${all.length ? 'No agent matches that.' : 'No agents yet. Invite one above.'}</p>`
1033
1031
  pages.hidden = hits.length <= AGENTS_PAGE
1034
1032
  pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
@@ -1048,23 +1046,24 @@ function bindAgentsGrid (root, list) {
1048
1046
  if (!card || e.target !== card || (e.key !== 'Enter' && e.key !== ' ')) return
1049
1047
  e.preventDefault()
1050
1048
  const a = all.find((x) => x.id === card.dataset.agent)
1051
- if (a) openPlacementDialog(a)
1049
+ if (a) details(a)
1052
1050
  })
1053
1051
  list.addEventListener('click', (e) => {
1054
1052
  const b = e.target.closest('[data-agent-menu]')
1055
1053
  const card = e.target.closest('[data-agent]')
1056
1054
  const a = all.find((x) => x.id === (b ? b.dataset.agentMenu : card && card.dataset.agent))
1057
1055
  if (!a) return
1058
- if (!b) { if (state.workspacesOn) openPlacementDialog(a); return }
1056
+ if (!b) { details(a); return }
1059
1057
  const items = [
1060
1058
  ...(state.workspacesOn ? [{ act: 'place', label: 'Where it works', icon: I.grid }] : []),
1059
+ { act: 'keys', label: 'Keys', icon: I.lock },
1061
1060
  { act: 'revoke', label: 'Revoke on heyquilt.com', icon: I.external, danger: true }
1062
1061
  ]
1063
1062
  openAgentMenu(b, {
1064
1063
  label: `Actions for ${a.name}`,
1065
1064
  items,
1066
1065
  on: (act) => {
1067
- if (act === 'place') openPlacementDialog(a)
1066
+ if (act === 'place' || act === 'keys') details(a)
1068
1067
  if (act === 'revoke') window.open('https://heyquilt.com/dashboard/agents', '_blank', 'noopener')
1069
1068
  }
1070
1069
  })
package/src/ui/loom.css CHANGED
@@ -150,7 +150,22 @@
150
150
  .lh-conv-ren:focus-visible { opacity: 1; }
151
151
  .lh-conv-ren:hover { color: var(--text); background: var(--panel-2); }
152
152
  .lh-conv.editing { flex: 0 1 auto; min-width: 0; gap: 5px; padding-left: 6px; border-color: var(--c); }
153
- .lh-conv-edit { width: 170px; min-width: 60px; height: 22px; padding: 0 6px 0 0; border: 0; background: transparent; font: inherit; font-size: 11.5px; color: var(--text); outline: none; }
153
+ .lh-conv-edit { flex: 1 1 auto; width: 170px; max-width: 100%; min-width: 60px; height: 22px; padding: 0 6px 0 0; border: 0; background: transparent; font: inherit; font-size: 11.5px; color: var(--text); outline: none; }
154
+ /* Following one: it leads and its name has the room; the rest condense to bare marks after it, and fold
155
+ into "+N" when even that leaves the name too little (.packed, set by packConvs in loom.js). */
156
+ .lh-convs.following { overflow: visible; gap: 6px; }
157
+ .lh-convs.following .lh-conv-name { max-width: none; }
158
+ .lh-others { position: relative; display: inline-flex; align-items: center; flex: none; }
159
+ .lh-pop { display: inline-flex; align-items: center; gap: 1px; }
160
+ .lh-pop .lh-conv { border-color: transparent; background: none; }
161
+ .lh-pop .lh-conv-btn { padding: 3px; border-radius: 4px; }
162
+ .lh-pop .lh-conv-btn:hover, .lh-pop .lh-conv-btn:focus-visible { background: var(--panel-2); }
163
+ .lh-more { display: none; align-items: center; height: 22px; padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel); font: inherit; font-size: 11.5px; color: var(--muted); cursor: pointer; }
164
+ .lh-more:hover, .lh-others.open .lh-more { color: var(--text); border-color: var(--border-strong); }
165
+ .lh-convs.packed .lh-more { display: inline-flex; }
166
+ .lh-convs.packed .lh-pop { display: none; position: absolute; top: 100%; right: 0; z-index: 6; gap: 2px; margin-top: 2px; padding: 4px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 8px; box-shadow: var(--shadow-pop); }
167
+ .lh-convs.packed .lh-others::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: 4px; } /* keeps the hover across the gap */
168
+ .lh-convs.packed .lh-others:is(:hover, :focus-within, .open) .lh-pop { display: inline-flex; }
154
169
 
155
170
  .lc-prompt { font-weight: 500; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
156
171
  .lc-reply { margin-top: 4px; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; padding-left: 8px; border-left: 2px solid var(--border); }
@@ -270,7 +285,8 @@
270
285
  .loom-empty .t { font-weight: 600; font-size: 15px; margin-bottom: 6px; }
271
286
  .loom-empty .hint { color: var(--muted); }
272
287
 
273
- .loom-jump { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 6; box-shadow: var(--shadow-pop); }
288
+ /* Centred with margins, not a transform: .btn:active's press would replace it and slide the button off the pointer. */
289
+ .loom-jump { position: absolute; bottom: 16px; left: 0; right: 0; width: max-content; margin: 0 auto; z-index: 6; box-shadow: var(--shadow-pop); }
274
290
  .loom-jump svg { width: 14px; height: 14px; }
275
291
 
276
292
  .loom-tip { position: fixed; z-index: 50; max-width: 340px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 6px; box-shadow: var(--shadow-pop); padding: 8px 11px; font-size: 12px; pointer-events: none; display: flex; flex-direction: column; gap: 3px; }
package/src/ui/loom.js CHANGED
@@ -72,6 +72,7 @@ export function renderLoom (el, { build, prefs, me, names, meAgent, onAction })
72
72
  lastHtml = html
73
73
  el.innerHTML = html
74
74
  phaseNeedles(el)
75
+ packConvs(el)
75
76
  const edit = el.querySelector('.lh-conv-edit')
76
77
  if (edit) { edit.focus(); edit.select() }
77
78
  const s = el.querySelector('.loom-scroll')
@@ -217,24 +218,46 @@ function headHtml (l, color) {
217
218
  }
218
219
 
219
220
  /**
220
- * A lane's conversations, when there's more than one: each with its piece of the mark and its
221
- * name. Click one to follow just that conversation in the lane; the pencil renames it (on this
222
- * computer). A conversation quiet a while keeps its piece, greyed.
221
+ * A lane's conversations, when there's more than one: one row of chips, each its piece of the mark.
222
+ * Click one to follow just that conversation: it moves to the front and opens out to its name (and
223
+ * the pencil, to rename it on this computer), and the others condense to bare marks after it, still a
224
+ * click away. Where even those leave the name no room, they fold into a "+N" that shows them on hover
225
+ * or click (packConvs). A conversation quiet a while keeps its piece, greyed; one working now laps.
223
226
  */
224
227
  function convsHtml (l) {
225
228
  if (!l.convs || l.convs.length < 2) return ''
226
229
  const live = l.convs.filter((c) => c.live).length
227
230
  const label = `${l.isMe ? 'Your' : `${l.name}'s`} conversations: ${l.convs.length}, ${live} live`
228
- return `<div class="lh-convs" role="group" aria-label="${esc(label)}">${l.convs.map((c) => {
231
+ const picked = (at) => !!at && at.lane === l.name && l.convs.some((c) => c.conv === at.conv) ? at.conv : null
232
+ const sel = picked(renaming) || picked(convFocus)
233
+ const chip = (c) => {
229
234
  const at = `data-lane="${esc(l.name)}" data-conv="${esc(c.conv)}"`
230
235
  if (renaming && renaming.lane === l.name && renaming.conv === c.conv) {
231
- return `<span class="lh-conv editing">${CONV_MARKS[c.slot]}<input class="lh-conv-edit" ${at} value="${esc(c.name)}" maxlength="60" aria-label="Name this conversation (Enter to keep, Esc to cancel, empty for its own name)"></span>`
236
+ return `<span class="lh-conv on editing">${CONV_MARKS[c.slot]}<input class="lh-conv-edit" ${at} value="${esc(c.name)}" maxlength="60" aria-label="Name this conversation (Enter to keep, Esc to cancel, empty for its own name)"></span>`
232
237
  }
233
- const on = !!convFocus && convFocus.lane === l.name && convFocus.conv === c.conv
238
+ const on = c.conv === sel
234
239
  const state = c.active ? 'working now' : c.live ? 'live' : `quiet since ${clock(c.last)}`
235
240
  const tip = `${c.name}: ${c.turns} turn${c.turns === 1 ? '' : 's'}, ${state}. ${on ? 'Click to see every conversation' : 'Click to follow just this conversation'}`
236
- return `<span class="lh-conv${on ? ' on' : ''}${c.live ? '' : ' quiet'}"><button class="lh-conv-btn" data-loom-follow ${at} aria-pressed="${on}" aria-label="${esc(c.name)}" title="${esc(tip)}">${(c.active ? CONV_MARKS_LIVE : CONV_MARKS)[c.slot]}<span class="lh-conv-name">${esc(c.name)}</span></button><button class="lh-conv-ren" data-loom-rename ${at} title="Rename it (on this computer)" aria-label="${esc(`Rename ${c.name}`)}">${I.pencil}</button></span>`
237
- }).join('')}</div>`
241
+ return `<span class="lh-conv${on ? ' on' : ''}${c.live ? '' : ' quiet'}"><button class="lh-conv-btn" data-loom-follow ${at} aria-pressed="${on}" aria-label="${esc(c.name)}" title="${esc(tip)}">${(c.active ? CONV_MARKS_LIVE : CONV_MARKS)[c.slot]}<span class="lh-conv-name">${esc(c.name)}</span></button>${on ? `<button class="lh-conv-ren" data-loom-rename ${at} title="Rename it (on this computer)" aria-label="${esc(`Rename ${c.name}`)}">${I.pencil}</button>` : ''}</span>`
242
+ }
243
+ if (!sel) return `<div class="lh-convs" role="group" aria-label="${esc(label)}">${l.convs.map(chip).join('')}</div>`
244
+ const others = l.convs.filter((c) => c.conv !== sel)
245
+ return `<div class="lh-convs following" role="group" aria-label="${esc(label)}">${chip(l.convs.find((c) => c.conv === sel))}<span class="lh-others">
246
+ <button class="lh-more" data-loom-more aria-expanded="false" title="${esc(`${others.length} other conversation${others.length === 1 ? '' : 's'}: ${others.map((c) => c.name).join(', ')}`)}">+${others.length}</button>
247
+ <span class="lh-pop">${others.map(chip).join('')}</span></span></div>`
248
+ }
249
+
250
+ /**
251
+ * A followed conversation's name gets the row; where the condensed marks beside it still leave it
252
+ * less than NAME_ROOM, they fold into their "+N" (.packed). Run after each render and resize.
253
+ */
254
+ const NAME_ROOM = 100
255
+ function packConvs (root) {
256
+ for (const row of root.querySelectorAll('.lh-convs.following')) {
257
+ row.classList.remove('packed')
258
+ const name = row.querySelector('.lh-conv.on .lh-conv-name, .lh-conv.on .lh-conv-edit')
259
+ if (name && name.clientWidth < NAME_ROOM && name.scrollWidth > name.clientWidth) row.classList.add('packed')
260
+ }
238
261
  }
239
262
 
240
263
  /** How long a lane has been on its focus (stitching), beside its status; tickClocks counts it up. */
@@ -437,6 +460,7 @@ function watchSize (el) {
437
460
  const root = el.querySelector(':scope > .loom')
438
461
  if (!root) return
439
462
  drawThreads(el)
463
+ packConvs(root)
440
464
  // A new width can fit more lanes or fewer (or fold them into one column): render again, once it settles.
441
465
  clearTimeout(settle)
442
466
  if (el.clientWidth !== lastWidth) {
@@ -660,6 +684,15 @@ function bind (el) {
660
684
  followed = !same
661
685
  return act('refresh')
662
686
  }
687
+ for (const o of r.querySelectorAll('.lh-others.open')) {
688
+ if (!o.contains(t)) { o.classList.remove('open'); o.querySelector('.lh-more').setAttribute('aria-expanded', 'false') }
689
+ }
690
+ if ((b = pick('[data-loom-more]'))) {
691
+ const open = b.getAttribute('aria-expanded') !== 'true'
692
+ b.setAttribute('aria-expanded', String(open))
693
+ b.closest('.lh-others').classList.toggle('open', open)
694
+ return
695
+ }
663
696
  if ((b = pick('[data-loom-rename]'))) { renaming = { lane: b.dataset.lane, conv: b.dataset.conv }; return act('refresh') }
664
697
  if (pick('.lh-conv-edit')) return
665
698
  if ((b = pick('[data-loom-conv]'))) return act('conv', { name: b.dataset.loomConv, conv: b.dataset.loomConvid })
@@ -1,5 +1,5 @@
1
1
  // The Files rail (rail.js): the folded Files pane as signals instead of names. Who holds
2
- // what, since when, why, who waits for it and when it frees up on its own; files to merge.
2
+ // what, since when, why, who waits for it and when it frees up on its own.
3
3
  // Pure, so tests can run it without a page.
4
4
  import { stitchTime } from './loom-model.js'
5
5
 
@@ -11,14 +11,14 @@ const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(
11
11
 
12
12
  /**
13
13
  * Everything the rail and its lock list show. `claims`: the session's ({ pattern, by, ts, note,
14
- * active, activeAt, queue }); `merges`: files to merge ({ id, path, state }); `me`: your name;
14
+ * active, activeAt, queue }); `me`: your name;
15
15
  * `owners`: AI session name -> its person (chat.js aiOwners), so a person's AI sessions are one
16
16
  * holder, "<person>'s AI"; `aiName`: person -> that name; `files`: the session's paths (to count a
17
- * folder's); `open`: paths you have open (a merge there is urgent); `focusOf`: name -> what they
17
+ * folder's); `focusOf`: name -> what they
18
18
  * said they're doing (a lock's why when it has no note); `kindOf`: name -> 'agent' for an AI agent.
19
- * Returns { locks, holders, waiting, mine, merges: { count, urgent } }, locks oldest first.
19
+ * Returns { locks, holders, waiting, mine }, locks oldest first.
20
20
  */
21
- export function railModel ({ claims = [], merges = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], open = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
21
+ export function railModel ({ claims = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
22
22
  const fileSet = new Set(files)
23
23
  const holderOf = (by) => owners.has(by) ? aiName(owners.get(by)) : by
24
24
  const locks = (claims || []).filter((c) => c && c.pattern && c.by).map((c) => {
@@ -52,14 +52,11 @@ export function railModel ({ claims = [], merges = [], me = '', owners = new Map
52
52
  h.locks++
53
53
  h.waiting += l.queue.length
54
54
  }
55
- const openSet = new Set(open)
56
- const toMerge = (merges || []).filter((m) => m && m.state !== 'done')
57
55
  return {
58
56
  locks,
59
57
  holders,
60
58
  waiting: locks.filter((l) => l.mine && l.queue.length), // someone waits for a file you hold: yours to act on
61
- mine: locks.filter((l) => l.ours).length,
62
- merges: { count: toMerge.length, urgent: toMerge.some((m) => openSet.has(m.path)) }
59
+ mine: locks.filter((l) => l.ours).length
63
60
  }
64
61
  }
65
62
 
@@ -144,7 +144,7 @@ export function openReleaseNotes () {
144
144
  <div class="rn-body">
145
145
  ${newer.map((rel) => releaseHtml(rel, 0, { latest: true })).join('')}
146
146
  ${rels.map((rel, i) => releaseHtml(rel, i + newer.length, { current: rel.version === r.version })).join('')}
147
- <footer class="rn-foot">All releases live on <a href="https://github.com/DanielCarmichaelGit/heyquilt/releases" target="_blank" rel="noopener">GitHub</a>.</footer>
147
+ <footer class="rn-foot">Every download is on <a href="https://heyquilt.com/#download" target="_blank" rel="noopener">heyquilt.com</a>.</footer>
148
148
  </div>
149
149
  </div>
150
150
  </div>`
@@ -0,0 +1,53 @@
1
+ // The Keys table on each agent (sandbox keys, QUILT_AGENT_KEY): its words and numbers, shared by
2
+ // the app (sandbox-keys.js) and heyquilt.com (web/components/SandboxKeys.js). Pure, no page.
3
+ const MIN = 60 * 1000
4
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
5
+
6
+ /** When a key was last used: "Just now", "2 min ago", "5 h ago", "3 days ago", then "Oct 12". */
7
+ export function usedText (ts, now = Date.now()) {
8
+ if (!ts) return 'Never'
9
+ const m = Math.max(0, Math.floor((now - ts) / MIN))
10
+ if (m < 1) return 'Just now'
11
+ if (m < 60) return `${m} min ago`
12
+ if (m < 24 * 60) return `${Math.floor(m / 60)} h ago`
13
+ const d = Math.floor(m / (24 * 60))
14
+ if (d < 7) return `${d} ${d === 1 ? 'day' : 'days'} ago`
15
+ const at = new Date(ts)
16
+ return `${MONTHS[at.getMonth()]} ${at.getDate()}`
17
+ }
18
+
19
+ /** Each day's bar as a percentage of the busiest day's. */
20
+ export function sparkHeights (uses) {
21
+ const top = Math.max(0, ...uses)
22
+ return uses.map((n) => (top ? Math.round((n / top) * 100) : 0))
23
+ }
24
+
25
+ const LABELS = { 'in-use': 'In use', idle: 'Idle', revoked: 'Revoked' }
26
+ const CLASSES = { 'in-use': 'live', idle: 'idle', revoked: 'revoked' }
27
+ export const stateLabel = (s) => LABELS[s] || LABELS.idle
28
+ export const stateClass = (s) => CLASSES[s] || CLASSES.idle
29
+
30
+ /** How a key is shown: never the key, its last four characters. */
31
+ export const keyLabel = (hint) => `qak_…${hint || '????'}`
32
+
33
+ /** One of four colours for a session's dot, the same for its room everywhere. */
34
+ export const sessionTone = (room) => [...String(room)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 4
35
+
36
+ /** Keys whose name or session has the search in it. */
37
+ export function matchKeys (keys, q) {
38
+ const s = String(q || '').trim().toLowerCase()
39
+ return s ? keys.filter((k) => [k.name, k.session].some((x) => String(x || '').toLowerCase().includes(s))) : keys
40
+ }
41
+
42
+ /**
43
+ * The New key session picker: `local` sessions ({ value, room, name }, on this computer, so their
44
+ * links are at hand) first, then `known` ones a key already works in ({ room, name }), each room once.
45
+ */
46
+ export function sessionOptions (local = [], known = []) {
47
+ const rooms = new Set(local.map((s) => s.room))
48
+ const named = new Map(known.map((s) => [s.room, s.name]))
49
+ return [
50
+ ...local.map((s) => ({ value: s.value, name: named.get(s.room) || s.name })),
51
+ ...known.filter((s) => !rooms.has(s.room)).map((s) => ({ value: `room:${s.room}`, name: s.name }))
52
+ ]
53
+ }
@@ -0,0 +1,132 @@
1
+ // An agent's Keys table in the app (Settings › Agents, opened from its card; mockup
2
+ // docs/mockups/2026-10-18-sandbox-keys.html, option A): its sandbox keys (QUILT_AGENT_KEY), each
3
+ // with its session, last use, use over seven days and state, Rotate and Revoke (Remove once
4
+ // revoked). New key opens a row at the top; a new key shows once, with Copy. The words and numbers
5
+ // are sandbox-keys-model.js, which heyquilt.com shares.
6
+ import { I, esc, api, toast } from './common.js'
7
+ import { usedText, sparkHeights, stateLabel, stateClass, keyLabel, sessionTone, matchKeys, sessionOptions } from './sandbox-keys-model.js'
8
+
9
+ const KEY = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="5" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 8h6M12 8v2.5M14 8v2" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
10
+ const ROTATE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
11
+
12
+ function sparkHtml (k) {
13
+ if (k.state === 'revoked') return ''
14
+ const total = k.uses.reduce((a, b) => a + b, 0)
15
+ return `<span class="sk-spark" title="${total} ${total === 1 ? 'request' : 'requests'} in 7 days">${sparkHeights(k.uses).map((h) => `<i${h ? ` style="height:${h}%"` : ' class="z"'}></i>`).join('')}</span>`
16
+ }
17
+
18
+ function rowHtml (k, now, q) {
19
+ const hidden = matchKeys([k], q).length ? '' : ' hidden'
20
+ const revoked = k.state === 'revoked'
21
+ const acts = revoked
22
+ ? `<button type="button" class="btn sm ghost" data-sk-remove="${esc(k.id)}">Remove</button>`
23
+ : `<button type="button" class="btn sm" data-sk-rotate="${esc(k.id)}">${ROTATE}<span>Rotate</span></button><button type="button" class="btn sm danger" data-sk-revoke="${esc(k.id)}"><span>Revoke</span></button>`
24
+ return `<tr data-sk-row="${esc(k.id)}"${revoked ? ' class="sk-revoked"' : ''}${hidden}>
25
+ <td><b>${esc(k.name)}</b><div class="sk-hint">${esc(keyLabel(k.hint))}</div></td>
26
+ <td><span class="sk-chip" title="${esc(k.session)}"><span class="sk-dot t${sessionTone(k.room)}"></span><span class="sk-chip-t">${esc(k.session)}</span></span></td>
27
+ <td class="sk-n">${esc(usedText(k.lastUsedAt, now))}</td>
28
+ <td class="sk-n sk-m">${sparkHtml(k)}</td>
29
+ <td><span class="pill ${stateClass(k.state)}">${stateLabel(k.state)}</span></td>
30
+ <td class="sk-act"><span>${acts}</span></td>
31
+ </tr>`
32
+ }
33
+
34
+ function newRowHtml (sessions) {
35
+ const where = sessions.length
36
+ ? `<select class="input sm" name="sk-session" aria-label="Session">${sessions.map((s) => `<option value="${esc(s.value)}">${esc(s.name)}</option>`).join('')}</select>`
37
+ : '<input class="input sm" name="sk-link" type="url" placeholder="Session invite link" aria-label="Session invite link" autocomplete="off">'
38
+ return `<tr class="sk-newrow"><td colspan="6"><form class="sk-inline" data-sk-form>
39
+ <input class="input sm" name="sk-name" maxlength="40" placeholder="Name, e.g. bolt.new · checkout" aria-label="Key name" autocomplete="off" required>
40
+ ${where}
41
+ <button type="submit" class="btn primary sm" data-sk-create>Create</button><button type="button" class="btn ghost sm" data-sk-cancel>Cancel</button>
42
+ </form></td></tr>`
43
+ }
44
+
45
+ /**
46
+ * The Keys card for `agent`. `v`: { keys, sessions (picker options), adding, fresh ({ envKey }),
47
+ * error, query, loading, now }.
48
+ */
49
+ export function keysCardHtml (agent, v) {
50
+ const now = v.now || Date.now()
51
+ const keys = v.keys || []
52
+ const body = v.loading
53
+ ? '<tr><td colspan="6" class="hint">Loading…</td></tr>'
54
+ : keys.length ? keys.map((k) => rowHtml(k, now, v.query)).join('') : '<tr><td colspan="6" class="hint">No keys yet.</td></tr>'
55
+ return `<div class="sk-card card" data-sk-agent="${esc(agent.id)}">
56
+ <div class="sk-head"><span class="sk-ico">${KEY}</span><span class="sk-t">Keys</span><span class="sk-who">${esc(agent.name)} (agent)</span><span class="sk-grow"></span>
57
+ <label class="sk-search"><span class="sr-only">Search keys</span><input class="input sm" data-sk-search type="search" placeholder="Search keys" value="${esc(v.query || '')}" autocomplete="off"></label>
58
+ <button type="button" class="btn primary sm" data-sk-new${v.adding ? ' disabled' : ''}>New key</button></div>
59
+ ${v.fresh ? `<div class="sk-once"><span class="pill live">New</span><code class="sk-key" data-sk-value>${esc(v.fresh.envKey)}</code><span class="sk-acts"><button type="button" class="btn sm" data-sk-copy>${I.copy}<span>Copy</span></button><span class="hint">shown once</span></span></div>` : ''}
60
+ ${v.error ? `<p class="error sk-error">${esc(v.error)}</p>` : ''}
61
+ <div class="sk-scroll"><table class="sk-tbl">
62
+ <thead><tr><th>Key</th><th>Session</th><th class="sk-n">Last used</th><th class="sk-n sk-m">Use, 7 days</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead>
63
+ <tbody>${v.adding ? newRowHtml(v.sessions || []) : ''}${body}</tbody>
64
+ </table></div>
65
+ </div>`
66
+ }
67
+
68
+ /** Draws and runs one agent's Keys card in `el`. */
69
+ export function bindSandboxKeys (el, agent) {
70
+ const base = `/api/agents/${encodeURIComponent(agent.id)}/sandbox-keys`
71
+ const v = { keys: [], sessions: [], adding: false, fresh: null, error: '', query: '', loading: true }
72
+ const draw = () => { el.innerHTML = keysCardHtml(agent, v) }
73
+ const load = async () => {
74
+ try {
75
+ const r = await api('GET', base)
76
+ Object.assign(v, { keys: r.keys, sessions: sessionOptions(r.local, r.sessions), loading: false })
77
+ } catch (err) { Object.assign(v, { loading: false, error: err.message }) }
78
+ draw()
79
+ }
80
+ // Rotate and Revoke ask once more on the button itself (no dialog): a second click within 4 s does it.
81
+ const armed = (b, label) => {
82
+ if (b.dataset.armed) return true
83
+ b.dataset.armed = '1'
84
+ b.classList.add('sk-armed')
85
+ b.querySelector('span').textContent = label
86
+ setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.classList.remove('sk-armed'); b.querySelector('span').textContent = label.replace(' now', '') } }, 4000)
87
+ return false
88
+ }
89
+ const act = async (fn) => {
90
+ v.error = ''
91
+ try { await fn() } catch (err) { v.error = err.message }
92
+ await load()
93
+ }
94
+ el.addEventListener('click', async (e) => {
95
+ const b = e.target.closest('button')
96
+ if (!b || b.disabled) return
97
+ if (b.matches('[data-sk-new]')) { v.adding = true; draw(); el.querySelector('[name="sk-name"]')?.focus(); return }
98
+ if (b.matches('[data-sk-cancel]')) { v.adding = false; v.error = ''; draw(); return }
99
+ if (b.matches('[data-sk-copy]')) {
100
+ try { await navigator.clipboard.writeText(v.fresh.envKey); toast('Copied') } catch { toast('Select the key and press ⌘/Ctrl+C to copy') }
101
+ return
102
+ }
103
+ if (b.dataset.skRotate && armed(b, 'Rotate now')) {
104
+ b.disabled = true
105
+ return act(async () => { const r = await api('POST', `${base}/rotate`, { keyId: b.dataset.skRotate }); v.fresh = { envKey: r.envKey } })
106
+ }
107
+ if (b.dataset.skRevoke && armed(b, 'Revoke now')) {
108
+ b.disabled = true
109
+ return act(() => api('POST', `${base}/revoke`, { keyId: b.dataset.skRevoke }))
110
+ }
111
+ if (b.dataset.skRemove) { b.disabled = true; return act(() => api('POST', `${base}/remove`, { keyId: b.dataset.skRemove })) }
112
+ })
113
+ el.addEventListener('submit', async (e) => {
114
+ e.preventDefault()
115
+ const f = e.target
116
+ const name = f.elements['sk-name'].value.trim()
117
+ const pick = f.elements['sk-session']?.value || ''
118
+ const link = f.elements['sk-link']?.value.trim() || ''
119
+ f.querySelector('[data-sk-create]').disabled = true
120
+ await act(async () => {
121
+ const r = await api('POST', base, { name, ...(pick ? { session: pick } : { link }) })
122
+ Object.assign(v, { fresh: { envKey: r.envKey }, adding: false })
123
+ })
124
+ })
125
+ el.addEventListener('input', (e) => {
126
+ if (!e.target.matches('[data-sk-search]')) return
127
+ v.query = e.target.value
128
+ for (const tr of el.querySelectorAll('[data-sk-row]')) tr.hidden = !matchKeys(v.keys.filter((k) => k.id === tr.dataset.skRow), v.query).length
129
+ })
130
+ draw()
131
+ load()
132
+ }