heyquilt 0.0.1 → 0.3.106

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 (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/ui/loom.js ADDED
@@ -0,0 +1,786 @@
1
+ // The Loom (see loom-model.js): everyone's AI conversations at once, one lane
2
+ // per person, time running down. Cards open in place; file chips light up every
3
+ // lane that touched the file; threads stitch lanes that worked on the same file.
4
+ // Lanes that don't fit fold into strips of knots (and a crowd column past that).
5
+ import { esc, clock, ago, avatar, colorFor, basename, I } from './common.js'
6
+ import { toolLogo, toolLabel } from './tool-logo.js'
7
+ import { markdown, visiblePrompt } from './feed.js'
8
+ import { textHtml } from './chat.js'
9
+ import { stitchTime } from './loom-model.js'
10
+ import { GRID, PIECES, CORNER } from './mark.js'
11
+
12
+ const MERGE_BELOW = 620 // narrower than this, lanes fold into one column
13
+ const FILES_SHOWN = 4
14
+
15
+ let expanded = new Set() // card keys opened in place
16
+ let seen = null // card keys already shown (null: nothing shown yet, so nothing is "new")
17
+ let lastHtml = ''
18
+ let model = null
19
+ let focused = null // file path lit from a chip or the file tree
20
+ let anchor = null // { key, y }: a card just opened or closed stays where it was on screen
21
+ let crowdOpen = false // the crowd column's list of people is showing
22
+ let lastWidth = 0
23
+ let handlers = { onAction: () => {} }
24
+ let ticker = 0 // counts stitching times up between renders
25
+ let convFocus = null // { lane, conv }: one conversation followed in its lane, the rest dimmed
26
+ let renaming = null // { lane, conv }: the conversation whose name is being typed
27
+ let followed = false // a conversation was just followed: show its latest card once drawn
28
+ let fresh = false // cards came in while scrolled up: the jump button says "New activity"
29
+ const bound = new WeakSet()
30
+
31
+ /** Forget open cards and what was shown: another session is being shown. */
32
+ export function resetLoom () {
33
+ expanded = new Set()
34
+ convFocus = null
35
+ renaming = null
36
+ seen = null
37
+ lastHtml = ''
38
+ model = null
39
+ focused = null
40
+ crowdOpen = false
41
+ }
42
+
43
+ /** True when `el` holds the Loom now. */
44
+ export const showsLoom = (el) => !!el?.querySelector(':scope > .loom')
45
+
46
+ /**
47
+ * Renders the Loom into `el`. `build(merged, width)` returns the model
48
+ * (loom-model.js) for one column or for lanes in `width` px; `prefs`: { chat,
49
+ * tasks, density, layout, hidden, pinned };
50
+ * `onAction(kind, value)`: open 'person' (a name), 'conv' ({ name, conv }), 'file'
51
+ * (a path) or 'task' (an id); change a pref: 'hide' / 'unhide' (a name), 'toggle'
52
+ * ('chat' | 'tasks'), 'density', 'layout', 'pin' / 'open-lane' (a name); or just
53
+ * render again ('expand', 'refresh').
54
+ * Skips the DOM when nothing changed.
55
+ */
56
+ export function renderLoom (el, { build, prefs, me, names, meAgent, onAction }) {
57
+ handlers = { onAction }
58
+ // Naming a conversation: don't redraw under the typing (it redraws once the name is in).
59
+ const typing = el.querySelector('.lh-conv-edit')
60
+ if (typing && typing === document.activeElement) return
61
+ if (!ticker) ticker = setInterval(tickClocks, 30000)
62
+ const wasLoom = showsLoom(el)
63
+ const merged = prefs.layout === 'merged' || (el.clientWidth > 0 && el.clientWidth < MERGE_BELOW)
64
+ lastWidth = el.clientWidth
65
+ model = build(merged, Math.max(0, el.clientWidth - 14)) // less a scrollbar
66
+ const html = loomHtml(model, { prefs, me, names, meAgent, merged })
67
+ if (wasLoom && html === lastHtml) return
68
+ const scroller = el.querySelector('.loom-scroll')
69
+ const atBottom = !scroller || scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80
70
+ const top = scroller ? scroller.scrollTop : 0
71
+ const left = scroller ? scroller.scrollLeft : 0
72
+ lastHtml = html
73
+ el.innerHTML = html
74
+ phaseNeedles(el)
75
+ packConvs(el)
76
+ const edit = el.querySelector('.lh-conv-edit')
77
+ if (edit) { edit.focus(); edit.select() }
78
+ const s = el.querySelector('.loom-scroll')
79
+ const held = anchor && [...s.querySelectorAll('[data-key]')].find((c) => c.dataset.key === anchor.key)
80
+ if (held) s.scrollTop += held.getBoundingClientRect().top - anchor.y
81
+ else if (!wasLoom || atBottom) s.scrollTop = s.scrollHeight
82
+ else {
83
+ s.scrollTop = top
84
+ if (seen && model.items.some((it) => it.lane && !seen.has(it.key))) fresh = true
85
+ }
86
+ s.scrollLeft = left
87
+ anchor = null
88
+ // A conversation just followed: its latest card, in the middle of the Loom (only the Loom scrolls).
89
+ if (followed && convFocus) {
90
+ const cards = [...s.querySelectorAll('.lc-turn')].filter((x) => x.dataset.lane === convFocus.lane && x.dataset.conv === convFocus.conv)
91
+ const last = cards[cards.length - 1]
92
+ if (last) s.scrollTop += last.getBoundingClientRect().top - s.getBoundingClientRect().top - (s.clientHeight - last.offsetHeight) / 2
93
+ }
94
+ followed = false
95
+ if (!seen) seen = new Set()
96
+ for (const it of model.items) seen.add(it.key)
97
+ // A file lit by a hover that ended while something else was showing (it was clicked open) stays
98
+ // dark: before drawThreads, which lights what's remembered.
99
+ if (!wasLoom) focused = null
100
+ bind(el)
101
+ watchSize(el)
102
+ drawThreads(el)
103
+ if (focused) focusFile(el, focused)
104
+ }
105
+
106
+ // ------------------------------------------------------------- markup --
107
+
108
+ function loomHtml (m, { prefs, me, names, meAgent, merged }) {
109
+ const lanes = m.lanes
110
+ const has = (at) => at && lanes.some((l) => l.name === at.lane && l.convs.some((c) => c.conv === at.conv))
111
+ if (convFocus && !has(convFocus)) convFocus = null
112
+ if (renaming && !has(renaming)) renaming = null
113
+ const byName = new Map(lanes.map((l) => [l.name, l]))
114
+ const WIDTH = { open: 'minmax(250px, 1fr)', strip: '46px', crowd: '58px', merged: 'minmax(0, 1fr)' }
115
+ const cols = m.columns.length ? m.columns.map((c) => WIDTH[c.kind]).join(' ') : 'minmax(0, 1fr)'
116
+ const colorOf = new Map(lanes.map((l) => [l.name, colorFor(l.name, l.color)]))
117
+ const turns = m.items.filter((it) => it.type === 'turn')
118
+ const convs = new Set(turns.map((t) => `${t.lane}|${t.conv}`)).size
119
+ const working = lanes.filter((l) => l.working).length
120
+ const dens = prefs.density === 'compact' ? 'compact' : 'detailed'
121
+ const hidden = (prefs.hidden || []).filter(Boolean)
122
+
123
+ const bar = `<div class="loom-bar">
124
+ <div class="loom-title"><b>Everyone</b><span class="hint">${convs} conversation${convs === 1 ? '' : 's'} · ${m.files.size} file${m.files.size === 1 ? '' : 's'} changed${working ? ` · <span class="loom-live"><span class="pulse"></span>${working} working</span>` : ''}</span></div>
125
+ <span class="spacer"></span>
126
+ ${hidden.map((n) => `<button class="loom-chip off" data-loom-unhide="${esc(n)}" title="Show ${esc(n)}'s lane">${I.eye}<span>${esc(n)}</span></button>`).join('')}
127
+ <div class="loom-seg" role="group" aria-label="Show">
128
+ <button class="loom-chip${prefs.chat !== false ? ' on' : ''}" data-loom-pref="chat" aria-pressed="${prefs.chat !== false}">${I.chat}<span>Chat</span></button>
129
+ <button class="loom-chip${prefs.tasks !== false ? ' on' : ''}" data-loom-pref="tasks" aria-pressed="${prefs.tasks !== false}">${I.board}<span>Tasks</span></button>
130
+ </div>
131
+ <div class="loom-seg" role="group" aria-label="Detail">
132
+ <button class="loom-chip${dens === 'detailed' ? ' on' : ''}" data-loom-density="detailed" aria-pressed="${dens === 'detailed'}">Detailed</button>
133
+ <button class="loom-chip${dens === 'compact' ? ' on' : ''}" data-loom-density="compact" aria-pressed="${dens === 'compact'}">Compact</button>
134
+ </div>
135
+ <div class="loom-seg wide-only" role="group" aria-label="Layout">
136
+ <button class="loom-chip${prefs.layout !== 'merged' ? ' on' : ''}" data-loom-layout="lanes" aria-pressed="${prefs.layout !== 'merged'}" title="A lane per person">Lanes</button>
137
+ <button class="loom-chip${prefs.layout === 'merged' ? ' on' : ''}" data-loom-layout="merged" aria-pressed="${prefs.layout === 'merged'}" title="One column, in time order">One column</button>
138
+ </div>
139
+ </div>`
140
+
141
+ const heads = merged
142
+ ? ''
143
+ : `<div class="loom-heads" style="grid-template-columns:${cols}">${m.columns.map((c) => {
144
+ if (c.kind === 'crowd') return crowdHeadHtml(c.lanes.map((n) => byName.get(n)), colorOf)
145
+ const l = byName.get(c.lane)
146
+ return c.kind === 'strip' ? stripHeadHtml(l, colorOf.get(l.name)) : headHtml(l, colorOf.get(l.name))
147
+ }).join('')}</div>`
148
+
149
+ const parts = []
150
+ if (!merged) {
151
+ m.columns.forEach((c, i) => {
152
+ const l = c.lane ? byName.get(c.lane) : null
153
+ // A lane with a needle: its thread ends at the needle's patch instead of running through it.
154
+ const needled = l && l.working && m.rows > 0
155
+ const cls = l ? `${l.sharing ? '' : ' paused'}${l.working ? ' live' : ''}${c.kind === 'strip' ? ' strip' : ''}${needled ? ' to-needle' : ''}` : ' crowd'
156
+ parts.push(`<div class="loom-thread${cls}" style="grid-column:${i + 1};grid-row:1 / ${m.rows + (needled ? 1 : 2)};--c:${esc(l ? colorOf.get(l.name) : 'var(--border-strong)')}" aria-hidden="true"></div>`)
157
+ })
158
+ } else {
159
+ parts.push(`<div class="loom-thread merged" style="grid-column:1;grid-row:1 / ${m.rows + 2}" aria-hidden="true"></div>`)
160
+ }
161
+ for (const it of m.items) if (!it.folded) parts.push(itemHtml(it, { colorOf, me, names, meAgent, merged, dens }))
162
+ for (const k of m.knots) parts.push(knotHtml(k, m, colorOf, me))
163
+ if (!m.items.some((it) => it.lane)) {
164
+ parts.push(`<div class="loom-empty" style="grid-column:1 / -1;grid-row:1">
165
+ <div class="t">Nothing woven yet</div>
166
+ <div class="hint">When anyone here works with their AI, each turn shows up in their lane: what they asked, what it did and which files it changed. Chat and task notes appear in the lane of whoever wrote them.</div></div>`)
167
+ }
168
+ // A working AI's needle, with the file its latest turn last changed.
169
+ let stacked = 0 // one column: needles one under another
170
+ lanes.forEach((l) => {
171
+ if (!l.working || l.fold === 'crowd') return
172
+ if (l.fold === 'strip') {
173
+ parts.push(`<div class="loom-needle mini" style="grid-column:${l.col + 1};grid-row:${m.rows + 1};--c:${esc(colorOf.get(l.name))}" title="${esc(`${l.name}'s AI is stitching`)}">${NEEDLE}</div>`)
174
+ return
175
+ }
176
+ const turn = m.items.filter((it) => it.type === 'turn' && it.lane === l.name).pop()
177
+ const file = turn && turn.edits.length ? turn.edits[turn.edits.length - 1] : ''
178
+ const who = l.isMe ? 'Your AI' : `${l.name}'s AI`
179
+ const what = file ? ` <code title="${esc(file)}">${esc(basename(file))}</code>` : ''
180
+ const col = merged ? 1 : l.col + 1
181
+ const row = m.rows + 1 + (merged ? stacked++ : 0)
182
+ parts.push(`<div class="loom-needle" style="grid-column:${col};grid-row:${row};--c:${esc(colorOf.get(l.name))}">${NEEDLE}<span>${esc(who)} is stitching${what}…</span></div>`)
183
+ })
184
+
185
+ return `<div class="loom${merged ? ' merged' : ''}${convFocus ? ' following' : ''}" data-density="${dens}">
186
+ ${bar}
187
+ <div class="loom-scroll">
188
+ ${heads}
189
+ <div class="loom-grid" role="feed" aria-label="Everyone's AI conversations, chat and task notes" style="grid-template-columns:${cols}">
190
+ ${parts.join('')}
191
+ <svg class="loom-stitches" aria-hidden="true"></svg>
192
+ </div>
193
+ </div>
194
+ <button class="btn sm loom-jump" title="Back down to the latest" hidden>${I.down}<span>Latest</span></button>
195
+ <div class="loom-tip" role="tooltip" hidden></div>
196
+ </div>`
197
+ }
198
+
199
+ function headHtml (l, color) {
200
+ const status = !l.sharing ? '<span class="lh-st paused">paused</span>'
201
+ : l.working ? '<span class="lh-st live"><span class="pulse"></span><span class="lh-word">working</span></span>'
202
+ : l.online ? '<span class="lh-st"><span class="lh-word">idle</span></span>' : '<span class="lh-st off">offline</span>'
203
+ const holds = l.holds.length
204
+ ? `<button type="button" class="lh-holds" data-loom-holds="${esc(l.name)}" title="${esc(`Holds ${l.holds.join(', ')}. Click for how long, why and who waits`)}">holds ${l.holds.length}${l.waiting ? ` · ${l.waiting} waiting` : ''}</button>`
205
+ : ''
206
+ return `<div class="loom-head" style="--c:${esc(color)}">
207
+ <button class="lh-who" data-loom-person="${esc(l.name)}" title="${esc(l.isMe ? 'Your AI chat' : `${l.name}'s AI chat`)}">
208
+ ${avatar(l.name, l.color, l.online)}
209
+ <span class="lh-name">${esc(l.isMe ? 'You' : l.name)}</span>
210
+ ${l.tool ? `<span class="lh-tool" title="${esc(toolLabel(l.tool))}">${toolLogo(l.tool)}</span>` : ''}
211
+ ${l.kind === 'agent' ? '<span class="tag">AI</span>' : ''}
212
+ </button>
213
+ <span class="lh-meta">${status}${sinceHtml(l)}${holds}</span>
214
+ <button class="lh-pin${l.pinned ? ' on' : ''}" data-loom-pin="${esc(l.name)}" title="${l.pinned ? 'Unpin: let this lane fold when there\'s no room' : 'Pin: keep this lane open'}" aria-pressed="${l.pinned}" aria-label="${esc(`${l.pinned ? 'Unpin' : 'Pin'} ${l.name}'s lane`)}">${PIN}</button>
215
+ <button class="lh-hide" data-loom-hide="${esc(l.name)}" title="Hide this lane" aria-label="Hide ${esc(l.name)}'s lane">${I.x}</button>
216
+ ${convsHtml(l)}
217
+ </div>`
218
+ }
219
+
220
+ /**
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.
226
+ */
227
+ function convsHtml (l) {
228
+ if (!l.convs || l.convs.length < 2) return ''
229
+ const live = l.convs.filter((c) => c.live).length
230
+ const label = `${l.isMe ? 'Your' : `${l.name}'s`} conversations: ${l.convs.length}, ${live} live`
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) => {
234
+ const at = `data-lane="${esc(l.name)}" data-conv="${esc(c.conv)}"`
235
+ if (renaming && renaming.lane === l.name && renaming.conv === c.conv) {
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>`
237
+ }
238
+ const on = c.conv === sel
239
+ const state = c.active ? 'working now' : c.live ? 'live' : `quiet since ${clock(c.last)}`
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'}`
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
+ }
261
+ }
262
+
263
+ /** How long a lane has been on its focus (stitching), beside its status; tickClocks counts it up. */
264
+ function sinceHtml (l) {
265
+ if (!l.since) return ''
266
+ const what = `${l.isMe ? 'You have' : `${l.name} has`} been stitching${l.focus ? ` on "${l.focus}"` : ''} since ${clock(l.since)}`
267
+ return `<span class="lh-since" data-since="${l.since}" title="${esc(what)}">${esc(stitchTime(l.since))}</span>`
268
+ }
269
+
270
+ function tickClocks () {
271
+ for (const n of document.querySelectorAll('.loom [data-since]')) n.textContent = stitchTime(Number(n.dataset.since))
272
+ }
273
+
274
+ /** A folded lane's head: its avatar, a pulse while its AI works. Click to open the lane. */
275
+ function stripHeadHtml (l, color) {
276
+ const status = !l.sharing ? 'paused' : l.working ? 'working' : l.online ? 'idle' : 'offline'
277
+ return `<button class="loom-head strip${l.working ? ' live' : ''}" data-loom-open="${esc(l.name)}" style="--c:${esc(color)}" title="${esc(`${l.isMe ? 'You' : l.name} (${status}${l.since ? `, stitching for ${stitchTime(l.since)}` : l.lastTs ? `, last active ${ago(l.lastTs)}` : ''}). Click to open this lane`)}" aria-label="${esc(`Open ${l.name}'s lane`)}">
278
+ ${avatar(l.name, l.color, l.online)}${l.working ? '<span class="pulse"></span>' : ''}
279
+ </button>`
280
+ }
281
+
282
+ /** The crowd column's head: a few avatars and a count; it opens a list of everyone in it. */
283
+ function crowdHeadHtml (lanes, colorOf) {
284
+ const live = lanes.filter((l) => l.working).length
285
+ const list = [...lanes].sort((a, b) => b.lastTs - a.lastTs).map((l) => `<button class="lcw-row" data-loom-open="${esc(l.name)}" role="menuitem">
286
+ ${avatar(l.name, l.color, l.online)}<span class="lcw-name">${esc(l.isMe ? 'You' : l.name)}</span>
287
+ <span class="lcw-st">${l.working ? `<span class="pulse"></span>working${l.since ? ` · ${esc(stitchTime(l.since))}` : ''}` : l.lastTs ? esc(ago(l.lastTs)) : 'nothing yet'}</span></button>`).join('')
288
+ return `<div class="loom-head crowd${crowdOpen ? ' open' : ''}">
289
+ <button class="lcw-btn" data-loom-crowd aria-haspopup="true" aria-expanded="${crowdOpen}" title="${esc(`${lanes.length} quieter lanes: ${lanes.map((l) => l.name).join(', ')}`)}">
290
+ <span class="lcw-stack">${lanes.slice(0, 3).map((l) => avatar(l.name, colorOf.get(l.name))).join('')}</span>
291
+ <span class="lcw-n">+${lanes.length}</span>${live ? '<span class="pulse"></span>' : ''}
292
+ </button>
293
+ <div class="lcw-list" role="menu" aria-label="Quieter lanes"${crowdOpen ? '' : ' hidden'}>
294
+ <div class="lcw-title">Quieter lanes</div>${list}
295
+ </div>
296
+ </div>`
297
+ }
298
+
299
+ /** Everything a folded lane did around one row, as one knot (a count when it's several). */
300
+ function knotHtml (k, m, colorOf, me) {
301
+ const lane = k.lanes[k.lanes.length - 1]
302
+ const fresh = seen && k.keys.some((key) => !seen.has(key)) ? ' kn-new' : ''
303
+ const who = k.lanes.map((n) => (n === me ? 'You' : n)).join(', ')
304
+ const n = k.ids.length
305
+ return `<button class="loom-knot${fresh}" data-knot="${k.ids.join(' ')}" data-loom-open="${esc(lane)}" tabindex="-1" style="grid-column:${k.col + 1};grid-row:${k.row};--c:${esc(colorOf.get(lane) || colorFor(lane))}" aria-label="${esc(`${who}: ${n} thing${n === 1 ? '' : 's'} at ${clock(k.ts)}. Open the lane`)}">
306
+ <span class="kn-dot"></span>${n > 1 ? `<span class="kn-n">${n}</span>` : ''}</button>`
307
+ }
308
+
309
+ function place (it, merged) {
310
+ return `grid-column:${merged || it.col == null ? '1 / -1' : it.col + 1};grid-row:${it.row} / span ${it.span || 1}`
311
+ }
312
+
313
+ function itemHtml (it, { colorOf, me, names, meAgent, merged, dens }) {
314
+ if (it.type === 'gap' || it.type === 'day') {
315
+ const label = it.type === 'day'
316
+ ? new Date(it.ts).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })
317
+ : `${later(it.ms)} later`
318
+ return `<div class="loom-gap" style="grid-column:1 / -1;grid-row:${it.row}"><span>${esc(label)}</span></div>`
319
+ }
320
+ const color = colorOf.get(it.lane) || colorFor(it.lane)
321
+ const style = `${place(it, merged)};--c:${esc(color)}`
322
+ // Following one conversation: everything else in the Loom steps back.
323
+ const dim = convFocus && !(it.type === 'turn' && it.lane === convFocus.lane && it.conv === convFocus.conv)
324
+ const fresh = (seen && !seen.has(it.key) ? ' lc-new' : '') + (dim ? ' lc-dim' : '')
325
+ const who = merged ? `<span class="lc-who">${avatar(it.lane, color)}<b>${esc(it.lane === me ? 'You' : it.lane)}</b></span>` : ''
326
+ const time = `<time title="${esc(new Date(it.ts).toLocaleString())}">${esc(clock(it.ts))}</time>`
327
+
328
+ if (it.type === 'paused' || it.type === 'resumed') {
329
+ return `<div class="lc-mark${fresh}" data-id="${it.id}" style="${style}">${who}<span>${it.type === 'paused' ? 'Paused sharing' : 'Resumed sharing'}</span>${time}</div>`
330
+ }
331
+ if (it.type === 'chat') {
332
+ const to = it.to ? `<span class="lc-dm">to ${esc(it.to === me ? 'you' : it.to)}</span>` : ''
333
+ return `<article class="lc lc-chat${fresh}" data-id="${it.id}" tabindex="0" style="${style}" aria-label="${esc(`${it.lane} in chat`)}">
334
+ <header class="lc-head">${who}<span class="lc-kind">${I.chat}</span><span class="lc-label">${it.to ? 'Direct message' : 'Chat'}</span>${to}${time}</header>
335
+ ${it.text ? `<div class="lc-text">${textHtml(it.text, names, me, { meAgent })}</div>` : ''}
336
+ ${it.file ? `<div class="lc-files"><span class="lc-file plain">${I.clip}${esc(it.file)}</span></div>` : ''}
337
+ </article>`
338
+ }
339
+ if (it.type === 'task' || it.type === 'note') {
340
+ const col = `<span class="lc-col c-${esc(it.task.column)}">${esc(COLUMN[it.task.column] || it.task.column)}</span>`
341
+ return `<article class="lc lc-task${fresh}" data-id="${it.id}" tabindex="0" style="${style}" aria-label="${esc(it.type === 'task' ? `${it.lane} added a task` : `${it.lane} left a note on a task`)}">
342
+ <header class="lc-head">${who}<span class="lc-kind">${I.board}</span><span class="lc-label">${it.type === 'task' ? 'Added a task' : 'Note on a task'}</span>${time}</header>
343
+ <button class="lc-tasklink" data-loom-task="${esc(it.task.id)}" title="Open the task board">${esc(it.task.title)}</button>${col}
344
+ ${it.type === 'note' ? `<div class="lc-text">${esc(it.text)}</div>` : ''}
345
+ </article>`
346
+ }
347
+
348
+ // An AI turn.
349
+ const open = expanded.has(it.key)
350
+ const prompt = visiblePrompt(it.prompt)
351
+ const reply = it.replies.length ? it.replies[it.replies.length - 1] : null
352
+ const done = (it.tasks || []).some((t) => t.column === 'done')
353
+ const files = open ? it.edits : it.edits.slice(0, FILES_SHOWN)
354
+ const more = it.edits.length - files.length
355
+ const counts = [
356
+ it.edits.length ? `${it.edits.length} file${it.edits.length === 1 ? '' : 's'} changed` : '',
357
+ it.reads ? `${it.reads} read` : '',
358
+ it.runs ? `${it.runs} command${it.runs === 1 ? '' : 's'}` : ''
359
+ ].filter(Boolean)
360
+ const tasks = (it.tasks || []).map((t) => `<button class="lc-taskchip c-${esc(t.column)}" data-loom-task="${esc(t.id)}" title="${esc(`${COLUMN[t.column] || t.column}: ${t.title}`)}">${I.board}<span>${esc(t.title)}</span></button>`).join('')
361
+ const summary = it.summary || (reply && reply.summary) ? '<span class="tag summary" title="Summarized before sharing">summary</span>' : ''
362
+ return `<article class="lc lc-turn${open ? ' open' : ''}${done ? ' hemmed' : ''}${fresh}" data-id="${it.id}" data-key="${esc(it.key)}" data-lane="${esc(it.lane)}" data-conv="${esc(it.conv)}" tabindex="0" style="${style}" aria-expanded="${open}" aria-label="${esc(`${it.lane}'s AI: ${prompt.slice(0, 80) || 'a turn'}`)}">
363
+ <header class="lc-head">${who}<span class="lc-tool" title="${esc(toolLabel(it.tool))}">${toolLogo(it.tool)}</span>
364
+ ${convChip(it)}${summary}${time}</header>
365
+ ${prompt ? `<div class="lc-prompt">${esc(prompt)}</div>` : ''}
366
+ ${reply && dens === 'detailed' && !open ? `<div class="lc-reply">${esc(plain(reply.text))}</div>` : ''}
367
+ ${open ? `<div class="lc-replies">${it.replies.map((r) => `<div class="md">${markdown(r.text)}</div>`).join('')}</div>` : ''}
368
+ ${files.length && (dens === 'detailed' || open) ? `<div class="lc-files">${files.map((p) => `<button class="lc-file" data-file="${esc(p)}">${esc(basename(p))}</button>`).join('')}${more > 0 ? `<span class="lc-more">+${more}</span>` : ''}</div>` : ''}
369
+ ${counts.length || tasks ? `<div class="lc-meta">${counts.length ? `<span>${esc(counts.join(' · '))}</span>` : ''}${tasks}</div>` : ''}
370
+ ${open && it.actions.length ? `<ol class="lc-actions">${it.actions.map((a) => `<li>${esc(a)}</li>`).join('')}</ol>` : ''}
371
+ ${open ? `<div class="lc-foot"><button class="btn sm" data-loom-conv="${esc(it.lane)}" data-loom-convid="${esc(it.conv)}">Open the whole conversation${I.arrowRight}</button></div>` : ''}
372
+ </article>`
373
+ }
374
+
375
+ const COLUMN = { todo: 'To do', doing: 'In progress', qa: 'QA', done: 'Done' }
376
+
377
+ /**
378
+ * A turn's conversation on its card. In a lane with several, it carries the conversation's mark
379
+ * and following it is a click away (opening it stays in the open card); with one, it opens it.
380
+ */
381
+ function convChip (it) {
382
+ if (it.convSlot == null) return `<button class="lc-conv" data-loom-conv="${esc(it.lane)}" data-loom-convid="${esc(it.conv)}" title="Open this conversation">${esc(it.convLabel || toolLabel(it.tool))}</button>`
383
+ const on = !!convFocus && convFocus.lane === it.lane && convFocus.conv === it.conv
384
+ return `<button class="lc-conv marked" data-loom-follow data-lane="${esc(it.lane)}" data-conv="${esc(it.conv)}" aria-pressed="${on}" title="${esc(`${it.convLabel}: click to ${on ? 'see every conversation' : 'follow just this conversation'}`)}">${CONV_MARKS[it.convSlot]}<span>${esc(it.convLabel)}</span></button>`
385
+ }
386
+
387
+ /** Markdown as one plain line of text, for a card's preview of a reply. */
388
+ function plain (text) {
389
+ return String(text || '')
390
+ .replace(/```[\s\S]*?```/g, ' [code] ')
391
+ .replace(/[*_`#>]+/g, '')
392
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
393
+ .replace(/\s+/g, ' ')
394
+ .trim()
395
+ }
396
+
397
+ function later (ms) {
398
+ const min = Math.round(ms / 60000)
399
+ if (min < 60) return `${min} min`
400
+ const h = Math.round(min / 6) / 10
401
+ return `${h % 1 ? h.toFixed(1) : h} h`
402
+ }
403
+
404
+ const PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5" /><path d="M9 10.8V4h6v6.8l2.6 3.2H6.4z" /></svg>'
405
+ // A conversation's mark: the Q with its own piece lit (slot 0-7, clockwise from the top left, the
406
+ // corner last), in its lane's colour (--c). One per slot, made once. The working variant adds a runner
407
+ // that laps the Q from that slot while the home piece waits (loom.css: .cm-live).
408
+ const CM_CYCLE = 2860
409
+ const convMark = (slot, working) => {
410
+ const { patch: p, radius: r } = GRID
411
+ const on = (i) => (i === slot ? (working ? ' on cm-home' : ' on') : '')
412
+ const run = working ? `<rect class="cm-run cm-run${slot}" width="${p}" height="${p}" rx="${r}" />` : ''
413
+ return `<svg class="cm${working ? ' cm-live' : ''}" viewBox="0 0 57 57" aria-hidden="true">${PIECES.map(([x, y], i) => `<rect class="cm-p${on(i)}" x="${x}" y="${y}" width="${p}" height="${p}" rx="${r}" />`).join('')}<path class="cm-p${on(7)}" d="${CORNER.ink}" /><path class="cm-a${on(7)}" d="${CORNER.accent}" />${run}</svg>`
414
+ }
415
+ const CONV_MARKS = Array.from({ length: 8 }, (_, slot) => convMark(slot, false))
416
+ const CONV_MARKS_LIVE = Array.from({ length: 8 }, (_, slot) => convMark(slot, true))
417
+
418
+ // A working AI's needle: it pieces patches on its lane's thread and sews them into the Q, slot by slot,
419
+ // the vermilion corner last (loom.css has the timeline). At rest (no motion), the finished Q.
420
+ const LQ_CYCLE = 10800
421
+ export const NEEDLE = (() => {
422
+ const { patch: p, radius: r } = GRID
423
+ const seam = `<rect class="lq-s" x=".6" y=".6" width="${p - 1.2}" height="${p - 1.2}" rx="${r}" pathLength="60" />`
424
+ const slots = [...PIECES, [40, 40]]
425
+ const outline = (x, y, i) => i < 7
426
+ ? `<rect class="lq-o lq-o${i}" x="${x + 0.6}" y="${y + 0.6}" width="${p - 1.2}" height="${p - 1.2}" rx="${r}" />`
427
+ : `<path class="lq-o lq-o${i}" d="M40.6 42.6 A2 2 0 0 1 42.6 40.6 H56.4 V54.4 A2 2 0 0 1 54.4 56.4 H40.6 Z" />`
428
+ const piece = (x, y, i) => `<g class="lq-p lq-p${i}" transform="translate(${x} ${y})">${i < 7
429
+ ? `<rect class="lq-f" width="${p}" height="${p}" rx="${r}" />`
430
+ : `<g transform="translate(-40 -40)"><path class="lq-f" d="${CORNER.ink}" /><path class="lq-f lq-a" d="${CORNER.accent}" /></g>`}${seam}</g>`
431
+ return `<svg class="lq" viewBox="-1.5 -40 60 98.5" aria-hidden="true">${slots.map(([x, y], i) => outline(x, y, i)).join('')}${slots.map(([x, y], i) => piece(x, y, i)).join('')}</svg>`
432
+ })()
433
+
434
+ /** Starts each needle (and each working conversation's lap) where the clock says it is in its cycle, so a re-render doesn't start it again. */
435
+ function phaseNeedles (el) {
436
+ const ph = `-${Date.now() % LQ_CYCLE}ms`
437
+ for (const n of el.querySelectorAll('.lq')) n.style.setProperty('--lq-ph', ph)
438
+ const lap = `-${Date.now() % CM_CYCLE}ms`
439
+ for (const n of el.querySelectorAll('.cm-live')) n.style.setProperty('--cm-ph', lap)
440
+ }
441
+
442
+ // ------------------------------------------------------------- threads --
443
+
444
+ const SVGNS = 'http://www.w3.org/2000/svg'
445
+ let sizeObs = null
446
+ let raf = 0
447
+ let settle = 0
448
+ // How long the width must hold still before the Loom renders again for it: folding the Files
449
+ // pane or the chat slides the middle's width for ~280ms, and rendering again on every frame
450
+ // of that (the model, the markup, the saved view) is what made the slide stutter.
451
+ const SETTLE_MS = 120
452
+
453
+ function watchSize (el) {
454
+ if (sizeObs) sizeObs.disconnect()
455
+ const grid = el.querySelector('.loom-grid')
456
+ if (!grid || typeof ResizeObserver === 'undefined') return
457
+ sizeObs = new ResizeObserver(() => {
458
+ cancelAnimationFrame(raf)
459
+ raf = requestAnimationFrame(() => {
460
+ const root = el.querySelector(':scope > .loom')
461
+ if (!root) return
462
+ drawThreads(el)
463
+ packConvs(root)
464
+ // A new width can fit more lanes or fewer (or fold them into one column): render again, once it settles.
465
+ clearTimeout(settle)
466
+ if (el.clientWidth !== lastWidth) {
467
+ settle = setTimeout(() => {
468
+ if (el.querySelector(':scope > .loom') && el.clientWidth !== lastWidth) { handlers.onAction('refresh'); drawThreads(el) }
469
+ }, SETTLE_MS)
470
+ }
471
+ })
472
+ })
473
+ sizeObs.observe(grid)
474
+ sizeObs.observe(el)
475
+ }
476
+
477
+ /** Draws the stitches between cards that touched the same file, and @mention arrows. */
478
+ function drawThreads (el) {
479
+ const grid = el.querySelector('.loom-grid')
480
+ const svg = el.querySelector('.loom-stitches')
481
+ if (!grid || !svg || !model) return
482
+ const g = grid.getBoundingClientRect()
483
+ // The grid's own box, not its scroll size: that counts this svg too, so once tall it could
484
+ // never shrink, and left empty space under the last card when the cards got shorter.
485
+ const w = grid.clientWidth
486
+ const h = grid.clientHeight
487
+ svg.setAttribute('width', String(w))
488
+ svg.setAttribute('height', String(h))
489
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
490
+ const merged = el.querySelector('.loom').classList.contains('merged')
491
+ const card = (id) => nodeFor(grid, id)
492
+ const box = (node) => { const r = node.getBoundingClientRect(); return { l: r.left - g.left, r: r.right - g.left, t: r.top - g.top, b: r.bottom - g.top, h: r.height } }
493
+ const colorOf = new Map(model.lanes.map((l) => [l.name, colorFor(l.name, l.color)]))
494
+ const byId = new Map(model.items.map((it) => [it.id, it]))
495
+ const out = []
496
+ for (const s of model.stitches) {
497
+ const a = card(s.from)
498
+ const b = card(s.to)
499
+ if (!a || !b || a === b) continue
500
+ const chipY = (node, fallback) => {
501
+ const chip = [...node.querySelectorAll('[data-file]')].find((c) => c.dataset.file === s.path)
502
+ if (!chip) return fallback.t + Math.min(20, fallback.h / 2)
503
+ const c = box(chip)
504
+ return c.t + c.h / 2
505
+ }
506
+ const A = box(a)
507
+ const B = box(b)
508
+ const y1 = chipY(a, A)
509
+ const y2 = chipY(b, B)
510
+ let d
511
+ if (merged) {
512
+ const x = Math.max(A.r, B.r)
513
+ const bulge = Math.min(60, 18 + Math.abs(y2 - y1) / 8)
514
+ d = `M ${A.r} ${y1} C ${x + bulge} ${y1}, ${x + bulge} ${y2}, ${B.r} ${y2}`
515
+ } else {
516
+ const right = A.l < B.l
517
+ const x1 = right ? A.r : A.l
518
+ const x2 = right ? B.l : B.r
519
+ const dx = (x2 - x1) / 2
520
+ d = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
521
+ }
522
+ const color = colorOf.get(byId.get(s.to)?.lane) || 'currentColor'
523
+ out.push(`<path class="st${s.collide ? ' collide' : ''}" data-path="${esc(s.path)}" data-from="${s.from}" data-to="${s.to}" d="${d}" style="--c:${esc(color)}" />`)
524
+ if (s.collide && !merged) {
525
+ const right = A.l < B.l
526
+ const mx = ((right ? A.r : A.l) + (right ? B.l : B.r)) / 2
527
+ const my = (y1 + y2) / 2
528
+ out.push(`<g class="knot" data-path="${esc(s.path)}" data-from="${s.from}" data-to="${s.to}" transform="translate(${mx} ${my})"><circle r="7" /><path d="M1 -4 L-2 1 L1 1 L-1 4" /></g>`)
529
+ }
530
+ }
531
+ if (!merged) {
532
+ const heads = [...el.querySelectorAll('.loom-head')]
533
+ for (const mn of model.mentions) {
534
+ const a = card(mn.from)
535
+ const head = heads[mn.col]
536
+ if (!a || !head || a.matches('.loom-knot')) continue // a folded lane's mentions stay folded too
537
+ const A = box(a)
538
+ const h = head.getBoundingClientRect()
539
+ const x2 = h.left - g.left + h.width / 2
540
+ const right = A.l < x2
541
+ const x1 = right ? A.r : A.l
542
+ const y = A.t + Math.min(26, A.h / 2)
543
+ if (Math.abs(x2 - x1) < 4) continue
544
+ const color = colorOf.get(mn.lane) || 'currentColor'
545
+ out.push(`<path class="mention-line" data-from="${mn.from}" d="M ${x1} ${y} C ${(x1 + x2) / 2} ${y - 18}, ${x2} ${y - 10}, ${x2} ${y}" style="--c:${esc(color)}" /><circle class="mention-dot" data-from="${mn.from}" cx="${x2}" cy="${y}" r="3.5" style="--c:${esc(color)}" />`)
546
+ }
547
+ }
548
+ svg.innerHTML = out.join('')
549
+ if (focused) focusFile(el, focused)
550
+ }
551
+
552
+ // ------------------------------------------------------------- focus --
553
+
554
+ /** The card showing item `id`, or the knot it's folded into. */
555
+ function nodeFor (root, id) {
556
+ return root.querySelector(`.loom-grid > [data-id="${id}"]`) || root.querySelector(`.loom-grid > [data-knot~="${id}"]`)
557
+ }
558
+
559
+ /** Lights every card and stitch on `path` and dims the rest (null: back to normal). */
560
+ export function focusFile (el, path) {
561
+ focused = path || null
562
+ const root = el?.querySelector('.loom')
563
+ if (!root) return
564
+ for (const n of root.querySelectorAll('.lit')) n.classList.remove('lit')
565
+ root.classList.toggle('focusing', !!focused)
566
+ if (!focused || !model) return
567
+ const f = model.files.get(focused)
568
+ for (const t of f ? f.touches : []) nodeFor(root, t.id)?.classList.add('lit')
569
+ for (const n of root.querySelectorAll('[data-path], [data-file]')) {
570
+ if ((n.dataset.path ?? n.dataset.file) === focused) n.classList.add('lit')
571
+ }
572
+ }
573
+
574
+ /** A card's own stitches and the cards at their other end, lit while the pointer is on it. */
575
+ function traceCard (root, id, on) {
576
+ for (const n of root.querySelectorAll('.traced')) n.classList.remove('traced')
577
+ root.classList.toggle('tracing', !!(on && id))
578
+ if (!on || !id) return
579
+ root.querySelector(`[data-id="${id}"]`)?.classList.add('traced')
580
+ for (const n of root.querySelectorAll(`.loom-stitches [data-from="${id}"], .loom-stitches [data-to="${id}"]`)) {
581
+ n.classList.add('traced')
582
+ const other = n.dataset.from === id ? n.dataset.to : n.dataset.from
583
+ if (other) nodeFor(root, other)?.classList.add('traced')
584
+ }
585
+ }
586
+
587
+ function tipFor (path) {
588
+ const f = model && model.files.get(path)
589
+ if (!f) return `<b>${esc(path)}</b>`
590
+ const touches = f.touches.map((t) => `${t.lane} ${clock(t.ts)}`)
591
+ const lanes = new Set(f.touches.map((t) => t.lane))
592
+ const claim = f.claim ? `<div>Held by ${esc(f.claim.by)}${f.claim.waiting.length ? ` · waiting: ${esc(f.claim.waiting.join(', '))}` : ''}</div>` : ''
593
+ return `<b>${esc(path)}</b>
594
+ <div>Changed by ${esc(touches.slice(-5).join(', '))}${touches.length > 5 ? ` and ${touches.length - 5} more` : ''}</div>
595
+ ${lanes.size > 1 ? `<div class="warn">${lanes.size} people's AIs worked on this file</div>` : ''}${claim}
596
+ <div class="hint">Click to open it</div>`
597
+ }
598
+
599
+ function knotTip (ids) {
600
+ const byId = new Map(model.items.map((it) => [it.id, it]))
601
+ const list = ids.map((id) => byId.get(id)).filter(Boolean)
602
+ const line = (it) => {
603
+ if (it.type === 'turn') {
604
+ const ask = visiblePrompt(it.prompt).split('\n')[0].slice(0, 90) || it.convLabel || 'AI turn'
605
+ const conv = it.convSlot != null && visiblePrompt(it.prompt) ? `<span class="hint">${esc(it.convLabel)}:</span> ` : ''
606
+ return `${conv}${esc(ask)}${it.edits.length ? ` <span class="hint">· ${it.edits.length} file${it.edits.length === 1 ? '' : 's'}</span>` : ''}`
607
+ }
608
+ if (it.type === 'chat') return `<span class="hint">Chat:</span> ${esc(it.text.slice(0, 90))}`
609
+ if (it.type === 'task') return `<span class="hint">Added a task:</span> ${esc(it.task.title)}`
610
+ if (it.type === 'note') return `<span class="hint">Note on</span> ${esc(it.task.title)}`
611
+ return esc(it.type === 'paused' ? 'Paused sharing' : 'Resumed sharing')
612
+ }
613
+ const lanes = [...new Set(list.map((it) => it.lane))]
614
+ return `<b class="kt-who">${esc(lanes.join(', '))}</b>
615
+ ${list.slice(-4).map((it) => `<div class="kt-line"><span class="hint">${esc(clock(it.ts))}</span> ${line(it)}</div>`).join('')}
616
+ ${list.length > 4 ? `<div class="hint">and ${list.length - 4} more</div>` : ''}
617
+ <div class="hint">Click to open ${lanes.length === 1 ? 'this lane' : `${esc(lanes[lanes.length - 1])}'s lane`}</div>`
618
+ }
619
+
620
+ function showTip (root, anchor, html) {
621
+ const tip = root.querySelector('.loom-tip')
622
+ if (!tip) return
623
+ tip.innerHTML = html
624
+ tip.hidden = false
625
+ const r = anchor.getBoundingClientRect()
626
+ const w = tip.offsetWidth
627
+ const h = tip.offsetHeight
628
+ let x = Math.min(window.innerWidth - w - 8, Math.max(8, r.left + r.width / 2 - w / 2))
629
+ let y = r.top - h - 8
630
+ if (y < 8) y = r.bottom + 8
631
+ tip.style.left = `${Math.round(x)}px`
632
+ tip.style.top = `${Math.round(y)}px`
633
+ }
634
+
635
+ // ------------------------------------------------------------- events --
636
+
637
+ function bind (el) {
638
+ const root = el.querySelector('.loom')
639
+ const s = root.querySelector('.loom-scroll')
640
+ const jump = root.querySelector('.loom-jump')
641
+ // Scrolled up a way (or anything new came in below): a button back down to the latest.
642
+ let jumping = false // on its way down: stays hidden while the smooth scroll runs
643
+ let lastTop = s.scrollTop
644
+ const syncJump = () => {
645
+ const below = s.scrollHeight - s.scrollTop - s.clientHeight
646
+ if (below < 80 || s.scrollTop < lastTop) jumping = false // there, or scrolled back up on the way
647
+ if (below < 80) fresh = false
648
+ lastTop = s.scrollTop
649
+ jump.hidden = jumping || !(below > Math.max(160, s.clientHeight / 2) || (fresh && below >= 80))
650
+ jump.lastChild.textContent = fresh ? 'New activity' : 'Latest'
651
+ }
652
+ jump.onclick = () => { jumping = true; jump.hidden = true; s.scrollTo({ top: s.scrollHeight, behavior: 'smooth' }) }
653
+ s.addEventListener('scroll', () => {
654
+ syncJump()
655
+ root.querySelector('.loom-tip').hidden = true
656
+ }, { passive: true })
657
+ syncJump()
658
+ if (bound.has(el)) return
659
+ bound.add(el)
660
+
661
+ el.addEventListener('click', (e) => {
662
+ const r = el.querySelector('.loom')
663
+ if (!r || !r.contains(e.target)) return
664
+ const act = handlers.onAction
665
+ const t = e.target
666
+ const pick = (sel) => t.closest(sel)
667
+ let b
668
+ const crowd = r.querySelector('.loom-head.crowd')
669
+ if ((b = pick('[data-loom-crowd]'))) {
670
+ crowdOpen = !crowdOpen
671
+ crowd.classList.toggle('open', crowdOpen)
672
+ b.setAttribute('aria-expanded', String(crowdOpen))
673
+ crowd.querySelector('.lcw-list').hidden = !crowdOpen
674
+ return
675
+ }
676
+ if (crowdOpen && crowd && !crowd.contains(t)) { crowdOpen = false; crowd.classList.remove('open'); crowd.querySelector('.lcw-list').hidden = true }
677
+ if ((b = pick('[data-loom-open]'))) { crowdOpen = false; return act('open-lane', b.dataset.loomOpen) }
678
+ if ((b = pick('[data-loom-pin]'))) return act('pin', b.dataset.loomPin)
679
+ if ((b = pick('[data-loom-holds]'))) return act('holds', { name: b.dataset.loomHolds, anchor: b })
680
+ if ((b = pick('[data-loom-person]'))) return act('person', b.dataset.loomPerson)
681
+ if ((b = pick('[data-loom-follow]'))) {
682
+ const same = convFocus && convFocus.lane === b.dataset.lane && convFocus.conv === b.dataset.conv
683
+ convFocus = same ? null : { lane: b.dataset.lane, conv: b.dataset.conv }
684
+ followed = !same
685
+ return act('refresh')
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
+ }
696
+ if ((b = pick('[data-loom-rename]'))) { renaming = { lane: b.dataset.lane, conv: b.dataset.conv }; return act('refresh') }
697
+ if (pick('.lh-conv-edit')) return
698
+ if ((b = pick('[data-loom-conv]'))) return act('conv', { name: b.dataset.loomConv, conv: b.dataset.loomConvid })
699
+ if ((b = pick('[data-loom-task]'))) return act('task', b.dataset.loomTask)
700
+ if ((b = pick('[data-file]'))) return act('file', b.dataset.file)
701
+ if ((b = pick('[data-loom-hide]'))) return act('hide', b.dataset.loomHide)
702
+ if ((b = pick('[data-loom-unhide]'))) return act('unhide', b.dataset.loomUnhide)
703
+ if ((b = pick('[data-loom-pref]'))) return act('toggle', b.dataset.loomPref)
704
+ if ((b = pick('[data-loom-density]'))) return act('density', b.dataset.loomDensity)
705
+ if ((b = pick('[data-loom-layout]'))) return act('layout', b.dataset.loomLayout)
706
+ if (t.closest('a, button')) return
707
+ if (String(window.getSelection?.() || '')) return // selecting text in a card, not opening it
708
+ const c = pick('.lc-turn')
709
+ if (c) toggle(el, c)
710
+ })
711
+ // Naming a conversation: Enter (or leaving the box) keeps the name, Esc drops it.
712
+ const named = (input, keep) => {
713
+ if (!renaming) return
714
+ renaming = null
715
+ input.blur() // focus out comes back here and stops at !renaming; the redraw no longer waits for typing
716
+ if (keep) return handlers.onAction('conv-name', { conv: input.dataset.conv, name: input.value.trim() })
717
+ handlers.onAction('refresh')
718
+ }
719
+ el.addEventListener('focusout', (e) => { if (e.target.matches?.('.lh-conv-edit')) named(e.target, true) })
720
+ el.addEventListener('keydown', (e) => {
721
+ if (e.target.matches?.('.lh-conv-edit')) {
722
+ if (e.key === 'Enter') { e.preventDefault(); named(e.target, true) } else if (e.key === 'Escape') { e.preventDefault(); named(e.target, false) }
723
+ return
724
+ }
725
+ if (e.key === 'Escape' && convFocus && !crowdOpen) { convFocus = null; handlers.onAction('refresh'); return }
726
+ if (e.key === 'Escape' && crowdOpen) {
727
+ crowdOpen = false
728
+ const crowd = el.querySelector('.loom-head.crowd')
729
+ if (crowd) { crowd.classList.remove('open'); crowd.querySelector('.lcw-list').hidden = true; crowd.querySelector('[data-loom-crowd]').focus() }
730
+ return
731
+ }
732
+ if (e.key !== 'Enter' && e.key !== ' ') return
733
+ const c = e.target.closest?.('.lc-turn')
734
+ if (!c || e.target !== c) return
735
+ e.preventDefault()
736
+ toggle(el, c)
737
+ })
738
+ el.addEventListener('mouseover', (e) => {
739
+ const r = el.querySelector('.loom')
740
+ if (!r || !r.contains(e.target)) return
741
+ const chip = e.target.closest('[data-file]')
742
+ if (chip) {
743
+ focusFile(el, chip.dataset.file)
744
+ showTip(r, chip, tipFor(chip.dataset.file))
745
+ return
746
+ }
747
+ const knot = e.target.closest('[data-knot]')
748
+ if (knot) {
749
+ showTip(r, knot, knotTip(knot.dataset.knot.split(' ')))
750
+ traceCard(r, knot.dataset.knot.split(' ')[0], true)
751
+ return
752
+ }
753
+ const c = e.target.closest('.loom-grid > [data-id]')
754
+ traceCard(r, c?.dataset.id, true)
755
+ })
756
+ el.addEventListener('mouseout', (e) => {
757
+ const r = el.querySelector('.loom')
758
+ if (!r) return
759
+ const chip = e.target.closest?.('[data-file]')
760
+ if (chip && !chip.contains(e.relatedTarget)) {
761
+ focusFile(el, null)
762
+ r.querySelector('.loom-tip').hidden = true
763
+ }
764
+ const knot = e.target.closest?.('[data-knot]')
765
+ if (knot && !knot.contains(e.relatedTarget)) { r.querySelector('.loom-tip').hidden = true; traceCard(r, null, false) }
766
+ const c = e.target.closest?.('.loom-grid > [data-id]')
767
+ if (c && !c.contains(e.relatedTarget)) traceCard(r, null, false)
768
+ })
769
+ // A chip or knot redrawn under the pointer never says it was left: leaving the Loom does.
770
+ el.addEventListener('mouseleave', () => {
771
+ const r = el.querySelector('.loom')
772
+ if (!r) return
773
+ r.querySelector('.loom-tip').hidden = true
774
+ traceCard(r, null, false)
775
+ focusFile(el, null)
776
+ })
777
+ }
778
+
779
+ function toggle (el, card) {
780
+ const key = card.dataset.key
781
+ if (!key) return
782
+ if (expanded.has(key)) expanded.delete(key)
783
+ else expanded.add(key)
784
+ anchor = { key, y: card.getBoundingClientRect().top }
785
+ handlers.onAction('expand', key) // the owner re-renders; the card keeps its place
786
+ }