heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,547 @@
1
+ // Workspaces: the home grid of cards, the Add workspace card, and a workspace's page
2
+ // (Sessions, Files, People & agents, Settings). Sessions stay in session.js; the file library
3
+ // (the Files section, All files and uploads) is files.js.
4
+ import { mountSecrets } from './secrets.js'
5
+ import { I, state, $, esc, basename, toast, api, ask, avatar, colorFor, ago, bytes, COLORS, loadingHtml, AGENT_COMMIT_WORDS } from './common.js'
6
+ import { gitControl, gitSegHtml } from './git-rule.js'
7
+ import { filesSectionHtml, bindFilesSection } from './files.js'
8
+ import { memberMiniHtml, memberMenuItems, openAgentMenu } from './agent-place.js'
9
+
10
+ export { COLORS }
11
+ const coverOf = (w) => COLORS[w.color] || COLORS.lilac
12
+ const initial = (name) => [...String(name || '?')][0].toUpperCase()
13
+ const spaceLabel = (w) => (w.space?.kind === 'org' ? w.space.name : 'Personal')
14
+ const spaceKey = (w) => (w.space?.kind === 'org' ? w.space.slug : 'personal')
15
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`
16
+ const wsUrl = (id, rest = '') => `/api/workspaces/${encodeURIComponent(id)}${rest}`
17
+ const tildify = (p) => (state.defaults.home && String(p).startsWith(state.defaults.home) ? `~${String(p).slice(state.defaults.home.length)}` : p)
18
+
19
+ export async function loadWorkspaces () {
20
+ try {
21
+ const r = await api('GET', '/api/workspaces')
22
+ state.workspacesOn = !!r.on
23
+ state.workspaces = r.workspaces || []
24
+ } catch (err) {
25
+ if (err.signedOut) throw err
26
+ state.workspacesOn = false
27
+ state.workspaces = []
28
+ }
29
+ // Your orgs, so an org's first workspace can be made here too. Without them: Personal only.
30
+ if (!state.workspacesOn) return
31
+ try { state.orgs = (await api('GET', '/api/orgs')).orgs || [] } catch (err) {
32
+ if (err.signedOut) throw err
33
+ state.orgs = []
34
+ }
35
+ }
36
+
37
+ /** The workspace id in a `ws:<id>` (its page) or `wsfiles:<id>` (All files) view. */
38
+ export const wsIdOf = (view) => String(view).replace(/^(ws|wsfiles):/, '')
39
+
40
+ export async function openWorkspace (id) {
41
+ const d = await api('GET', wsUrl(id))
42
+ // A slow answer for a workspace you've since left mustn't replace the one on screen.
43
+ // Its files and usage come in the same answer.
44
+ if (state.view === `ws:${id}` || state.view === `wsfiles:${id}`) state.workspace = d
45
+ }
46
+
47
+ // ------------------------------------------------------------------ grid --
48
+ function spaces () {
49
+ const keys = new Map([['all', 'All'], ['personal', 'Personal']])
50
+ for (const o of state.orgs || []) if (o.slug) keys.set(o.slug, o.name || o.slug)
51
+ for (const w of state.workspaces || []) if (w.space?.kind === 'org' && w.space.slug) keys.set(w.space.slug, w.space.name)
52
+ return keys
53
+ }
54
+
55
+ function cardHtml (w) {
56
+ const live = w.counts?.open || 0
57
+ return `
58
+ <div class="ws-card" data-open-ws="${esc(w.id)}" role="button" tabindex="0" aria-label="Open ${esc(w.name)}">
59
+ <div class="ws-cover" style="--c:${coverOf(w)}"><span class="ws-mark">${esc(initial(w.name))}</span>${live ? `<span class="pill ok ws-live"><span class="dot"></span>${live} open</span>` : ''}</div>
60
+ <div class="ws-card-body">
61
+ <h3>${esc(w.name)} <span class="pill ws-space${w.space?.kind === 'org' ? ' coral' : ''}">${esc(spaceLabel(w))}</span></h3>
62
+ <p class="ws-desc">${esc(w.description || '')}</p>
63
+ <div class="ws-stats"><span><b>${w.counts?.sessions ?? 0}</b> ${w.counts?.sessions === 1 ? 'session' : 'sessions'}</span><span><b>${w.counts?.files ?? 0}</b> ${w.counts?.files === 1 ? 'file' : 'files'}</span></div>
64
+ </div>
65
+ <div class="ws-foot"><span>${esc(plural((w.counts?.members ?? 0) + (w.space?.kind === 'personal' ? 1 : 0), 'member', 'members'))}</span><span class="spacer"></span><span>${w.archivedAt ? 'archived' : esc(ago(w.createdAt))}</span></div>
66
+ </div>`
67
+ }
68
+
69
+ function addCardHtml () {
70
+ return `
71
+ <div class="ws-card add" data-add-ws role="button" tabindex="0">
72
+ <span class="ws-plus">${I.plus}</span><b>Add workspace</b><span>Sessions, files and agents in one place</span>
73
+ </div>`
74
+ }
75
+
76
+ function addFormHtml () {
77
+ const orgs = [...spaces()].filter(([k]) => k !== 'all' && k !== 'personal')
78
+ // Just a name and where it lives: colour and a description are set later, in the workspace's settings.
79
+ return `
80
+ <form class="ws-card form" data-add-ws-form>
81
+ <div class="ws-cover" style="--c:${COLORS.lilac};height:36px"></div>
82
+ <div class="ws-card-body">
83
+ <label class="label" for="ws-name">Name</label><input class="input" id="ws-name" name="name" maxlength="80" required placeholder="Launch">
84
+ ${orgs.length ? `<label class="label" for="ws-org">Where</label>
85
+ <select class="input" id="ws-org" name="org"><option value="">Personal</option>${orgs.map(([slug, name]) => `<option value="${esc(slug)}">${esc(name)}</option>`).join('')}</select>` : ''}
86
+ <p class="error" data-add-ws-error></p>
87
+ <div class="actions"><button type="button" class="btn sm ghost" data-add-ws-cancel>Cancel</button><button class="btn sm primary" type="submit">Create</button></div>
88
+ </div>
89
+ </form>`
90
+ }
91
+
92
+ function looseRows () {
93
+ const known = new Set((state.workspaces || []).map((w) => w.id))
94
+ const loose = (ws) => !ws || !known.has(ws)
95
+ const running = [...state.sessions.values()].filter((s) => loose(s.workspace)).map((s) => ({ live: true, id: s.id, dir: s.dir, peers: s.status.peers.length }))
96
+ const recent = state.recent.filter((r) => loose(r.workspace) && !r.unsupported).map((r) => ({ live: false, dir: r.dir, lastUsed: r.lastUsed }))
97
+ return [...running, ...recent]
98
+ }
99
+
100
+ export function workspacesHtml () {
101
+ const filter = state.spaceFilter || 'all'
102
+ const list = (state.workspaces || []).filter((w) => filter === 'all' || spaceKey(w) === filter)
103
+ const loose = looseRows()
104
+ return `
105
+ <section class="workspaces">
106
+ <div class="sec-head"><h2>Your workspaces</h2><span class="count">${list.length}</span><span class="spacer"></span>
107
+ ${spaces().size > 2 ? `<div class="segmented ws-filter" role="tablist">${[...spaces()].map(([k, label]) => `<button type="button" role="tab" data-space-filter="${esc(k)}" class="${filter === k ? 'on' : ''}" aria-selected="${filter === k}">${esc(label)}</button>`).join('')}</div>` : ''}
108
+ </div>
109
+ <div class="ws-grid">${list.map(cardHtml).join('')}${state.addingWorkspace ? addFormHtml() : addCardHtml()}</div>
110
+ </section>
111
+ <section class="ws-loose">
112
+ <div class="sec-head"><h2 class="ws-loose-h">Sessions not in a workspace</h2><span class="count">${loose.length}</span><span class="spacer"></span>
113
+ <button class="btn sm ghost" data-join-session>${I.link}<span>Join with an invite</span></button>
114
+ <button class="btn sm" data-new-session>${I.plus}<span>New session</span></button>
115
+ </div>
116
+ ${loose.length ? `<div class="ws-chips">${loose.map((r) => `
117
+ <div class="ws-chip"><span class="folder-ico${r.live ? ' live' : ''}">${I.folder}</span>
118
+ <span class="t"><b>${esc(basename(r.dir))}</b><span>${r.live ? (r.peers ? `${r.peers} other${r.peers === 1 ? '' : 's'} here` : 'just you') : esc(ago(r.lastUsed))}</span></span>
119
+ ${r.live ? `<button class="btn sm primary" data-go="${esc(r.id)}">Open</button>` : `<button class="btn sm" data-rejoin="${esc(r.dir)}">Rejoin</button>`}
120
+ ${state.workspaces?.length ? `<button class="btn sm ghost" data-move-session="${esc(r.dir)}" title="Move to a workspace" aria-label="Move to a workspace">${I.folder}</button>` : ''}
121
+ </div>`).join('')}</div>` : `<p class="hint">${state.sessions.size || state.recent.length ? 'Every session is in a workspace.' : 'No sessions yet.'}</p>`}
122
+ </section>`
123
+ }
124
+
125
+ export function bindWorkspaces (root, { go, rerender }) {
126
+ root.querySelectorAll('[data-open-ws]').forEach((el) => {
127
+ const open = () => go(`ws:${el.dataset.openWs}`)
128
+ el.onclick = open
129
+ el.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() } }
130
+ })
131
+ root.querySelectorAll('.ws-chips [data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
132
+ root.querySelectorAll('[data-space-filter]').forEach((b) => { b.onclick = () => { state.spaceFilter = b.dataset.spaceFilter; rerender() } })
133
+ const add = root.querySelector('[data-add-ws]')
134
+ if (add) {
135
+ const open = () => { state.addingWorkspace = true; rerender(); $('#ws-name')?.focus() } // rerender replaces root
136
+ add.onclick = open
137
+ add.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() } }
138
+ }
139
+ const form = root.querySelector('[data-add-ws-form]')
140
+ if (form) {
141
+ form.querySelector('[data-add-ws-cancel]').onclick = () => { state.addingWorkspace = false; rerender() }
142
+ form.onsubmit = async (e) => {
143
+ e.preventDefault()
144
+ const f = new FormData(form)
145
+ const body = { name: f.get('name'), org: f.get('org') || undefined }
146
+ form.querySelector('button[type=submit]').disabled = true
147
+ try {
148
+ const { workspace } = await api('POST', '/api/workspaces', body)
149
+ state.addingWorkspace = false
150
+ await loadWorkspaces()
151
+ go(`ws:${workspace.id}`)
152
+ } catch (err) {
153
+ form.querySelector('[data-add-ws-error]').textContent = err.message
154
+ form.querySelector('button[type=submit]').disabled = false
155
+ }
156
+ }
157
+ }
158
+ root.querySelectorAll('[data-move-session]').forEach((b) => {
159
+ b.onclick = async () => {
160
+ const options = (state.workspaces || []).filter((w) => w.access === 'edit')
161
+ if (!options.length) return toast('No workspace you can edit.')
162
+ const choice = await ask({ title: `Move ${basename(b.dataset.moveSession)} to`, input: { select: options.map((w) => ({ value: w.id, label: w.name })) }, ok: 'Move' })
163
+ if (!choice) return
164
+ try {
165
+ const moved = await api('POST', wsUrl(choice, '/sessions/move'), { dir: b.dataset.moveSession })
166
+ // The folder's workspace changed on this computer: take the fresh recent list and summaries.
167
+ const st = await api('GET', '/api/state')
168
+ state.recent = st.recent
169
+ for (const sum of st.sessions) { const s = state.sessions.get(sum.id); if (s) s.workspace = sum.workspace }
170
+ await loadWorkspaces(); rerender()
171
+ const n = (moved.added || []).length
172
+ toast(moved.peopleNeedAdmin ? "Moved. Only the workspace's admins can add its people." : n ? `Moved, with ${n} ${n === 1 ? 'person or agent' : 'people and agents'} added to the workspace` : 'Moved')
173
+ } catch (err) { toast(err.message) }
174
+ }
175
+ })
176
+ }
177
+
178
+ // ------------------------------------------------------------------ page --
179
+ /** A session in the workspace, as a row: name and folder, who is there or when, and Open or Rejoin. */
180
+ function sessionRowHtml (s, { live, dir, id, peers, lastUsed, mine }) {
181
+ const name = s?.name || basename(dir || '') || s?.room || ''
182
+ const who = live ? (peers ? `${peers} other${peers === 1 ? '' : 's'} here` : 'Just you') : (dir ? ago(lastUsed) : (mine ? 'Yours, on another computer' : 'Someone else\'s'))
183
+ return `
184
+ <div class="sr${live ? ' live' : ''}">
185
+ <span class="folder-ico${live ? ' live' : ''}">${I.folder}</span>
186
+ <div class="sr-t"><b title="${esc(name)}">${esc(name)}</b><span class="mono">${esc(dir ? tildify(dir) : (s?.room || ''))}</span></div>
187
+ <span class="sr-who">${live ? '<span class="dot"></span>' : ''}${esc(who)}</span>
188
+ ${live ? `<button class="btn sm primary" data-go="${esc(id)}">Open</button>` : dir ? `<button class="btn sm" data-rejoin="${esc(dir)}">Rejoin</button>` : '<span></span>'}
189
+ </div>`
190
+ }
191
+
192
+ const MEMBERS_PAGE = 9
193
+
194
+ /** The people and agents of the open workspace, people first, as the cards show them. */
195
+ function workspaceMembers (d) {
196
+ const members = [...(d.owner.account ? [{ account: d.owner.account, name: d.owner.name, kind: 'person', access: 'edit', owner: true }] : []), ...d.members]
197
+ const agents = d.agents || []
198
+ const listed = new Set(agents.map((a) => a.account))
199
+ return [...members.filter((m) => !listed.has(m.account)), ...agents]
200
+ }
201
+
202
+ /** One page of member cards, with the search and pager state kept on the section. */
203
+ function drawMembers (sec, d, { admin, orgName }) {
204
+ const q = (sec.querySelector('[data-people-search]')?.value || '').trim().toLowerCase()
205
+ const all = workspaceMembers(d)
206
+ const hits = q ? all.filter((m) => [m.name, m.account, m.provider, m.kind || 'agent'].some((x) => String(x || '').toLowerCase().includes(q))) : all
207
+ const last = Math.max(0, Math.ceil(hits.length / MEMBERS_PAGE) - 1)
208
+ const page = Math.min(Number(sec.dataset.page || 0), last)
209
+ sec.dataset.page = String(page)
210
+ const from = page * MEMBERS_PAGE
211
+ const shown = hits.slice(from, from + MEMBERS_PAGE)
212
+ sec.querySelector('[data-people-list]').innerHTML = shown.length
213
+ ? `<div class="ag-grid">${shown.map((m) => memberMiniHtml(m, { admin, orgName })).join('')}</div>`
214
+ : `<p class="hint">${all.length ? 'Nobody matches that.' : 'Just you so far.'}</p>`
215
+ const pages = sec.querySelector('[data-people-pages]')
216
+ pages.hidden = hits.length <= MEMBERS_PAGE
217
+ pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
218
+ <button type="button" class="btn sm icon" data-page="-1" aria-label="Previous page" ${page === 0 ? 'disabled' : ''}>${I.arrowLeft}</button>
219
+ <button type="button" class="btn sm icon" data-page="1" aria-label="Next page" ${page >= last ? 'disabled' : ''}>${I.arrowRight}</button>`
220
+ }
221
+
222
+ export function workspacePageHtml () {
223
+ const d = state.workspace
224
+ if (!d || d.workspace.id !== String(state.view).slice(3)) {
225
+ const id = String(state.view).slice(3)
226
+ // A refresh while it is still loading draws it again without fading it in again.
227
+ const steady = state.wsLoading === id
228
+ state.wsLoading = id
229
+ return loadingHtml({ label: 'Loading workspace', name: (state.workspaces || []).find((x) => x.id === id)?.name || '', steady })
230
+ }
231
+ const w = d.workspace
232
+ const admin = d.access.admin
233
+ const running = d.running.map((id) => state.sessions.get(id)).filter(Boolean)
234
+ const runningRooms = new Set(running.map((s) => s.status.room))
235
+ const recent = d.recent
236
+ const recentRooms = new Set(recent.map((r) => r.room))
237
+ const others = d.sessions.filter((s) => !runningRooms.has(s.room) && !recentRooms.has(s.room))
238
+ const me = `person:${state.account?.id}`
239
+ const members = [...(d.owner.account ? [{ account: d.owner.account, name: d.owner.name, kind: 'person', access: 'edit', owner: true }] : []), ...d.members]
240
+ // Agents with why they are here (added, placed or global) come from `agents`; a member agent
241
+ // missing there (revoked, or gone from the org) keeps its plain card so it can be removed.
242
+ const agents = d.agents || []
243
+ const listed = new Set(agents.map((a) => a.account))
244
+ const people = members.filter((m) => !listed.has(m.account))
245
+ const orgName = w.orgId ? d.owner.name : ''
246
+ const sessionCount = running.length + recent.length + others.length
247
+ const fileCount = (d.files || []).filter((f) => f.kind !== 'folder').length
248
+ const stat = (n, one, many) => `<span><b>${n}</b> ${n === 1 ? one : many}</span>`
249
+ // Fades in once, the first time it is drawn after loading (not on every refresh).
250
+ const enter = state.wsEnter === w.id
251
+ if (enter) state.wsEnter = null
252
+ state.wsLoading = null
253
+ return `
254
+ <div class="ws-page${enter ? ' enter' : ''}">
255
+ <a class="ws-back" href="#" data-ws-back>${I.caret} All workspaces</a>
256
+ <header class="ws-head">
257
+ <span class="ws-mark big" style="background:${coverOf(w)}">${esc(initial(w.name))}</span>
258
+ <div class="ws-head-t">
259
+ <h1>${esc(w.name)} <span class="pill ws-space${w.orgId ? ' coral' : ''}">${esc(w.orgId ? d.owner.name : 'Personal')}</span>${w.archivedAt ? ' <span class="pill">Archived</span>' : ''}</h1>
260
+ ${w.description ? `<p>${esc(w.description)}</p>` : ''}
261
+ <div class="ws-statline">${running.length ? `<span class="live"><span class="dot"></span><b>${running.length}</b> open now</span>` : ''}${stat(sessionCount, 'session', 'sessions')}${stat(fileCount, 'file', 'files')}${stat(people.length + agents.length, 'person or agent', 'people and agents')}</div>
262
+ </div>
263
+ <div class="acts"><button class="btn sm" data-team title="People and agents in this workspace">${I.users}<span>Team</span><span class="count">${people.length + agents.length}</span></button><button class="btn sm" data-ws-github title="GitHub for this workspace's sessions">${I.branch}<span>GitHub</span></button>${admin ? `<button class="btn sm" data-invite-ws>${I.link}<span>Invite</span></button><button class="btn sm ghost icon" data-ws-settings title="Workspace settings" aria-label="Workspace settings">${I.gear}</button>` : ''}</div>
264
+ </header>
265
+
266
+ <div class="ws-dash">
267
+ <div class="ws-main">
268
+ <section class="sec">
269
+ <div class="sec-head"><h2>Sessions</h2><span class="count">${sessionCount}</span><span class="spacer"></span>
270
+ ${d.access.access === 'edit' ? `<button type="button" class="btn sm" data-new-session-in="${esc(w.id)}">${I.plus}<span>New session</span></button>` : ''}</div>
271
+ ${sessionCount
272
+ ? `<div class="sr-list">
273
+ ${running.map((s) => sessionRowHtml(d.sessions.find((x) => x.room === s.status.room), { live: true, dir: s.dir, id: s.id, peers: s.status.peers.length })).join('')}
274
+ ${recent.map((r) => sessionRowHtml(d.sessions.find((x) => x.room === r.room), { live: false, dir: r.dir, lastUsed: r.lastUsed })).join('')}
275
+ ${others.map((s) => sessionRowHtml(s, { live: false, mine: s.ownerAccount === me })).join('')}
276
+ </div>`
277
+ : `<p class="hint ws-empty">No sessions yet.${d.access.access === 'edit' ? ' Start one with New session: it opens a folder of yours here.' : ''}</p>`}
278
+ </section>
279
+ ${filesSectionHtml(d)}
280
+ ${state.secretsOn ? '<section class="sec ws-secrets" data-ws-secrets></section>' : ''}
281
+ </div>
282
+ </div>
283
+ </div>`
284
+ }
285
+
286
+ /**
287
+ * The team, in a dialog from the Team button: people and agents as small cards (agents wear a bot
288
+ * badge), nine a page with a search, Add for admins, and each card's ⋮ menu. It reads the open
289
+ * workspace afresh after every change, so it stays right while open.
290
+ */
291
+ function openTeamDialog ({ id, inviteDialog, reload, rerender }) {
292
+ $('#team-back')?.remove()
293
+ const d = state.workspace
294
+ if (!d || d.workspace.id !== id) return
295
+ const admin = d.access.admin
296
+ const back = document.createElement('div')
297
+ back.id = 'team-back'
298
+ back.className = 'modal-back settings-back'
299
+ const n = workspaceMembers(d).length
300
+ back.innerHTML = `
301
+ <div class="settings-modal team-modal" role="dialog" aria-modal="true" aria-labelledby="team-title">
302
+ <header class="settings-head team-head">
303
+ <h2 id="team-title">${I.users}<span>People &amp; agents</span><span class="pill count">${n}</span></h2>
304
+ <label class="search-box"><span class="sr-only">Search people and agents</span><input class="input" type="search" data-people-search placeholder="Search people and agents…" autocomplete="off"></label>
305
+ ${admin ? `<button type="button" class="btn primary" data-add-member title="Invite a person or add an agent">${I.plus}<span>Add</span></button>` : ''}
306
+ <button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
307
+ </header>
308
+ <div class="settings-body team-body">
309
+ <div data-people-list></div>
310
+ <div class="ag-pages" data-people-pages hidden></div>
311
+ </div>
312
+ </div>`
313
+ document.body.appendChild(back)
314
+ const sec = back.querySelector('.team-modal')
315
+ const orgName = () => (state.workspace.workspace.orgId ? state.workspace.owner.name : '')
316
+ const draw = () => {
317
+ if (!state.workspace || state.workspace.workspace.id !== id) { back.remove(); return }
318
+ drawMembers(sec, state.workspace, { admin: state.workspace.access.admin, orgName: orgName() })
319
+ sec.querySelector('.pill.count').textContent = String(workspaceMembers(state.workspace).length)
320
+ }
321
+ draw()
322
+ sec.querySelector('[data-people-search]').addEventListener('input', () => { sec.dataset.page = '0'; draw() })
323
+ sec.querySelector('[data-people-pages]').addEventListener('click', (e) => {
324
+ const b = e.target.closest('[data-page]')
325
+ if (!b || b.disabled) return
326
+ sec.dataset.page = String(Number(sec.dataset.page || 0) + Number(b.dataset.page))
327
+ draw()
328
+ })
329
+ sec.querySelector('[data-add-member]')?.addEventListener('click', () => inviteDialog(id))
330
+ const agentUrl = (agentId, rest = '') => wsUrl(id, `/agents/${encodeURIComponent(agentId)}${rest}`)
331
+ const act = async (m, what) => {
332
+ try {
333
+ if (what === 'access:edit' || what === 'access:view') { await api('POST', wsUrl(id, '/members'), { account: m.account, access: what.slice(7), ...(m.agentId ? { sessions: m.sessions } : {}) }); toast('Saved') }
334
+ if (what === 'joins:all' || what === 'joins:invited') {
335
+ if (m.via === 'member') await api('POST', wsUrl(id, '/members'), { account: m.account, access: m.access, sessions: what.slice(6) })
336
+ else await api('POST', agentUrl(m.agentId), { sessions: what.slice(6) })
337
+ toast('Saved')
338
+ }
339
+ if (what === 'remove') {
340
+ if (!await ask({ title: 'Remove from this workspace?', message: 'They lose access to its sessions unless a session owner lets them in directly.', ok: 'Remove', danger: true })) return
341
+ await api('POST', wsUrl(id, '/members/remove'), { account: m.account })
342
+ }
343
+ if (what === 'exclude') {
344
+ if (!await ask({ title: 'Not in this workspace?', message: `${m.name} leaves this workspace and its sessions. It keeps its other workspaces, and you can let it back in here.`, ok: 'Not in this workspace', danger: true })) return
345
+ await api('POST', agentUrl(m.agentId), { excluded: true })
346
+ }
347
+ if (what === 'include') { await api('POST', agentUrl(m.agentId, '/remove')); toast('Back in') }
348
+ await reload()
349
+ } catch (err) { toast(err.message); rerender() }
350
+ draw()
351
+ }
352
+ sec.addEventListener('click', (e) => {
353
+ const b = e.target.closest('[data-member-menu]')
354
+ if (!b) return
355
+ const m = workspaceMembers(state.workspace).find((x) => x.account === b.dataset.memberMenu)
356
+ if (!m) return
357
+ openAgentMenu(b, { label: `Actions for ${m.name || m.account}`, items: memberMenuItems(m), on: (what) => act(m, what) })
358
+ })
359
+ const close = () => back.remove()
360
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
361
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
362
+ back.querySelector('[data-settings-close]').onclick = close
363
+ back.querySelector('[data-people-search]').focus()
364
+ }
365
+
366
+ export function bindWorkspacePage (root, { go, rerender, newSessionDialog, inviteDialog, dialog }) {
367
+ const d = state.workspace
368
+ if (!d || d.workspace.id !== String(state.view).slice(3)) return
369
+ const id = d.workspace.id
370
+ const reload = async () => { await openWorkspace(id); await loadWorkspaces(); rerender() }
371
+ root.querySelector('[data-ws-back]').onclick = (e) => { e.preventDefault(); go('home') }
372
+ root.querySelectorAll('[data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
373
+ root.querySelector('[data-new-session-in]')?.addEventListener('click', () => newSessionDialog(id))
374
+ root.querySelector('[data-invite-ws]')?.addEventListener('click', () => inviteDialog(id))
375
+ // The team (people and agents) is a dialog from the Team button, so the page is sessions and files.
376
+ root.querySelector('[data-team]')?.addEventListener('click', () => openTeamDialog({ id, inviteDialog, reload, rerender }))
377
+ root.querySelector('[data-ws-settings]')?.addEventListener('click', () => settingsDialog(reload, go, dialog))
378
+ root.querySelector('[data-ws-github]')?.addEventListener('click', () => githubDialog(id, dialog))
379
+ bindFilesSection(root, { id, reload, go })
380
+ // Secrets: the workspace's, with its team's above (secrets.js); .env files come from its sessions on this computer.
381
+ const sec = root.querySelector('[data-ws-secrets]')
382
+ if (sec) mountSecrets(sec, { type: 'workspace', id, importFrom: { workspaceId: id } })
383
+ }
384
+
385
+ /** "1.2 GB of 5.0 GB used · 34 files", from the workspace's usage; nothing without one. */
386
+ function usageLine (u) {
387
+ if (!u) return ''
388
+ const used = Number.isFinite(u.quotaBytes) ? `${bytes(u.usedBytes || 0)} of ${bytes(u.quotaBytes)}` : bytes(u.usedBytes || 0)
389
+ const n = u.fileCount || 0
390
+ return `<p class="hint" data-ws-usage>${esc(`${used} used · ${n} ${n === 1 ? 'file' : 'files'}`)}</p>`
391
+ }
392
+
393
+ /**
394
+ * GitHub for a workspace (plan §2): who connected Quilt's GitHub App for it (its sessions use that before
395
+ * their people's own, then the org's), which repositories its sessions may use, and, in an org, whether
396
+ * the org requires GitHub. Admins change it; everyone else sees it.
397
+ */
398
+ let githubOpening = false
399
+ /**
400
+ * Git for everyone in a workspace's GitHub settings (mockup option A): the workspace's rule, and its org's
401
+ * for org admins, each as the one segmented control. A pick saves at once, as in the session's settings.
402
+ */
403
+ function gitRuleCardsHtml (g, wsName) {
404
+ const org = g.org
405
+ const orgRule = org && org.agentCommits ? { by: 'org', name: org.name, mode: org.agentCommits } : null
406
+ const ws = gitControl({ value: g.agentCommits || '', rule: orgRule, canEdit: !!g.admin })
407
+ const wsPill = ws.rulePill ? `<span class="pill rule">${esc(ws.rulePill)}</span>` : org ? `<span class="pill">Org ${esc(org.name)} sets nothing</span>` : g.agentCommits ? '' : '<span class="pill">Not set</span>'
408
+ const clear = (scope) => `<button type="button" class="text-btn" data-git-clear="${scope}">Clear it</button>`
409
+ const wsHint = g.agentCommits
410
+ ? `A ceiling for its sessions: each owner may choose stricter. ${g.admin ? clear('workspace') : 'Clear it'} to let each session decide.`
411
+ : 'Nothing selected: each session\'s owner decides. A choice here is a ceiling for its sessions: each owner may choose stricter.'
412
+ const cards = [`<div class="pm-card git-card" data-git-scope="workspace">
413
+ <div class="git-head"><h4>Git for every session in ${esc(wsName)}</h4>${wsPill}</div>
414
+ ${gitSegHtml(ws, { attr: 'data-git-pick', label: `Git for every session in ${wsName}` })}
415
+ ${ws.lockedHint ? `<span class="hint">${esc(ws.lockedHint)}</span>` : ''}
416
+ <span class="hint">${wsHint}${g.admin ? '' : ' Its admins change this.'}</span>
417
+ </div>`]
418
+ if (org) {
419
+ const o = gitControl({ value: org.agentCommits || '', canEdit: !!org.admin })
420
+ const oHint = org.agentCommits
421
+ ? `A ceiling for every workspace, team and session in ${esc(org.name)}: each may choose stricter. ${org.admin ? clear('org') : 'Clear it'} to let them decide.`
422
+ : 'Nothing selected: its workspaces, teams and sessions decide.'
423
+ cards.push(`<div class="pm-card git-card" data-git-scope="org">
424
+ <div class="git-head"><h4>Git for every session in ${esc(org.name)}</h4><span class="pill">${org.agentCommits ? 'Org rule' : 'Not set'}</span></div>
425
+ ${gitSegHtml(o, { attr: 'data-git-pick', label: `Git for every session in ${org.name}` })}
426
+ <span class="hint">${oHint}${org.admin ? '' : ' Org admins change this.'}</span>
427
+ </div>`)
428
+ }
429
+ return cards.join('')
430
+ }
431
+
432
+ async function githubDialog (id, dialog) {
433
+ if (githubOpening || document.querySelector('.modal-back [data-gh-connect]')) return // one at a time, however often the button is clicked
434
+ githubOpening = true
435
+ let g
436
+ try { g = await api('GET', wsUrl(id, '/github')) } catch (err) { toast(err.message); return } finally { githubOpening = false }
437
+ const conn = (list, mine) => list.length
438
+ ? `<ul class="ws-gh-list">${list.map((c) => `<li><b>${c.login ? `@${esc(c.login)}` : 'GitHub'}</b> <span class="hint">connected by ${esc(c.name || 'someone')}${c.at ? `, ${esc(ago(c.at))}` : ''}</span>${mine && (g.admin || c.connectedBy === g.me) ? ` <button type="button" class="btn sm ghost" data-gh-remove="${esc(c.connectedBy)}">Remove</button>` : ''}</li>`).join('')}</ul>`
439
+ : '<p class="hint">Nobody has connected GitHub here yet.</p>'
440
+ const wsName = (state.workspace && state.workspace.workspace.id === id && state.workspace.workspace.name) || 'this workspace'
441
+ const { form, close } = dialog(`
442
+ <h3>GitHub for this workspace</h3>
443
+ <p class="hint">Sessions here use this workspace's GitHub connection first, then ${g.org ? `${esc(g.org.name)}'s` : 'their org\'s'}, then their own people's.</p>
444
+ <div class="field"><span class="label">This workspace</span>${conn(g.connections, true)}
445
+ <button type="button" class="btn sm" data-gh-connect>${I.branch}<span>Connect GitHub for this workspace</span></button></div>
446
+ ${g.org ? `<div class="field"><span class="label">${esc(g.org.name)}</span>${conn(g.org.connections, false)}${g.org.admin ? `<button type="button" class="btn sm" data-gh-connect-org>${I.branch}<span>Connect GitHub for ${esc(g.org.name)}</span></button>` : ''}</div>` : ''}
447
+ <div class="field"><span class="label">Repositories</span>
448
+ <label class="radio"><input type="radio" name="access" value="open" ${g.access !== 'repos' ? 'checked' : ''} ${g.admin ? '' : 'disabled'}> Any repository its people can reach</label>
449
+ <label class="radio"><input type="radio" name="access" value="repos" ${g.access === 'repos' ? 'checked' : ''} ${g.admin ? '' : 'disabled'}> Only these, one per line (owner/name)</label>
450
+ <textarea class="input" name="repos" rows="3" placeholder="acme/widgets" ${g.admin ? '' : 'disabled'}>${esc((g.repos || []).join('\n'))}</textarea></div>
451
+ <div class="field"><span class="label">Git for everyone</span><div class="git-cards" data-git-cards>${gitRuleCardsHtml(g, wsName)}</div></div>
452
+ ${g.org ? `<label class="toggle"><input type="checkbox" name="requireGithub" ${g.org.requireGithub ? 'checked' : ''} ${g.org.admin ? '' : 'disabled'}><span class="track"><span class="knob"></span></span><span class="tg-text"><b>${esc(g.org.name)} requires GitHub</b><span class="hint">An agent then gets the GitHub permissions of the person it works for, no more. ${g.org.admin ? 'An org setting: it applies to every workspace of the org.' : 'Org admins change this.'}</span></span></label>` : ''}
453
+ <p class="error" data-error></p>
454
+ <div class="actions"><span class="spacer"></span><button type="button" class="btn ghost" data-cancel>${g.admin || (g.org && g.org.admin) ? 'Cancel' : 'Close'}</button>${g.admin || (g.org && g.org.admin) ? '<button class="btn primary" type="submit">Save</button>' : ''}</div>`)
455
+ form.querySelector('[data-gh-connect]').onclick = async () => {
456
+ try {
457
+ const { url } = await api('POST', '/api/github/connect', { scope: 'workspace', id })
458
+ window.open(url, '_blank', 'noopener')
459
+ toast('Finish on GitHub, then open this again')
460
+ close()
461
+ } catch (err) { form.querySelector('[data-error]').textContent = err.message }
462
+ }
463
+ form.querySelector('[data-gh-connect-org]')?.addEventListener('click', async () => {
464
+ try {
465
+ const { url } = await api('POST', '/api/github/connect', { scope: 'org', id: g.org.slug })
466
+ window.open(url, '_blank', 'noopener')
467
+ toast('Finish on GitHub, then open this again')
468
+ close()
469
+ } catch (err) { form.querySelector('[data-error]').textContent = err.message }
470
+ })
471
+ form.querySelector('[data-git-cards]').addEventListener('click', async (e) => {
472
+ const pick = e.target.closest('[data-git-pick]')
473
+ const clear = e.target.closest('[data-git-clear]')
474
+ if (!pick && !clear) return
475
+ if (pick && (pick.disabled || pick.classList.contains('on'))) return
476
+ const scope = clear ? clear.dataset.gitClear : pick.closest('[data-git-scope]').dataset.gitScope
477
+ const agentCommits = clear ? '' : pick.dataset.gitPick
478
+ try {
479
+ if (scope === 'org') await api('POST', `/api/orgs/${encodeURIComponent(g.org.slug)}/github`, { agentCommits })
480
+ else await api('POST', wsUrl(id, '/github'), { agentCommits })
481
+ g = await api('GET', wsUrl(id, '/github'))
482
+ form.querySelector('[data-git-cards]').innerHTML = gitRuleCardsHtml(g, wsName)
483
+ toast(agentCommits ? AGENT_COMMIT_WORDS[agentCommits] : scope === 'org' ? 'Cleared: its workspaces and sessions decide.' : 'Cleared: each session decides.')
484
+ } catch (err) { form.querySelector('[data-error]').textContent = err.message }
485
+ })
486
+ form.querySelectorAll('[data-gh-remove]').forEach((b) => {
487
+ b.onclick = async () => {
488
+ try { await api('POST', wsUrl(id, '/github/disconnect'), { connectedBy: b.dataset.ghRemove }); close(); githubDialog(id, dialog) } catch (err) { form.querySelector('[data-error]').textContent = err.message }
489
+ }
490
+ })
491
+ form.onsubmit = async (e) => {
492
+ e.preventDefault()
493
+ const f = new FormData(form)
494
+ const save = form.querySelector('button[type=submit]')
495
+ save.disabled = true
496
+ try {
497
+ if (g.admin) {
498
+ const repos = String(f.get('repos') || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean)
499
+ await api('POST', wsUrl(id, '/github'), { access: f.get('access') === 'repos' ? 'repos' : 'open', repos })
500
+ }
501
+ const require = f.get('requireGithub') === 'on'
502
+ if (g.org && g.org.admin && require !== !!g.org.requireGithub) await api('POST', `/api/orgs/${encodeURIComponent(g.org.slug)}/github`, { requireGithub: require })
503
+ close(); toast('Saved')
504
+ } catch (err) {
505
+ form.querySelector('[data-error]').textContent = err.message
506
+ save.disabled = false
507
+ }
508
+ }
509
+ }
510
+
511
+ /** `dialog(html)` is home.js's: a modal form that closes on Cancel, Escape or a click outside. */
512
+ function settingsDialog (reload, go, dialog) {
513
+ const w = state.workspace.workspace
514
+ const { form, close } = dialog(`
515
+ <h3>Workspace settings</h3>
516
+ <div class="field"><label for="wss-name">Name</label><input class="input" id="wss-name" name="name" maxlength="80" required value="${esc(w.name)}"></div>
517
+ <div class="field"><label for="wss-desc">About</label><input class="input" id="wss-desc" name="description" maxlength="500" value="${esc(w.description || '')}"></div>
518
+ <div class="field"><span class="label">Colour</span><div class="swatches">${Object.entries(COLORS).map(([k, c]) => `<label class="swatch"><input type="radio" name="color" value="${k}" ${(w.color || 'lilac') === k ? 'checked' : ''}><span style="background:${c}"></span></label>`).join('')}</div></div>
519
+ <label class="toggle"><input type="checkbox" name="archived" ${w.archivedAt ? 'checked' : ''}><span class="track"><span class="knob"></span></span><span class="tg-text"><b>Archived</b><span class="hint">Kept, but out of the way.</span></span></label>
520
+ ${usageLine(state.workspace.usage)}
521
+ <p class="error" data-error></p>
522
+ <div class="actions">${state.workspace.canDelete ? '<button type="button" class="btn ghost danger" data-delete>Delete workspace</button>' : ''}<span class="spacer"></span><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Save</button></div>`)
523
+ // Focus inside the dialog, so Escape (bound on the dialog) closes it straight away.
524
+ form.querySelector('#wss-name').focus()
525
+ form.onsubmit = async (e) => {
526
+ e.preventDefault()
527
+ const f = new FormData(form)
528
+ const save = form.querySelector('button[type=submit]')
529
+ save.disabled = true
530
+ try {
531
+ const patch = { name: f.get('name'), description: f.get('description'), color: f.get('color') }
532
+ // Only a change of the toggle: sending it again would reset when it was archived.
533
+ const archived = f.get('archived') === 'on'
534
+ if (archived !== !!w.archivedAt) patch.archived = archived
535
+ await api('POST', wsUrl(w.id, '/update'), patch)
536
+ close(); await reload()
537
+ } catch (err) {
538
+ form.querySelector('[data-error]').textContent = err.message
539
+ save.disabled = false
540
+ }
541
+ }
542
+ form.querySelector('[data-delete]')?.addEventListener('click', async () => {
543
+ const typed = await ask({ title: `Delete ${w.name}?`, message: 'Its sessions stay, outside any workspace. Type the workspace name to confirm.', ok: 'Delete', danger: true, input: { placeholder: w.name } })
544
+ if (typed !== w.name) { if (typed) toast('That is not the name.'); return }
545
+ try { await api('POST', wsUrl(w.id, '/delete')); close(); await loadWorkspaces(); go('home') } catch (err) { toast(err.message) }
546
+ })
547
+ }