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