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/mark.js ADDED
@@ -0,0 +1,104 @@
1
+ // The Quilt mark (brand/SPEC.md): Patch Q. A nine-patch ring of eight pieces around an open
2
+ // centre; the bottom-right piece is a half-square triangle, and its vermilion half is the Q's
3
+ // tail. The wordmark is a drawn lowercase "quilt" whose i is dotted with the same folded square.
4
+ // One source for every drawing: the app, the website (web/lib/mark.js is an identical copy) and
5
+ // the brand kit (scripts/make-brand.cjs imports the geometry below). Colours come from the page:
6
+ // --mark-ink for the pieces and letters, --mark-accent for the vermilion half (app.css, globals.css).
7
+
8
+ /** The 57-unit grid: patch 17, gap 3, corner radius 2. */
9
+ export const GRID = { size: 57, patch: 17, gap: 3, radius: 2 }
10
+ /** The seven square pieces, clockwise from the top-left; the corner piece comes last (piece 8). */
11
+ export const PIECES = [[0, 0], [20, 0], [40, 0], [40, 20], [20, 40], [0, 40], [0, 20]]
12
+ /** The corner piece's two halves, split by a 3-unit seam (long edges on x + y = 94.88 and 99.12). */
13
+ export const CORNER = {
14
+ ink: 'M42 40 H54.88 L40 54.88 V42 A2 2 0 0 1 42 40 Z',
15
+ accent: 'M57 42.12 V55 A2 2 0 0 1 55 57 H42.12 Z'
16
+ }
17
+ /**
18
+ * The wordmark on its 162 x 90 box (viewBox -6 -4 162 90): 11-unit strokes with flat ends.
19
+ * Guide lines: ascender 2, x-height 19, baseline 61.5, descender 80.
20
+ */
21
+ export const WORD = {
22
+ viewBox: '-6 -4 162 90',
23
+ stroke: 11,
24
+ bowl: { cx: 22, cy: 40.25, r: 15.75 },
25
+ paths: [
26
+ 'M37.75 19 V80', // q stem
27
+ 'M55 19 V41 A15 15 0 0 0 85 41 V19 M85 19 V61.5', // u
28
+ 'M101 19 V61.5', // i
29
+ 'M117 2 V61.5', // l
30
+ 'M138 8 V46 Q138 56 148 56 H152', // t
31
+ 'M128 24.5 H152' // t bar
32
+ ],
33
+ dot: { ink: 'M95.5 2 L106.5 13 H95.5 Z', accent: 'M95.5 2 H106.5 V13 Z' }
34
+ }
35
+ /**
36
+ * Lockups: where the mark and the wordmark sit. Horizontal: the mark as tall as the "l" and one
37
+ * patch from the q. Stacked: the mark half the wordmark's width, one patch above it.
38
+ */
39
+ export const LOCKUP = {
40
+ horizontal: { viewBox: '0 0 229 82', mark: 'translate(0 2) scale(1.04386)', word: 'translate(76.25 0)' },
41
+ stacked: { viewBox: '0 0 152 177', mark: 'translate(38 0) scale(1.324561)', word: 'translate(-0.75 96)' }
42
+ }
43
+ /** Pixel masters for 16, 24 and 32 px: [patch, gap, corner ink if px + py <= this, vermilion if >= this]. */
44
+ export const PIXEL_MASTERS = { 16: [4, 2, 2, 4], 24: [6, 3, 3, 7], 32: [8, 4, 4, 10] }
45
+
46
+ const rect = ([x, y]) => `x="${x}" y="${y}" width="${GRID.patch}" height="${GRID.patch}" rx="${GRID.radius}"`
47
+
48
+ /** The mark's shapes. Pieces carry --o, their place in the piecing order, for the animations. */
49
+ function pieces () {
50
+ return PIECES.map((p, i) => `<rect class="qm-p qm-i" style="--o:${i}" ${rect(p)}/>`).join('') +
51
+ `<path class="qm-p qm-i qm-c" style="--o:7" d="${CORNER.ink}"/><path class="qm-p qm-a qm-c" style="--o:7" d="${CORNER.accent}"/>`
52
+ }
53
+
54
+ /** The wordmark's shapes. */
55
+ function letters () {
56
+ const { bowl } = WORD
57
+ return `<g class="qm-w" fill="none" stroke-width="${WORD.stroke}"><circle cx="${bowl.cx}" cy="${bowl.cy}" r="${bowl.r}"/>${WORD.paths.map((d) => `<path d="${d}"/>`).join('')}</g>` +
58
+ `<path class="qm-i" d="${WORD.dot.ink}"/><path class="qm-a" d="${WORD.dot.accent}"/>`
59
+ }
60
+
61
+ /**
62
+ * Every pixel of a pixel master: { ink: [[x, y, w, h]], accent: [...] }, rows merged into runs.
63
+ * The seven square pieces sit at 0, patch + gap and twice that; the corner piece is split along
64
+ * its diagonal by the rule in PIXEL_MASTERS.
65
+ */
66
+ export function pixelRects (size) {
67
+ const m = PIXEL_MASTERS[size]
68
+ if (!m) throw new Error(`no pixel master for ${size} px`)
69
+ const [p, g, inkMax, accentMin] = m
70
+ const at = [0, p + g, 2 * (p + g)]
71
+ const ink = PIECES.map(([x, y]) => [at[x / 20], at[y / 20], p, p])
72
+ const accent = []
73
+ const c = at[2]
74
+ for (let py = 0; py < p; py++) {
75
+ if (inkMax - py >= 0) ink.push([c, c + py, inkMax - py + 1, 1])
76
+ const from = accentMin - py
77
+ if (from <= p - 1) accent.push([c + Math.max(0, from), c + py, p - Math.max(0, from), 1])
78
+ }
79
+ return { ink, accent }
80
+ }
81
+
82
+ let sewn = false
83
+
84
+ /**
85
+ * The mark as inline SVG.
86
+ * word: the horizontal lockup, mark and "quilt" (default); false for the mark alone
87
+ * sew: piece it together once (`'first'`: only the first time this is called in the page)
88
+ * loop: the loading animation: pieced patch by patch, clockwise, the corner last, over and over
89
+ * px: 16, 24 or 32 for the pixel master (crisp at that size; no animation)
90
+ */
91
+ export function quiltMark ({ word = true, sew = false, loop = false, px = 0, cls = '' } = {}) {
92
+ const extra = cls ? ' ' + cls : ''
93
+ const label = loop ? 'Loading' : 'Quilt'
94
+ if (px) {
95
+ const { ink, accent } = pixelRects(px)
96
+ const r = (cl) => ([x, y, w, h]) => `<rect class="${cl}" x="${x}" y="${y}" width="${w}" height="${h}"/>`
97
+ return `<svg class="qm qm-sym qm-px${extra}" viewBox="0 0 ${px} ${px}" width="${px}" height="${px}" shape-rendering="crispEdges" role="img" aria-label="${label}">${ink.map(r('qm-i')).join('')}${accent.map(r('qm-a')).join('')}</svg>`
98
+ }
99
+ const doSew = sew === 'first' ? !sewn && (sewn = true) : !!sew
100
+ const mode = loop ? ' qm-loop' : doSew ? ' qm-sew' : ''
101
+ if (!word) return `<svg class="qm qm-sym${mode}${extra}" viewBox="0 0 ${GRID.size} ${GRID.size}" role="img" aria-label="${label}">${pieces()}</svg>`
102
+ const L = LOCKUP.horizontal
103
+ return `<svg class="qm qm-word${mode}${extra}" viewBox="${L.viewBox}" role="img" aria-label="${label}"><g transform="${L.mark}">${pieces()}</g><g class="qm-wm" transform="${L.word}">${letters()}</g></svg>`
104
+ }
@@ -0,0 +1,158 @@
1
+ // Merges: the buttons for a file changed in two places (the file tree marks them) and
2
+ // the side-by-side view of one.
3
+ import { esc, I, api, toast, loadingNote } from './common.js'
4
+ import { changedLines } from './fileview.js'
5
+
6
+ const you = (name, me, fallback = 'someone') => !name ? fallback : name === me ? 'you' : esc(name)
7
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1)
8
+
9
+ /** How the opener's side came about (merges.js VIAS). */
10
+ export function done (m, deleted = false) {
11
+ if (m.via === 'pull') return deleted ? 'pulled commits that delete this' : 'pulled commits that change this'
12
+ if (m.via === 'hold') return deleted ? 'deleted this during a git command' : 'changed this during a git command'
13
+ return deleted ? 'deleted this offline' : 'changed this offline'
14
+ }
15
+
16
+ /** One plain sentence: who changed what, where; then who holds the file now. */
17
+ function describe (m, me) {
18
+ const held = m.heldBy && m.heldBy !== me && m.kind !== 'claimed' ? ` ${cap(esc(m.heldBy))} has it claimed now.` : ''
19
+ return what(m, me) + held
20
+ }
21
+
22
+ function what (m, me) {
23
+ const who = cap(you(m.by, me))
24
+ const other = you(m.others[0], me)
25
+ const did = done(m)
26
+ if (m.kind === 'ai') return `${who} ${did} and ${other} changed it in the session. An AI combined the two: have a look.`
27
+ if (m.kind === 'claimed' && !m.heldBy) return `${who} ${did} while ${you(m.claimedBy, me)} had it claimed. The session's version is in the file.`
28
+ if (m.kind === 'claimed') return `${who} ${did}, but ${you(m.heldBy, me)} ${m.heldBy === me ? 'have' : 'has'} it claimed. The session's version is in the file.`
29
+ if (m.oursDeleted) return `${who} ${done(m, true)} and ${other} changed it in the session.`
30
+ if (m.theirsHash === null) return `${who} ${did}, but it was deleted in the session.`
31
+ return `${who} ${did} and ${other} changed it in the session${m.reason ? ` (${esc(m.reason)})` : ''}.`
32
+ }
33
+
34
+ const mineLabel = (m, me) => m.oursDeleted ? 'Delete it' : m.by === me ? 'Keep mine' : `Keep ${esc(m.by)}'s`
35
+ const theirsLabel = (m, me) => m.theirsHash === null ? 'Keep it deleted' : !m.others[0] ? "Keep the session's" : m.others[0] === me ? 'Keep mine' : `Keep ${esc(m.others[0])}'s`
36
+
37
+ /**
38
+ * The buttons for one record. `full` (the compare view) also offers both
39
+ * versions on rows an AI combined. Viewers see the record but can't act.
40
+ */
41
+ export function mergeActionsHtml (m, me, editors, { full = false, viewer = false } = {}) {
42
+ const id = esc(m.id)
43
+ const b = (how, label, cls = 'ghost') => `<button class="btn sm ${cls}" data-merge="${id}" data-how="${how}">${label}</button>`
44
+ if (m.state === 'done') return `<span class="tag">Settled by ${you(m.resolvedBy, me)}</span>`
45
+ if (viewer) return '<span class="tag">View only</span>'
46
+ // Markers are in the file: only "done" and "keep mine" still make sense.
47
+ if (m.state === 'editing') return `<span class="hint">Markers are in the file</span>${b('mine', mineLabel(m, me))}${b('agent', 'Resolved', 'primary')}`
48
+ if (m.kind === 'ai') return (full ? b('mine', mineLabel(m, me)) : '') + b('review', 'Looks fine', 'primary')
49
+ // Settling writes the file, and so would an AI sent to merge it: ask whoever holds it, or keep theirs.
50
+ if (m.heldBy && m.heldBy !== me) {
51
+ const ask = m.asked
52
+ ? `<span class="tag">Asked ${esc(m.heldBy)} for it</span>`
53
+ : `<button class="btn sm" data-merge="${id}" data-ask="${esc(m.path)}" title="Join the file queue: ${esc(m.heldBy)} hands it to you when done">Ask ${esc(m.heldBy)} for it</button>`
54
+ return b('theirs', theirsLabel(m, me)) + ask
55
+ }
56
+ const hand = m.binary || m.oursDeleted || m.theirsHash === null ? '' : b('hand', 'Edit by hand')
57
+ const [first, ...rest] = editors
58
+ const send = first
59
+ ? `<span class="merge-send"><button class="btn sm" data-merge="${id}" data-send="${esc(first.id)}" ${first.auto ? 'title="Your AI merges it on its own, on this computer, and says how it went in chat"' : ''}>${first.auto ? 'Have your AI merge it' : `Send to ${esc(first.name)}`}</button>${rest.length
60
+ ? `<select class="input" data-merge-apps="${id}" aria-label="Send to another app"><option value="">Other app…</option>${rest.map((e) => `<option value="${esc(e.id)}">${e.auto ? 'Your AI, on its own' : esc(e.name)}</option>`).join('')}</select>`
61
+ : ''}</span>`
62
+ : ''
63
+ return b('mine', mineLabel(m, me)) + b('theirs', theirsLabel(m, me)) + hand + send
64
+ }
65
+
66
+ function sentToast (r, name) {
67
+ if (r.app === 'auto') { toast(`Your AI is on it in ${r.tool || 'your AI tool'}. It says how it went in chat.`); return }
68
+ toast(r.filled ? `Opened ${name} with the merge prompt filled in: send it.` : r.copied ? `Opened ${name}. The merge prompt is on your clipboard: paste it in.` : `Opened ${name}.`)
69
+ }
70
+
71
+ const settledToast = { hand: 'Markers are in the file. Remove them and save to finish.', mine: 'Done', theirs: 'Done', review: 'Thanks for checking', agent: 'Done' }
72
+
73
+ /**
74
+ * Wires the merge buttons inside `el` (the bar, or the compare view). Call once
75
+ * per mount: the listeners are delegated, so redraws need no rebinding.
76
+ * `onCompare(id)` opens the compare view; `editors()` names the apps.
77
+ */
78
+ export function bindMerges (el, { sessionId, onCompare, editors }) {
79
+ const nameOf = (app) => (editors().find((e) => e.id === app) || {}).name || 'the app'
80
+ const send = async (id, app) => sentToast(await api('POST', `/api/sessions/${sessionId()}/merges/send`, { id, app }), nameOf(app))
81
+ el.addEventListener('click', async (e) => {
82
+ const cmp = e.target.closest('[data-compare]')
83
+ if (cmp) { onCompare(cmp.dataset.compare); return }
84
+ const btn = e.target.closest('button[data-merge]')
85
+ if (!btn) return
86
+ btn.disabled = true
87
+ try {
88
+ if (btn.dataset.send) await send(btn.dataset.merge, btn.dataset.send)
89
+ else if (btn.dataset.ask) {
90
+ const file = btn.dataset.ask
91
+ await api('POST', `/api/sessions/${sessionId()}/request-file`, { path: file, title: `Settle the merge of ${file}`, description: 'I changed this file outside the session while you had it. When you hand it over, I will merge my changes with yours.' })
92
+ toast(`Asked for ${file}. It comes to you, with their notes, when they hand it over.`)
93
+ }
94
+ else {
95
+ await api('POST', `/api/sessions/${sessionId()}/merges/resolve`, { id: btn.dataset.merge, how: btn.dataset.how })
96
+ toast(settledToast[btn.dataset.how] || 'Done')
97
+ }
98
+ } catch (err) { toast(err.message) }
99
+ btn.disabled = false // a settled row goes away on the next status
100
+ })
101
+ el.addEventListener('change', async (e) => {
102
+ const sel = e.target.closest('[data-merge-apps]')
103
+ if (!sel || !sel.value) return
104
+ const app = sel.value
105
+ sel.value = ''
106
+ sel.blur() // lets the bar redraw again
107
+ try { await send(sel.dataset.mergeApps, app) } catch (err) { toast(err.message) }
108
+ })
109
+ }
110
+
111
+ /** One side: its lines, with the ones that differ from the base highlighted. */
112
+ function column (title, text, base, note) {
113
+ let body
114
+ if (text == null) body = `<div class="fv-note">${note}</div>`
115
+ else {
116
+ const lines = text.split('\n')
117
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
118
+ const changed = changedLines(base == null ? [] : base.split('\n'), lines)
119
+ body = `<table class="fv-code"><tbody>${lines.map((l, i) =>
120
+ `<tr${changed.has(i) ? ' class="mchg"' : ''}><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>`
121
+ }
122
+ return `<div class="merge-col"><div class="merge-col-head">${title}</div><div class="fv-scroll">${body}</div></div>`
123
+ }
124
+
125
+ /** A short fingerprint of a text (FNV-1a and length), so a redraw check needn't keep or compare whole files. */
126
+ function textSig (t) {
127
+ if (t == null) return t === undefined ? 'u' : 'n'
128
+ let h = 0x811c9dc5
129
+ for (let i = 0; i < t.length; i++) h = Math.imul(h ^ t.charCodeAt(i), 0x01000193)
130
+ return `${t.length}:${(h >>> 0).toString(36)}`
131
+ }
132
+
133
+ /**
134
+ * The compare view. `m`: the record without its texts, as status has it.
135
+ * `texts`: { ours, base } from the full record, `undefined` while it loads.
136
+ * `theirs`: the session's text now, `null` when it isn't there, `undefined` while it loads.
137
+ */
138
+ export function renderMergeView (el, { merge: m, texts, theirs, me, editors, viewer = false }) {
139
+ const showing = el.querySelector(':scope > .merge-view')
140
+ // Don't redraw under someone picking an app.
141
+ if (showing && el.contains(document.activeElement) && document.activeElement.tagName === 'SELECT') return
142
+ const sig = JSON.stringify([m, textSig(texts?.ours), textSig(texts?.base), texts === undefined, textSig(theirs), me, editors.map((e) => e.id), viewer])
143
+ if (showing && el._mergeSig === sig) return
144
+ el._mergeSig = sig
145
+ if (!m) { el.innerHTML = '<div class="main-empty merge-view"><p class="hint">That merge is settled or gone.</p></div>'; return }
146
+ const ours = texts ? texts.ours : null
147
+ const base = texts ? texts.base : null
148
+ const mine = m.by === me ? 'Yours, from offline' : `${esc(m.by)}'s, from offline`
149
+ const sess = m.kind === 'ai' ? 'In the session now, combined by AI' : `In the session${m.others[0] ? `, from ${you(m.others[0], me)}` : ''}`
150
+ const oursNote = m.oursDeleted ? 'Deleted offline' : texts === undefined ? loadingNote() : m.local ? `Too big to show here. It's in the merge folder on ${m.by === me ? 'this computer' : `${esc(m.by)}'s computer`}.` : 'Not available'
151
+ const theirsNote = theirs === undefined ? loadingNote() : m.theirsHash === null ? 'Deleted in the session' : 'Not in the session any more'
152
+ el.innerHTML = `<div class="fv merge-view">
153
+ <div class="fv-banner"><span class="fv-path mono">${esc(m.path)}</span><span class="fv-edited">${describe(m, me)}</span><span class="spacer"></span>${mergeActionsHtml(m, me, editors, { full: true, viewer })}</div>
154
+ ${m.binary
155
+ ? `<div class="fv-note">${I.file} Binary file: pick a version above.</div>`
156
+ : `<div class="merge-cols">${column(mine, ours, base, oursNote)}${column(sess, theirs, base, theirsNote)}</div>`}
157
+ </div>`
158
+ }
@@ -0,0 +1,78 @@
1
+ // The Files rail (rail.js): the folded Files pane as signals instead of names. Who holds
2
+ // what, since when, why, who waits for it and when it frees up on its own; files to merge.
3
+ // Pure, so tests can run it without a page.
4
+ import { stitchTime } from './loom-model.js'
5
+
6
+ /** A claim whose holder does nothing in the session this long is let go of (the relay's claimIdleMs). */
7
+ export const CLAIM_IDLE_MS = 20 * 60 * 1000
8
+
9
+ const isFolder = (pattern) => /\/(\*\*)?$/.test(String(pattern))
10
+ const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(/\/+$/, '') // as tree.js
11
+
12
+ /**
13
+ * Everything the rail and its lock list show. `claims`: the session's ({ pattern, by, ts, note,
14
+ * active, activeAt, queue }); `merges`: files to merge ({ id, path, state }); `me`: your name;
15
+ * `owners`: AI session name -> its person (chat.js aiOwners), so a person's AI sessions are one
16
+ * holder, "<person>'s AI"; `aiName`: person -> that name; `files`: the session's paths (to count a
17
+ * folder's); `open`: paths you have open (a merge there is urgent); `focusOf`: name -> what they
18
+ * said they're doing (a lock's why when it has no note); `kindOf`: name -> 'agent' for an AI agent.
19
+ * Returns { locks, holders, waiting, mine, merges: { count, urgent } }, locks oldest first.
20
+ */
21
+ export function railModel ({ claims = [], merges = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], open = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
22
+ const fileSet = new Set(files)
23
+ const holderOf = (by) => owners.has(by) ? aiName(owners.get(by)) : by
24
+ const locks = (claims || []).filter((c) => c && c.pattern && c.by).map((c) => {
25
+ const bare = claimFolder(c.pattern)
26
+ // "src/auth", "src/auth/" and "src/auth/**" all claim the folder (tree.js claimFolder).
27
+ const folder = isFolder(c.pattern) || (!fileSet.has(bare) && files.some((f) => f.startsWith(`${bare}/`)))
28
+ const path = folder ? bare : String(c.pattern)
29
+ const queue = Array.isArray(c.queue) ? c.queue : []
30
+ const last = typeof c.activeAt === 'number' ? c.activeAt : c.ts || now
31
+ return {
32
+ pattern: c.pattern,
33
+ path,
34
+ folder,
35
+ count: folder ? files.filter((f) => f.startsWith(`${path}/`)).length : 1,
36
+ by: c.by,
37
+ holder: holderOf(c.by),
38
+ mine: c.by === me,
39
+ ours: c.by === me || owners.get(c.by) === me, // yours or one of your AI sessions'
40
+ away: c.active === false,
41
+ since: c.ts || now,
42
+ why: c.note || focusOf(c.by) || '',
43
+ queue: queue.map((r) => ({ by: holderOf(r.by), title: r.title || '' })),
44
+ freesAt: last + idleMs
45
+ }
46
+ }).sort((a, b) => a.since - b.since)
47
+
48
+ const holders = []
49
+ for (const l of locks) {
50
+ let h = holders.find((x) => x.name === l.holder)
51
+ if (!h) holders.push(h = { name: l.holder, by: l.by, ours: l.ours, ai: owners.has(l.by) || kindOf(l.by) === 'agent', locks: 0, waiting: 0 })
52
+ h.locks++
53
+ h.waiting += l.queue.length
54
+ }
55
+ const openSet = new Set(open)
56
+ const toMerge = (merges || []).filter((m) => m && m.state !== 'done')
57
+ return {
58
+ locks,
59
+ holders,
60
+ waiting: locks.filter((l) => l.mine && l.queue.length), // someone waits for a file you hold: yours to act on
61
+ mine: locks.filter((l) => l.ours).length,
62
+ merges: { count: toMerge.length, urgent: toMerge.some((m) => openSet.has(m.path)) }
63
+ }
64
+ }
65
+
66
+ /** "25m", how long a lock has been held. */
67
+ export const heldFor = (l, now = Date.now()) => stitchTime(l.since, now)
68
+
69
+ /**
70
+ * When a lock frees up if its holder stays idle: "frees in 12m if idle", or, with someone
71
+ * waiting, "goes to Sriram in 12m if idle"; "frees any moment" once that time has come.
72
+ */
73
+ export function freesText (l, now = Date.now()) {
74
+ const next = l.queue[0]
75
+ const left = l.freesAt - now
76
+ if (left <= 30 * 1000) return next ? `goes to ${next.by} any moment` : 'frees any moment'
77
+ return `${next ? `goes to ${next.by}` : 'frees'} in ${stitchTime(now - left, now)} if idle`
78
+ }
package/src/ui/rail.js ADDED
@@ -0,0 +1,173 @@
1
+ // The Files rail: the folded Files pane (session.js) as signals instead of names. An hourglass
2
+ // when someone waits for a file you hold (it pulses: it's yours to act on), the lock with how
3
+ // many files are locked, then one avatar per holder with their count. Each opens the lock list
4
+ // beside it (rail-model.js): who holds what, for how long, why, who waits for it, and when it
5
+ // frees up on its own. The Loom's "holds" opens the same list for one lane.
6
+ import { esc, I, avatar, basename } from './common.js'
7
+ import { heldFor, freesText, CLAIM_IDLE_MS } from './rail-model.js'
8
+
9
+ export const HOURGLASS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2h12M6 22h12"/><path d="M7 2v4a5 5 0 0 0 10 0V2"/><path d="M7 22v-4a5 5 0 0 1 10 0v4"/></svg>'
10
+ const AVATARS = 5 // holders shown on the rail; past that, a "+N" that opens everyone's
11
+
12
+ let pop = null // the lock list, once made
13
+ let open = null // { filter, anchor, pinned }: what the open list shows and where
14
+ let last = null // { model, opts }: the latest model, so an open list follows it
15
+ let hoverTimer = 0
16
+ let leaveTimer = 0
17
+
18
+ const who = (name, me) => name === me ? 'You' : name
19
+ const whose = (name, me) => name === me ? 'Your locks' : `${name}'s locks`
20
+
21
+ /** The rail's buttons for model `m` (railModel). */
22
+ export function railHtml (m, { me, colorOf }) {
23
+ const out = []
24
+ if (m.waiting.length) {
25
+ const n = m.waiting.reduce((s, l) => s + l.queue.length, 0)
26
+ const what = `${n} waiting for ${m.waiting.length === 1 ? `${basename(m.waiting[0].path)}, which you hold` : `${m.waiting.length} files you hold`}`
27
+ out.push(`<button type="button" class="rail-sig waiting" data-rail="waiting" title="${esc(what)}" aria-label="${esc(what)}">${HOURGLASS}<span class="rail-n">${n}</span></button>`)
28
+ }
29
+ const total = m.locks.length
30
+ const lockWhat = total ? `${total} locked${m.mine ? `, ${m.mine} by you or your AI` : ''}. Who holds what, and for how long` : 'Nothing is locked'
31
+ out.push(`<button type="button" class="rail-sig lock${m.mine ? ' mine' : ''}${total ? '' : ' none'}" data-rail="all" title="${esc(lockWhat)}" aria-label="${esc(lockWhat)}">${I.lock}${total ? `<span class="rail-n">${total}</span>` : ''}</button>`)
32
+ if (m.holders.length) {
33
+ out.push('<span class="rail-sep" aria-hidden="true"></span>')
34
+ const shown = m.holders.length > AVATARS + 1 ? m.holders.slice(0, AVATARS) : m.holders
35
+ for (const h of shown) {
36
+ const what = `${who(h.name, me)}: ${h.locks} locked${h.waiting ? `, ${h.waiting} waiting` : ''}`
37
+ out.push(`<button type="button" class="rail-who${h.ai ? ' ai' : ''}" data-rail="who" data-name="${esc(h.name)}" title="${esc(what)}" aria-label="${esc(what)}">${avatar(h.name, colorOf(h.by))}<span class="rail-n">${h.locks}</span>${h.waiting ? '<span class="rail-q" aria-hidden="true"></span>' : ''}</button>`)
38
+ }
39
+ if (shown.length < m.holders.length) out.push(`<button type="button" class="rail-more" data-rail="all" title="Everyone's locks">+${m.holders.length - shown.length}</button>`)
40
+ }
41
+ return out.join('')
42
+ }
43
+
44
+ /** The lock list for `filter`: 'all', 'waiting', or { who: holder }. */
45
+ export function locksHtml (m, filter, { me, colorOf, now = Date.now() }) {
46
+ const byWho = filter && typeof filter === 'object' ? filter.who : null
47
+ const rows = filter === 'waiting' ? m.waiting : byWho ? m.locks.filter((l) => l.holder === byWho) : m.locks
48
+ const title = filter === 'waiting' ? 'Waiting on you' : byWho ? whose(byWho, me) : 'Locked files'
49
+ const row = (l) => {
50
+ const dir = l.folder ? '' : l.path.includes('/') ? l.path.slice(0, l.path.lastIndexOf('/') + 1) : ''
51
+ const name = l.folder ? `${l.path}/` : basename(l.path)
52
+ const queue = l.queue.length
53
+ ? `<div class="lk-q">${HOURGLASS}<span>${l.queue.length} waiting: ${esc(l.queue.map((r) => who(r.by, me)).join(', '))} · ${esc(freesText(l, now))}</span></div>`
54
+ : `<div class="lk-free">${esc(freesText(l, now))}</div>`
55
+ return `<li class="lk-row${l.ours ? ' ours' : ''}">
56
+ <button type="button" class="lk-main"${l.folder ? '' : ` data-lk-file="${esc(l.path)}"`} title="${esc(l.folder ? `${l.path}/ and everything in it` : `Open ${l.path}`)}"${l.folder ? ' disabled' : ''}>
57
+ <span class="lk-dot" style="--c:${esc(colorOf(l.by))}"></span>
58
+ <span class="lk-name">${esc(name)}${l.folder ? `<span class="lk-count">${l.count} file${l.count === 1 ? '' : 's'}</span>` : ''}</span>
59
+ <span class="lk-held" title="Held since ${esc(new Date(l.since).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }))}">${esc(heldFor(l, now))}</span>
60
+ </button>
61
+ ${dir ? `<div class="lk-dir">${esc(dir)}</div>` : ''}
62
+ <div class="lk-who"><b>${esc(who(l.holder, me))}</b>${l.holder !== l.by ? ` <i>${esc(l.by)}</i>` : ''}${l.away ? ' <i>(away)</i>' : ''}${l.why ? ` · ${esc(l.why)}` : ''}</div>
63
+ ${queue}
64
+ </li>`
65
+ }
66
+ return `<div class="lk-head"><span>${esc(title)}</span><span class="lk-total">${rows.length}</span></div>
67
+ ${rows.length ? `<ul class="lk-list">${rows.map(row).join('')}</ul>` : `<p class="lk-none">${filter === 'waiting' ? 'Nobody is waiting for your files.' : byWho ? 'Nothing locked.' : 'Nothing is locked right now.'}</p>`}
68
+ <div class="lk-foot">A lock is let go after ${Math.round(CLAIM_IDLE_MS / 60000)} minutes with no activity from its holder.</div>`
69
+ }
70
+
71
+ /**
72
+ * Draws the rail into `el` and keeps an open lock list up to date. `opts`: { me, colorOf, onFile }.
73
+ */
74
+ export function renderRail (el, model, opts) {
75
+ last = { model, opts }
76
+ const html = railHtml(model, opts)
77
+ if (el.dataset.html !== html) { el.innerHTML = html; el.dataset.html = html }
78
+ if (!el.dataset.bound) bindRail(el)
79
+ if (open && pop && !pop.hidden) {
80
+ if (!open.anchor.isConnected) {
81
+ // The rail redrew: keep the list on the button that opened it.
82
+ const again = el.querySelector(open.filter && typeof open.filter === 'object' ? `[data-rail="who"][data-name="${CSS.escape(open.filter.who)}"]` : `[data-rail="${open.filter}"]`)
83
+ if (again) open.anchor = again
84
+ }
85
+ fill()
86
+ }
87
+ }
88
+
89
+ function bindRail (el) {
90
+ el.dataset.bound = '1'
91
+ const filterOf = (b) => b.dataset.rail === 'who' ? { who: b.dataset.name } : b.dataset.rail
92
+ el.addEventListener('click', (e) => {
93
+ const b = e.target.closest('[data-rail]')
94
+ if (!b) return
95
+ const filter = filterOf(b)
96
+ if (open && open.pinned && same(open.filter, filter)) { closeLocks(); return }
97
+ openLocks(b, filter, { pinned: true })
98
+ })
99
+ el.addEventListener('pointerover', (e) => {
100
+ const b = e.target.closest('[data-rail]')
101
+ if (!b || e.pointerType === 'touch') return
102
+ clearTimeout(leaveTimer)
103
+ if (open && open.pinned) return
104
+ clearTimeout(hoverTimer)
105
+ hoverTimer = setTimeout(() => openLocks(b, filterOf(b), { pinned: false }), open ? 0 : 250)
106
+ })
107
+ el.addEventListener('pointerleave', () => { clearTimeout(hoverTimer); leaveSoon() })
108
+ }
109
+
110
+ const same = (a, b) => (a && typeof a === 'object' && b && typeof b === 'object') ? a.who === b.who : a === b
111
+
112
+ function leaveSoon () {
113
+ if (!open || open.pinned) return
114
+ clearTimeout(leaveTimer)
115
+ leaveTimer = setTimeout(closeLocks, 300)
116
+ }
117
+
118
+ /** Opens the lock list beside `anchor`. `pinned`: opened by a click, so it stays until a click elsewhere or Escape. */
119
+ export function openLocks (anchor, filter, { pinned = true } = {}) {
120
+ if (!last) return
121
+ if (!pop) {
122
+ pop = document.createElement('div')
123
+ pop.className = 'lk-pop'
124
+ pop.id = 'lock-list'
125
+ pop.setAttribute('role', 'dialog')
126
+ pop.setAttribute('aria-label', 'Locked files')
127
+ pop.hidden = true
128
+ document.body.appendChild(pop)
129
+ pop.addEventListener('click', (e) => {
130
+ const f = e.target.closest('[data-lk-file]')
131
+ if (f) { closeLocks(); last.opts.onFile(f.dataset.lkFile) }
132
+ })
133
+ pop.addEventListener('pointerenter', () => clearTimeout(leaveTimer))
134
+ pop.addEventListener('pointerleave', leaveSoon)
135
+ document.addEventListener('pointerdown', (e) => {
136
+ if (!open || pop.hidden || pop.contains(e.target) || open.anchor.contains(e.target)) return
137
+ if (e.target.closest?.('[data-rail], [data-loom-holds]')) return // their own click decides
138
+ closeLocks()
139
+ }, true)
140
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && open && !pop.hidden) { const a = open.anchor; closeLocks(); a.focus?.() } })
141
+ }
142
+ clearTimeout(leaveTimer)
143
+ const fresh = pop.hidden
144
+ open = { filter, anchor, pinned: pinned || !!(open && open.pinned && !fresh) }
145
+ pop.hidden = false
146
+ fill()
147
+ if (fresh) { pop.classList.remove('in'); void pop.offsetWidth; pop.classList.add('in') }
148
+ for (const b of document.querySelectorAll('[aria-controls="lock-list"]')) b.setAttribute('aria-expanded', 'false')
149
+ anchor.setAttribute('aria-controls', 'lock-list')
150
+ anchor.setAttribute('aria-expanded', 'true')
151
+ }
152
+
153
+ export function closeLocks () {
154
+ clearTimeout(hoverTimer)
155
+ clearTimeout(leaveTimer)
156
+ if (open) open.anchor.setAttribute?.('aria-expanded', 'false')
157
+ open = null
158
+ if (pop) pop.hidden = true
159
+ }
160
+
161
+ function fill () {
162
+ const { model, opts } = last
163
+ pop.innerHTML = locksHtml(model, open.filter, opts)
164
+ // Beside its button: to the right (the rail, a lane head), else below.
165
+ const r = open.anchor.getBoundingClientRect()
166
+ const w = pop.offsetWidth
167
+ const h = pop.offsetHeight
168
+ const right = r.right + 8 + w <= window.innerWidth - 8
169
+ const left = right ? r.right + 8 : Math.max(8, Math.min(r.left, window.innerWidth - w - 8))
170
+ const top = right ? r.top - 6 : r.bottom + 6
171
+ pop.style.left = `${left}px`
172
+ pop.style.top = `${Math.max(8, Math.min(top, window.innerHeight - h - 8))}px`
173
+ }