heyquilt 0.0.1 → 0.3.90

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