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,465 @@
1
+ // The session task board: four columns. Task ids and titles come from the
2
+ // shared room, so every string is escaped. Column ids match src/tasks.js.
3
+ import { I, esc, ago, colorFor } from './common.js'
4
+ import { markdown } from './feed.js'
5
+ import { cronToText } from './schedule.js'
6
+
7
+ // Arms stay inside the view box so a round stroke isn't clipped into a plus.
8
+ const CLOSE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round"><path d="M7 7l10 10M17 7 7 17"/></svg>'
9
+ // Fold a column to a strip (chevrons pointing in), and open one (pointing out).
10
+ const FOLD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7l5 5-5 5M20 7l-5 5 5 5"/></svg>'
11
+ const UNFOLD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 7l-5 5 5 5M15 7l5 5-5 5"/></svg>'
12
+ const SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/></svg>'
13
+ const FILTER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16l-6 7.5V19l-4 1.5v-8L4 5z"/></svg>'
14
+ const RESTORE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>'
15
+ const ARCHIVE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"/></svg>'
16
+
17
+ const COLUMNS = [
18
+ { id: 'todo', name: 'To do', moves: [{ column: 'doing', name: 'In progress', label: 'Start', icon: 'arrowRight' }] },
19
+ { id: 'doing', name: 'In progress', moves: [{ column: 'todo', name: 'To do', label: 'To do', icon: 'arrowLeft' }, { column: 'qa', name: 'QA', label: 'QA', icon: 'arrowRight' }] },
20
+ { id: 'qa', name: 'QA', moves: [{ column: 'doing', name: 'In progress', label: 'Reopen', icon: 'arrowLeft' }, { column: 'done', name: 'Done', label: 'Done', icon: 'check' }] },
21
+ { id: 'done', name: 'Done', moves: [{ column: 'qa', name: 'QA', label: 'Reopen', icon: 'arrowLeft' }] }
22
+ ]
23
+
24
+ /**
25
+ * `archived` shows the archived tasks in place of the columns. `show` keeps only some tasks:
26
+ * '' everyone's, 'none' unassigned ones, `p:name` a person's, `a:name` that person's AI's.
27
+ */
28
+ export function renderBoard (tasks, me, people = [], assignTo = '', { archived = false, show = '', closedGroups = [] } = {}) {
29
+ const every = (Array.isArray(tasks) ? tasks : []).filter(Boolean)
30
+ const all = every.filter((t) => showsTask(t, show))
31
+ const list = all.filter((t) => !t.archived)
32
+ const shelved = all.filter((t) => t.archived)
33
+ const showArchived = archived && shelved.length > 0
34
+ const cols = COLUMNS.map((col) => {
35
+ const cards = list.filter((t) => t && t.column === col.id)
36
+ const body = cards.length
37
+ ? cards.map((t) => card(t, col, me, people)).join('')
38
+ : `<p class="board-empty">${col.id === 'todo' ? 'Nothing here yet.' : 'Nothing here.'}</p>`
39
+ const n = cards.length
40
+ // When the board is too narrow for every column, session.js folds some to a strip:
41
+ // the strip (its name and count) opens it again, and takes a dropped card.
42
+ return `<section class="board-col" data-column="${col.id}" aria-label="${esc(col.name)}" tabindex="-1">
43
+ <button type="button" class="board-strip" data-unfold="${col.id}" title="Open ${esc(col.name)}" aria-label="Open ${esc(col.name)}, ${n} task${n === 1 ? '' : 's'}">
44
+ <span class="board-strip-n" data-n="${n}">${n}</span><span class="board-strip-name">${esc(col.name)}</span>${UNFOLD}
45
+ </button>
46
+ <h3>${esc(col.name)} <span class="board-n">${n}</span><button type="button" class="board-fold" data-fold="${col.id}" title="Fold ${esc(col.name)} to a strip" aria-label="Fold ${esc(col.name)}">${FOLD}</button></h3>
47
+ <div class="board-list">${body}</div>
48
+ </section>`
49
+ }).join('')
50
+ const head = showArchived
51
+ ? `<h2 class="board-title">Archived <span class="board-n">${shelved.length}</span></h2>
52
+ <label class="board-archive-search">
53
+ ${SEARCH}
54
+ <input id="archive-filter" type="search" maxlength="200" placeholder="Filter archived tasks" aria-label="Filter archived tasks" autocomplete="off">
55
+ </label>`
56
+ : `<h2 class="board-title" title="Shared with everyone in this session. Assign a task to a person or their AI.">Tasks</h2>
57
+ <form id="task-add-form" class="board-add">
58
+ <label class="board-add-task">
59
+ ${I.plus}
60
+ <input id="task-add" maxlength="200" placeholder="What needs doing?" aria-label="New task" autocomplete="off">
61
+ </label>
62
+ <div class="board-add-assign">
63
+ <label for="task-add-assign">Assign to</label>
64
+ <select id="task-add-assign" class="task-add-assign" aria-label="Assign new task to">${assignOptions(taskForAssign(assignTo), me, people)}</select>
65
+ </div>
66
+ <button class="btn sm primary" type="submit">Add task</button>
67
+ </form>`
68
+ return `<div class="board${showArchived ? ' archived' : ''}">
69
+ <div class="board-head">
70
+ ${head}
71
+ ${filterHtml(every, me, people, show, { search: !showArchived })}
72
+ ${archiveToggle(shelved.length, showArchived)}
73
+ </div>
74
+ ${showArchived ? archivedHtml(shelved, me, people, closedGroups) : `<div class="board-cols">${cols}</div>`}
75
+ </div>`
76
+ }
77
+
78
+ // Folding (session.js lays it out): open columns are BOARD_COL wide at least, a folded
79
+ // one is a BOARD_STRIP-wide strip, BOARD_GAP apart. Match .board-col and .board-cols in app.css.
80
+ export const BOARD_COL = 320
81
+ export const BOARD_STRIP = 44
82
+ export const BOARD_GAP = 10
83
+ // Before you open or fold one yourself, the work in flight stays open first.
84
+ const IN_FLIGHT_FIRST = ['doing', 'qa', 'todo', 'done']
85
+
86
+ /**
87
+ * Which columns are open in `room` px. `picked`: the columns you opened, most recent first;
88
+ * `folded`: ones you folded yourself, which stay folded at any width; `counts`: tasks per
89
+ * column, so empty ones give way. As many open as fit beside the strips (at least one).
90
+ * Too narrow even for that, the board stacks: one open, the others as bars.
91
+ */
92
+ export function foldPlan ({ room, picked = [], folded = [], counts = {} }) {
93
+ const ids = COLUMNS.map((c) => c.id)
94
+ const known = (id) => ids.includes(id)
95
+ const fallback = [...IN_FLIGHT_FIRST].sort((a, b) => Number(!counts[a]) - Number(!counts[b])) // stable: empty ones last
96
+ const order = [...new Set([...picked.filter(known), ...fallback])]
97
+ const fits = (n) => n * BOARD_COL + (ids.length - n) * BOARD_STRIP + (ids.length - 1) * BOARD_GAP <= room
98
+ const stack = !fits(1)
99
+ const n = stack ? 1 : [4, 3, 2, 1].find(fits)
100
+ const mine = folded.filter(known)
101
+ const wanted = order.filter((id) => !mine.includes(id))
102
+ return { layout: stack ? 'stack' : 'row', open: wanted.length ? wanted.slice(0, n) : order.slice(0, 1), order, folded: mine }
103
+ }
104
+
105
+ /** Whether the board's Show choice keeps `t`. See renderBoard. */
106
+ export function showsTask (t, show) {
107
+ if (!show) return true
108
+ if (show === 'none') return !t.assignee
109
+ return assignValue(t) === show
110
+ }
111
+
112
+ /** Everyone, Unassigned, then each person and their AI, with how many tasks each has on the board. */
113
+ /** Everyone, Unassigned, then each person and their AI, with how many tasks each has on the board: [{ value, label, n }]. */
114
+ export function showChoices (tasks, me, people, show) {
115
+ const open = tasks.filter((t) => !t.archived)
116
+ const n = (value) => open.filter((t) => showsTask(t, value)).length
117
+ const label = (value) => {
118
+ const name = value.slice(2)
119
+ const you = name === me
120
+ if (value.startsWith('p:')) return you ? 'You' : name
121
+ const tool = people.find((p) => p?.name === name)?.tool || open.find((t) => t.assignee === name && t.forAi)?.tool || 'AI'
122
+ return you ? `Your ${tool}` : `${name}'s ${tool}`
123
+ }
124
+ // People here now, then anyone who isn't but still has tasks, then the current choice.
125
+ const values = []
126
+ const add = (v) => { if (v && !values.includes(v)) values.push(v) }
127
+ for (const p of people) {
128
+ if (!p?.name) continue
129
+ add(`p:${p.name}`)
130
+ if (!p.agent) add(`a:${p.name}`)
131
+ }
132
+ for (const t of open) add(assignValue(t))
133
+ if (show && show !== 'none') add(show)
134
+ return [
135
+ { value: '', label: 'Everyone', n: open.length },
136
+ { value: 'none', label: 'Unassigned', n: n('none') },
137
+ ...values.map((v) => ({ value: v, label: label(v), n: n(v) }))
138
+ ]
139
+ }
140
+
141
+ /**
142
+ * The Filter button and its panel: the search (on the board; the archive has its own
143
+ * filter) and whose tasks to show. The button says what's on, so a filtered board never
144
+ * passes for a short one: session.js adds the search to what the button says.
145
+ */
146
+ function filterHtml (tasks, me, people, show, { search = true } = {}) {
147
+ const choices = showChoices(tasks, me, people, show)
148
+ const picked = choices.find((c) => c.value === show) || choices[0]
149
+ return `<div class="board-filter${show ? ' on' : ''}" id="board-filter" data-show-label="${show ? esc(picked.label) : ''}" data-total="${tasks.filter((t) => !t.archived).length}">
150
+ <button type="button" class="btn sm board-filter-btn" data-filter-toggle aria-haspopup="dialog" aria-expanded="false" aria-controls="board-filter-panel" title="Search and filter tickets (/)">
151
+ ${FILTER}<span class="board-filter-label">${show ? esc(picked.label) : 'Filter'}</span>
152
+ </button>
153
+ <button type="button" class="board-filter-x" data-filter-clear title="Clear filters" aria-label="Clear filters"${show ? '' : ' hidden'}>${CLOSE}</button>
154
+ <div class="popover board-filter-panel" id="board-filter-panel" role="dialog" aria-label="Search and filter tickets" hidden>
155
+ ${search ? `<label class="board-search">
156
+ ${SEARCH}
157
+ <input id="board-search" type="search" maxlength="200" placeholder="Search tickets" aria-label="Search tickets" autocomplete="off">
158
+ <span class="board-search-n" aria-live="polite"></span>
159
+ </label>
160
+ <p class="board-filter-hint">Title, id, who it's for, who made it, files and notes. Every word, any order.</p>` : ''}
161
+ <div class="board-filter-show" role="radiogroup" aria-label="Show tasks for">
162
+ <span class="board-filter-head">Show</span>
163
+ ${choices.map((c) => `<button type="button" role="radio" class="board-filter-opt" data-show-pick="${esc(c.value)}" aria-checked="${c.value === show}"><span class="bf-name">${esc(c.label)}</span><span class="bf-n">${c.n}</span></button>`).join('')}
164
+ </div>
165
+ <button type="button" class="btn sm ghost board-filter-clear" data-filter-clear${show ? '' : ' hidden'}>Clear filters</button>
166
+ </div>
167
+ </div>`
168
+ }
169
+
170
+ function archiveToggle (n, on) {
171
+ if (!n) return ''
172
+ const label = on ? 'Back to the board' : 'Archived'
173
+ return `<button type="button" class="btn sm board-archive-toggle${on ? ' on' : ''}" data-archived-toggle aria-pressed="${on ? 'true' : 'false'}" title="${on ? 'Show the board' : 'Show archived tasks'}">${ARCHIVE}<span>${label}</span>${on ? '' : ` <span class="board-n">${n}</span>`}</button>`
174
+ }
175
+
176
+ /**
177
+ * The archived tasks: off the board, kept until someone brings one back or removes it. Two
178
+ * groups, each newest archived first: Finished (they were in Done) and Set aside (archived
179
+ * from any other column). session.js filters the rows as you type and confirms a removal.
180
+ * `closedGroups`: the groups you folded ('finished', 'aside').
181
+ */
182
+ export function archivedHtml (tasks, me, people = [], closedGroups = []) {
183
+ const newest = (a, b) => (b.archivedAt || 0) - (a.archivedAt || 0) || (b.ts || 0) - (a.ts || 0)
184
+ const groups = [
185
+ { id: 'finished', name: 'Finished', hint: 'Done when they were archived', tasks: tasks.filter((t) => t.column === 'done').sort(newest) },
186
+ { id: 'aside', name: 'Set aside', hint: 'Archived before they were done', tasks: tasks.filter((t) => t.column !== 'done').sort(newest) }
187
+ ].filter((g) => g.tasks.length)
188
+ const colorOf = (name) => colorFor(name, people.find((p) => p.name === name)?.color)
189
+ return `<section class="board-archive" aria-label="Archived tasks">
190
+ <p class="board-archive-lead">Off the board and out of every AI's task list. Restore one to put it back where it was.</p>
191
+ ${groups.map((g) => `
192
+ <details class="archive-group" data-group="${g.id}"${closedGroups.includes(g.id) ? '' : ' open'}>
193
+ <summary><span class="archive-caret">${I.caret}</span><span class="archive-group-name">${g.name}</span><span class="board-n" data-group-count>${g.tasks.length}</span><span class="archive-group-hint">${g.hint}</span></summary>
194
+ <ul class="board-archive-list">${g.tasks.map((t) => archivedRow(t, me, colorOf)).join('')}</ul>
195
+ </details>`).join('')}
196
+ <p class="board-archive-none" hidden>No archived tasks match.</p>
197
+ </section>`
198
+ }
199
+
200
+ function archivedRow (t, me, colorOf) {
201
+ const col = COLUMNS.find((c) => c.id === t.column)?.name || 'its column'
202
+ const who = assigneeText(t, me)
203
+ const when = archivedWhen(t.archivedAt)
204
+ const meta = [
205
+ t.column === 'done'
206
+ ? (t.verified ? `<span class="arch-verified" title="${esc(t.verified)}">${I.check}Verified</span>` : '')
207
+ : `<span class="arch-col" data-column="${esc(t.column)}">${esc(col)}</span>`,
208
+ who ? `<span class="arch-who"><i style="--c:${esc(colorOf(t.assignee))}"></i>${esc(who)}</span>` : '',
209
+ t.by ? `<span>by ${esc(t.by === me ? 'you' : t.by)}</span>` : '',
210
+ when ? `<span title="Archived ${esc(new Date(t.archivedAt).toLocaleString())}">${esc(when)}</span>` : ''
211
+ ].filter(Boolean).join('')
212
+ return `<li class="task-archived" data-task="${esc(t.id)}" data-title="${esc(t.title.toLowerCase())}">
213
+ <div class="task-archived-text">
214
+ <span class="task-archived-title" title="${esc(t.title)}">${esc(t.title)}</span>
215
+ <span class="task-archived-meta">${meta}</span>
216
+ </div>
217
+ <div class="task-archived-actions">
218
+ ${notesButton(t)}
219
+ <button type="button" class="btn sm ghost arch-restore" data-task-unarchive title="Put it back in ${esc(col)}">${RESTORE}<span>Restore to ${esc(col)}</span></button>
220
+ <button type="button" class="task-icon task-x" data-task-remove-ask title="Remove for good" aria-label="Remove ${esc(t.title)} for good">${CLOSE}</button>
221
+ </div>
222
+ <div class="arch-confirm" role="group" aria-label="Remove ${esc(t.title)} for good?">
223
+ <span>Remove for good? It can't be brought back.</span>
224
+ <button type="button" class="btn sm danger" data-task-delete>Remove</button>
225
+ <button type="button" class="btn sm ghost" data-task-remove-cancel>Keep it</button>
226
+ </div>
227
+ </li>`
228
+ }
229
+
230
+ /** "archived just now", "archived 5m ago", "archived 3h ago", "archived 10/2/2026"; '' when not known. */
231
+ export function archivedWhen (at) {
232
+ if (!at) return ''
233
+ const t = ago(at)
234
+ if (t === 'now') return 'archived just now'
235
+ return /^\d+[mh]$/.test(t) ? `archived ${t} ago` : `archived ${t}`
236
+ }
237
+
238
+ function assigneeText (t, me) {
239
+ if (!t.assignee) return ''
240
+ const tool = t.tool || 'AI'
241
+ if (t.forAi) return t.assignee === me ? `your ${tool}` : `${t.assignee}'s ${tool}`
242
+ return t.assignee === me ? 'you' : t.assignee
243
+ }
244
+
245
+ function assignValue (t) {
246
+ if (!t.assignee) return ''
247
+ return `${t.forAi ? 'a' : 'p'}:${t.assignee}`
248
+ }
249
+
250
+ /** `p:name` is the person, `a:name` is their AI. Anything else is unassigned. */
251
+ function taskForAssign (value) {
252
+ const forAi = typeof value === 'string' && value.startsWith('a:')
253
+ const person = typeof value === 'string' && value.startsWith('p:')
254
+ if (!forAi && !person) return { assignee: '' }
255
+ return { assignee: value.slice(2), forAi, tool: '' }
256
+ }
257
+
258
+ function assignOptions (task, me, people) {
259
+ const opts = [{ value: '', label: 'Unassigned' }]
260
+ const seen = new Set()
261
+ for (const p of people) {
262
+ if (!p?.name || seen.has(p.name)) continue
263
+ seen.add(p.name)
264
+ const you = p.name === me
265
+ opts.push({ value: `p:${p.name}`, label: you ? 'You' : p.name })
266
+ if (!p.agent) {
267
+ const tool = p.tool || 'AI'
268
+ opts.push({ value: `a:${p.name}`, label: you ? `Your ${tool}` : `${p.name}'s ${tool}` })
269
+ }
270
+ }
271
+ const cur = assignValue(task)
272
+ if (cur && !opts.some((o) => o.value === cur)) {
273
+ const tool = task.tool || 'AI'
274
+ const label = task.forAi
275
+ ? (task.assignee === me ? `Your ${tool}` : `${task.assignee}'s ${tool}`)
276
+ : (task.assignee === me ? 'You' : task.assignee)
277
+ opts.push({ value: cur, label })
278
+ }
279
+ const selected = cur
280
+ return opts.map((o) => `<option value="${esc(o.value)}"${o.value === selected ? ' selected' : ''}>${esc(o.label)}</option>`).join('')
281
+ }
282
+
283
+ function filesHtml (files) {
284
+ if (!files?.length) return ''
285
+ return `<ul class="task-files">${files.map((f) => {
286
+ const base = f.split('/').pop()
287
+ return `<li class="task-file-chip" title="${esc(f)}"><code>${esc(base)}</code><button type="button" class="task-file-x" data-file-remove="${esc(f)}" aria-label="Remove ${esc(f)}">${CLOSE}</button></li>`
288
+ }).join('')}</ul>`
289
+ }
290
+
291
+ function noteFields (t) {
292
+ const qa = String(t?.qaNotes || '').trim()
293
+ const verified = String(t?.verified || '').trim()
294
+ const sections = []
295
+ if (qa) sections.push({ label: 'QA notes', text: qa })
296
+ if (verified) sections.push({ label: 'Done notes', text: verified })
297
+ return sections
298
+ }
299
+
300
+ /** Full ticket notes and comments for the notes modal. Markdown is rendered; raw HTML is escaped. */
301
+ export function taskNotesModalHtml (t) {
302
+ const sections = noteFields(t)
303
+ const notes = sections.map((s) => `<section class="task-notes-sec"><h4>${esc(s.label)}</h4><div class="task-notes-body md">${markdown(s.text)}</div></section>`).join('')
304
+ const comments = Array.isArray(t?.comments) ? t.comments : []
305
+ const thread = comments.length
306
+ ? `<ol class="task-comments">${comments.map((c) => `<li class="task-comment"><div class="task-comment-head"><b>${esc(c.by)}</b> <span>${esc(ago(c.ts))}</span></div><div class="md">${markdown(c.text)}</div></li>`).join('')}</ol>`
307
+ : '<p class="lead">No comments yet.</p>'
308
+ const col = COLUMNS.find((c) => c.id === t?.column)?.name || ''
309
+ return `<div class="card modal task-notes-modal" role="dialog" aria-modal="true" aria-labelledby="task-notes-title">
310
+ <h3 id="task-notes-title">${esc(t?.title || 'Task')}</h3>
311
+ ${col ? `<p class="lead">${esc(col)}</p>` : ''}
312
+ ${notes}
313
+ <section class="task-notes-sec"><h4>Comments</h4>${thread}</section>
314
+ <form class="task-comment-form">
315
+ <textarea class="task-comment-text" rows="2" maxlength="2000" placeholder="Add a comment: a work note, a handoff, why it went to whom" aria-label="Comment on ${esc(t?.title || 'this task')}"></textarea>
316
+ <div class="actions"><button type="button" class="btn" data-close-notes>Close</button><button type="submit" class="btn primary">Comment</button></div>
317
+ </form>
318
+ </div>`
319
+ }
320
+
321
+ function notesButton (t) {
322
+ const n = Array.isArray(t.comments) ? t.comments.length : 0
323
+ const has = noteFields(t).length || n
324
+ const label = `Notes and comments${n ? ` (${n})` : ''}`
325
+ return `<button type="button" class="task-icon task-notes-btn${has ? ' on' : ''}" data-task-notes title="${esc(label)}" aria-label="${esc(label)} on ${esc(t.title)}">${has && !n ? I.eye : I.chat}${n ? `<span class="task-count">${n}</span>` : ''}</button>`
326
+ }
327
+
328
+ // Only a repeating task shows the icon (it turns repeating off); the ⋯ menu turns it on.
329
+ function recurButton (t) {
330
+ const on = !!t.recurring
331
+ if (!on) return ''
332
+ const when = t.cron ? cronToText(t.cron) : ''
333
+ const title = when ? `Recurring: ${when}` : 'Recurring'
334
+ return `<button type="button" class="task-icon task-recur${on ? ' on' : ''}" data-task-recur aria-pressed="${on ? 'true' : 'false'}" title="${esc(title)}" aria-label="${esc(title)} for ${esc(t.title)}">${I.repeat}</button>`
335
+ }
336
+
337
+ function recurHtml (t) {
338
+ if (!t.recurring) return ''
339
+ const read = t.cron ? cronToText(t.cron) : 'Add a schedule'
340
+ return `<form class="task-cron-form">
341
+ <div class="task-cron-row">
342
+ <input class="task-cron" value="${esc(t.cron || '')}" placeholder="daily at 9, or 0 9 * * *" aria-label="Repeat schedule for ${esc(t.title)}" maxlength="80" autocomplete="off">
343
+ <button type="submit" class="task-cron-set">Set</button>
344
+ </div>
345
+ <span class="task-cron-read">${esc(read)}</span>
346
+ </form>`
347
+ }
348
+
349
+ function verifiedHtml (t) {
350
+ if (t.column !== 'done' || !t.verified) return ''
351
+ const v = String(t.verified).replace(/\s+/g, ' ').trim()
352
+ return `<p class="task-verified" title="${esc(v)}">${esc(v.length > 160 ? `${v.slice(0, 157)}…` : v)}</p>`
353
+ }
354
+
355
+ function qaNotesHtml (t) {
356
+ if (t.column !== 'qa' || !t.qaNotes) return ''
357
+ const v = String(t.qaNotes).replace(/\s+/g, ' ').trim()
358
+ return `<p class="task-qa-notes" title="${esc(v)}">${esc(v.length > 160 ? `${v.slice(0, 157)}…` : v)}</p>`
359
+ }
360
+
361
+ /**
362
+ * The ⋯ menu on a card: rename, repeat, archive, remove. `on` is { edit, recur, archive, remove }.
363
+ * It hangs off `anchor` and closes on a choice, a click elsewhere, Escape, or the ⋯ again.
364
+ */
365
+ export function openTaskMenu (anchor, t, on) {
366
+ const again = closeTaskMenu.anchor === anchor
367
+ closeTaskMenu()
368
+ if (again) return
369
+ const menu = document.createElement('div')
370
+ menu.className = 'popover more-menu task-menu'
371
+ menu.setAttribute('role', 'menu')
372
+ menu.setAttribute('aria-label', `Actions for ${t.title}`)
373
+ menu.innerHTML = `
374
+ <button type="button" class="pop-item" data-act="edit" role="menuitem">${I.pencil}Rename</button>
375
+ <button type="button" class="pop-item" data-act="recur" role="menuitem">${I.repeat}${t.recurring ? 'Stop repeating' : 'Make recurring'}</button>
376
+ <button type="button" class="pop-item" data-act="archive" role="menuitem">${ARCHIVE}Archive</button>
377
+ <button type="button" class="pop-item danger" data-act="remove" role="menuitem">${I.trash}Remove</button>`
378
+ document.body.appendChild(menu)
379
+ const r = anchor.getBoundingClientRect()
380
+ const w = menu.offsetWidth
381
+ menu.style.left = `${Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))}px`
382
+ menu.style.top = `${Math.max(8, Math.min(r.bottom + 4, window.innerHeight - menu.offsetHeight - 8))}px`
383
+ anchor.setAttribute('aria-expanded', 'true')
384
+ const items = [...menu.querySelectorAll('.pop-item')]
385
+ items[0].focus()
386
+ menu.addEventListener('click', (e) => {
387
+ const act = e.target.closest('[data-act]')?.dataset.act
388
+ if (!act) return
389
+ closeTaskMenu()
390
+ on[act]?.()
391
+ })
392
+ const away = (e) => { if (!menu.contains(e.target) && !anchor.contains(e.target)) closeTaskMenu() }
393
+ const keys = (e) => {
394
+ if (e.key === 'Escape') { closeTaskMenu(); anchor.focus?.() }
395
+ const i = items.indexOf(document.activeElement)
396
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
397
+ e.preventDefault()
398
+ items[(i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length].focus()
399
+ }
400
+ }
401
+ setTimeout(() => document.addEventListener('mousedown', away), 0)
402
+ document.addEventListener('keydown', keys)
403
+ // A scroll would leave the menu hanging away from its card.
404
+ const scroll = (e) => { if (!menu.contains(e.target)) closeTaskMenu() }
405
+ document.addEventListener('scroll', scroll, true)
406
+ closeTaskMenu.cleanup = () => {
407
+ document.removeEventListener('mousedown', away)
408
+ document.removeEventListener('keydown', keys)
409
+ document.removeEventListener('scroll', scroll, true)
410
+ anchor.setAttribute('aria-expanded', 'false')
411
+ menu.remove()
412
+ closeTaskMenu.anchor = null
413
+ }
414
+ closeTaskMenu.anchor = anchor
415
+ }
416
+
417
+ export function closeTaskMenu () {
418
+ if (closeTaskMenu.cleanup) { closeTaskMenu.cleanup(); closeTaskMenu.cleanup = null }
419
+ }
420
+
421
+ /**
422
+ * What the board's search looks through for a task, lowercased: its title and id, who it's
423
+ * for (as the card says it, and by name and tool), who made it, its files and its QA and
424
+ * verified notes. session.js hides the cards whose text doesn't hold every word typed.
425
+ */
426
+ export function searchText (t, me) {
427
+ return [t.title, t.id, assigneeText(t, me), t.assignee, t.tool, t.by === me ? 'you' : '', t.by, ...(t.files || []), t.qaNotes, t.verified]
428
+ .filter(Boolean).join(' ').replace(/\s+/g, ' ').toLowerCase()
429
+ }
430
+
431
+ /** Whether a task's search text (searchText) holds every word of `query`, in any order. */
432
+ export function matchesSearch (text, query) {
433
+ const words = String(query || '').toLowerCase().split(/\s+/).filter(Boolean)
434
+ return words.every((w) => text.includes(w))
435
+ }
436
+
437
+ function card (t, col, me, people) {
438
+ const who = t.by === me ? 'You' : (t.by || '')
439
+ const mine = t.assignee === me && !t.forAi ? ' mine' : t.assignee === me && t.forAi ? ' mine-ai' : ''
440
+ const moves = col.moves.map((m) =>
441
+ `<button type="button" class="task-move" data-move="${m.column}" title="Move to ${esc(m.name)}" aria-label="Move to ${esc(m.name)}">${I[m.icon]}<span>${esc(m.label)}</span></button>`
442
+ ).join('')
443
+ return `<article class="task${mine}" draggable="true" data-task="${esc(t.id)}" data-search="${esc(searchText(t, me))}">
444
+ <div class="task-main">
445
+ <div class="task-tools">
446
+ ${notesButton(t)}
447
+ ${recurButton(t)}
448
+ <button type="button" class="task-icon" data-task-more title="More" aria-label="More for ${esc(t.title)}" aria-haspopup="menu" aria-expanded="false">${I.more}</button>
449
+ </div>
450
+ <p class="task-title" title="Click to rename">${esc(t.title)}</p>
451
+ </div>
452
+ <select class="task-assign" aria-label="Assign ${esc(t.title)}">${assignOptions(t, me, people)}</select>
453
+ ${recurHtml(t)}
454
+ ${filesHtml(t.files)}
455
+ ${verifiedHtml(t)}
456
+ ${qaNotesHtml(t)}
457
+ <form class="task-file-form">
458
+ <input class="task-file" placeholder="Add a file…" aria-label="Add a file to ${esc(t.title)}" maxlength="240" autocomplete="off">
459
+ </form>
460
+ <div class="task-foot">
461
+ <span class="task-by">${esc(who)}</span>
462
+ <div class="task-moves">${moves}</div>
463
+ </div>
464
+ </article>`
465
+ }
@@ -0,0 +1,76 @@
1
+ // Boxes in the people panel (docs/mockups/2026-10-16-boxes.html, option C). A box is someone's
2
+ // unfinished work on one file: everyone sees it, but it is in nobody else's folder until its owner
3
+ // commits it. Each person's boxes are listed under them: "2 files not in your folder yet", each
4
+ // file with its +/−, and its change opens in place (never in a dialog). Someone else's has a way to
5
+ // ask for it (its file queue); your own (or your AI's) has Unpack, which puts it in every folder now.
6
+ import { esc, I, basename } from './common.js'
7
+ import { diffLines } from './diffview.js'
8
+
9
+ const files = (n) => `${n} file${n === 1 ? '' : 's'}`
10
+ const ordinal = (n) => `${n}${n % 100 >= 11 && n % 100 <= 13 ? 'th' : ['th', 'st', 'nd', 'rd'][n % 10] || 'th'}`
11
+
12
+ /** The line under a person: how many of their files are boxed, and where those are. */
13
+ export function boxSummary (list) {
14
+ if (!list.length) return 'No boxes'
15
+ const mine = list.filter((b) => b.mine).length
16
+ const theirs = list.length - mine
17
+ return [theirs ? `${files(theirs)} not in your folder yet` : '', mine ? `${files(mine)} in your folder only` : ''].filter(Boolean).join(', ')
18
+ }
19
+
20
+ /** The boxes held under any of `names` (a person, or each of a person's AI sessions). */
21
+ export const boxesOf = (boxes, names) => boxes.filter((b) => names.includes(b.by))
22
+
23
+ /** Boxes whose holder has no row in the panel (they left), as [holder, boxes] in the order first seen. */
24
+ export function unplaced (boxes, shown) {
25
+ const out = new Map()
26
+ for (const b of boxes) if (!shown.includes(b.by)) out.set(b.by, [...(out.get(b.by) || []), b])
27
+ return [...out]
28
+ }
29
+
30
+ /** Where `me` is in a box's file queue (1 for first), or 0 when I haven't asked for it. */
31
+ export const askedBy = (b, me) => (b.queue || []).findIndex((r) => r.by === me) + 1
32
+
33
+ /** What the app's boxes depend on in the session's claims: a new one, one gone, one asked for. */
34
+ export const boxesSig = (claims) => (claims || []).filter((c) => c && c.box).map((c) => `${c.by}\0${c.pattern}\0${(c.queue || []).map((r) => r.by).join(',')}`).sort().join('\n')
35
+
36
+ const delta = (b) => b.added || b.removed
37
+ ? `<span class="bx-delta">${b.added ? `<span class="add">+${b.added}</span>` : ''}${b.added && b.removed ? ' ' : ''}${b.removed ? `<span class="del">−${b.removed}</span>` : ''}</span>`
38
+ : ''
39
+
40
+ function whatOf (b, holder) {
41
+ const who = `<b>${esc(holder)}</b>'s unfinished work on <b>${esc(basename(b.path))}</b>`
42
+ if (b.kind === 'new') return `${who}: a new file, not in your folder until they commit it`
43
+ if (b.kind === 'deleted') return `${who}: they deleted it; it stays in your folder until they commit that`
44
+ return `${who}: not in your folder until they commit it`
45
+ }
46
+
47
+ function changeOf (b) {
48
+ if (b.kind === 'binary') return '<p class="bx-none">A binary file: no lines to show.</p>'
49
+ if (!b.diff) return '<p class="bx-none">The same as your folder\'s version.</p>'
50
+ return `<pre class="bx-diff">${diffLines(b.diff.replace(/\n$/, ''))}</pre>`
51
+ }
52
+
53
+ /**
54
+ * One person's boxes. `open`: the paths whose change is shown. `holder`: how people know whoever
55
+ * holds them ("Dex", "Dex's AI"). Your own say "Your" or "Your AI's" (one of your AI sessions).
56
+ */
57
+ export function boxListHtml (list, { me, open = new Set(), holder = '' } = {}) {
58
+ if (!list.length) return ''
59
+ return `<div class="pm-boxes">${list.map((b) => {
60
+ const shown = open.has(b.path)
61
+ const who = holder || b.by
62
+ const row = `<button type="button" class="pm-box" data-box-toggle="${esc(b.path)}" aria-expanded="${shown}" title="${esc(b.mine ? `${b.path}: your unfinished work, in your folder only` : `${b.path}: ${who}'s unfinished work, not in your folder yet`)}">${I.box}<span class="pm-box-name">${esc(b.path)}</span>${delta(b)}</button>`
63
+ const head = b.mine
64
+ ? `${b.by === me ? 'Your' : 'Your AI\'s'} unfinished work: in your folder only, until you commit it. Unpack puts it in everyone's folder now.`
65
+ : whatOf(b, who)
66
+ const at = askedBy(b, me)
67
+ const foot = b.mine
68
+ ? ''
69
+ : at
70
+ ? `<div class="bx-foot"><span class="bx-asked">${I.chat}You asked ${esc(who)} for it · ${ordinal(at)} in line</span></div>`
71
+ : `<div class="bx-foot"><button type="button" class="btn sm bx-ask" data-ask="${esc(b.path)}">${I.chat}Ask ${esc(who)} for it</button></div>`
72
+ const body = shown ? `<div class="bx-open"><div class="bx-head"><span class="bx-ico">${I.box}</span><span class="bx-what">${head}</span>${delta(b)}</div>${changeOf(b)}${foot}</div>` : ''
73
+ const unpack = b.mine ? `<button type="button" class="btn sm bx-unpack" data-unpack="${esc(b.path)}">${I.unpack}Unpack ${esc(basename(b.path))}</button>` : ''
74
+ return row + body + unpack
75
+ }).join('')}</div>`
76
+ }