heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
package/src/ui/home.js ADDED
@@ -0,0 +1,1322 @@
1
+ // Home and Settings: a sidebar with your profile and open sessions, next to
2
+ // either the start/join page or your settings. Sessions themselves live in session.js.
3
+ import { I, state, $, esc, basename, ago, toast, api, ask, decodeInvite, avatar, PALETTE, AGENT_COMMIT_WORDS, loadingNote } from './common.js'
4
+ import { go, pickFolder, signedOutNow, agentInviteHtml } from './app.js'
5
+ import { agentPaste } from './invite.js'
6
+ import { openKindMenu, kindLineHtml } from './agent-kinds.js'
7
+ import { quiltMark } from './mark.js'
8
+ import { updateControl } from './releases.js'
9
+ import { workspacesHtml, bindWorkspaces, workspacePageHtml, bindWorkspacePage, loadWorkspaces, openWorkspace, COLORS } from './workspaces.js'
10
+ import { allFilesHtml, bindAllFiles } from './files.js'
11
+ import { invitesHtml, bindInvites } from './invites.js'
12
+ import { agentCardHtml, bindPlacement, placeableWorkspaces, agentMiniHtml, openAgentMenu } from './agent-place.js'
13
+ import { bindSandboxKeys } from './sandbox-keys.js'
14
+ import { SOUND_EVENTS, playSound } from './sounds.js'
15
+ import { gitControl, gitSegHtml } from './git-rule.js'
16
+ import { cloudAiCardHtml, bindCloudAi } from './cloud-ai.js'
17
+
18
+ export const tildify = (p) => state.defaults.home && String(p).startsWith(state.defaults.home) ? `~${String(p).slice(state.defaults.home.length)}` : p
19
+ const hostOf = (url) => { try { return new URL(String(url).replace(/^ws/, 'http')).host } catch { return url } }
20
+ const firstName = () => String(state.profile.name || '').split(/[\s._-]/)[0] || state.profile.name
21
+
22
+ function greeting () {
23
+ const h = new Date().getHours()
24
+ return h < 5 ? 'Working late' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'
25
+ }
26
+
27
+ // ---------------------------------------------------------------- shell --
28
+ /** Whether the sidebar is collapsed to a rail of icons (wide windows only; kept on this computer). */
29
+ function sideCollapsed () {
30
+ if (state.sideCollapsed === undefined) { try { state.sideCollapsed = localStorage.getItem('quilt.sideCollapsed') === '1' } catch { state.sideCollapsed = false } }
31
+ return state.sideCollapsed
32
+ }
33
+
34
+ export function renderShell (view) {
35
+ const page = $('#page')
36
+ const scroll = page ? page.scrollTop : 0
37
+ $('#app').innerHTML = `
38
+ <div class="app-shell${sideCollapsed() ? ' side-collapsed' : ''}">
39
+ ${mobileBarHtml(view)}
40
+ <div class="side-scrim" data-side-close></div>
41
+ ${sidebarHtml(view)}
42
+ <main class="page" id="page">${view === 'settings' ? settingsHtml() : view === 'agents' ? agentsPageHtml() : view.startsWith('wsfiles:') ? allFilesHtml(state.workspace) : view.startsWith('ws:') ? workspacePageHtml() : homeHtml()}</main>
43
+ </div>`
44
+ if (page && view === state.shellView) $('#page').scrollTop = scroll
45
+ state.shellView = view
46
+ bindSidebar()
47
+ if (view === 'settings') bindSettings($('#page'), () => renderShell('settings'))
48
+ else if (view === 'agents') bindAgents($('#page'))
49
+ else if (view.startsWith('wsfiles:')) {
50
+ const id = view.slice('wsfiles:'.length)
51
+ const rerender = () => { if (state.view === view) renderShell(view) }
52
+ bindAllFiles($('#page'), { id, go, rerender, reload: async () => { await openWorkspace(id).catch((err) => toast(err.message)); rerender() } })
53
+ } else if (view.startsWith('ws:')) {
54
+ bindWorkspacePage($('#page'), { go, rerender: () => renderShell(view), newSessionDialog, inviteDialog: workspaceInviteDialog, dialog })
55
+ bindSessionActions($('#page'))
56
+ } else bindHome()
57
+ }
58
+
59
+ /** Narrow windows: a slim bar with the logo, where you are, your avatar and a menu
60
+ * button; the full sidebar slides in from the left as a drawer. Hidden on wide windows. */
61
+ function placeName (view) {
62
+ if (view === 'settings') return 'Settings'
63
+ if (view === 'agents') return 'Agents'
64
+ const id = view.startsWith('wsfiles:') ? view.slice(8) : view.startsWith('ws:') ? view.slice(3) : ''
65
+ if (!id) return 'Home'
66
+ const name = (state.workspaces || []).find((w) => w.id === id)?.name || state.workspace?.workspace?.name || 'Workspace'
67
+ return view.startsWith('wsfiles:') ? `${name} · Files` : name
68
+ }
69
+
70
+ function mobileBarHtml (view) {
71
+ const p = state.profile
72
+ return `
73
+ <header class="mbar">
74
+ <button type="button" class="btn ghost icon" data-side-open aria-label="Open the menu" aria-expanded="false">${I.menu}</button>
75
+ <button type="button" class="mbar-brand" data-view="home" aria-label="Home">${quiltMark({ word: false, px: 24 })}</button>
76
+ <span class="mbar-title">${esc(placeName(view))}</span>
77
+ <button type="button" class="mbar-me" data-view="settings" title="Your profile and settings">${avatar(p.name, p.color)}</button>
78
+ </header>`
79
+ }
80
+
81
+ function sidebarHtml (view) {
82
+ const p = state.profile
83
+ const running = [...state.sessions.values()]
84
+ const reopenable = state.recent.filter((r) => !r.unsupported)
85
+ const slim = sideCollapsed()
86
+ return `
87
+ <aside class="side">
88
+ <div class="side-top">
89
+ <button class="brand" data-view="home" aria-label="Home" title="Home">${slim ? quiltMark({ word: false, px: 32 }) : quiltMark({ sew: 'first' })}</button>
90
+ <button type="button" class="btn icon ghost side-toggle" data-side-collapse aria-label="${slim ? 'Expand the sidebar' : 'Collapse the sidebar'}" title="${slim ? 'Expand' : 'Collapse'}" aria-expanded="${!slim}">${I.chevrons}</button>
91
+ </div>
92
+
93
+ <button class="me-card" data-view="settings" title="${slim ? esc(p.name) + ' · your profile and settings' : 'Edit your profile'}">
94
+ ${avatar(p.name, p.color)}
95
+ <span class="me-text"><b>${esc(p.name)}</b><span>${esc(p.tool)}</span></span>
96
+ <span class="me-edit">Edit</span>
97
+ </button>
98
+
99
+ ${state.workspacesOn
100
+ ? `<button class="btn primary full sessions-btn" type="button" data-join-session title="Join a session">${I.link}<span>Join a session</span></button>`
101
+ : `<div class="menu-wrap" id="sessions-menu-wrap">
102
+ <button class="btn primary full sessions-btn" id="sessions-btn" aria-haspopup="true" aria-expanded="false">${I.folder}<span>Sessions</span>${running.length ? `<span class="badge-count">${running.length}</span>` : ''}<span class="caret">${I.caret}</span></button>
103
+ <div class="popover menu" id="sessions-menu" role="menu" hidden>
104
+ <button class="pop-item" role="menuitem" data-new-session>${I.plus}<span>New session…</span></button>
105
+ <button class="pop-item" role="menuitem" data-join-session>${I.link}<span>Join with an invite…</span></button>
106
+ ${running.length ? `<div class="pop-sep"></div><div class="pop-label">Open now</div>${running.map((s) => `
107
+ <button class="pop-item" role="menuitem" data-go="${s.id}"><span class="dot" style="background:${s.status.connected ? 'var(--ok)' : 'var(--warn)'}"></span><span class="grow">${esc(s.status.sessionName || basename(s.dir))}</span><span class="hint">${s.status.peers.length + 1} here</span></button>`).join('')}` : ''}
108
+ ${reopenable.length ? `<div class="pop-sep"></div><div class="pop-label">Recent</div>${reopenable.slice(0, 5).map((r) => `
109
+ <button class="pop-item" role="menuitem" data-rejoin="${esc(r.dir)}"><span class="dot"></span><span class="grow">${esc(basename(r.dir))}</span><span class="hint">${esc(ago(r.lastUsed))}</span></button>`).join('')}` : ''}
110
+ </div>
111
+ </div>`}
112
+
113
+ <nav class="side-nav" aria-label="Main">
114
+ <button data-view="home" class="${view === 'home' ? 'on' : ''}" title="Home">${I.home}<span>Home</span></button>
115
+ ${state.workspacesOn ? `<button data-view="agents" class="${view === 'agents' ? 'on' : ''}" title="Agents">${I.bot}<span>Agents</span></button>` : ''}
116
+ </nav>
117
+ ${sideWorkspacesHtml(view)}
118
+
119
+ <nav class="side-nav side-foot" aria-label="App">
120
+ <button data-view="settings" class="${view === 'settings' ? 'on' : ''}" title="Settings">${I.gear}<span>Settings</span></button>
121
+ </nav>
122
+ </aside>`
123
+ }
124
+
125
+ /** The sidebar's Workspaces list, with the open one marked. Only once the API has workspaces on. */
126
+ function sideWorkspacesHtml (view) {
127
+ view = String(view).replace(/^wsfiles:/, 'ws:') // All files marks its workspace too
128
+ const list = state.workspacesOn ? (state.workspaces || []) : []
129
+ if (!list.length) return ''
130
+ return `
131
+ <div class="side-label">Workspaces</div>
132
+ <nav class="side-nav side-workspaces" aria-label="Workspaces">${list.map((w) => `
133
+ <button data-view="ws:${esc(w.id)}" class="${view === `ws:${w.id}` ? 'on' : ''}" title="${esc(w.name)}"><span class="sw" style="background:${COLORS[w.color] || COLORS.lilac}"></span><span class="nm">${esc(w.name)}${w.space?.kind === 'org' ? `<span class="org"> · ${esc(w.space.name)}</span>` : ''}</span>${w.counts?.open ? `<span class="ct">${w.counts.open}</span>` : ''}</button>`).join('')}
134
+ </nav>`
135
+ }
136
+
137
+ function bindSidebar () {
138
+ document.querySelectorAll('[data-view]').forEach((b) => {
139
+ b.onclick = () => go(b.dataset.view)
140
+ })
141
+ // The narrow drawer: open from the bar's menu button; the scrim, Escape or going anywhere closes it.
142
+ const shell = $('.app-shell')
143
+ const toggle = $('[data-side-open]')
144
+ const setDrawer = (open) => { shell.classList.toggle('side-open', open); toggle?.setAttribute('aria-expanded', String(open)) }
145
+ if (toggle) toggle.onclick = () => setDrawer(!shell.classList.contains('side-open'))
146
+ $('[data-side-close]')?.addEventListener('click', () => setDrawer(false))
147
+ // Collapse to a rail of icons (wide windows): kept on this computer; the shell is drawn again.
148
+ const collapse = $('[data-side-collapse]')
149
+ if (collapse) collapse.onclick = () => {
150
+ state.sideCollapsed = !sideCollapsed()
151
+ try { localStorage.setItem('quilt.sideCollapsed', state.sideCollapsed ? '1' : '0') } catch {}
152
+ renderShell(state.shellView || 'home')
153
+ $('[data-side-collapse]')?.focus()
154
+ }
155
+ shell.addEventListener('keydown', (e) => { if (e.key === 'Escape' && shell.classList.contains('side-open')) { setDrawer(false); toggle?.focus() } })
156
+ const btn = $('#sessions-btn')
157
+ const menu = $('#sessions-menu')
158
+ // With workspaces on there is no Sessions menu (sessions live in their workspaces), only Join a session.
159
+ if (btn && menu) {
160
+ const setOpen = (open) => { menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)) }
161
+ btn.onclick = () => setOpen(menu.hidden)
162
+ menu.addEventListener('click', () => setOpen(false))
163
+ menu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen(false); btn.focus() } })
164
+ }
165
+ if (!bindSidebar.listening) {
166
+ bindSidebar.listening = true
167
+ document.addEventListener('mousedown', (e) => {
168
+ const wrap = $('#sessions-menu-wrap')
169
+ if (wrap && !wrap.contains(e.target)) { $('#sessions-menu').hidden = true; $('#sessions-btn').setAttribute('aria-expanded', 'false') }
170
+ })
171
+ }
172
+ document.querySelectorAll('.side [data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
173
+ bindSessionActions(document.querySelector('.side'))
174
+ }
175
+
176
+ /** New / join / rejoin buttons, wherever they appear. */
177
+ function bindSessionActions (root) {
178
+ root.querySelectorAll('[data-new-session]').forEach((b) => { b.onclick = () => newSessionDialog() })
179
+ root.querySelectorAll('[data-join-session]').forEach((b) => { b.onclick = () => joinSessionDialog() })
180
+ root.querySelectorAll('[data-rejoin]').forEach((b) => {
181
+ b.onclick = async () => {
182
+ const label = b.querySelector('.grow') ? null : b.textContent
183
+ b.disabled = true
184
+ if (label) b.innerHTML = `${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>Connecting…</span>`
185
+ try {
186
+ const sum = await api('POST', '/api/sessions', { mode: 'rejoin', dir: b.dataset.rejoin })
187
+ state.sessions.set(sum.id, sum)
188
+ await go(sum.id)
189
+ } catch (err) {
190
+ toast(err.message)
191
+ b.disabled = false
192
+ if (label) b.textContent = label
193
+ }
194
+ }
195
+ })
196
+ }
197
+
198
+ // ------------------------------------------------------------------ home --
199
+ function sessionRows () {
200
+ const running = [...state.sessions.values()].map((s) => ({
201
+ live: true, id: s.id, dir: s.dir, name: s.status.me.name, tool: s.status.me.tool,
202
+ server: s.status.server, peers: s.status.peers.length
203
+ }))
204
+ const recent = state.recent.map((r) => ({ live: false, dir: r.dir, name: r.name, tool: r.tool, server: r.server, lastUsed: r.lastUsed, unsupported: !!r.unsupported }))
205
+ return [...running, ...recent]
206
+ }
207
+
208
+ const relayLabel = (server) => server ? hostOf(server) : ''
209
+
210
+ function sessionRowHtml (r) {
211
+ if (r.unsupported) {
212
+ return `
213
+ <div class="session-row gone">
214
+ <div class="folder-ico">${I.folder}</div>
215
+ <div class="meta">
216
+ <div class="name">${esc(basename(r.dir))}</div>
217
+ <div class="sub">This session ran on your computer's own relay, which Quilt no longer supports. Your files are untouched.</div>
218
+ </div>
219
+ <div class="facts"><span>${esc(ago(r.lastUsed))}</span></div>
220
+ <div class="acts"><button class="btn sm" data-forget="${esc(r.dir)}">Remove from list</button></div>
221
+ </div>`
222
+ }
223
+ return `
224
+ <div class="session-row${r.live ? ' live' : ''}">
225
+ <div class="folder-ico${r.live ? ' live' : ''}">${I.folder}</div>
226
+ <div class="meta">
227
+ <div class="name">${esc(basename(r.dir))}${r.live ? '<span class="pill ok"><span class="dot"></span>Open</span>' : ''}</div>
228
+ <div class="sub">${esc(tildify(r.dir))}</div>
229
+ </div>
230
+ <div class="facts">
231
+ <span title="Your name there">${I.user}${esc(r.name || '')}</span>
232
+ ${r.server ? `<span title="Relay">${I.globe}${esc(relayLabel(r.server))}</span>` : ''}
233
+ <span>${r.live ? `${r.peers ? `${r.peers} other${r.peers === 1 ? '' : 's'} here` : 'Just you'}` : esc(ago(r.lastUsed))}</span>
234
+ </div>
235
+ <div class="acts">
236
+ ${r.live
237
+ ? `<button class="btn sm primary" data-go="${r.id}">Open</button>`
238
+ : `<button class="btn sm" data-rejoin="${esc(r.dir)}">Rejoin</button>
239
+ <button class="btn sm ghost icon" data-forget="${esc(r.dir)}" title="Remove from this list" aria-label="Remove ${esc(basename(r.dir))} from this list">${I.x}</button>`}
240
+ </div>
241
+ </div>`
242
+ }
243
+
244
+ function homeHtml () {
245
+ const rows = sessionRows()
246
+ return `
247
+ <header class="page-head">
248
+ <h1>${greeting()}, ${esc(firstName())}</h1>
249
+ <p>${state.workspacesOn ? 'Your workspaces. Open one, or add a new one.' : rows.some((r) => r.live) ? 'Pick up a session, or start something new from the Sessions menu.' : 'Start a session on one of your folders, or join one a partner shared with you.'}</p>
250
+ </header>
251
+ <section class="invites" id="invites-slot"${(state.invites || []).length ? '' : ' hidden'}>${invitesHtml()}</section>
252
+
253
+ ${state.workspacesOn ? workspacesHtml() : rows.length ? `
254
+ <section class="sessions">
255
+ <div class="sec-head"><h2>Your sessions</h2><span class="count">${rows.length}</span>
256
+ <span class="spacer"></span>
257
+ <button class="btn sm" data-join-session>${I.link}<span>Join</span></button>
258
+ <button class="btn sm primary" data-new-session>${I.plus}<span>New session</span></button>
259
+ </div>
260
+ <div class="card session-list">${rows.map(sessionRowHtml).join('')}</div>
261
+ </section>` : `
262
+ <section class="card welcome">
263
+ <div class="welcome-steps">
264
+ <div><span class="n">1</span><b>Start a session</b><p class="hint">Pick a folder on your computer to work on together.</p></div>
265
+ <div><span class="n">2</span><b>Send an invite link</b><p class="hint">Partners click it or paste it into Quilt. You approve who gets in.</p></div>
266
+ <div><span class="n">3</span><b>Code together</b><p class="hint">Files sync live, and you can watch each other's AI work.</p></div>
267
+ </div>
268
+ <div class="welcome-actions">
269
+ <button class="btn primary" data-new-session>${I.plus}<span>New session</span></button>
270
+ <button class="btn" data-join-session>${I.link}<span>Join with an invite</span></button>
271
+ </div>
272
+ </section>`}`
273
+ }
274
+
275
+ function bindHome () {
276
+ const page = $('#page')
277
+ bindSessionActions(page)
278
+ page.querySelectorAll('.session-list [data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
279
+ if (state.workspacesOn) bindWorkspaces(page, { go, rerender: () => renderShell('home') })
280
+ bindInvites(page, { joinDialog: joinSessionDialog, after: async () => { await loadWorkspaces(); if (state.view === 'home') renderShell('home') } })
281
+ page.querySelectorAll('[data-forget]').forEach((b) => {
282
+ b.onclick = async () => {
283
+ try {
284
+ state.recent = (await api('POST', '/api/recent/forget', { dir: b.dataset.forget })).recent
285
+ renderShell('home')
286
+ toast('Removed from the list. The folder is untouched.')
287
+ } catch (err) { toast(err.message) }
288
+ }
289
+ })
290
+ }
291
+
292
+ // --------------------------------------------------------------- dialogs --
293
+ function dialog (html) {
294
+ const back = document.createElement('div')
295
+ back.className = 'modal-back'
296
+ back.innerHTML = `<form class="card modal" role="dialog" aria-modal="true" autocomplete="off">${html}</form>`
297
+ document.body.appendChild(back)
298
+ const form = back.querySelector('form')
299
+ const close = () => back.remove()
300
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
301
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
302
+ form.querySelector('[data-cancel]').onclick = close
303
+ return { back, form, close }
304
+ }
305
+
306
+ export function newSessionDialog (workspace = '') {
307
+ const { form, close } = dialog(`
308
+ <h3>New session${workspace ? ` in ${esc(state.workspaces?.find((w) => w.id === workspace)?.name || 'this workspace')}` : ''}</h3>
309
+ <p class="lead">Pick what you want to work on together. You'll get invites to send once it starts.</p>
310
+ <div class="segmented src-switch" role="tablist" aria-label="Start from">
311
+ <button type="button" role="tab" data-src="folder" class="on" aria-selected="true">${I.folder}<span>Folder</span></button>
312
+ <button type="button" role="tab" data-src="github" aria-selected="false">${I.branch}<span>GitHub</span></button>
313
+ </div>
314
+ <div id="src-folder">
315
+ <div class="field">
316
+ <label for="n-dir">Project folder</label>
317
+ <div class="row"><input class="input grow" id="n-dir" name="dir" placeholder="~/code/my-app" value="${esc(tildify(state.lastCreateDir || state.defaults.cwd || ''))}" required>
318
+ <button type="button" class="btn icon" id="n-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
319
+ </div>
320
+ </div>
321
+ <div id="src-github" hidden></div>
322
+ <p class="error" id="n-error"></p>
323
+ <div class="actions"><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Start session</button></div>`)
324
+ let src = 'folder'
325
+ const gh = githubPicker(form.querySelector('#src-github'))
326
+ form.querySelectorAll('[data-src]').forEach((b) => {
327
+ b.onclick = () => {
328
+ src = b.dataset.src
329
+ form.querySelectorAll('[data-src]').forEach((x) => { x.classList.toggle('on', x === b); x.setAttribute('aria-selected', String(x === b)) })
330
+ form.querySelector('#src-folder').hidden = src !== 'folder'
331
+ form.querySelector('#src-github').hidden = src !== 'github'
332
+ form.querySelector('#n-dir').required = src === 'folder'
333
+ form.querySelector('button[type=submit]').textContent = src === 'github' ? 'Clone and start' : 'Start session'
334
+ form.querySelector('#n-error').textContent = ''
335
+ if (src === 'github') gh.open()
336
+ else form.querySelector('#n-dir').focus()
337
+ }
338
+ })
339
+ form.querySelector('#n-browse').onclick = () => pickFolder(form.querySelector('#n-dir'))
340
+ form.onsubmit = async (e) => {
341
+ e.preventDefault()
342
+ if (src === 'github') {
343
+ let body
344
+ try { body = gh.value() } catch (err) { form.querySelector('#n-error').textContent = err.message; return }
345
+ if (await submit(form, '#n-error', { mode: 'github', ...body, workspace }, 'Cloning…')) close()
346
+ return
347
+ }
348
+ const dir = form.querySelector('#n-dir').value
349
+ state.lastCreateDir = dir
350
+ if (await submit(form, '#n-error', { mode: 'create', dir, workspace })) close()
351
+ }
352
+ form.querySelector('#n-dir').focus()
353
+ }
354
+
355
+ /**
356
+ * Invite someone to a workspace, at the access picked above: people you've worked with (an
357
+ * Invite button) or anyone by email get an invite in their Quilt (Home, and heyquilt.com) and
358
+ * by email, and are in once they accept; the agents that can be added (your own in a personal
359
+ * workspace, the org's in an org's) are added at once. Agents can also join every session here
360
+ * as it starts, and Invite a new agent makes a link for an agent that joins once it registers.
361
+ */
362
+ function workspaceInviteDialog (id) {
363
+ const d = state.workspace
364
+ // An org's workspace offers the org's agents only (never your own: they can't join it).
365
+ const isOrg = !!d?.workspace?.orgId
366
+ const org = isOrg ? (state.workspaces || []).find((w) => w.id === id)?.space : null
367
+ const orgName = org?.name || d?.owner?.name || 'the org'
368
+ const { back, form, close } = dialog(`
369
+ <h3>Invite to ${esc(d?.workspace?.name || 'this workspace')}</h3>
370
+ <p class="lead">People get the invite in their Quilt and by email, and are in once they accept. Agents are added straight away.</p>
371
+ <div class="field"><label for="wi-access">Access</label>
372
+ <select class="input" id="wi-access"><option value="edit">Can edit</option><option value="view">View only</option></select></div>
373
+ ${toggle('wiEvery', false, 'Also invite to every new session in this workspace', 'For your own agents. Each new session sends them its link, and its owner lets them in. Off: the agent is in the workspace and sees its files, and joins a session only when someone invites it there.')}
374
+ <div class="inv-sub-row"><div class="label inv-sub">People you've worked with, and ${isOrg ? `${esc(orgName)}'s agents` : 'your agents'}</div>
375
+ <label class="search-box"><span class="sr-only">Search people and agents</span><input class="input sm" id="wi-search" type="search" placeholder="Search…" autocomplete="off"></label></div>
376
+ <div id="wi-people"><p class="hint">${loadingNote()}</p></div>
377
+ <div class="ag-pages" id="wi-pages" hidden></div>
378
+ <div class="field wi-email"><label for="wi-email">Invite by email</label>
379
+ <div class="row"><input class="input grow" id="wi-email" type="email" placeholder="name@example.com" autocomplete="off"><button class="btn" type="button" data-wi-email>Send invite</button></div></div>
380
+ <div class="label inv-sub">Waiting to accept</div>
381
+ <div class="inv-list" id="wi-pending"><p class="hint">${loadingNote()}</p></div>
382
+ <div class="inv-agent wi-new-agent" id="wi-new-agent"><button class="btn sm" type="button" data-wi-invite-agent aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite a new agent</span></button><span class="hint">You'll get a link to paste into your AI.</span></div>
383
+ <p class="error" id="wi-error"></p>
384
+ <div class="actions"><button type="button" class="btn primary" data-cancel>Done</button></div>`)
385
+ // Anchored at the top: it grows downward as the list and an agent invite come in, never jumps.
386
+ back.classList.add('top')
387
+ form.onsubmit = (e) => e.preventDefault()
388
+ const joins = () => (form.querySelector('[name=wiEvery]').checked ? 'all' : 'invited')
389
+ const already = new Set([d?.owner?.account, ...(d?.members || []).map((m) => m.account), ...(d?.agents || []).filter((a) => !a.excluded).map((a) => a.account)].filter(Boolean))
390
+ const list = $('#wi-people', back)
391
+ // Only an agent's owner can make it join every session: someone else's agent joins when invited.
392
+ const ownAgents = new Set()
393
+ let ownKnown = false // when your agents couldn't be listed, the API decides (it refuses someone else's)
394
+ // { list, note }: a note says why no agents could be listed (an org's need Agents: Read).
395
+ const agents = !isOrg
396
+ ? api('GET', '/api/agents').then((r) => ({ list: r.agents }), () => ({ list: [], failed: true }))
397
+ : !org
398
+ ? Promise.resolve({ list: [], note: `${orgName}'s agents can't be listed here.` })
399
+ : api('GET', `/api/orgs/${encodeURIComponent(org.slug)}/agents`).then((r) => ({ list: r.agents }), (err) => ({ list: [], note: `${orgName}'s agents can't be listed: ${err.message}` }))
400
+ // People with an open invite show as Invited (inviting them again would be refused).
401
+ const invited = api('GET', `/api/workspaces/${encodeURIComponent(id)}/invites`).then((r) => new Set(r.invites.filter((i) => i.status === 'waiting' && i.account).map((i) => i.account)), () => new Set())
402
+ Promise.all([
403
+ api('GET', '/api/collaborators').then((r) => r.collaborators).catch(() => []),
404
+ agents,
405
+ invited
406
+ ]).then(([people, { list: mine, note, failed }, waiting]) => {
407
+ for (const a of mine) ownAgents.add(`agent:${a.id}`)
408
+ ownKnown = !note && !failed
409
+ const seen = new Set()
410
+ // The people you were with most recently first, then your agents by when they were last used.
411
+ const rows = [...people.filter((c) => c.kind !== 'agent' || !isOrg), ...mine.map((a) => ({ account: `agent:${a.id}`, name: a.name, kind: 'agent', provider: a.provider, type: a.type, lastTogetherAt: a.lastUsedAt || 0 }))]
412
+ .filter((c) => c.account && !seen.has(c.account) && seen.add(c.account))
413
+ .sort((a, b) => (b.lastTogetherAt || 0) - (a.lastTogetherAt || 0))
414
+ const why = note ? `<p class="hint wi-note">${esc(note)}</p>` : ''
415
+ const action = (c) => already.has(c.account) ? '<span class="pill">In</span>' : waiting.has(c.account) ? '<span class="pill">Invited</span>' : c.kind === 'agent'
416
+ ? `<button class="btn sm" type="button" data-add-account="${esc(c.account)}" data-name="${esc(c.name)}">Add</button>`
417
+ : `<button class="btn sm" type="button" data-invite-person="${esc(c.account)}" data-name="${esc(c.name)}">Invite</button>`
418
+ const card = (c) => `<div class="ag-mini member${c.kind === 'agent' ? ' bot' : ''}" title="${esc(c.name)}">
419
+ <span class="ag-av">${avatar(c.name, null, false)}${c.kind === 'agent' ? `<span class="kind-badge" title="Agent">${I.bot}</span>` : ''}</span>
420
+ <span class="ag-t"><b>${esc(c.name)}</b><span class="hint">${esc(c.kind === 'agent' ? (c.type || c.provider || 'agent') : 'person')}${c.lastTogetherAt ? ` · ${esc(ago(c.lastTogetherAt))}` : ''}</span></span>
421
+ ${action(c)}
422
+ </div>`
423
+ // Six a page, with a search: the list grows with everyone you have ever worked with.
424
+ const PAGE = 6
425
+ const search = $('#wi-search', back)
426
+ const pages = $('#wi-pages', back)
427
+ let page = 0
428
+ const draw = () => {
429
+ const q = (search.value || '').trim().toLowerCase()
430
+ const hits = q ? rows.filter((c) => [c.name, c.kind, c.provider, c.type].some((x) => String(x || '').toLowerCase().includes(q))) : rows
431
+ const last = Math.max(0, Math.ceil(hits.length / PAGE) - 1)
432
+ page = Math.min(page, last)
433
+ const from = page * PAGE
434
+ const shown = hits.slice(from, from + PAGE)
435
+ list.innerHTML = (shown.length ? `<div class="ag-grid wi-grid">${shown.map(card).join('')}</div>` : rows.length ? '<p class="hint">Nobody matches that.</p>' : "<p class=\"hint\">Nobody yet. People and agents you've been in a session with show up here.</p>") + why
436
+ pages.hidden = hits.length <= PAGE
437
+ pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
438
+ <button type="button" class="btn sm icon" data-page="-1" aria-label="Previous page" ${page === 0 ? 'disabled' : ''}>${I.arrowLeft}</button>
439
+ <button type="button" class="btn sm icon" data-page="1" aria-label="Next page" ${page >= last ? 'disabled' : ''}>${I.arrowRight}</button>`
440
+ }
441
+ draw()
442
+ search.addEventListener('input', () => { page = 0; draw() })
443
+ pages.addEventListener('click', (e) => {
444
+ const b = e.target.closest('[data-page]')
445
+ if (!b || b.disabled) return
446
+ page += Number(b.dataset.page)
447
+ draw()
448
+ })
449
+ })
450
+ const wsInvites = `/api/workspaces/${encodeURIComponent(id)}/invites`
451
+ const pendingEl = $('#wi-pending', back)
452
+ async function pending () {
453
+ try {
454
+ const open = (await api('GET', wsInvites)).invites.filter((i) => i.status === 'waiting')
455
+ pendingEl.innerHTML = open.length
456
+ ? open.map((i) => `<div class="inv-row"><span class="grow">${esc(i.email || i.name)}<span class="hint"> · ${i.access === 'view' ? 'View only' : 'Can edit'}</span></span><button class="btn sm ghost" type="button" data-wi-cancel="${esc(i.id)}">Cancel</button></div>`).join('')
457
+ : '<p class="hint">Nobody yet.</p>'
458
+ } catch (err) { pendingEl.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
459
+ }
460
+ pending()
461
+ const sendInvite = async (to) => {
462
+ $('#wi-error', back).textContent = ''
463
+ await api('POST', wsInvites, { to, access: $('#wi-access', back).value })
464
+ await pending()
465
+ }
466
+ pendingEl.addEventListener('click', async (e) => {
467
+ const b = e.target.closest('[data-wi-cancel]')
468
+ if (!b) return
469
+ b.disabled = true
470
+ try { await api('POST', `${wsInvites}/${encodeURIComponent(b.dataset.wiCancel)}/cancel`, {}); toast('Invite cancelled'); await pending() } catch (err) { $('#wi-error', back).textContent = err.message; b.disabled = false }
471
+ })
472
+ const emailBtn = $('[data-wi-email]', back)
473
+ const emailInput = $('#wi-email', back)
474
+ const byEmail = async () => {
475
+ const email = emailInput.value.trim()
476
+ if (!email) return emailInput.focus()
477
+ emailBtn.disabled = true
478
+ try { await sendInvite({ email }); emailInput.value = ''; toast(`Invite sent to ${email}`) } catch (err) { $('#wi-error', back).textContent = err.message }
479
+ emailBtn.disabled = false
480
+ }
481
+ emailBtn.onclick = byEmail
482
+ emailInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); byEmail() } })
483
+ list.addEventListener('click', async (e) => {
484
+ const p = e.target.closest('[data-invite-person]')
485
+ if (p) {
486
+ p.disabled = true
487
+ try {
488
+ await sendInvite({ account: p.dataset.invitePerson })
489
+ p.outerHTML = '<span class="pill">Invited</span>'
490
+ toast(`Invited ${p.dataset.name}`)
491
+ } catch (err) { $('#wi-error', back).textContent = err.message; p.disabled = false }
492
+ return
493
+ }
494
+ const b = e.target.closest('[data-add-account]')
495
+ if (!b) return
496
+ b.disabled = true
497
+ $('#wi-error', back).textContent = ''
498
+ const account = b.dataset.addAccount
499
+ try {
500
+ await api('POST', `/api/workspaces/${encodeURIComponent(id)}/members`, { account, access: $('#wi-access', back).value, ...(account.startsWith('agent:') ? { sessions: ownAgents.has(account) || !ownKnown ? joins() : 'invited' } : {}) })
501
+ b.outerHTML = '<span class="pill">Added</span>'
502
+ toast(`Added ${b.dataset.name}`)
503
+ await openWorkspace(id)
504
+ await loadWorkspaces()
505
+ if (state.view === `ws:${id}` || state.view === `wsfiles:${id}`) renderShell(state.view)
506
+ } catch (err) {
507
+ $('#wi-error', back).textContent = err.message
508
+ b.disabled = false
509
+ }
510
+ })
511
+ const invite = $('[data-wi-invite-agent]', back)
512
+ invite.onclick = async () => {
513
+ // Workspaces are on here: pick a global, workspace (this one first) or session agent. A
514
+ // workspace agent gets the access and every-session choice above.
515
+ if (state.workspacesOn) {
516
+ return openKindMenu(invite, {
517
+ workspaceId: id,
518
+ workspaceBody: () => ({ access: $('#wi-access', back).value, sessions: joins() }),
519
+ onInvite: ({ kind, text, where }) => { $('#wi-new-agent', back).innerHTML = agentInviteHtml(text, 'wi-paste', kindLineHtml(kind, where)) }
520
+ })
521
+ }
522
+ invite.disabled = true
523
+ $('#wi-error', back).textContent = ''
524
+ try {
525
+ const { link } = await api('POST', `/api/workspaces/${encodeURIComponent(id)}/agent-invites`, { access: $('#wi-access', back).value, sessions: joins() })
526
+ $('#wi-new-agent', back).innerHTML = agentInviteHtml(agentPaste({ link, invitedToSessions: joins() === 'all' }), 'wi-paste')
527
+ } catch (err) {
528
+ $('#wi-error', back).textContent = err.message
529
+ invite.disabled = false
530
+ }
531
+ }
532
+ form.addEventListener('click', async (e) => {
533
+ const b = e.target.closest('[data-copy]')
534
+ if (!b) return
535
+ e.preventDefault()
536
+ try { await navigator.clipboard.writeText($(`#${b.dataset.copy}`, form).textContent); toast('Copied') } catch { toast('Select the text and press ⌘/Ctrl+C to copy') }
537
+ })
538
+ return close
539
+ }
540
+
541
+ /** The GitHub side of the New session dialog: repo, branch, and where to clone it. */
542
+ function githubPicker (root) {
543
+ let repos = null
544
+ let repo = null // { name, defaultBranch }
545
+ let branches = null // { branches, defaultBranch }
546
+ let loaded = false
547
+
548
+ async function open () {
549
+ if (loaded) return root.querySelector('#gh-search')?.focus()
550
+ loaded = true
551
+ root.innerHTML = `<p class="hint gh-wait">${loadingNote('Checking GitHub…')}</p>`
552
+ let st
553
+ try { st = await api('GET', '/api/github/status') } catch (err) { st = { authenticated: false, message: err.message } }
554
+ if (!st.authenticated) {
555
+ loaded = false
556
+ root.innerHTML = `<div class="note gh-setup">${I.branch}<span>Quilt can't list your GitHub repositories on this computer. Start from a folder instead: a clone of the repository works the same, and once anyone connects GitHub for the session, git is on for everyone in it.</span></div>`
557
+ return
558
+ }
559
+ root.innerHTML = `
560
+ <div class="field">
561
+ <label for="gh-search">Repository${st.user ? ` <span class="hint">· signed in as ${esc(st.user)}</span>` : ''}</label>
562
+ <input class="input" id="gh-search" placeholder="Search your repositories" spellcheck="false" autocomplete="off">
563
+ <div class="gh-list" id="gh-list" role="listbox" aria-label="Repositories"><p class="hint">${loadingNote('Loading repositories…')}</p></div>
564
+ </div>
565
+ <div id="gh-branch" hidden>
566
+ <div class="field">
567
+ <span class="label">Branch</span>
568
+ <div class="choice-cards">
569
+ <label class="choice-card"><input type="radio" name="ghb" value="existing" checked><span><b>Use an existing branch</b></span></label>
570
+ <label class="choice-card"><input type="radio" name="ghb" value="new"><span><b>Create a new branch</b></span></label>
571
+ </div>
572
+ <div id="gh-existing"><select class="input" id="gh-branch-select" aria-label="Branch"></select></div>
573
+ <div id="gh-new" class="row" hidden>
574
+ <input class="input grow" id="gh-new-name" placeholder="my-feature" spellcheck="false" aria-label="New branch name">
575
+ <span class="hint">from</span>
576
+ <select class="input gh-base" id="gh-base" aria-label="Start from branch"></select>
577
+ </div>
578
+ </div>
579
+ <div class="field">
580
+ <label for="gh-dir">Clone into</label>
581
+ <div class="row"><input class="input grow" id="gh-dir" spellcheck="false">
582
+ <button type="button" class="btn icon" id="gh-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
583
+ <span class="hint">Leave empty to use a new folder in ${esc(state.profile.joinDir)}.</span>
584
+ </div>
585
+ </div>`
586
+ const search = root.querySelector('#gh-search')
587
+ search.addEventListener('input', paintRepos)
588
+ search.addEventListener('keydown', (e) => {
589
+ if (e.key !== 'Enter') return
590
+ e.preventDefault() // pick the first match rather than submitting
591
+ root.querySelector('#gh-list [data-repo]')?.click()
592
+ })
593
+ root.querySelector('#gh-browse').onclick = () => pickFolder(root.querySelector('#gh-dir'))
594
+ root.querySelectorAll('[name=ghb]').forEach((r) => {
595
+ r.onchange = () => {
596
+ root.querySelector('#gh-existing').hidden = r.value !== 'existing'
597
+ root.querySelector('#gh-new').hidden = r.value !== 'new'
598
+ if (r.value === 'new') root.querySelector('#gh-new-name').focus()
599
+ }
600
+ })
601
+ search.focus()
602
+ try {
603
+ repos = (await api('GET', '/api/github/repos')).repos
604
+ } catch (err) {
605
+ root.querySelector('#gh-list').innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
606
+ return
607
+ }
608
+ paintRepos()
609
+ }
610
+
611
+ function paintRepos () {
612
+ const list = root.querySelector('#gh-list')
613
+ if (!list || !repos) return
614
+ const q = root.querySelector('#gh-search').value.trim().toLowerCase()
615
+ const shown = repos.filter((r) => !q || r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 60)
616
+ list.innerHTML = shown.length
617
+ ? shown.map((r) => `<button type="button" class="gh-repo${repo && repo.name === r.name ? ' on' : ''}" role="option" aria-selected="${repo && repo.name === r.name}" data-repo="${esc(r.name)}">
618
+ <span class="grow"><b>${esc(r.name)}</b>${r.description ? `<span class="hint">${esc(r.description)}</span>` : ''}</span>
619
+ ${r.updatedAt ? `<span class="hint">${esc(ago(Date.parse(r.updatedAt)))}</span>` : ''}</button>`).join('')
620
+ : `<p class="hint">${repos.length ? 'No repositories match.' : 'You have no repositories on GitHub yet.'}</p>`
621
+ list.querySelectorAll('[data-repo]').forEach((b) => { b.onclick = () => pickRepo(repos.find((r) => r.name === b.dataset.repo)) })
622
+ }
623
+
624
+ async function pickRepo (r) {
625
+ repo = r
626
+ branches = null
627
+ paintRepos()
628
+ root.querySelector('#gh-branch').hidden = false
629
+ root.querySelector('#gh-dir').placeholder = `${state.profile.joinDir}/${r.name.split('/').pop()}`
630
+ const sel = root.querySelector('#gh-branch-select')
631
+ const baseSel = root.querySelector('#gh-base')
632
+ sel.innerHTML = baseSel.innerHTML = '<option>Loading…</option>'
633
+ sel.disabled = baseSel.disabled = true
634
+ try {
635
+ const b = await api('GET', `/api/github/branches?repo=${encodeURIComponent(r.name)}`)
636
+ if (repo !== r) return // picked another meanwhile
637
+ branches = b
638
+ const opts = b.branches.map((n) => `<option value="${esc(n)}" ${n === b.defaultBranch ? 'selected' : ''}>${esc(n)}${n === b.defaultBranch ? ' (default)' : ''}</option>`).join('')
639
+ sel.innerHTML = baseSel.innerHTML = opts || '<option value="">No branches</option>'
640
+ sel.disabled = baseSel.disabled = false
641
+ } catch (err) {
642
+ sel.innerHTML = baseSel.innerHTML = '<option value="">Couldn’t load branches</option>'
643
+ toast(err.message)
644
+ }
645
+ }
646
+
647
+ function value () {
648
+ if (!repo) throw new Error('Pick a repository.')
649
+ if (!branches) throw new Error('Wait for the branches to load.')
650
+ const dir = root.querySelector('#gh-dir').value.trim() || undefined
651
+ if (root.querySelector('[name=ghb]:checked').value === 'new') {
652
+ const newBranch = root.querySelector('#gh-new-name').value.trim()
653
+ if (!newBranch) throw new Error('Name your new branch.')
654
+ return { repo: repo.name, newBranch, base: root.querySelector('#gh-base').value || undefined, dir }
655
+ }
656
+ return { repo: repo.name, branch: root.querySelector('#gh-branch-select').value || undefined, dir }
657
+ }
658
+
659
+ return { open, value }
660
+ }
661
+
662
+ export function joinSessionDialog (invite = '') {
663
+ const p = state.profile
664
+ const { form, close } = dialog(`
665
+ <h3>Join a session</h3>
666
+ <p class="lead">${invite ? 'You were invited to a session. Choose where the files go, then join.' : 'Paste the invite link your partner sent you.'} They'll be asked to let you in.</p>
667
+ <div class="field">
668
+ <label for="j-invite">Invite link</label>
669
+ <textarea class="input mono" id="j-invite" rows="2" spellcheck="false" placeholder="https://join.heyquilt.com/…" required></textarea>
670
+ <span class="hint warn" id="invite-hint" hidden>That doesn’t look like a quilt invite link. Copy the whole link they sent.</span>
671
+ </div>
672
+ <div class="field">
673
+ <label for="j-dir">Put the files in</label>
674
+ <div class="row"><input class="input grow" id="j-dir" placeholder="${esc(p.joinDir)}/<room>">
675
+ <button type="button" class="btn icon" id="j-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
676
+ <span class="hint">Leave empty to use a new folder in ${esc(p.joinDir)}.</span>
677
+ </div>
678
+ <p class="error" id="j-error"></p>
679
+ <div class="actions"><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Join session</button></div>`)
680
+ const inv = form.querySelector('#j-invite')
681
+ inv.addEventListener('input', () => {
682
+ const d = decodeInvite(inv.value)
683
+ form.querySelector('#invite-hint').hidden = !inv.value.trim() || !!d
684
+ form.querySelector('#j-dir').placeholder = `${p.joinDir}/${d ? d.room : '<room>'}`
685
+ })
686
+ if (invite) { inv.value = invite; inv.dispatchEvent(new Event('input')) }
687
+ form.querySelector('#j-browse').onclick = () => pickFolder(form.querySelector('#j-dir'))
688
+ form.onsubmit = async (e) => {
689
+ e.preventDefault()
690
+ if (!decodeInvite(inv.value)) { form.querySelector('#j-error').textContent = 'Paste the invite link your partner sent you.'; return }
691
+ if (await submit(form, '#j-error', { mode: 'join', invite: inv.value, dir: form.querySelector('#j-dir').value.trim() || undefined })) close()
692
+ }
693
+ if (invite) form.querySelector('button[type=submit]').focus()
694
+ else inv.focus()
695
+ }
696
+
697
+ async function submit (form, errSel, body, busy = 'Connecting…') {
698
+ const btn = form.querySelector('button[type=submit]')
699
+ const label = btn.textContent
700
+ btn.disabled = true
701
+ btn.innerHTML = `${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>${esc(busy)}</span>`
702
+ form.querySelector(errSel).textContent = ''
703
+ try {
704
+ const sum = await api('POST', '/api/sessions', body)
705
+ state.sessions.set(sum.id, sum)
706
+ await go(sum.id)
707
+ return true
708
+ } catch (err) {
709
+ form.querySelector(errSel).textContent = err.message
710
+ btn.disabled = false
711
+ btn.textContent = label
712
+ return false
713
+ }
714
+ }
715
+
716
+ // -------------------------------------------------------------- settings --
717
+ const THEMES = [['light', 'Light'], ['dark', 'Dark'], ['system', 'System']]
718
+
719
+ function toggle (name, checked, label, hint) {
720
+ return `<label class="toggle"><input type="checkbox" name="${name}" ${checked ? 'checked' : ''}><span class="track"><span class="knob"></span></span>
721
+ <span class="tg-text"><b>${label}</b><span class="hint">${hint}</span></span></label>`
722
+ }
723
+
724
+ /** The Agents page (workspaces on): your agents as cards, nine a page; a card opens where it works. */
725
+ function agentsPageHtml () {
726
+ return `
727
+ <div id="agents-sec">
728
+ <header class="page-head ag-page-head">
729
+ <h1>Agents</h1><span class="pill count" id="agents-count" hidden></span>
730
+ <label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label>
731
+ <button class="btn primary" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button>
732
+ </header>
733
+ <p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card for where it works and its keys.</p>
734
+ <div id="agents-invite"><p class="error" id="agents-error"></p></div>
735
+ <div id="agents-list"><p class="hint">${loadingNote()}</p></div>
736
+ <div class="ag-pages" id="agents-pages" hidden></div>
737
+ <div class="ag-details" id="agent-details" hidden></div>
738
+ <p class="hint ag-foot"><a href="https://heyquilt.com/docs/agents#kinds" target="_blank" rel="noopener">How agent kinds work</a> · Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a></p>
739
+ </div>`
740
+ }
741
+
742
+ /**
743
+ * An agent's details, in place under the cards (no dialog): with workspaces on, where it works
744
+ * (Works in and Invited to new sessions; each change saves), then its sandbox keys (sandbox-keys.js).
745
+ */
746
+ async function showAgentDetails (root, a) {
747
+ const box = $('#agent-details', root)
748
+ box.hidden = false
749
+ box.dataset.agent = a.id
750
+ box.innerHTML = `${state.workspacesOn ? '<div data-ag-place-slot></div>' : ''}<div data-sk-slot></div>`
751
+ bindSandboxKeys($('[data-sk-slot]', box), a)
752
+ box.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' })
753
+ if (!state.workspacesOn) return
754
+ const slot = $('[data-ag-place-slot]', box)
755
+ let place
756
+ try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) { slot.innerHTML = `<p class="hint warn">${esc(err.message)}</p>`; return }
757
+ if (box.dataset.agent !== a.id) return
758
+ if (!place) { slot.innerHTML = '<p class="hint">This agent has no placement yet.</p>'; return }
759
+ slot.innerHTML = agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })
760
+ bindPlacement(slot.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
761
+ }
762
+
763
+ // Settings come in tabs: You (profile, appearance, sounds), Sessions (defaults for starting and
764
+ // joining), Agents (your AI, your agents, agents and git), Workspaces (with workspaces on), Account
765
+ // (sign-in, this computer, the version).
766
+ const SETTINGS_TABS = [['you', 'You'], ['sessions', 'Sessions'], ['agents', 'Agents'], ['workspaces', 'Workspaces'], ['account', 'Account']]
767
+ const settingsTabIcons = () => ({ you: I.user || '', sessions: I.folder || '', agents: I.bot || '', workspaces: I.grid || I.folder || '', account: I.gear || '' })
768
+ export function settingsTab () {
769
+ if (!state.settingsTab) { try { state.settingsTab = localStorage.getItem('quilt.settingsTab') || 'you' } catch { state.settingsTab = 'you' } }
770
+ const tabs = SETTINGS_TABS.filter(([id]) => id !== 'workspaces' || state.workspacesOn)
771
+ return tabs.some(([id]) => id === state.settingsTab) ? state.settingsTab : 'you'
772
+ }
773
+
774
+ function settingsTabsHtml (tab) {
775
+ const icons = settingsTabIcons()
776
+ return `<nav class="settings-tabs" role="tablist" aria-label="Settings">${SETTINGS_TABS.filter(([id]) => id !== 'workspaces' || state.workspacesOn).map(([id, label]) => `<button type="button" role="tab" class="tab ${tab === id ? 'on' : ''}" data-settings-tab="${id}" aria-selected="${tab === id}">${icons[id] || ''}<span>${label}</span></button>`).join('')}</nav>`
777
+ }
778
+
779
+ function workspacesSettingsHtml () {
780
+ const list = state.workspaces || []
781
+ return `
782
+ <section class="card settings-sec" id="workspaces-sec">
783
+ <div class="sec-intro"><h2>Workspaces</h2><p>The workspaces you are in, and the organizations behind them. Each one's own settings (people, agents, GitHub) are on its page.</p></div>
784
+ <div class="sec-body">
785
+ ${list.length ? list.map((w) => `<div class="ws-settings-row"><span class="sw" style="background:${esc(w.color || 'var(--accent)')}"></span><span class="nm"><b>${esc(w.name)}</b>${w.org && w.org.name ? `<span class="org">${esc(w.org.name)}</span>` : ''}</span><button class="btn sm" type="button" data-view="ws:${esc(w.id)}">Open</button></div>`).join('') : '<p class="hint">You are not in a workspace yet. Make one from Home.</p>'}
786
+ </div>
787
+ </section>`
788
+ }
789
+
790
+ const AUTO_CHOICES = [['chat', 'Leave it for my chat'], ['open', 'Open it in my tool'], ['auto', 'Work on its own']]
791
+
792
+ /** "When work comes to my AI" (src/autowork.js): leave it, open it, or let the AI work on its own. */
793
+ function autoSettingsHtml (p) {
794
+ const a = p.auto || { mode: 'chat', supported: false, models: [], model: '', changeFiles: false, limitsFiles: true, cap: 0, tool: p.tool }
795
+ const tool = esc(a.tool || p.tool || 'your AI tool')
796
+ return `<form class="card settings-sec" id="auto-sec" autocomplete="off">
797
+ <div class="sec-intro"><h2>When work comes to your AI</h2><p>A task given to your AI, or a merge sent to it, while none of your AI sessions is open.</p></div>
798
+ <div class="sec-body">
799
+ <div class="field">
800
+ <label for="s-autowork">Do this</label>
801
+ <select class="input" id="s-autowork" name="autoWork">${AUTO_CHOICES.map(([v, l]) => {
802
+ const off = v === 'auto' && !a.supported && a.mode !== 'auto'
803
+ return `<option value="${v}" ${a.mode === v ? 'selected' : ''} ${off ? 'disabled' : ''}>${l}${off ? ` (not available for ${tool})` : ''}</option>`
804
+ }).join('')}</select>
805
+ <span class="hint" id="s-autowork-hint">${autoHint(a.mode, tool)}</span>
806
+ </div>
807
+ <div class="auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>
808
+ ${a.supported ? '' : `<p class="error">${tool} can't work on its own. Pick Claude Code, Cursor, Codex or GitHub Copilot in your profile, or choose another option.</p>`}
809
+ <div class="field">
810
+ <label for="s-automodel">Model</label>
811
+ <input class="input" id="s-automodel" name="autoModel" list="s-automodels" value="${esc(a.model || '')}" placeholder="${esc(a.models[0] || '')}">
812
+ <datalist id="s-automodels">${a.models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist>
813
+ <span class="hint">${a.models.length ? `Suggested: ${a.models.map(esc).join(', ')}. ${esc(a.models[0])} is the default; type any model ${tool} knows.` : 'The model your tool uses by default.'}</span>
814
+ </div>
815
+ ${toggle('autoFiles', a.changeFiles, 'Let it change files', a.limitsFiles ? 'Off: it reads, plans, comments and asks, and leaves changes to you. File claims apply either way.' : `${tool} can change files either way; this also lets it run commands. File claims apply either way.`)}
816
+ <div class="field">
817
+ <label for="s-autocap">Daily limit (tokens)</label>
818
+ <input class="input" id="s-autocap" name="autoCap" type="number" min="0" step="1000" value="${a.cap || ''}" placeholder="No limit">
819
+ <span class="hint">Past it, work waits for your chat until tomorrow, and you're told once.</span>
820
+ </div>
821
+ </div>
822
+ <div class="sec-actions"><span><span class="hint auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>Runs on this computer, with your own ${tool} account, while Quilt is open. It asks questions in Quilt chat; a reply carries the same chat on.</span></span><button class="btn primary" type="submit">Save</button></div>
823
+ <div id="auto-usage"></div>
824
+ </div>
825
+ </form>`
826
+ }
827
+
828
+ function autoHint (mode, tool) {
829
+ if (mode === 'auto') return `${tool} starts on the work by itself, in the session's folder. You see what it does in the feed and its questions in chat.`
830
+ if (mode === 'open') return `${tool} opens on the work in the session's folder, for you to drive.`
831
+ return 'Nothing opens: your AI picks it up from its inbox the next time you work with it.'
832
+ }
833
+
834
+ /** Today's runs and tokens, and the latest runs with a way to carry each chat on. */
835
+ function autoUsageHtml (u) {
836
+ const tools = Object.entries(u.today || {})
837
+ const n = (x) => Number(x || 0).toLocaleString()
838
+ const today = tools.length
839
+ ? tools.map(([t, v]) => `<div class="kv"><span>${esc(t)}</span><b>${n(v.input + v.output)} tokens</b><span class="hint">${v.runs} run${v.runs === 1 ? '' : 's'} today${v.costUsd ? ` · $${v.costUsd.toFixed(2)}` : ''}</span></div>`).join('')
840
+ : '<p class="hint">No runs today.</p>'
841
+ const runs = (u.runs || []).slice(0, 8).map((r) => `<div class="auto-run">
842
+ <span class="nm"><b>${esc(r.title || r.kind)}</b><span class="hint">${esc(r.tool)} · ${r.reported ? `${n(r.input + r.output)} tokens` : 'tokens not reported'} · ${r.outcome === 'ok' ? 'finished' : 'failed'} · ${ago(r.ts)}</span></span>
843
+ ${r.chatId ? `<button class="btn sm" type="button" data-take-over="${esc(r.run)}">Open chat</button>` : ''}
844
+ </div>`).join('')
845
+ return `<h3 class="auto-usage-head">Today</h3>${today}${runs ? `<h3 class="auto-usage-head">Latest runs</h3><div class="auto-runs">${runs}</div>` : ''}`
846
+ }
847
+
848
+ async function loadAutoUsage (root) {
849
+ const box = $('#auto-usage', root)
850
+ if (!box) return
851
+ try { box.innerHTML = autoUsageHtml(await api('GET', '/api/autowork')) } catch (err) { box.innerHTML = `<p class="error">${esc(err.message)}</p>` }
852
+ }
853
+
854
+ function settingsHtml ({ head = true } = {}) {
855
+ const p = state.profile
856
+ const a = state.account || { name: p.name, email: '' }
857
+ const theme = p.theme || document.documentElement.dataset.theme || 'dark'
858
+ const tab = settingsTab()
859
+ const show = (id) => ({ account: ['account', 'about', 'computer'], you: ['profile', 'appearance', 'sounds'], sessions: ['sessions'], agents: ['your-ai', 'auto', 'cloud-ai', 'agents', 'agents-git'], workspaces: ['workspaces'] })[tab].includes(id)
860
+ return `
861
+ ${head ? `<header class="page-head">
862
+ <h1>Settings</h1>
863
+ <p>Saved on this computer and used for every new session.</p>
864
+ </header>` : ''}
865
+ ${settingsTabsHtml(tab)}
866
+ ${tab === 'workspaces' ? workspacesSettingsHtml() : ''}
867
+ ${show('account') ? `<section class="card settings-sec" id="account-sec">
868
+ <div class="sec-intro"><h2>Account</h2><p>This computer is signed in to your heyquilt.com account.</p></div>
869
+ <div class="sec-body">
870
+ <div class="kv"><span>Signed in as</span><b>${esc(a.name)}</b><span class="hint">${esc(a.email)}</span></div>
871
+ <div class="sec-actions"><span class="hint">Signing out stops your sessions on this computer. Your files stay put.</span><button class="btn" type="button" id="sign-out">Sign out</button></div>
872
+ </div>
873
+ </section>` : ''}
874
+
875
+ ${show('your-ai') ? `<form class="card settings-sec" id="your-ai-sec" autocomplete="off">
876
+ <div class="sec-intro"><h2>Your AI</h2><p>The AI tool you code with, in every session you are in.</p></div>
877
+ <div class="sec-body">
878
+ <div class="field">
879
+ <label for="s-aitasks">Let my AI pick up tasks by itself</label>
880
+ <select class="input" id="s-aitasks" name="aiTasks">${[['off', 'Off'], ['mine', 'Tasks assigned to it'], ['any', 'Assigned to it, then unassigned ones']].map(([v, l]) => `<option value="${v}" ${p.aiTasks === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
881
+ <span class="hint">When your AI finishes a piece of work, Quilt hands it the next To do task from the board, in whichever AI tool you use. Applies at once.</span>
882
+ </div>
883
+ ${toggle('shareAgent', p.shareAgent, 'Share my AI chat', 'Partners see your prompts, the replies and which files it touches. You can pause it inside any session.')}
884
+ ${toggle('summarize', p.summarize, 'Summarize my chats', 'Your prompts and your AI’s replies are shortened to a sentence or two on this computer before they’re shared. Uses your claude CLI (a few Haiku tokens each); if it isn’t available, the text is just shortened.')}
885
+ <div class="sec-actions"><span></span><button class="btn primary" type="submit">Save</button></div>
886
+ </div>
887
+ </form>` : ''}
888
+
889
+ ${show('auto') ? autoSettingsHtml(p) : ''}
890
+
891
+ ${show('cloud-ai') ? cloudAiCardHtml() : ''}
892
+
893
+ ${show('agents') ? `<section class="card settings-sec" id="agents-sec">
894
+ <div class="sec-intro"><h2>Agents</h2><span class="pill count" id="agents-count" hidden></span><label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label><button class="btn primary sec-head-btn" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button></div>
895
+ <div class="sec-body">
896
+ <div id="agents-invite"><p class="error" id="agents-error"></p></div>
897
+ <div id="agents-list"><p class="hint">${loadingNote()}</p></div>
898
+ <div class="ag-pages" id="agents-pages" hidden></div>
899
+ <div class="ag-details" id="agent-details" hidden></div>
900
+ <div class="sec-actions"><span class="hint">${state.workspacesOn ? 'Open a card for where the agent works, whether it joins new sessions, and its keys.' : 'An invite is pasted into the AI once; from then on it joins any session you send it. Open a card for its keys.'} Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a>.</span>${state.workspacesOn ? `<button class="btn sm" type="button" data-view="agents">Open Agents</button>` : ''}</div>
901
+ </div>
902
+ </section>` : ''}
903
+
904
+ ${show('agents-git') ? `<section class="card settings-sec" id="agents-git-sec">
905
+ <div class="sec-intro"><h2>Git for everyone</h2><p>People and agents use git from their own folders, as themselves, with short-lived credentials from Quilt's GitHub app bound to the session's repository: nobody else connects or sets anything up.</p></div>
906
+ <div class="sec-body">
907
+ <div class="field">
908
+ <span class="label" id="s-agentcommits-l">In sessions I start, git is</span>
909
+ <div id="s-agentcommits">${gitSegHtml(gitControl({ value: p.agentCommits || 'branches' }), { attr: 'data-git-default', label: 'In sessions I start, git is' })}</div>
910
+ <span class="hint">Git is for everyone in the session, people and agents, through one GitHub connection. Off for everyone: nobody pushes through Quilt, and agents ask a person to commit. Branches of their own: a task puts the agent on quilt/&lt;agent&gt;/&lt;task&gt;, its push opens a pull request, and a person merges by moving the task to Done. Any branch: agents may push to the session's branch and merge too. Saved at once, for sessions you start from now on; a running session's owner changes it in its settings (GitHub).</span>
911
+ </div>
912
+ <div class="kv"><span>GitHub</span><span id="agents-gh"><span class="hint">${loadingNote('Checking…')}</span></span></div>
913
+ <div class="sec-actions"><span class="hint">Connect once, with a GitHub account that can write to your repositories. Git is then on for everyone in your sessions, people and agents; pushes come in within seconds.</span><button class="btn" type="button" id="agents-gh-connect" hidden>${I.link}<span>Connect GitHub</span></button></div>
914
+ </div>
915
+ </section>` : ''}
916
+
917
+ ${show('profile') ? `<form class="card settings-sec" id="profile-sec" autocomplete="off">
918
+ <div class="sec-intro"><h2>Profile</h2><p>How you show up to the people you code with.</p></div>
919
+ <div class="sec-body">
920
+ <div class="profile-preview" id="pv">${avatar(p.name, p.color)}<div><b id="pv-name">${esc(p.name)}</b><span id="pv-tool">coding with ${esc(p.tool)}</span></div></div>
921
+ <div class="field">
922
+ <label for="s-name">Name</label>
923
+ <input class="input" id="s-name" value="${esc(p.name)}" readonly>
924
+ <span class="hint"><a href="https://heyquilt.com/settings" target="_blank" rel="noopener">Change it on heyquilt.com</a></span>
925
+ </div>
926
+ <div class="field">
927
+ <span class="label">Color</span>
928
+ <div class="swatches" role="radiogroup" aria-label="Color">
929
+ <label class="swatch auto" title="Automatic"><input type="radio" name="color" value="" ${p.color ? '' : 'checked'}><span>Auto</span></label>
930
+ ${PALETTE.map((c) => `<label class="swatch" title="${c}"><input type="radio" name="color" value="${c}" ${p.color === c ? 'checked' : ''}><span style="background:${c}"></span></label>`).join('')}
931
+ </div>
932
+ </div>
933
+ <div class="field">
934
+ <label for="s-tool">AI tool you use</label>
935
+ <select class="input" id="s-tool" name="tool">${state.defaults.tools.map((t) => `<option ${t === p.tool ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
936
+ <span class="hint">Shown next to your name, and used to find your AI chat so partners can follow along.</span>
937
+ </div>
938
+ <div class="sec-actions"><span class="hint">New sessions use this. Rejoin a running session to update it there.</span><button class="btn primary" type="submit">Save profile</button></div>
939
+ </div>
940
+ </form>` : ''}
941
+
942
+ ${show('appearance') ? `<section class="card settings-sec" id="appearance-sec">
943
+ <div class="sec-intro"><h2>Appearance</h2><p>How Quilt looks on this computer.</p></div>
944
+ <div class="sec-body">
945
+ <div class="segmented theme-switch" role="radiogroup" aria-label="Theme">
946
+ ${THEMES.map(([v, label]) => `<button type="button" role="radio" data-theme-pick="${v}" class="${theme === v ? 'on' : ''}" aria-checked="${theme === v}">${theme === v ? I.check : ''}<span>${label}</span></button>`).join('')}
947
+ </div>
948
+ <span class="hint">System follows your computer's light or dark setting.</span>
949
+ </div>
950
+ </section>` : ''}
951
+
952
+ ${show('sounds') ? `<section class="card settings-sec" id="sounds-sec">
953
+ <div class="sec-intro"><h2>Sounds</h2><p>A short chime when something needs you, in any open session.</p></div>
954
+ <div class="sec-body">
955
+ ${SOUND_EVENTS.map((ev) => `<div class="row sound-row">${toggle(ev.setting, p[ev.setting] !== false, ev.label, ev.hint)}
956
+ <button type="button" class="btn sm ghost" data-sound-test="${ev.kind}" title="Play this sound" aria-label="Play the ${esc(ev.label.toLowerCase())} sound">Play</button></div>`).join('')}
957
+ <span class="hint">Changes apply at once.</span>
958
+ </div>
959
+ </section>` : ''}
960
+
961
+ ${show('sessions') ? `<form class="card settings-sec" id="sessions-sec" autocomplete="off">
962
+ <div class="sec-intro"><h2>Sessions</h2><p>Defaults for starting and joining.</p></div>
963
+ <div class="sec-body">
964
+ <div class="field">
965
+ <label for="s-joindir">Put projects you join in</label>
966
+ <div class="row"><input class="input grow" id="s-joindir" name="joinDir" value="${esc(p.joinDir)}">
967
+ <button type="button" class="btn icon" data-browse-settings="s-joindir" title="Browse" aria-label="Browse">${I.folder}</button></div>
968
+ <span class="hint">Each session gets its own folder in here.</span>
969
+ </div>
970
+ ${toggle('preferLocal', p.preferLocal, 'Keep my files when joining a folder that has some', 'When off, their versions of the same files win.')}
971
+ ${toggle('pushLocalCommits', p.pushLocalCommits, 'Push my local commits for me', 'When you commit in a folder whose branch syncs with GitHub, Quilt pushes the commit for you (through the relay if your git can\'t). When off, you push with git yourself.')}
972
+ ${toggle('report', p.report, 'Send problem reports to Quilt', 'The app tells Quilt which actions you take (not what you read), whether they worked and how long they took, with the error message when something fails, your app version and OS. Never your files, your chats or your links.')}
973
+ <div class="sec-actions"><span></span><button class="btn primary" type="submit">Save</button></div>
974
+ </div>
975
+ </form>` : ''}
976
+
977
+ ${show('about') ? `<section class="card settings-sec" id="about-sec">
978
+ <div class="sec-intro"><h2>About Quilt</h2><p>${state.release?.outOfDate ? `Quilt ${esc(state.release.latest.version)} is out.` : 'The version on this computer.'}</p></div>
979
+ <div class="sec-body">
980
+ <div class="kv"><span>Version</span><span>${esc(state.release?.version || '…')}${state.release?.outOfDate ? ` <span class="pill warn">update available</span>` : state.release ? ' <span class="pill">up to date</span>' : ''}</span></div>
981
+ <div class="sec-actions"><span class="hint">Everything that changed, release by release.</span><span class="row">
982
+ ${state.release?.outOfDate ? updateControl() : ''}
983
+ <button class="btn" type="button" data-release-notes>${I.sparkle}<span>What's new</span></button></span></div>
984
+ </div>
985
+ </section>` : ''}
986
+
987
+ ${show('computer') ? `<section class="card settings-sec" id="computer-sec">
988
+ <div class="sec-intro"><h2>This computer</h2><p>Where Quilt keeps things.</p></div>
989
+ <div class="sec-body">
990
+ <div class="kv"><span>Identity key</span><code>~/.quilt/identity.json</code><span class="hint">Proves your name is yours. Copy it to another computer to keep your name there.</span></div>
991
+ <div class="kv"><span>Sign-in</span><code>~/.quilt/account.json</code><span class="hint">This computer's sign-in. Only you can read it.</span></div>
992
+ <div class="kv"><span>Settings</span><code>~/.quilt/settings.json</code></div>
993
+ <div class="sec-actions"><span class="hint">Stops every session and this app. Your files stay put.</span><button class="btn" type="button" data-shutdown>${I.power}<span>Shut down Quilt</span></button></div>
994
+ </div>
995
+ </section>` : ''}`
996
+ }
997
+
998
+ /**
999
+ * The Agents card: your agents from the accounts API, and a one-time invite for a new one.
1000
+ * With workspaces on, each agent's placement loads first, so its row (Available in and Joins
1001
+ * included) draws once.
1002
+ */
1003
+ const AGENTS_PAGE = 9
1004
+
1005
+ /** The agents as small cards, a page at a time, with a search (Settings and the Agents page); with workspaces on, a card opens where it works. */
1006
+ function bindAgentsGrid (root, list) {
1007
+ const count = $('#agents-count', root)
1008
+ const pages = $('#agents-pages', root)
1009
+ const search = $('#agents-search', root)
1010
+ let all = []
1011
+ let page = 0
1012
+ let open = '' // the agent whose details show under the cards
1013
+ const details = (a) => {
1014
+ open = a.id
1015
+ for (const c of list.querySelectorAll('[data-agent]')) c.setAttribute('aria-pressed', String(c.dataset.agent === a.id))
1016
+ showAgentDetails(root, a)
1017
+ }
1018
+ const matching = () => {
1019
+ const q = (search.value || '').trim().toLowerCase()
1020
+ return q ? all.filter((a) => [a.name, a.type, a.provider].some((x) => String(x || '').toLowerCase().includes(q))) : all
1021
+ }
1022
+ const draw = () => {
1023
+ const hits = matching()
1024
+ const last = Math.max(0, Math.ceil(hits.length / AGENTS_PAGE) - 1)
1025
+ page = Math.min(page, last)
1026
+ const from = page * AGENTS_PAGE
1027
+ const shown = hits.slice(from, from + AGENTS_PAGE)
1028
+ list.innerHTML = shown.length
1029
+ ? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens: true })).join('').replace(`data-agent="${esc(open)}"`, `data-agent="${esc(open)}" aria-pressed="true"`)}</div>`
1030
+ : `<p class="hint">${all.length ? 'No agent matches that.' : 'No agents yet. Invite one above.'}</p>`
1031
+ pages.hidden = hits.length <= AGENTS_PAGE
1032
+ pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
1033
+ <button type="button" class="btn sm icon" data-page="-1" aria-label="Previous page" ${page === 0 ? 'disabled' : ''}>${I.arrowLeft}</button>
1034
+ <button type="button" class="btn sm icon" data-page="1" aria-label="Next page" ${page >= last ? 'disabled' : ''}>${I.arrowRight}</button>`
1035
+ }
1036
+ pages.addEventListener('click', (e) => {
1037
+ const b = e.target.closest('[data-page]')
1038
+ if (!b || b.disabled) return
1039
+ page += Number(b.dataset.page)
1040
+ draw()
1041
+ })
1042
+ search.addEventListener('input', () => { page = 0; draw() })
1043
+ // A card is a button with workspaces on (Enter or Space opens it, like a click).
1044
+ list.addEventListener('keydown', (e) => {
1045
+ const card = e.target.closest && e.target.closest('.ag-mini.open')
1046
+ if (!card || e.target !== card || (e.key !== 'Enter' && e.key !== ' ')) return
1047
+ e.preventDefault()
1048
+ const a = all.find((x) => x.id === card.dataset.agent)
1049
+ if (a) details(a)
1050
+ })
1051
+ list.addEventListener('click', (e) => {
1052
+ const b = e.target.closest('[data-agent-menu]')
1053
+ const card = e.target.closest('[data-agent]')
1054
+ const a = all.find((x) => x.id === (b ? b.dataset.agentMenu : card && card.dataset.agent))
1055
+ if (!a) return
1056
+ if (!b) { details(a); return }
1057
+ const items = [
1058
+ ...(state.workspacesOn ? [{ act: 'place', label: 'Where it works', icon: I.grid }] : []),
1059
+ { act: 'keys', label: 'Keys', icon: I.lock },
1060
+ { act: 'revoke', label: 'Revoke on heyquilt.com', icon: I.external, danger: true }
1061
+ ]
1062
+ openAgentMenu(b, {
1063
+ label: `Actions for ${a.name}`,
1064
+ items,
1065
+ on: (act) => {
1066
+ if (act === 'place' || act === 'keys') details(a)
1067
+ if (act === 'revoke') window.open('https://heyquilt.com/dashboard/agents', '_blank', 'noopener')
1068
+ }
1069
+ })
1070
+ })
1071
+ api('GET', '/api/agents').then(({ agents }) => {
1072
+ all = agents
1073
+ count.textContent = String(agents.length)
1074
+ count.hidden = !agents.length
1075
+ draw()
1076
+ }).catch((err) => { list.innerHTML = `<p class="hint warn">${esc(err.message)}</p>` })
1077
+ }
1078
+
1079
+ function bindAgents (root) {
1080
+ const list = $('#agents-list', root)
1081
+ const sec = $('#agents-sec', root)
1082
+ bindAgentsGrid(root, list)
1083
+ sec.addEventListener('click', async (e) => {
1084
+ const b = e.target.closest('[data-copy]')
1085
+ if (!b) return
1086
+ try { await navigator.clipboard.writeText($(`#${b.dataset.copy}`, sec).textContent); toast('Copied') } catch { toast('Select the text and press ⌘/Ctrl+C to copy') }
1087
+ })
1088
+ const btn = $('#agents-make', root)
1089
+ btn.onclick = async () => {
1090
+ // With workspaces on: pick a global, workspace or session agent first.
1091
+ if (state.workspacesOn) {
1092
+ return openKindMenu(btn, {
1093
+ onInvite: ({ kind, text, where }) => {
1094
+ $('#agents-invite', root).innerHTML = agentInviteHtml(text, 'agents-paste', kindLineHtml(kind, where))
1095
+ btn.innerHTML = `${I.bot}<span>Invite another</span>`
1096
+ }
1097
+ })
1098
+ }
1099
+ btn.disabled = true
1100
+ try {
1101
+ const inv = await api('POST', '/api/agent-invites')
1102
+ $('#agents-invite', root).innerHTML = agentInviteHtml(agentPaste({ link: inv.link }), 'agents-paste')
1103
+ btn.innerHTML = `${I.bot}<span>Invite another</span>`
1104
+ } catch (err) {
1105
+ $('#agents-error', root).textContent = err.message
1106
+ }
1107
+ btn.disabled = false
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Agents and git: your default for what agents may commit in sessions you start (saved at once),
1113
+ * and this account's GitHub connection. Connect opens GitHub's install page; the card then watches
1114
+ * for the connection for up to ten minutes.
1115
+ */
1116
+ function bindAgentsGit (sec) {
1117
+ const box = $('#s-agentcommits', sec)
1118
+ const drawSeg = (v) => { box.innerHTML = gitSegHtml(gitControl({ value: v }), { attr: 'data-git-default', label: 'In sessions I start, git is' }) }
1119
+ box.addEventListener('click', async (e) => {
1120
+ const b = e.target.closest('[data-git-default]')
1121
+ if (!b || b.classList.contains('on')) return
1122
+ const v = b.dataset.gitDefault
1123
+ drawSeg(v)
1124
+ try {
1125
+ state.profile = await api('POST', '/api/settings', { agentCommits: v })
1126
+ toast(AGENT_COMMIT_WORDS[v] || 'Saved')
1127
+ } catch (err) {
1128
+ drawSeg(state.profile.agentCommits || 'branches')
1129
+ toast(err.message)
1130
+ }
1131
+ })
1132
+ const out = $('#agents-gh', sec)
1133
+ const btn = $('#agents-gh-connect', sec)
1134
+ const draw = (gh) => {
1135
+ if (!gh.available) { out.innerHTML = '<span class="hint">GitHub is not set up on this server.</span>'; btn.hidden = true; return }
1136
+ out.innerHTML = gh.connected ? `<b>Connected as @${esc(gh.login)}</b>` : '<span class="hint warn">Not connected</span>'
1137
+ btn.hidden = false
1138
+ btn.innerHTML = `${I.link}<span>${gh.connected ? 'Add repositories' : 'Connect GitHub'}</span>`
1139
+ }
1140
+ const load = () => api('GET', '/api/github').then(draw, (err) => { out.innerHTML = `<span class="hint warn">${esc(err.message)}</span>` })
1141
+ load()
1142
+ btn.onclick = async () => {
1143
+ let was = null
1144
+ try { was = (await api('GET', '/api/github')).login || null } catch {}
1145
+ btn.disabled = true
1146
+ try {
1147
+ const { url } = await api('POST', '/api/github/connect', {})
1148
+ window.open(url, '_blank', 'noopener')
1149
+ toast('Finish on GitHub, then come back')
1150
+ } catch (err) { btn.disabled = false; toast(err.message); return }
1151
+ out.innerHTML = `<span class="hint">${loadingNote('Finish on GitHub, then come back…')}</span>`
1152
+ const until = Date.now() + 10 * 60 * 1000
1153
+ while (sec.isConnected && Date.now() < until) {
1154
+ await new Promise((r) => setTimeout(r, 3000))
1155
+ let gh = null
1156
+ try { gh = await api('GET', '/api/github') } catch { continue }
1157
+ if (!gh.login || gh.login === was) continue
1158
+ btn.disabled = false
1159
+ draw(gh)
1160
+ toast(`Connected as @${gh.login}: agents can commit in your sessions now`)
1161
+ return
1162
+ }
1163
+ btn.disabled = false
1164
+ if (sec.isConnected) load()
1165
+ }
1166
+ }
1167
+
1168
+ /** Wires the settings cards inside `root`; `refresh()` redraws them after a save. */
1169
+ function bindSettings (root, refresh) {
1170
+ // Tabs: the pick is kept on this computer, and the cards are drawn again.
1171
+ root.querySelector('.settings-tabs')?.addEventListener('click', (e) => {
1172
+ const b = e.target.closest('[data-settings-tab]')
1173
+ if (!b || b.classList.contains('on')) return
1174
+ state.settingsTab = b.dataset.settingsTab
1175
+ try { localStorage.setItem('quilt.settingsTab', state.settingsTab) } catch {}
1176
+ refresh()
1177
+ })
1178
+ const saveForm = (form, pick, done) => {
1179
+ form.onsubmit = async (e) => {
1180
+ e.preventDefault()
1181
+ const btn = form.querySelector('button[type=submit]')
1182
+ btn.disabled = true
1183
+ try {
1184
+ state.profile = await api('POST', '/api/settings', pick(new FormData(form)))
1185
+ toast('Saved')
1186
+ refresh()
1187
+ done && done()
1188
+ } catch (err) {
1189
+ toast(err.message)
1190
+ btn.disabled = false
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ // Profile: live preview while picking. The name comes from the account.
1196
+ const prof = $('#profile-sec', root)
1197
+ if (prof) {
1198
+ const preview = () => {
1199
+ const f = new FormData(prof)
1200
+ $('#pv', root).querySelector('.avatar').outerHTML = avatar(state.profile.name, f.get('color') || null)
1201
+ $('#pv-tool', root).textContent = `coding with ${f.get('tool')}`
1202
+ }
1203
+ prof.addEventListener('input', preview)
1204
+ prof.addEventListener('change', preview)
1205
+ saveForm(prof, (f) => ({ color: f.get('color'), tool: f.get('tool') }))
1206
+ }
1207
+
1208
+ const sess = $('#sessions-sec', root)
1209
+ if (sess) sess.querySelector('[data-browse-settings]').onclick = () => pickFolder($('#s-joindir', root))
1210
+ if (sess) saveForm(sess, (f) => ({ joinDir: f.get('joinDir'), preferLocal: !!f.get('preferLocal'), pushLocalCommits: !!f.get('pushLocalCommits'), report: !!f.get('report') }))
1211
+
1212
+ const ai = $('#your-ai-sec', root)
1213
+ if (ai) saveForm(ai, (f) => ({ shareAgent: !!f.get('shareAgent'), summarize: !!f.get('summarize'), aiTasks: f.get('aiTasks') }))
1214
+
1215
+ const auto = $('#auto-sec', root)
1216
+ if (auto) {
1217
+ saveForm(auto, (f) => ({ autoWork: f.get('autoWork'), ...(f.get('autoWork') === 'auto' ? { autoModel: f.get('autoModel'), autoFiles: !!f.get('autoFiles'), autoCap: f.get('autoCap') } : {}) }))
1218
+ const sel = $('#s-autowork', auto)
1219
+ sel.onchange = () => {
1220
+ for (const el of auto.querySelectorAll('.auto-only')) el.hidden = sel.value !== 'auto'
1221
+ $('#s-autowork-hint', auto).textContent = autoHint(sel.value, state.profile.auto?.tool || state.profile.tool)
1222
+ }
1223
+ auto.addEventListener('click', async (e) => {
1224
+ const b = e.target.closest('[data-take-over]')
1225
+ if (!b) return
1226
+ b.disabled = true
1227
+ try {
1228
+ const r = await api('POST', '/api/autowork/take-over', { run: b.dataset.takeOver })
1229
+ toast(r.opened ? 'Opened the chat in your AI tool' : r.copied ? `Copied: ${r.command}. Paste it in a terminal.` : r.command)
1230
+ } catch (err) { toast(err.message) }
1231
+ b.disabled = false
1232
+ })
1233
+ loadAutoUsage(root)
1234
+ }
1235
+
1236
+ const cloud = $('#cloud-ai-sec', root)
1237
+ if (cloud) bindCloudAi(cloud)
1238
+
1239
+ const agit = $('#agents-git-sec', root)
1240
+ if (agit) bindAgentsGit(agit)
1241
+
1242
+ // Appearance applies at once; the server puts it on <html> at the next launch.
1243
+ $('#appearance-sec', root)?.addEventListener('click', async (e) => {
1244
+ const pick = e.target.closest('[data-theme-pick]')
1245
+ if (!pick || pick.classList.contains('on')) return
1246
+ const before = document.documentElement.dataset.theme
1247
+ document.documentElement.dataset.theme = pick.dataset.themePick
1248
+ try {
1249
+ state.profile = await api('POST', '/api/settings', { theme: pick.dataset.themePick })
1250
+ refresh()
1251
+ } catch (err) {
1252
+ document.documentElement.dataset.theme = before
1253
+ toast(err.message)
1254
+ }
1255
+ })
1256
+
1257
+ // Sounds apply at once, like the theme; Play previews one.
1258
+ const sounds = $('#sounds-sec', root)
1259
+ sounds?.addEventListener('click', (e) => {
1260
+ const b = e.target.closest('[data-sound-test]')
1261
+ if (b) playSound(b.dataset.soundTest, { force: true })
1262
+ })
1263
+ sounds?.addEventListener('change', async (e) => {
1264
+ const box = e.target
1265
+ if (!SOUND_EVENTS.some((ev) => ev.setting === box.name)) return
1266
+ try {
1267
+ state.profile = await api('POST', '/api/settings', { [box.name]: box.checked })
1268
+ } catch (err) {
1269
+ box.checked = !box.checked
1270
+ toast(err.message)
1271
+ }
1272
+ })
1273
+
1274
+ if ($('#agents-sec', root)) bindAgents(root)
1275
+
1276
+ const signOut = $('#sign-out', root)
1277
+ if (signOut) signOut.onclick = async () => {
1278
+ if (!await ask({ title: 'Sign out of Quilt?', message: 'This stops your sessions on this computer. Your files stay where they are.', ok: 'Sign out', danger: true })) return
1279
+ try {
1280
+ await api('POST', '/api/account/signout')
1281
+ signedOutNow()
1282
+ } catch (err) {
1283
+ toast(err.message)
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Settings in a pop-up, for inside a session: the same cards as the Settings page, so you
1290
+ * can change your profile without leaving. Saving redraws the cards in place.
1291
+ */
1292
+ export function openSettings () {
1293
+ $('#settings-back')?.remove()
1294
+ const back = document.createElement('div')
1295
+ back.id = 'settings-back'
1296
+ back.className = 'modal-back settings-back'
1297
+ back.innerHTML = `
1298
+ <div class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
1299
+ <header class="settings-head">
1300
+ <h2 id="settings-title">${I.gear}<span>Settings</span></h2>
1301
+ <p>Saved on this computer and used for every new session.</p>
1302
+ <button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
1303
+ </header>
1304
+ <div class="settings-body" id="settings-body"></div>
1305
+ </div>`
1306
+ document.body.appendChild(back)
1307
+ const body = $('#settings-body', back)
1308
+ const draw = () => {
1309
+ const scroll = body.scrollTop
1310
+ body.innerHTML = settingsHtml({ head: false })
1311
+ bindSettings(body, draw)
1312
+ body.scrollTop = scroll
1313
+ // The sidebar's profile card, if it is showing, follows the saved profile.
1314
+ if (state.shellView && $('#app .side')) renderShell(state.shellView)
1315
+ }
1316
+ draw()
1317
+ const close = () => back.remove()
1318
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
1319
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
1320
+ back.querySelector('[data-settings-close]').onclick = close
1321
+ back.querySelector('[data-settings-close]').focus()
1322
+ }