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/app.js ADDED
@@ -0,0 +1,542 @@
1
+ // Quilt app: boot, live events, home screen, folder picker and invites.
2
+ // The session workspace lives in session.js. Plain ES modules, no build step.
3
+ import { TOKEN, I, state, $, esc, basename, toast, api, ask, decodeInvite, remember, recall, startDropdowns, avatar, loadAccessTypes, typeOptions, loadingHtml, loadingNote } from './common.js'
4
+ import { renderShell, joinSessionDialog } from './home.js'
5
+ import { mountSession, sessionUpdated, sessionMessage, sessionFeed, sessionFileChanged, sessionLog, sessionUnmount } from './session.js'
6
+ import { quiltMark } from './mark.js'
7
+ import { renderSignIn } from './signin.js'
8
+ import { checkRelease, openReleaseNotes } from './releases.js'
9
+ import { loadWorkspaces, openWorkspace, wsIdOf } from './workspaces.js'
10
+ import { previewBusy } from './files.js'
11
+ import { agentPaste } from './invite.js'
12
+ import { openKindMenu, kindLineHtml } from './agent-kinds.js'
13
+ import { messageSound, letInSound, taskSound, soundOn, playSound, unlockSounds } from './sounds.js'
14
+
15
+ // ---------------------------------------------------------------- boot --
16
+ const SIGNED_OUT = 'This computer was signed out. Sign in again.'
17
+
18
+ // Once per page, not per boot: signing in again calls boot() and mustn't add listeners twice.
19
+ startDropdowns()
20
+ unlockSounds()
21
+
22
+ /** Plays the sound for `kind` if it's switched on in Settings. */
23
+ function chime (kind) {
24
+ if (kind && soundOn(state.profile, kind)) playSound(kind)
25
+ }
26
+
27
+ async function boot () {
28
+ if (!TOKEN) return renderLocked()
29
+ // Shown only if loading takes a moment: the Q pieces itself together while we wait.
30
+ const waiting = setTimeout(() => { if (!state.loaded) $('#app').innerHTML = `<div class="booting">${quiltMark({ word: false, loop: true })}</div>` }, 250)
31
+ try {
32
+ const acc = await api('GET', '/api/account')
33
+ if (!acc.signedIn) {
34
+ clearTimeout(waiting)
35
+ return renderSignIn(acc.reason === 'revoked' ? SIGNED_OUT : '', boot)
36
+ }
37
+ state.account = acc.account
38
+ const s = await api('GET', '/api/state')
39
+ state.recent = s.recent
40
+ state.defaults = s.defaults
41
+ state.profile = s.profile
42
+ state.maxFileBytes = s.maxFileBytes
43
+ for (const sum of s.sessions) state.sessions.set(sum.id, sum)
44
+ state.loaded = true
45
+ await loadWorkspaces()
46
+ clearTimeout(waiting)
47
+ const last = recall('view')
48
+ state.view = state.sessions.has(last) || last === 'settings' || (last === 'agents' && state.workspacesOn) || isWorkspace(last) ? last : (state.sessions.size ? [...state.sessions.keys()][0] : 'home')
49
+ if (isWorkspace(state.view)) await openWorkspace(wsIdOf(state.view)).catch(() => { state.view = 'home' })
50
+ if (isSession(state.view)) await loadMessages(state.view)
51
+ if (!state.events) connectEvents()
52
+ render()
53
+ pollWorkspace()
54
+ // Invite links that opened the desktop app wait until you're signed in.
55
+ if (!boot.invites) { boot.invites = true; window.quiltDesktop?.onInvite(openInviteLink); window.quiltDesktop?.onReleaseNotes?.(openReleaseNotes) }
56
+ // Is a newer Quilt out, and has this version's "what's new" been shown? Never blocks the app.
57
+ checkRelease()
58
+ } catch (err) {
59
+ clearTimeout(waiting)
60
+ if (!err.signedOut) renderLocked(err.message)
61
+ }
62
+ }
63
+
64
+ /** Back to the sign-in screen: after Sign out, or when the API turned this computer away. */
65
+ export function signedOutNow (message = '') {
66
+ sessionUnmount() // stops an open session view's timers and bindings
67
+ clearInterval(state.pollTimer)
68
+ state.pollTimer = null
69
+ state.events?.close()
70
+ state.events = null
71
+ state.loaded = false
72
+ state.account = null
73
+ state.workspaces = null
74
+ state.workspacesOn = false
75
+ state.workspace = null
76
+ state.orgs = []
77
+ state.addingWorkspace = false
78
+ for (const m of [state.sessions, state.messages, state.feeds, state.trees, state.files]) m.clear()
79
+ document.querySelectorAll('.modal-back').forEach((m) => m.remove())
80
+ renderSignIn(message, boot)
81
+ }
82
+
83
+ window.addEventListener('quilt-signed-out', () => signedOutNow(SIGNED_OUT))
84
+
85
+ function connectEvents () {
86
+ const es = state.events = new EventSource(`/api/events?t=${encodeURIComponent(TOKEN)}`)
87
+ // The browser reopens a dropped stream by itself, but whatever was pushed meanwhile is
88
+ // gone (a partner back after a blip, say), and nothing pushes again until something
89
+ // changes. So after a reopen, fetch the state over again.
90
+ let opened = false
91
+ es.addEventListener('open', () => {
92
+ if (!opened) { opened = true; return }
93
+ resync()
94
+ })
95
+ es.addEventListener('session', (e) => {
96
+ const sum = JSON.parse(e.data)
97
+ const prev = state.sessions.get(sum.id)
98
+ // Something new for you since the last summary: someone asking in, or a task handed to you.
99
+ if (prev && prev.status && sum.status) {
100
+ chime(letInSound(prev.status.waiting, sum.status.waiting) || taskSound(prev.status.tasks, sum.status.tasks, sum.status.me?.name))
101
+ }
102
+ // Session summaries don't carry the full log; keep what we have.
103
+ state.sessions.set(sum.id, prev ? { ...sum, logs: prev.logs } : sum)
104
+ if (state.view === sum.id) sessionUpdated(sum.id)
105
+ else renderTabs()
106
+ // A session's status changes its card on the open workspace page (All files shows no sessions).
107
+ if (isWorkspace(state.view) && !isFiles(state.view) && state.workspace?.running.includes(sum.id)) renderShell(state.view)
108
+ })
109
+ es.addEventListener('feed', (e) => {
110
+ const { id, entries } = JSON.parse(e.data)
111
+ sessionFeed(id, entries)
112
+ })
113
+ es.addEventListener('file-changed', (e) => {
114
+ const { id, ...change } = JSON.parse(e.data)
115
+ sessionFileChanged(id, change)
116
+ })
117
+ es.addEventListener('message', (e) => {
118
+ const { id, message } = JSON.parse(e.data)
119
+ const list = state.messages.get(id)
120
+ if (!list || !list.some((m) => m.id === message.id)) chime(messageSound(message, state.sessions.get(id)?.status.me.name))
121
+ if (list && !list.some((m) => m.id === message.id)) list.push(message)
122
+ if (state.view === id) {
123
+ sessionMessage(id, message)
124
+ if (document.visibilityState === 'visible') markRead(id)
125
+ } else renderTabs()
126
+ if (message.by !== state.sessions.get(id)?.status.me.name && document.visibilityState !== 'visible') {
127
+ document.title = `• ${message.by}: ${message.text || message.file?.name || ''}`.slice(0, 60)
128
+ }
129
+ })
130
+ es.addEventListener('log', (e) => {
131
+ const { id, ts, line } = JSON.parse(e.data)
132
+ const s = state.sessions.get(id)
133
+ if (s) { s.logs.push({ ts, line }); if (s.logs.length > 200) s.logs.shift() }
134
+ if (state.view === id) sessionLog(id)
135
+ })
136
+ es.addEventListener('stopped', (e) => {
137
+ const { id } = JSON.parse(e.data)
138
+ state.sessions.delete(id)
139
+ state.messages.delete(id)
140
+ state.feeds.delete(id)
141
+ state.trees.delete(id)
142
+ if (state.view === id) state.view = 'home'
143
+ render()
144
+ // On a workspace page the stopped session moves from running to recent.
145
+ if (isWorkspace(state.view)) {
146
+ const view = state.view
147
+ refreshRecent().then(() => openWorkspace(wsIdOf(view))).then(() => { if (state.view === view) render() }).catch(() => {})
148
+ }
149
+ // The folder moves from "open" to "recent", but the recent list is the
150
+ // server's: draw Home again once the fresh one arrives, or the session you
151
+ // just left is missing from it until the next visit.
152
+ refreshRecent().then(() => { if (state.view === 'home') render() })
153
+ })
154
+ es.addEventListener('signed-out', (e) => {
155
+ const { reason } = JSON.parse(e.data)
156
+ signedOutNow(reason === 'revoked' ? SIGNED_OUT : '')
157
+ })
158
+ }
159
+
160
+ // A workspace's page (ws:<id>) or its All files view (wsfiles:<id>).
161
+ const isFiles = (v) => typeof v === 'string' && v.startsWith('wsfiles:')
162
+ const isWorkspace = (v) => typeof v === 'string' && (v.startsWith('ws:') || isFiles(v))
163
+
164
+ /** While a workspace is open, fetch it again every 20 seconds and redraw if it changed
165
+ * (someone else's upload shows up). go() and signing out stop it. */
166
+ function pollWorkspace () {
167
+ clearInterval(state.pollTimer)
168
+ state.pollTimer = null
169
+ if (!isWorkspace(state.view)) return
170
+ const view = state.view
171
+ state.pollTimer = setInterval(async () => {
172
+ if (state.view !== view) { clearInterval(state.pollTimer); state.pollTimer = null; return }
173
+ if (document.querySelector('.modal-back')) return // a dialog is open: leave the page under it alone
174
+ const before = JSON.stringify(state.workspace)
175
+ try { await openWorkspace(wsIdOf(view)) } catch { return }
176
+ if (state.view === view && JSON.stringify(state.workspace) !== before && !previewBusy()) render()
177
+ }, 20000)
178
+ }
179
+ const isSession = (view) => view !== 'home' && view !== 'settings' && view !== 'agents' && !isWorkspace(view)
180
+
181
+ document.addEventListener('visibilitychange', () => {
182
+ if (document.visibilityState === 'visible') {
183
+ document.title = 'quilt'
184
+ if (isSession(state.view)) markRead(state.view)
185
+ }
186
+ })
187
+
188
+ export async function loadMessages (id) {
189
+ const { messages } = await api('GET', `/api/sessions/${id}/messages`)
190
+ state.messages.set(id, messages)
191
+ }
192
+
193
+ export async function markRead (id) {
194
+ const list = state.messages.get(id) || []
195
+ if (!list.some((m) => m.unread)) return
196
+ for (const m of list) m.unread = false
197
+ await api('POST', `/api/sessions/${id}/read`).catch(() => {})
198
+ }
199
+
200
+ async function refreshRecent () {
201
+ try {
202
+ const s = await api('GET', '/api/state')
203
+ state.recent = s.recent
204
+ state.profile = s.profile
205
+ } catch {}
206
+ }
207
+
208
+ /** Brings every session's summary (and the recent list) up to date with the server. */
209
+ async function resync () {
210
+ let s
211
+ try { s = await api('GET', '/api/state') } catch { return }
212
+ state.recent = s.recent
213
+ state.profile = s.profile
214
+ const live = new Set()
215
+ for (const sum of s.sessions) {
216
+ live.add(sum.id)
217
+ const prev = state.sessions.get(sum.id)
218
+ state.sessions.set(sum.id, prev ? { ...sum, logs: prev.logs } : sum)
219
+ }
220
+ for (const id of [...state.sessions.keys()]) if (!live.has(id)) state.sessions.delete(id)
221
+ if (isSession(state.view)) {
222
+ if (state.sessions.has(state.view)) sessionUpdated(state.view)
223
+ else { state.view = 'home'; render() }
224
+ } else if (state.view === 'home' || isWorkspace(state.view)) render()
225
+ else renderTabs()
226
+ }
227
+
228
+ export async function go (view) {
229
+ clearInterval(state.pollTimer)
230
+ state.pollTimer = null
231
+ state.view = view
232
+ state.pending = []
233
+ state.to = ''
234
+ if (view !== 'home') state.addingWorkspace = false
235
+ if (isWorkspace(view)) {
236
+ if (state.workspace?.workspace?.id !== wsIdOf(view)) {
237
+ // Another workspace: its loading screen right away, not a wait on the last page.
238
+ state.workspace = null
239
+ state.filesView = { folder: '', picked: null, mode: state.filesView.mode }
240
+ render()
241
+ }
242
+ await refreshRecent()
243
+ const loaded = await openWorkspace(wsIdOf(view)).then(() => true, (err) => { toast(err.message); return false })
244
+ if (state.view !== view) return // somewhere else was opened while this one loaded
245
+ if (loaded) state.wsEnter = wsIdOf(view) // its page fades in once, now that all of it is here
246
+ else view = 'home'
247
+ state.view = view
248
+ }
249
+ remember('view', view)
250
+ if (view === 'home') { await refreshRecent(); if (state.workspacesOn) await loadWorkspaces() }
251
+ if (isSession(view) && !state.messages.has(view)) {
252
+ // A session opened for the first time: its loading screen until its chat is here; the
253
+ // session view keeps it up until the files and the AI feeds are in too (mountSession).
254
+ sessionUnmount()
255
+ const sum = state.sessions.get(view)
256
+ $('#app').innerHTML = loadingHtml({ label: 'Loading session', name: sum?.status?.sessionName || basename(sum?.dir || ''), full: true })
257
+ await loadMessages(view).catch(() => {})
258
+ if (state.view !== view) return
259
+ }
260
+ render()
261
+ pollWorkspace()
262
+ if (isSession(view)) markRead(view)
263
+ }
264
+
265
+ // --------------------------------------------------------------- render --
266
+ let shuttingDown = false // one "Shut down Quilt?" at a time, however often the button is clicked
267
+ export async function shutdown () {
268
+ if (shuttingDown) return
269
+ shuttingDown = true
270
+ try {
271
+ if (!await ask({ title: 'Shut down Quilt?', message: 'This stops every session and this app. Your files stay where they are.', ok: 'Shut down', danger: true })) return
272
+ $('#settings-back')?.remove()
273
+ await api('POST', '/api/shutdown')
274
+ state.events?.close() // don't re-render or reconnect as sessions stop
275
+ renderLocked('quilt is shut down. You can close this tab.')
276
+ } catch (err) {
277
+ toast(err.message)
278
+ } finally {
279
+ shuttingDown = false
280
+ }
281
+ }
282
+
283
+ document.addEventListener('click', (e) => { if (e.target.closest('[data-shutdown]')) shutdown() })
284
+
285
+ export function render () {
286
+ sessionUnmount()
287
+ if (!isSession(state.view)) {
288
+ renderShell(state.view)
289
+ } else {
290
+ mountSession(state.view)
291
+ bindTopbar()
292
+ }
293
+ }
294
+
295
+ export function renderLocked (msg) {
296
+ $('#app').innerHTML = `
297
+ <div class="home"><div class="hero">
298
+ <div class="wordmark">${quiltMark({ sew: 'first' })}</div>
299
+ <p class="tagline">${esc(msg || 'Open Quilt using the link printed in your terminal by')} ${msg ? '' : '<code>quilt ui</code>.'}</p>
300
+ </div></div>`
301
+ }
302
+
303
+ export function renderTabs () {
304
+ const el = $('#tabs')
305
+ if (!el) return
306
+ // One session is the normal case; tabs only help when there are several.
307
+ el.hidden = state.sessions.size < 2 && state.view !== 'home'
308
+ el.innerHTML = [...state.sessions.values()].map((s) => {
309
+ const unread = s.status.unread
310
+ return `<button class="tab${state.view === s.id ? ' on' : ''}" data-go="${s.id}">
311
+ <span class="dot" style="background:${s.status.connected ? 'var(--ok)' : 'var(--warn)'}"></span>
312
+ ${esc(s.status.sessionName || basename(s.dir))}
313
+ ${unread && state.view !== s.id ? `<span class="badge">${unread}</span>` : ''}
314
+ </button>`
315
+ }).join('')
316
+ }
317
+
318
+ export function bindTopbar () {
319
+ renderTabs()
320
+ document.querySelectorAll('[data-go]').forEach((b) => { if (!b.closest('#tabs')) b.onclick = () => go(b.dataset.go) })
321
+ $('#tabs')?.addEventListener('click', (e) => {
322
+ const b = e.target.closest('[data-go]')
323
+ if (b) go(b.dataset.go)
324
+ })
325
+ }
326
+
327
+ // In the desktop app, clicking an invite link opens the join dialog with it filled in.
328
+ async function openInviteLink (link) {
329
+ document.querySelectorAll('.modal-back').forEach((m) => m.remove())
330
+ if (state.view !== 'home') await go('home')
331
+ joinSessionDialog(link)
332
+ }
333
+
334
+ // -------------------------------------------------------- folder picker --
335
+ export async function pickFolder (input) {
336
+ if (window.quiltDesktop) {
337
+ const dir = await window.quiltDesktop.pickFolder(input.value)
338
+ if (dir) { input.value = dir; input.dispatchEvent(new Event('input', { bubbles: true })) }
339
+ return
340
+ }
341
+ const back = document.createElement('div')
342
+ back.className = 'modal-back'
343
+ back.innerHTML = `<div class="card modal" role="dialog" aria-modal="true" aria-labelledby="pick-title">
344
+ <h3 id="pick-title">Choose a folder</h3>
345
+ <p class="lead">Pick the project folder, or type a path. New folders are created for you.</p>
346
+ <div class="row"><button class="btn icon" id="pick-up" title="Up" aria-label="Up">${I.up}</button><input class="input grow mono" id="pick-path"><button class="btn" id="pick-go">Go</button></div>
347
+ <div class="dirlist" id="pick-list"></div>
348
+ <div class="hint" id="pick-note"></div>
349
+ <div class="actions"><button class="btn ghost" id="pick-cancel">Cancel</button><button class="btn primary" id="pick-use">Use this folder</button></div>
350
+ </div>`
351
+ document.body.appendChild(back)
352
+ let done
353
+ const closed = new Promise((resolve) => { done = resolve })
354
+ const close = () => { back.remove(); done() }
355
+ let current = null
356
+ const load = async (p) => {
357
+ try {
358
+ const d = await api('GET', `/api/fs?path=${encodeURIComponent(p)}`)
359
+ current = d
360
+ $('#pick-path', back).value = d.path
361
+ $('#pick-up', back).disabled = !d.parent
362
+ $('#pick-list', back).innerHTML = d.dirs.length
363
+ ? d.dirs.map((n) => `<button data-dir="${esc(n)}">${I.folder}<span>${esc(n)}</span></button>`).join('')
364
+ : '<div class="empty-note" style="padding:14px">No subfolders</div>'
365
+ $('#pick-note', back).textContent = d.hasSession ? 'This folder has been used with Quilt before.' : d.isEmpty ? 'This folder is empty.' : ''
366
+ $('#pick-list', back).querySelectorAll('[data-dir]').forEach((b) => {
367
+ b.onclick = () => load(`${d.path.replace(/[\\/]$/, '')}/${b.dataset.dir}`)
368
+ })
369
+ } catch (err) {
370
+ $('#pick-note', back).textContent = err.message
371
+ }
372
+ }
373
+ $('#pick-up', back).onclick = () => current?.parent && load(current.parent)
374
+ $('#pick-go', back).onclick = () => load($('#pick-path', back).value)
375
+ $('#pick-path', back).onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); load(e.target.value) } }
376
+ $('#pick-cancel', back).onclick = close
377
+ $('#pick-use', back).onclick = () => { input.value = $('#pick-path', back).value; close() }
378
+ back.onclick = (e) => { if (e.target === back) close() }
379
+ back.onkeydown = (e) => { if (e.key === 'Escape') close() }
380
+ await load(input.value || state.defaults.home)
381
+ $('#pick-path', back).focus()
382
+ return closed
383
+ }
384
+
385
+
386
+ // --------------------------------------------------------------- invite --
387
+ export function openInvite (id) {
388
+ const s = state.sessions.get(id)
389
+ if (!s) return
390
+ const d = decodeInvite(s.invite)
391
+ // The owner of a session with approvals can invite people as an access type.
392
+ const owner = !!(s.viewInvite && s.status.access?.owner)
393
+ const back = document.createElement('div')
394
+ back.className = 'modal-back'
395
+ back.innerHTML = `<div class="card modal" role="dialog" aria-modal="true" aria-labelledby="inv-title">
396
+ <h3 id="inv-title">Invite someone</h3>
397
+ <p class="lead">Send them a link. Clicking it opens the session in Quilt (after signing in). It also works with <code>quilt join &lt;link&gt;</code>.</p>
398
+ <div class="label" style="margin-bottom:6px">${s.viewInvite ? 'Can edit' : 'Invite link'}</div>
399
+ <div class="codebox"><code id="inv-code">${esc(s.invite)}</code><button class="btn icon" data-copy="inv-code" title="Copy" aria-label="Copy invite link">${I.copy}</button></div>
400
+ ${s.viewInvite ? `<div class="label" style="margin-bottom:6px">View only</div>
401
+ <div class="codebox"><code id="inv-view">${esc(s.viewInvite)}</code><button class="btn icon" data-copy="inv-view" title="Copy" aria-label="Copy view-only link">${I.copy}</button></div>` : ''}
402
+ ${d ? `<p class="hint">Room <code>${esc(d.room)}</code> via <code>${esc(d.server)}</code></p>` : ''}
403
+ <p class="hint">${s.viewInvite ? 'Everyone who uses a link waits until you let them in, and you can change what they may do later in Session settings.' : 'Anyone with this link can edit the project. Only share it with people you trust.'}</p>
404
+ ${owner ? `<div class="inv-section" id="inv-access">
405
+ <div class="label inv-agent-label">Invite as</div>
406
+ <select class="input" id="inv-type" aria-label="Invite as"></select>
407
+ <p class="hint">People you invite here get straight in with this access once they sign in. No waiting for you.</p>
408
+ <div class="label inv-sub">People you've worked with</div>
409
+ <div class="inv-list" id="inv-people"><p class="hint">${loadingNote()}</p></div>
410
+ <div class="label inv-sub">Invite by email</div>
411
+ <form class="row" id="inv-email-form">
412
+ <input class="input grow" type="email" id="inv-email" placeholder="name@example.com" aria-label="Email address" required>
413
+ <button class="btn primary" type="submit">Send invite</button>
414
+ </form>
415
+ <div class="label inv-sub">Pending invites</div>
416
+ <div class="inv-list" id="inv-pending"></div>
417
+ <p class="error" id="inv-error"></p>
418
+ </div>` : ''}
419
+ <div class="label inv-agent-label">Your AI</div>
420
+ <div id="inv-agent" class="inv-agent">
421
+ <p class="hint">An AI that already has the Quilt command just needs the link above: tell it "Join my Quilt session: &lt;link&gt;". To add an AI that isn't registered with Quilt yet, make it an agent invite and paste the text into it: it carries the link above too. It joins as your own agent, listed in Settings.</p>
422
+ <button class="btn" type="button" id="inv-agent-make"${state.workspacesOn ? ' aria-haspopup="menu" aria-expanded="false"' : ''}>${I.bot}<span>Invite an AI agent</span></button>
423
+ <p class="error" id="inv-agent-error"></p>
424
+ </div>
425
+ ${s.status.access?.owner || s.status.access?.canAdmit ? `<div class="label inv-agent-label">A chat AI</div>
426
+ <div id="inv-chat" class="inv-agent">
427
+ <p class="hint">For ChatGPT, claude.ai, Grok and other AIs you use in a chat window: make a chat link and paste it into the chat. It can read and send messages, add, assign, move and comment on tasks, read files, and add pictures, documents and notes. It can't change existing files. It shows up in the session as its own member and works for 10 minutes: extend it in Session settings while it works, or make a new one after.</p>
428
+ <button class="btn" type="button" id="inv-chat-make">${I.link}<span>Make a chat link</span></button>
429
+ <p class="error" id="inv-chat-error"></p>
430
+ </div>` : ''}
431
+ <div class="actions"><button class="btn primary" id="inv-done">Done</button></div>
432
+ </div>`
433
+ document.body.appendChild(back)
434
+ const close = () => back.remove()
435
+ back.addEventListener('click', async (e) => {
436
+ const b = e.target.closest('[data-copy]')
437
+ if (!b) return
438
+ const el = $(`#${b.dataset.copy}`, back)
439
+ try { await navigator.clipboard.writeText(el.textContent); toast('Copied') } catch {
440
+ const r = document.createRange(); r.selectNodeContents(el)
441
+ getSelection().removeAllRanges(); getSelection().addRange(r); toast('Press ⌘/Ctrl+C to copy')
442
+ }
443
+ })
444
+ $('#inv-agent-make', back).onclick = async () => {
445
+ const btn = $('#inv-agent-make', back)
446
+ // With workspaces on: pick a global, workspace or session agent (this session first).
447
+ if (state.workspacesOn) {
448
+ return openKindMenu(btn, {
449
+ sessionId: id,
450
+ workspaceId: s.workspace || '',
451
+ onInvite: ({ kind, text, where }) => { $('#inv-agent', back).innerHTML = agentInviteHtml(text, 'inv-agent-text', kindLineHtml(kind, where)) }
452
+ })
453
+ }
454
+ btn.disabled = true
455
+ try {
456
+ const inv = await api('POST', '/api/agent-invites')
457
+ $('#inv-agent', back).innerHTML = agentInviteHtml(agentPaste({ link: inv.link, session: s.invite }), 'inv-agent-text')
458
+ } catch (err) {
459
+ btn.disabled = false
460
+ $('#inv-agent-error', back).textContent = err.message
461
+ }
462
+ }
463
+ const chatMake = $('#inv-chat-make', back)
464
+ if (chatMake) {
465
+ chatMake.onclick = async () => {
466
+ chatMake.disabled = true
467
+ try {
468
+ const r = await api('POST', `/api/sessions/${id}/chat-link`, {})
469
+ const until = new Date(r.expiresAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
470
+ $('#inv-chat', back).innerHTML = `<p class="hint">Paste this into the chat. It works until ${esc(until)} (extend it in Session settings) and shows in the session as <b>${esc(r.name)}</b>. Anyone with it can act as ${esc(r.name)}, so only paste it there.</p>
471
+ <div class="codebox"><code id="inv-chat-text">${esc(`Open this link and follow it to join our Quilt session: ${r.url}`)}</code><button class="btn icon" data-copy="inv-chat-text" title="Copy" aria-label="Copy chat link">${I.copy}</button></div>`
472
+ } catch (err) {
473
+ chatMake.disabled = false
474
+ $('#inv-chat-error', back).textContent = err.message
475
+ }
476
+ }
477
+ }
478
+ $('#inv-done', back).onclick = close
479
+ back.onclick = (e) => { if (e.target === back) close() }
480
+ if (owner) bindInviteAs(id, back)
481
+ }
482
+
483
+ /** The owner's invite panel: "Invite as" a type, people you've worked with, by email, and pending invites. */
484
+ function bindInviteAs (id, back) {
485
+ const error = (msg) => { $('#inv-error', back).textContent = msg || '' }
486
+ const typeId = () => $('#inv-type', back).value
487
+ const invite = async (to, done) => {
488
+ error('')
489
+ try {
490
+ await api('POST', `/api/sessions/${id}/invites`, { typeId: typeId(), to })
491
+ toast(done)
492
+ await pending()
493
+ } catch (err) { error(err.message) }
494
+ }
495
+ async function pending () {
496
+ const el = $('#inv-pending', back)
497
+ try {
498
+ const open = (await api('GET', `/api/sessions/${id}/invites`)).invites.filter((i) => i.status === 'waiting')
499
+ el.innerHTML = open.length
500
+ ? open.map((i) => `<div class="inv-row"><span class="grow">${esc(i.email || i.name)}<span class="hint"> · ${esc(i.typeName)}</span></span><button class="btn sm ghost" type="button" data-cancel-invite="${esc(i.id)}">Cancel</button></div>`).join('')
501
+ : '<p class="hint">No pending invites.</p>'
502
+ } catch (err) { el.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
503
+ }
504
+ async function people () {
505
+ const el = $('#inv-people', back)
506
+ try {
507
+ const list = (await api('GET', '/api/collaborators')).collaborators
508
+ el.innerHTML = list.length
509
+ ? list.map((c) => `<div class="inv-row">${avatar(c.name, null)}<span class="grow">${esc(c.name)}${c.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}</span><button class="btn sm" type="button" data-invite-account="${esc(c.account)}" data-name="${esc(c.name)}">Invite</button></div>`).join('')
510
+ : "<p class=\"hint\">Nobody yet. People and agents you've been in a session with show up here.</p>"
511
+ } catch (err) { el.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
512
+ }
513
+ ;(state.accessTypes ? Promise.resolve(state.accessTypes) : loadAccessTypes()).then((types) => {
514
+ const sel = $('#inv-type', back)
515
+ if (types) sel.innerHTML = typeOptions()
516
+ else { sel.innerHTML = '<option value="builtin:edit">Can edit</option><option value="builtin:view">View only</option>' }
517
+ })
518
+ people()
519
+ pending()
520
+ back.addEventListener('click', async (e) => {
521
+ const inv = e.target.closest('[data-invite-account]')
522
+ if (inv) { inv.disabled = true; await invite({ account: inv.dataset.inviteAccount }, `Invited ${inv.dataset.name}`); inv.disabled = false; return }
523
+ const cancel = e.target.closest('[data-cancel-invite]')
524
+ if (cancel) {
525
+ try { await api('POST', `/api/sessions/${id}/invites/cancel`, { inviteId: cancel.dataset.cancelInvite }); toast('Invite cancelled'); await pending() } catch (err) { error(err.message) }
526
+ }
527
+ })
528
+ $('#inv-email-form', back).onsubmit = async (e) => {
529
+ e.preventDefault()
530
+ const input = $('#inv-email', back)
531
+ await invite({ email: input.value.trim() }, 'Invite sent')
532
+ if (!$('#inv-error', back).textContent) input.value = ''
533
+ }
534
+ }
535
+
536
+ /** A fresh agent invite, as the text to paste into an AI, with Copy; `kind` (workspaces on) says which kind of agent it makes. */
537
+ export function agentInviteHtml (text, id, kind = '') {
538
+ return `${kind}<p class="hint"><b>Paste this into your AI.</b> It works once, within an hour. Anyone with it can join as your agent, so only give it to your own AI.</p>
539
+ <div class="codebox"><code id="${id}" class="paste">${esc(text)}</code><button class="btn icon" data-copy="${id}" title="Copy" aria-label="Copy agent invite">${I.copy}</button></div>`
540
+ }
541
+
542
+ boot()