heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,75 @@
1
+ // The sign-in screen. Until this computer is linked to a heyquilt.com account the
2
+ // app shows only this. Signing in opens the website, where you approve this computer.
3
+ import { $, esc, api, loadingNote } from './common.js'
4
+ import { quiltMark } from './mark.js'
5
+
6
+ const SIGNUP = 'https://heyquilt.com/signup'
7
+ let poller = null
8
+
9
+ function screen (inner) {
10
+ clearInterval(poller)
11
+ $('#app').innerHTML = `<div class="signin"><div class="card signin-card">
12
+ <div class="signin-mark">${quiltMark({ word: false })}</div>
13
+ ${inner}
14
+ </div></div>`
15
+ }
16
+
17
+ /** Shows the sign-in screen. `onSignedIn` runs once this computer is approved. */
18
+ export function renderSignIn (message = '', onSignedIn = () => window.location.reload()) {
19
+ screen(`
20
+ <h1>Sign in to Quilt</h1>
21
+ ${message ? `<p class="signin-note">${esc(message)}</p>` : ''}
22
+ <button class="btn primary signin-btn" id="signin-go">Sign in</button>
23
+ <p class="hint">New to Quilt? <a href="${SIGNUP}" target="_blank" rel="noopener">Create an account</a></p>
24
+ <p class="error" id="signin-error"></p>`)
25
+ $('#signin-go').onclick = () => begin(onSignedIn)
26
+ }
27
+
28
+ async function begin (onSignedIn) {
29
+ const btn = $('#signin-go')
30
+ btn.disabled = true
31
+ try {
32
+ const acc = await api('POST', '/api/account/start')
33
+ // A computer linked before is signed straight back in: nothing to approve.
34
+ if (acc.signedIn) return onSignedIn()
35
+ window.open(acc.link.verificationUrl, '_blank', 'noopener')
36
+ waiting(acc.link, onSignedIn)
37
+ } catch (err) {
38
+ btn.disabled = false
39
+ $('#signin-error').textContent = err.message
40
+ }
41
+ }
42
+
43
+ function waiting (link, onSignedIn) {
44
+ screen(`
45
+ <h1>Sign in to Quilt</h1>
46
+ <p>Approve this computer in your browser</p>
47
+ <div class="signin-code" aria-label="Your code">${esc(link.userCode)}</div>
48
+ <p class="hint">Check the page shows this code.</p>
49
+ <p class="signin-wait">${loadingNote('Waiting for you to approve it…')}</p>
50
+ <div class="signin-actions"><button class="btn" id="signin-cancel">Cancel</button><button class="btn primary" id="signin-again">Open the page again</button></div>`)
51
+ $('#signin-again').onclick = () => window.open(link.verificationUrl, '_blank', 'noopener')
52
+ $('#signin-cancel').onclick = async () => {
53
+ await api('POST', '/api/account/cancel').catch(() => {})
54
+ renderSignIn('', onSignedIn)
55
+ }
56
+ poller = setInterval(async () => {
57
+ let acc
58
+ try { acc = await api('GET', '/api/account') } catch { return }
59
+ if (acc.signedIn) { clearInterval(poller); onSignedIn(); return }
60
+ // No link any more (cancelled elsewhere, or the app restarted): nothing left to wait for.
61
+ if (!acc.link) return over('That sign-in was stopped. Start over to get a new code.', onSignedIn)
62
+ const st = acc.link.state
63
+ if (st === 'expired') over('That code expired. Start over to get a new one.', onSignedIn)
64
+ else if (st === 'denied') over('This computer was not approved. Start over to try again.', onSignedIn)
65
+ else if (st === 'failed') over(acc.link.error || 'Signing in did not work. Start over to try again.', onSignedIn)
66
+ }, 2000)
67
+ }
68
+
69
+ function over (message, onSignedIn) {
70
+ screen(`
71
+ <h1>Sign in to Quilt</h1>
72
+ <p class="signin-note">${esc(message)}</p>
73
+ <button class="btn primary signin-btn" id="signin-go">Start over</button>`)
74
+ $('#signin-go').onclick = () => begin(onSignedIn)
75
+ }
@@ -0,0 +1,110 @@
1
+ // Sounds for the moments that need you: someone mentions you or writes to you directly,
2
+ // someone asks to be let in (only when you may let them in), or a task is handed to you.
3
+ // What to play is worked out by pure helpers (tested in node); the tones are made with
4
+ // Web Audio, so there are no sound files to ship and nothing for the CSP to allow.
5
+
6
+ /** The events that can play a sound, the profile setting that turns each on, and its label. */
7
+ export const SOUND_EVENTS = [
8
+ { kind: 'mention', setting: 'soundMentions', label: 'Mentions and direct messages', hint: 'Someone writes @you, or sends you a direct message.' },
9
+ { kind: 'letIn', setting: 'soundLetIn', label: 'Requests to join', hint: 'Someone asks to be let in, and you may let them in.' },
10
+ { kind: 'task', setting: 'soundTasks', label: 'Tasks for you', hint: 'A task is assigned to you or to your AI.' }
11
+ ]
12
+
13
+ const escapeRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14
+
15
+ /** True when `text` mentions `name` the way chat does: @ starts a word, the name ends one, and "@Name's AI" is the AI's. */
16
+ export function mentions (text, name) {
17
+ const n = String(name || '').trim()
18
+ if (!n || !text) return false
19
+ return new RegExp(`(^|[^\\w@])@${escapeRe(n)}(?![\\w-])(?!'s AI(?![\\w-]))`, 'iu').test(String(text))
20
+ }
21
+
22
+ /** Whether a live chat message is for `me`: a direct message to me, or one that mentions me. Never my own. */
23
+ export function messageSound (message, me) {
24
+ if (!message || !me || typeof message.by !== 'string' || message.by === me) return null
25
+ if (message.to) return message.to === me ? 'mention' : null
26
+ return mentions(message.text, me) ? 'mention' : null
27
+ }
28
+
29
+ /** Whether someone new is asking to be let in. `waiting` is only sent to people who may let them in. */
30
+ export function letInSound (before, after) {
31
+ if (!Array.isArray(after) || !after.length) return null
32
+ const known = new Set((Array.isArray(before) ? before : []).map((p) => p && p.key))
33
+ return after.some((p) => p && p.key && !known.has(p.key)) ? 'letIn' : null
34
+ }
35
+
36
+ const forMe = (t, me) => !!t && !t.archived && t.assignee === me && t.column !== 'done'
37
+
38
+ /** Whether a task became mine (to me or to my AI) between two task lists, by someone else. */
39
+ export function taskSound (before, after, me) {
40
+ if (!me || !Array.isArray(after)) return null
41
+ const was = new Map((Array.isArray(before) ? before : []).map((t) => [t && t.id, t]))
42
+ for (const t of after) {
43
+ if (!forMe(t, me)) continue
44
+ const old = was.get(t.id)
45
+ if (old && old.assignee === me && !!old.forAi === !!t.forAi) continue
46
+ // Your own new task, assigned to you as you add it, needs no sound.
47
+ if (!old && t.by === me) continue
48
+ return 'task'
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** Whether `kind` is switched on in the profile. Every sound is on unless switched off. */
54
+ export function soundOn (profile, kind) {
55
+ const ev = SOUND_EVENTS.find((e) => e.kind === kind)
56
+ return !!ev && (profile ? profile[ev.setting] : undefined) !== false
57
+ }
58
+
59
+ // A short, soft chime per event: [frequency Hz, start s, length s].
60
+ const TONES = {
61
+ mention: [[880, 0, 0.12], [1318.5, 0.1, 0.18]],
62
+ letIn: [[659.3, 0, 0.12], [880, 0.12, 0.12], [1046.5, 0.24, 0.2]],
63
+ task: [[987.8, 0, 0.1], [740, 0.1, 0.18]]
64
+ }
65
+ const GAP_MS = 1500 // one sound for a burst of events, not one per event
66
+
67
+ let ctx = null
68
+ let last = 0
69
+
70
+ function audio () {
71
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext
72
+ if (!AC) return null
73
+ if (!ctx) ctx = new AC()
74
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {})
75
+ return ctx
76
+ }
77
+
78
+ /** Plays the chime for `kind`, at most once every GAP_MS. `force` skips the gap (the Settings preview). */
79
+ export function playSound (kind, { force = false } = {}) {
80
+ const tones = TONES[kind]
81
+ if (!tones) return false
82
+ const now = Date.now()
83
+ if (!force && now - last < GAP_MS) return false
84
+ last = now
85
+ try {
86
+ const ac = audio()
87
+ if (!ac) return false
88
+ const t0 = ac.currentTime + 0.01
89
+ for (const [freq, start, len] of tones) {
90
+ const osc = ac.createOscillator()
91
+ const gain = ac.createGain()
92
+ osc.type = 'sine'
93
+ osc.frequency.value = freq
94
+ gain.gain.setValueAtTime(0.0001, t0 + start)
95
+ gain.gain.exponentialRampToValueAtTime(0.18, t0 + start + 0.015)
96
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + start + len)
97
+ osc.connect(gain).connect(ac.destination)
98
+ osc.start(t0 + start)
99
+ osc.stop(t0 + start + len + 0.02)
100
+ }
101
+ return true
102
+ } catch { return false }
103
+ }
104
+
105
+ /** Browsers start audio muted until the page is used: wake it on the first click or key. */
106
+ export function unlockSounds () {
107
+ const wake = () => { try { audio() } catch {} }
108
+ globalThis.addEventListener?.('pointerdown', wake, { once: true })
109
+ globalThis.addEventListener?.('keydown', wake, { once: true })
110
+ }
@@ -0,0 +1,86 @@
1
+ // Favicon marks for AI coding tools shown in the partner feed (conversation
2
+ // chips and AI reply badges). Names match profile/tool strings in TOOLS.
3
+ // Browser-free so unit tests can import it from Node.
4
+
5
+ function esc (s) {
6
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
7
+ }
8
+
9
+ /** Accessible display name for a tool string (empty → "AI"). */
10
+ export function toolLabel (tool) {
11
+ const t = String(tool || '').trim()
12
+ return t || 'AI'
13
+ }
14
+
15
+ // Company domains for Google's favicon service (sz=128 → crisp at 14–16px).
16
+ const DOMAINS = {
17
+ 'Claude Code': 'anthropic.com',
18
+ Cursor: 'cursor.com',
19
+ Codex: 'openai.com',
20
+ Windsurf: 'windsurf.com',
21
+ 'GitHub Copilot': 'github.com',
22
+ Zed: 'zed.dev',
23
+ Aider: 'aider.chat/docs', // root returns Google's empty globe; /docs has the real icon
24
+ xAI: 'x.ai'
25
+ // Other → generic sparkle below
26
+ }
27
+
28
+ const faviconImg = (domain) =>
29
+ `<img class="tool-mark" src="https://www.google.com/s2/favicons?domain=${esc(domain)}&sz=128" width="16" height="16" alt="" />`
30
+
31
+ const GENERIC = (
32
+ // Tiny inline SVG sparkle — fallback ONLY for unknown / empty / Other
33
+ `<svg class="tool-mark" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">` +
34
+ '<path fill="currentColor" d="M12 2l1.9 5.6L19.5 9.5l-5.6 1.9L12 17l-1.9-5.6L4.5 9.5l5.6-1.9z"/>' +
35
+ '<path fill="currentColor" d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>' +
36
+ '</svg>'
37
+ )
38
+
39
+ const ALIASES = {
40
+ claude: 'Claude Code',
41
+ 'claude code': 'Claude Code',
42
+ 'claude-code': 'Claude Code',
43
+ anthropic: 'Claude Code',
44
+ cursor: 'Cursor',
45
+ codex: 'Codex',
46
+ openai: 'Codex',
47
+ windsurf: 'Windsurf',
48
+ codeium: 'Windsurf',
49
+ copilot: 'GitHub Copilot',
50
+ 'github copilot': 'GitHub Copilot',
51
+ 'vs code': 'GitHub Copilot',
52
+ vscode: 'GitHub Copilot',
53
+ zed: 'Zed',
54
+ aider: 'Aider',
55
+ xai: 'xAI',
56
+ 'x.ai': 'xAI',
57
+ grok: 'xAI',
58
+ other: 'Other'
59
+ }
60
+
61
+ /** Canonical TOOLS key for a free-form tool string, or null if unknown. */
62
+ export function resolveToolKey (tool) {
63
+ const raw = String(tool || '').trim()
64
+ if (!raw) return null
65
+ if (Object.prototype.hasOwnProperty.call(DOMAINS, raw) || raw === 'Other') return raw
66
+ const lower = raw.toLowerCase()
67
+ if (ALIASES[lower]) return ALIASES[lower]
68
+ for (const key of [...Object.keys(DOMAINS), 'Other']) {
69
+ if (key.toLowerCase() === lower) return key
70
+ }
71
+ return null
72
+ }
73
+
74
+ /**
75
+ * HTML for a provider logo. Uses title + aria-label for the tool name so the
76
+ * visible string can be dropped from chips/badges. Known tools get a Google
77
+ * favicon <img>; unknown / empty / Other get a generic AI sparkle mark.
78
+ */
79
+ export function toolLogo (tool, { className = 'tool-logo' } = {}) {
80
+ const label = toolLabel(tool)
81
+ const key = resolveToolKey(tool)
82
+ const domain = key && DOMAINS[key]
83
+ const mark = domain ? faviconImg(domain) : GENERIC
84
+ const cls = className ? ` class="${esc(className)}"` : ''
85
+ return `<span${cls} title="${esc(label)}" aria-label="${esc(label)}">${mark}</span>`
86
+ }
package/src/ui/tree.js ADDED
@@ -0,0 +1,281 @@
1
+ // The shared project's file tree: who edited what recently, what's claimed.
2
+ import { esc, I, ago, colorFor, loadingNote } from './common.js'
3
+
4
+ /** Where each person's chat files are kept (CHAT_FILES in pathrules.js, which the page can't load). */
5
+ export const CHAT_FILES = 'Chat files'
6
+
7
+ export const RECENT_MS = 2 * 60 * 1000
8
+ /** How long a file brought in from GitHub keeps its GitHub badge (an edit in the session takes it away sooner). */
9
+ export const GITHUB_BADGE_MS = 10 * 60 * 1000
10
+
11
+ const shortAgo = (ts) => {
12
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
13
+ return s < 60 ? `${s}s` : `${Math.round(s / 60)}m`
14
+ }
15
+
16
+ /** Who holds a claim, as the tree shows it: "you", "Duncan", or "Duncan (away)" when they aren't in the session. */
17
+ export const claimHolder = (c, me) => c.by === me && c.active !== false ? 'you' : `${c.by}${c.active === false ? ' (away)' : ''}`
18
+
19
+ const queueOf = (c) => (c && Array.isArray(c.queue) ? c.queue : [])
20
+
21
+ /** A claim's tooltip: who, since when, their note, the account that holds it, and who waits for it. */
22
+ export const claimTitle = (c, me) => [
23
+ `Claimed by ${claimHolder(c, me)}${c.ts ? ` at ${new Date(c.ts).toLocaleString()}` : ''}`,
24
+ c.note || '',
25
+ c.byId ? `Account: ${c.byId}` : '',
26
+ c.activeAt ? `Last active ${new Date(c.activeAt).toLocaleTimeString()}; let go after 20 minutes with no activity` : '',
27
+ ...queueOf(c).map((r, i) => `${i + 1}. ${r.by === me ? 'You' : r.by} waiting: ${r.title}`)
28
+ ].filter(Boolean).join('\n')
29
+
30
+ /** "src/auth", "src/auth/", "src/auth/**" all claim the folder src/auth. */
31
+ export const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(/\/+$/, '')
32
+
33
+ function buildTree (files) {
34
+ const root = { name: '', path: '', dirs: new Map(), files: [] }
35
+ for (const f of files) {
36
+ const parts = f.path.split('/')
37
+ let node = root
38
+ for (let i = 0; i < parts.length - 1; i++) {
39
+ const p = parts.slice(0, i + 1).join('/')
40
+ if (!node.dirs.has(parts[i])) node.dirs.set(parts[i], { name: parts[i], path: p, dirs: new Map(), files: [] })
41
+ node = node.dirs.get(parts[i])
42
+ }
43
+ node.files.push({ ...f, name: parts[parts.length - 1] })
44
+ }
45
+ return root
46
+ }
47
+
48
+ /**
49
+ * Claims on files or folders inside a folder (not the folder itself): a collapsed folder shows them,
50
+ * so a claim deep in the tree is seen without opening every folder.
51
+ */
52
+ export function claimsInside (claims, dir) {
53
+ const pre = dir ? `${dir}/` : ''
54
+ return (claims || []).filter((c) => { const f = claimFolder(c.pattern); return f !== dir && f.startsWith(pre) })
55
+ }
56
+
57
+ /** The badge a collapsed folder shows for the claims inside it: "Brandon", "you +1", with each claim in the tooltip. */
58
+ export function insideBadge (claims, me) {
59
+ if (!claims.length) return ''
60
+ const holders = [...new Set(claims.map((c) => claimHolder(c, me)))]
61
+ const label = holders.length > 1 ? `${holders[0]} +${holders.length - 1}` : holders[0]
62
+ const title = claims.map((c) => `${claimFolder(c.pattern)}: ${claimHolder(c, me)}${c.note ? ` (${c.note})` : ''}`).join('\n')
63
+ const away = claims.every((c) => c.active === false)
64
+ // Only boxes inside (boxes.js): the box mark, so a folded folder reads like its files.
65
+ if (claims.every((c) => c.box)) {
66
+ const who = [...new Set(claims.map((c) => c.by === me || c.of === me ? 'you' : c.by))]
67
+ return `<span class="t-badge box inside" title="${esc(`Unfinished work inside:\n${claims.map((c) => `${c.pattern}: ${c.by === me || c.of === me ? 'yours' : c.by}`).join('\n')}`)}">${I.box}${esc(who.length > 1 ? `${who[0]} +${who.length - 1}` : who[0])}</span>`
68
+ }
69
+ return `<span class="t-badge claim inside${away ? ' away' : ''}" title="${esc(`Claimed inside:\n${title}`)}">${I.lock}${esc(label)}</span>`
70
+ }
71
+
72
+ function hasRecent (node, me) {
73
+ return node.files.some((f) => f.edited && f.edited.by !== me && Date.now() - f.edited.ts < RECENT_MS) ||
74
+ [...node.dirs.values()].some((d) => hasRecent(d, me))
75
+ }
76
+
77
+ /**
78
+ * @param {HTMLElement} el
79
+ * @param {{ files: object[], claims: object[] }} tree
80
+ * @param {{ me: string, expanded: object, selected: string|null }} view
81
+ */
82
+ export function renderTree (el, tree, { me, expanded, selected }) {
83
+ if (!tree) { el.innerHTML = `<div class="empty-note" style="padding:12px 14px">${loadingNote('Loading files…')}</div>`; return }
84
+ const files = tree.files
85
+ if (!files.length) { el.innerHTML = '<div class="empty-note" style="padding:12px 14px">No shared files yet.</div>'; return }
86
+ const folderClaims = new Map()
87
+ for (const c of tree.claims) folderClaims.set(claimFolder(c.pattern), c)
88
+ const root = buildTree(files)
89
+ const rows = []
90
+
91
+ // A box (boxes.js): someone's unfinished work, marked in amber; theirs isn't in this folder yet.
92
+ const boxMine = (c) => c.by === me || c.of === me
93
+ const boxBadge = (c) => `<span class="t-badge box${boxMine(c) ? ' own' : ''}" title="${esc(boxMine(c) ? `Your unfinished work: in your folder only, until you commit it${queueOf(c).length ? `. ${queueOf(c).length} waiting for it` : ''}` : `${c.by}'s unfinished work: not in your folder until they commit it. See it under them in People.`)}">${I.box}${esc(boxMine(c) ? 'you' : c.by)}${queueOf(c).length ? ` · ${queueOf(c).length} waiting` : ''}</span>`
94
+ const claimBadge = (c) => c && c.box ? boxBadge(c) : c ? `<span class="t-badge claim${c.active === false ? ' away' : ''}" title="${esc(claimTitle(c, me))}">${I.lock}${esc(claimHolder(c, me))}${queueOf(c).length ? ` · ${queueOf(c).length} waiting` : ''}</span>` : ''
95
+ // Who just edited a file: a button to its Changes tab. Its +/− shows in the row's change card.
96
+ const editBadge = (f, byOther) => `<button type="button" class="t-badge ${byOther ? 'edit' : 'mine'}" data-changes="${esc(f.path)}" aria-label="See what changed in ${esc(f.path)}">${esc(f.edited.by === me ? 'you' : f.edited.by)} · ${shortAgo(f.edited.ts)}</button>`
97
+ const walk = (node, depth) => {
98
+ const dirs = [...node.dirs.values()].sort((a, b) => (a.path === CHAT_FILES) - (b.path === CHAT_FILES) || a.name.localeCompare(b.name)) // chat files last
99
+ for (const d of dirs) {
100
+ const open = expanded[d.path] ?? depth === 0
101
+ const claim = folderClaims.get(d.path)
102
+ // The chat files folder (session.js): each person's own copy of what was sent in chat, nothing to claim.
103
+ if (d.path === CHAT_FILES) {
104
+ rows.push(`<div class="t-row t-dir t-chat" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}" title="Files sent in this session's chat. Only people in the session get them; never synced or committed.">
105
+ <span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span><span class="t-badge chat">${I.lock}session only</span></div>`)
106
+ if (open) walk(d, depth + 1)
107
+ continue
108
+ }
109
+ rows.push(`<div class="t-row t-dir" role="treeitem" aria-expanded="${open}" tabindex="-1" data-dir="${esc(d.path)}" style="--depth:${depth}">
110
+ <span class="t-caret${open ? ' open' : ''}">${I.caret}</span><span class="t-name">${esc(d.name)}</span>
111
+ ${!open && hasRecent(d, me) ? '<span class="t-dot" title="Recently edited inside"></span>' : ''}${claim ? claimBadge(claim) : !open ? insideBadge(claimsInside(tree.claims, d.path), me) : ''}
112
+ <button class="t-more" data-more="${esc(d.path)}" data-kind="dir" aria-label="More for ${esc(d.name)}">${I.more}</button></div>`)
113
+ if (open) walk(d, depth + 1)
114
+ }
115
+ for (const f of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
116
+ if (f.chat) {
117
+ rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(`Sent in chat by ${f.chat.by === me ? 'you' : f.chat.by}${f.chat.to ? ` to ${f.chat.to === me ? 'you' : f.chat.to}` : ''}`)}">
118
+ <span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span><span class="t-badge chat">${esc(f.chat.by === me ? 'you' : f.chat.by)}</span></div>`)
119
+ continue
120
+ }
121
+ const recent = !!f.edited && Date.now() - f.edited.ts < RECENT_MS
122
+ const byOther = recent && f.edited.by !== me
123
+ const ownClaim = f.claim && claimFolder(f.claim.pattern) === f.path ? f.claim : null
124
+ // The holder of a claim is the one editing it: their claim badge says so, one badge is enough on a narrow row.
125
+ // GitHub's version, not edited in the session since: GitHub's commit takes the "who edited" place, for ten
126
+ // minutes after it came in (an older relay doesn't say when: then after the commit).
127
+ const gh = f.github && Date.now() - (f.github.came || f.github.at || 0) < GITHUB_BADGE_MS ? f.github : null
128
+ const ghBadge = gh ? `<span class="t-badge gh" title="${esc(`From GitHub: ${gh.sha.slice(0, 7)}${gh.by ? ` by ${gh.by}` : ''}${gh.subject ? `: ${gh.subject}` : ''}. Not edited in the session since.`)}">${I.github}<span class="t-gh">${esc(gh.by || 'GitHub')}${gh.at ? ` · ${shortAgo(gh.at)}` : ''}</span></span>` : ''
129
+ const ghost = ownClaim && ownClaim.box && !boxMine(ownClaim)
130
+ rows.push(`<div class="t-row t-file${selected === f.path ? ' on' : ''}${ghost ? ' ghost' : ''}" role="treeitem" tabindex="-1" data-file="${esc(f.path)}" style="--depth:${depth}" title="${esc(f.path)}">
131
+ <span class="t-ico">${I.file}</span><span class="t-name">${esc(f.name)}</span>${ghBadge}
132
+ ${recent && !gh && !(ownClaim && ownClaim.by === f.edited.by) ? editBadge(f, byOther) : ''}
133
+ ${claimBadge(ownClaim)}
134
+ <button class="t-more" data-more="${esc(f.path)}" data-kind="file" aria-label="More for ${esc(f.name)}">${I.more}</button></div>`)
135
+ }
136
+ }
137
+ walk(root, 0)
138
+ el.innerHTML = `<div class="t-list" role="tree">${rows.join('')}</div>`
139
+ }
140
+
141
+ /**
142
+ * The ⋯ menu for a file or folder: claim it (with an optional note) or
143
+ * release your claim. Others' claims are shown with their file queue: ask for the file
144
+ * (join the queue) or withdraw; the holder hands it off to the first one waiting, with
145
+ * context. The owner may release anyone's claim (it goes to the first one waiting).
146
+ */
147
+ export function openTreeMenu (anchor, { path, kind, claim, me, owner, onClaim, onRelease, onClearAway, onRequest, onWithdraw, onHandoff, onOpen }) {
148
+ closeTreeMenu()
149
+ const menu = document.createElement('div')
150
+ menu.className = 'popover tree-menu'
151
+ menu.setAttribute('role', 'menu')
152
+ const isAway = claim && claim.active === false
153
+ const mine = claim && claim.by === me && !isAway
154
+ const theirs = claim && !mine
155
+ const queue = queueOf(claim)
156
+ const myRequest = queue.find((r) => r.by === me)
157
+ // The owner may release anyone's claim; anyone may release one held under their name from before.
158
+ const canRelease = theirs && (owner || (isAway && claim.by === me))
159
+ const waiting = queue.length ? `<div class="pop-queue"><div class="label">File queue</div>${queue.map((r, i) => `<div class="pop-req"><b>${i + 1}. ${esc(r.by === me ? 'You' : r.by)}</b> ${esc(r.title)}${r.description ? `<div class="hint">${esc(r.description)}</div>` : ''}</div>`).join('')}</div>` : ''
160
+ menu.innerHTML = `
161
+ <div class="pop-title">${esc(path)}</div>
162
+ ${kind === 'file' ? `<button class="pop-item" data-act="open" role="menuitem">${I.file}Open</button>` : ''}
163
+ ${theirs ? `<div class="pop-note" title="${esc(claimTitle(claim, me))}">${I.lock}<span>Claimed by <b>${esc(claimHolder(claim, me))}</b>${claim.note ? `: ${esc(claim.note)}` : ''}</span></div>` : ''}
164
+ ${waiting}
165
+ ${mine && !queue.length ? `<button class="pop-item" data-act="release" role="menuitem">${I.lock}Release your claim</button>` : ''}
166
+ ${mine && queue.length && onHandoff ? `<form class="pop-form" data-form="handoff"><label class="label" for="handoff-context">Hand off to ${esc(queue[0].by)}</label>
167
+ <textarea class="input" id="handoff-context" rows="3" maxlength="2000" placeholder="What you changed, what's left, anything they should know" required></textarea>
168
+ <button class="btn sm primary" type="submit">Hand off</button></form>` : ''}
169
+ ${canRelease ? `<button class="pop-item" data-act="release" role="menuitem">${I.lock}${queue.length ? `Release ${esc(claim.by)}'s claim (goes to ${esc(queue[0].by)})` : `Release ${esc(claim.by)}'s claim`}</button>` : ''}
170
+ ${isAway && owner && onClearAway ? '<button class="pop-item" data-act="clear-away" role="menuitem">Release every claim held by someone away</button>' : ''}
171
+ ${myRequest && onWithdraw ? '<button class="pop-item" data-act="withdraw" role="menuitem">Withdraw your request</button>' : ''}
172
+ ${theirs && !myRequest && kind === 'file' && onRequest ? `<form class="pop-form" data-form="request"><label class="label" for="req-title">Ask for this file</label>
173
+ <input class="input" id="req-title" maxlength="120" placeholder="Working on … for …" autocomplete="off" required>
174
+ <textarea class="input" id="req-desc" rows="3" maxlength="300" placeholder="What you plan to change, in short (300 characters)"></textarea>
175
+ <button class="btn sm primary" type="submit">Join the queue</button></form>` : ''}
176
+ ${!claim ? `<form class="pop-form" data-form="claim"><label class="label" for="claim-note">Claim this ${kind === 'dir' ? 'folder' : 'file'}</label>
177
+ <input class="input" id="claim-note" placeholder="What are you doing? (optional)" autocomplete="off">
178
+ <button class="btn sm primary" type="submit">${I.lock}Claim</button></form>` : ''}`
179
+ document.body.appendChild(menu)
180
+ const r = anchor.getBoundingClientRect()
181
+ const w = 280
182
+ menu.style.left = `${Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))}px`
183
+ menu.style.top = `${Math.max(8, Math.min(r.bottom + 4, window.innerHeight - menu.offsetHeight - 8))}px`
184
+ menu.addEventListener('click', (e) => {
185
+ const b = e.target.closest('[data-act]')
186
+ if (!b) return
187
+ if (b.dataset.act === 'open') onOpen()
188
+ if (b.dataset.act === 'release') onRelease()
189
+ if (b.dataset.act === 'clear-away') onClearAway()
190
+ if (b.dataset.act === 'withdraw') onWithdraw(myRequest.id)
191
+ closeTreeMenu()
192
+ })
193
+ const form = menu.querySelector('form')
194
+ const val = (sel) => menu.querySelector(sel).value.trim()
195
+ if (form) {
196
+ form.onsubmit = (e) => {
197
+ e.preventDefault()
198
+ const f = form.dataset.form
199
+ if (f === 'claim') onClaim(val('#claim-note'))
200
+ if (f === 'request') onRequest({ title: val('#req-title'), description: val('#req-desc') })
201
+ if (f === 'handoff') onHandoff({ to: queue[0].id, context: val('#handoff-context') })
202
+ closeTreeMenu()
203
+ }
204
+ setTimeout(() => form.querySelector('.input')?.focus(), 0)
205
+ } else {
206
+ menu.querySelector('.pop-item')?.focus()
207
+ }
208
+ const away = (e) => { if (!menu.contains(e.target) && e.target !== anchor) closeTreeMenu() }
209
+ const esc_ = (e) => { if (e.key === 'Escape') { closeTreeMenu(); anchor.focus?.() } }
210
+ setTimeout(() => document.addEventListener('mousedown', away), 0)
211
+ document.addEventListener('keydown', esc_)
212
+ closeTreeMenu.cleanup = () => { document.removeEventListener('mousedown', away); document.removeEventListener('keydown', esc_); menu.remove() }
213
+ }
214
+
215
+ export function closeTreeMenu () {
216
+ if (closeTreeMenu.cleanup) { closeTreeMenu.cleanup(); closeTreeMenu.cleanup = null }
217
+ }
218
+
219
+ // ------------------------------------------------------------ change card --
220
+ // Hovering (or arrowing to) a file the session changed shows a card beside the
221
+ // row: its +/− in this session, who made them and when. session.js decides when.
222
+
223
+ const CARD_PEOPLE = 4
224
+ const CARD_FOOT = {
225
+ all: 'Click to open · its <b>Changes</b> tab shows the diff',
226
+ some: 'Click to open · its <b>Changes</b> tab shows the latest diffs',
227
+ none: 'Click to open · its line-by-line diff is no longer kept'
228
+ }
229
+
230
+ /**
231
+ * The card for one file, from changes.js fileChanges().
232
+ * @param {{ path: string, added: number, removed: number, kind?: string, by: object[] }} f
233
+ * @param {{ who?: (name: string) => string, colorOf?: (name: string) => string|null, kept?: 'all'|'some'|'none'|null }} [o]
234
+ * kept: how much of it the history still has a diff for (null: not known yet)
235
+ */
236
+ export function changeCardHtml (f, { who = (n) => n, colorOf = () => null, kept = null } = {}) {
237
+ const by = [...(f.by || []).filter((b) => !b.pulled), ...(f.by || []).filter((b) => b.pulled)] // their own edits before git pulls
238
+ const person = (b) => `<li>
239
+ <span class="tcard-dot" style="--c:${esc(colorFor(b.name, colorOf(b.name)))}"></span>
240
+ <span class="tcard-who">${esc(who(b.name))}${b.pulled ? ' <i>pulled from git</i>' : ''}</span>
241
+ <span class="tcard-delta"><span class="add">+${b.added}</span> <span class="del">−${b.removed}</span></span>
242
+ <span class="tcard-ago">${esc(ago(b.ts))}</span>
243
+ </li>`
244
+ const more = by.length - CARD_PEOPLE
245
+ return `<div class="tcard-path">${esc(f.path)}</div>
246
+ <div class="tcard-total"><span class="add">+${f.added}</span> <span class="del">−${f.removed}</span><span class="tcard-in">in this session${f.kind === 'created' ? ' · new file' : ''}</span></div>
247
+ ${by.length ? `<ul class="tcard-people">${by.slice(0, CARD_PEOPLE).map(person).join('')}${more > 0 ? `<li class="tcard-more">and ${more} more</li>` : ''}</ul>` : ''}
248
+ <div class="tcard-foot">${CARD_FOOT[kept] || 'Click to open'}</div>`
249
+ }
250
+
251
+ let card = null
252
+
253
+ /** Shows `html` in the card beside `row` (to its right, or under it when there's no room). */
254
+ export function showChangeCard (row, html) {
255
+ if (!card) {
256
+ card = document.createElement('div')
257
+ card.className = 'tcard'
258
+ card.id = 'tree-change-card'
259
+ card.setAttribute('role', 'tooltip')
260
+ document.body.appendChild(card)
261
+ }
262
+ const fresh = card.hidden !== false
263
+ card.innerHTML = html
264
+ card.hidden = false
265
+ if (fresh) { card.classList.remove('in'); void card.offsetWidth; card.classList.add('in') }
266
+ const r = row.getBoundingClientRect()
267
+ const w = card.offsetWidth
268
+ const h = card.offsetHeight
269
+ const right = r.right + 8 + w <= window.innerWidth - 8
270
+ const left = right ? r.right + 8 : Math.max(8, Math.min(r.left + 16, window.innerWidth - w - 8))
271
+ const top = right ? r.top - 6 : r.bottom + 4
272
+ card.style.left = `${left}px`
273
+ card.style.top = `${Math.max(8, Math.min(top, window.innerHeight - h - 8))}px`
274
+ for (const el of document.querySelectorAll('[aria-describedby="tree-change-card"]')) if (el !== row) el.removeAttribute('aria-describedby')
275
+ row.setAttribute('aria-describedby', 'tree-change-card')
276
+ }
277
+
278
+ export function hideChangeCard () {
279
+ if (card) card.hidden = true
280
+ for (const el of document.querySelectorAll('[aria-describedby="tree-change-card"]')) el.removeAttribute('aria-describedby')
281
+ }