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
@@ -0,0 +1,3394 @@
1
+ // The session workspace: file tree on the left; in the middle the Loom (everyone's
2
+ // AI conversations at once), one person's AI chat, a shared file or the task board;
3
+ // the team chat on the right.
4
+ import { TOKEN, I, state, $, esc, basename, bytes, clock, ago, avatar, colorFor, toast, api, ask, remember, recall, AGENT_COMMIT_WORDS, toolsOf, busyPeople, NO_POSTING, ACCOUNT_KEY, loadAccessTypes, typeOptions, accessLine, loadingHtml, loadingNote } from './common.js'
5
+ import { openInvite, renderTabs, markRead, go } from './app.js'
6
+ import { renderFeed } from './feed.js'
7
+ import { renderLoom, resetLoom, focusFile, showsLoom } from './loom.js'
8
+ import { buildLoom, foldAiLanes } from './loom-model.js'
9
+ import { railModel } from './rail-model.js'
10
+ import { renderRail, openLocks, closeLocks } from './rail.js'
11
+ import { conversations } from './feed-convs.js'
12
+ import { renderTree, openTreeMenu, closeTreeMenu, claimFolder, changeCardHtml, showChangeCard, hideChangeCard } from './tree.js'
13
+ import { renderFileView, renderFileTabs } from './fileview.js'
14
+ import { rolledOff, diffLines } from './diffview.js'
15
+ import { changesMarkup, bindChanges, unbindChanges, changesChanged, fileChanges, loadChanges, changedCount } from './changes.js'
16
+ import { quiltMark } from './mark.js'
17
+ import { openSettings } from './home.js'
18
+ import { fileCardHref, renderable, textHtml, mentionAt, mentionCandidates, completeMention, ALL_AGENTS, RELAY_NAME, isRelay, relayPrompts, typingNames, typingHtml, TYPING_MS, foldPersonas, aiOwners, aiName, shownName, ownAiChatter } from './chat.js'
19
+ import { renderBoard, taskNotesModalHtml, openTaskMenu, foldPlan, matchesSearch } from './board.js'
20
+ import { accessFormValues, accessSaveBody, grantsLoading, grantsLoaded, grantsFailed } from './access-form.js'
21
+ import { bindMerges, renderMergeView } from './merges.js'
22
+ import { workspaceFilePicker } from './files.js'
23
+ import { sessionAgentsHtml } from './agent-place.js'
24
+ import { renderCatchUp, bindCatchUp } from './catchup.js'
25
+ import { branchMenuHtml, upstreamText, needsHand } from './branches.js'
26
+ import { boxSummary, boxesOf, unplaced, boxesSig, boxListHtml } from './boxes.js'
27
+ import { gitControl, gitStatusParts, gitSegHtml } from './git-rule.js'
28
+
29
+ let current = null // session id being shown
30
+ let timers = []
31
+ let holdNoteTimer = null // shows the "git is busy" note once a hold has lasted HOLD_NOTE_MS
32
+ const HOLD_NOTE_MS = 1000 // a `git add` holds for a moment: no note flashing by for it
33
+ let grantLoad = grantsLoading() // this session's grants (the owner's view, from the API), for the Access sections
34
+ let wsAgents = { id: null, agents: [] } // the agents this session's workspace invites (workspaces on), for its People
35
+ let mounted = null // AbortController for document-level listeners of this mount
36
+ // Boxes in the people panel (boxes.js): the latest from the app, what they were fetched for, and
37
+ // the ones whose change is shown in place.
38
+ let boxView = { id: null, sig: null, at: 0, loading: false, data: { on: false, boxes: [] }, open: new Set() }
39
+ const BOXES_FRESH_MS = 3000 // while the panel is open, a box's change is fetched again at most this often
40
+
41
+ // ------------------------------------------------------------ layout state --
42
+ // Per session: mode ('ai' | 'files' | 'merge'), open tabs per mode, expanded folders.
43
+ function ws (id) {
44
+ if (!state.ws.has(id)) {
45
+ let saved = null
46
+ try { saved = JSON.parse(recall(`ws-${id}`, 'null')) } catch {}
47
+ state.ws.set(id, {
48
+ mode: 'ai',
49
+ aiTabs: [],
50
+ aiSel: null,
51
+ convSel: {}, // person -> pinned conversation id (absent: follow the newest)
52
+ fileTabs: [],
53
+ fileSel: null,
54
+ fileTab: {}, // file path -> 'changes' when its Changes tab is shown (absent: the file)
55
+ mergeSel: null, // merge id shown in the compare view
56
+ expanded: {},
57
+ treeCollapsed: true, // the Files pane starts folded into its rail (rail.js)
58
+ stale: {}, // file path -> changed while not visible
59
+ loom: { chat: true, tasks: true, density: 'detailed', layout: 'lanes', hidden: [] },
60
+ ...(saved || {}),
61
+ drawer: null
62
+ })
63
+ const w = state.ws.get(id)
64
+ // The Loom is where the AI area starts: once, for a session opened before it existed.
65
+ if (!w.loomSeen) { w.loomSeen = true; w.aiSel = null }
66
+ }
67
+ return state.ws.get(id)
68
+ }
69
+
70
+ function saveWs (id) {
71
+ const w = ws(id)
72
+ const { drawer, stale, ...keep } = w
73
+ remember(`ws-${id}`, JSON.stringify(keep))
74
+ }
75
+
76
+ const sum = () => state.sessions.get(current)
77
+ const me = () => sum()?.status.me.name
78
+
79
+ // People only: an agent whose name starts with a person's first name would make "Daniel · …"
80
+ // sessions anyone's, and they'd show one by one instead of as "<person>'s AI".
81
+ const humanNames = (list) => (list || []).filter((x) => x && x.kind !== 'agent').map((x) => x.name)
82
+
83
+ /**
84
+ * Who is here as people see them: each person's AI sessions as one "<person>'s AI", including
85
+ * a session connected as its own member under a "<first name> · <label>" name.
86
+ */
87
+ function peopleHere (st) {
88
+ const peers = st.peers || []
89
+ const who = aiOwners({ peers, people: [st.me?.name, ...humanNames(peers), ...humanNames(st.members)], names: peers.map((p) => p.name) })
90
+ return foldPersonas(peers.map((p) => p && !p.persona && who.has(p.name) ? { ...p, persona: true, of: who.get(p.name) } : p))
91
+ }
92
+
93
+ /** Whose AI session each name in this session is (chat.js aiOwners). */
94
+ function owners (s) {
95
+ const st = s.status
96
+ return aiOwners({ peers: st.peers, messages: renderable(state.messages.get(current)), people: [st.me?.name, ...humanNames(st.peers), ...humanNames(st.members)], names: (st.members || []).map((m) => m.name) })
97
+ }
98
+
99
+ function personInfo (name) {
100
+ const st = sum().status
101
+ if (name === st.me.name) return { ...st.me, online: st.connected, isMe: true }
102
+ const p = st.peers.find((x) => x.name === name)
103
+ return p ? { ...p, online: p.hosted ? 'http' : true } : { name, online: false, agent: null }
104
+ }
105
+
106
+ // ------------------------------------------------------------------ mount --
107
+ export function mountSession (id) {
108
+ current = id
109
+ pendingAssign = null
110
+ if (boxView.id !== id) boxView = { id, sig: null, at: 0, loading: false, data: { on: false, boxes: [] }, open: new Set() }
111
+ resetLoom()
112
+ boardQuery = '' // a search is for the board it was typed on
113
+ filterOpen = false
114
+ mounted = new AbortController()
115
+ // The first time a session opens in this app, its loading screen stays over it until its files
116
+ // and AI feeds are in, then fades, so nothing pops in afterwards. Later visits draw from memory.
117
+ const firstVisit = !state.trees.has(id)
118
+
119
+ $('#app').innerHTML = `
120
+ <div class="ws" id="ws">
121
+ <header class="ws-top">
122
+ <button class="brand" data-go="home" aria-label="Home">${quiltMark({ sew: 'first' })}</button>
123
+ <nav class="tabs" id="tabs" aria-label="Sessions"></nav>
124
+ <span class="spacer"></span>
125
+ <span class="relay-problem" id="relay-problem" role="status" hidden></span>
126
+ <span class="access-pill" id="access-pill" hidden></span>
127
+ <button class="btn sm ghost icon narrow-only" id="toggle-tree" title="Files" aria-label="Show files">${I.tree}</button>
128
+ <div class="people" id="people">
129
+ <button class="people-btn" id="people-btn" aria-haspopup="true" aria-expanded="false" aria-controls="people-menu"></button>
130
+ <div class="popover people-menu" id="people-menu" role="dialog" aria-label="People in this session" hidden></div>
131
+ </div>
132
+ <div class="git-wrap" id="git-wrap">
133
+ <button type="button" class="git-chip quiet" id="git-chip" aria-haspopup="true" aria-expanded="false" aria-controls="git-panel"><span class="git-branch" id="git-branch" hidden></span><span class="git-sep" id="git-sep" hidden>·</span><span class="git-chg" id="git-chg" hidden>${I.diff}<span class="chg-count" id="chg-count"></span></span><span class="git-say" id="git-say" hidden></span><span class="sd" id="git-dot" hidden></span></button>
134
+ <div class="popover git-panel" id="git-panel" role="dialog" aria-label="Branch, GitHub and changes" hidden>
135
+ <section class="git-sec branch-menu" id="branch-menu" hidden></section>
136
+ <section class="git-sec commit-panel" id="commit-panel" hidden></section>
137
+ ${changesMarkup()}
138
+ </div>
139
+ </div>
140
+ ${openInMarkup()}
141
+ <button class="btn sm ghost" id="tasks-btn" type="button" aria-pressed="false" title="Tasks">${I.board}<span class="wide-only">Tasks</span><span class="tasks-n" id="tasks-count" hidden></span></button>
142
+ <button class="btn sm primary" id="invite-btn">${I.link}<span class="wide-only">Invite</span></button>
143
+ <button class="btn sm ghost icon narrow-only" id="toggle-chat" title="Chat" aria-label="Show chat">${I.chat}<span class="badge" id="chat-badge" hidden></span></button>
144
+ <button class="btn sm ghost icon" id="settings-btn" type="button" title="Settings" aria-label="Settings">${I.gear}</button>
145
+ <div class="overflow">
146
+ <button class="btn sm ghost icon" id="more-btn" title="More" aria-label="More" aria-haspopup="true" aria-expanded="false">${I.more}</button>
147
+ <div class="popover more-menu" id="more-menu" role="menu" hidden>
148
+ <button class="pop-item" role="menuitem" id="rename-btn" hidden>Rename session…</button>
149
+ <button class="pop-item" role="menuitem" id="move-ws-btn" hidden>Move to a workspace…</button>
150
+ <button class="pop-item" role="menuitem" data-session-settings>Session settings…</button>
151
+ <button class="pop-item" role="menuitem" data-gather-rules>One set of AI rules…</button>
152
+ <button class="pop-item" role="menuitem" id="ask-commit">Ask for a commit…</button>
153
+ <button class="pop-item" role="menuitem" id="leave-btn">Leave this session</button>
154
+ <button class="pop-item" role="menuitem" data-shutdown>Shut down Quilt</button>
155
+ </div>
156
+ </div>
157
+ </header>
158
+ <div class="ws-body" id="ws-body">
159
+ <aside class="ws-tree" aria-label="Project files">
160
+ <div class="pane-head">
161
+ <button type="button" class="tree-collapse wide-tree" id="collapse-tree" title="Collapse files" aria-expanded="true" aria-controls="tree">${I.arrowLeft}</button>
162
+ <span class="pane-title">Files</span><span class="hint" id="file-count"></span>
163
+ <button type="button" class="merge-pill" id="merge-filter" aria-pressed="false" title="Show only the files to merge" hidden></button>
164
+ </div>
165
+ <div class="tree-rail" id="tree-rail" role="toolbar" aria-orientation="vertical" aria-label="Locks"></div>
166
+ <div class="tree-scroll" id="tree"></div>
167
+ </aside>
168
+ <main class="ws-main">
169
+ <div class="requests catchup" id="catchup" role="region" aria-label="While you were away" hidden></div>
170
+ <div class="requests github" id="github-card" role="region" aria-label="GitHub" hidden></div>
171
+ <div class="requests" id="requests" hidden></div>
172
+ <div class="ws-mainbar" id="mainbar" hidden>
173
+ <div class="ws-tabs" id="main-tabs" role="tablist"></div>
174
+ </div>
175
+ <div class="ws-content" id="main"></div>
176
+ </main>
177
+ <aside class="ws-chat" id="chat-pane" aria-label="Chat">
178
+ <div class="chat-resize" id="chat-resize" role="separator" aria-orientation="vertical" aria-label="Resize chat" title="Drag to resize · double-click to collapse" tabindex="0"></div>
179
+ <button type="button" class="chat-rail" id="chat-rail" title="Open chat" aria-label="Open chat">${I.chat}<span class="chat-badge" id="chat-rail-badge" hidden></span></button>
180
+ <div class="chat-head"><h3>Chat</h3><span class="hint" id="chat-sub"></span><button type="button" class="tree-collapse chat-collapse" id="collapse-chat" title="Collapse chat" aria-label="Collapse chat" aria-controls="chat-pane">${I.arrowRight}</button></div>
181
+ <div class="messages" id="messages"></div>
182
+ <div class="typing" id="typing" role="status" aria-live="polite" hidden></div>
183
+ <div class="drop">Drop files to send</div>
184
+ <form class="composer" id="composer">
185
+ <div class="to"><label for="to-select">To</label><select id="to-select"></select></div>
186
+ <div class="attachments" id="attachments"></div>
187
+ <div class="mention-menu" id="mention-menu" role="listbox" aria-label="Mention someone" hidden></div>
188
+ <div class="box">
189
+ <button type="button" class="btn ghost icon" id="attach-btn" title="Send a file" aria-label="Send a file">${I.clip}</button>
190
+ ${sum()?.workspace ? `<button type="button" class="btn ghost icon" data-attach-workspace title="Attach from workspace" aria-label="Attach from workspace">${I.folder}</button>` : ''}
191
+ <input type="file" id="file-input" multiple hidden>
192
+ <textarea id="msg-input" rows="1" placeholder="Message everyone…"></textarea>
193
+ <button type="submit" class="btn primary icon" id="send-btn" title="Send" aria-label="Send">${I.send}</button>
194
+ </div>
195
+ </form>
196
+ </aside>
197
+ <div class="scrim" id="scrim"></div>
198
+ </div>
199
+ </div>${firstVisit ? `<div class="sv-loading" id="sv-loading">${loadingHtml({ label: 'Loading session', name: sum()?.status?.sessionName || basename(sum()?.dir || '') })}</div>` : ''}`
200
+
201
+ bindTop()
202
+ bindAccess()
203
+ bindMerges($('#main'), { sessionId: () => current, onCompare: openMerge, editors: mergeEditors })
204
+ bindChanges(id, mounted.signal, { onOpen: (path) => openFile(path, { tab: 'changes' }), close: () => setGitOpen(false), onCount: renderGitChip })
205
+ bindCatchUp($('#catchup'), { sessionId: () => current, onOpen: openFile })
206
+ bindGithubCard()
207
+ bindMain()
208
+ bindTreeEvents()
209
+ applyTreeCollapsed()
210
+ applyChatLayout()
211
+ bindChatPane()
212
+ bindChat()
213
+ bindBoard()
214
+ renderTop()
215
+ renderMainBar()
216
+ renderMain()
217
+ renderTreePane()
218
+ renderMessages(false, true)
219
+ renderTyping()
220
+ renderRecipients()
221
+ renderComposer()
222
+ grantLoad = grantsLoading()
223
+ // The approve control and the people menu offer access types once they're here.
224
+ const accessLoad = loadAccessTypes().then(() => { if (current === id) { renderAccess(); refreshPeople() } })
225
+ const loads = [accessLoad, loadTree(), loadFeeds()]
226
+ if (firstVisit) {
227
+ // In once everything is here, or after a few seconds at most (a slow feed never locks you out).
228
+ Promise.race([Promise.allSettled(loads), new Promise((resolve) => setTimeout(resolve, 6000))]).then(() => {
229
+ const cover = $('#sv-loading')
230
+ if (current !== id || !cover) return
231
+ cover.classList.add('done')
232
+ setTimeout(() => cover.remove(), 300)
233
+ })
234
+ }
235
+ const shown = ws(id).mode === 'merge' && shownMerge()
236
+ if (shown && !shown.binary) refreshFile(shown.path, false) // the cached copy may be from before
237
+ loadNewPeople(id)
238
+ // Relative times ("4s ago") and recent-edit badges age out.
239
+ timers.push(setInterval(() => { renderTreePane(); renderTop() }, 15000))
240
+ }
241
+
242
+ export function sessionUnmount () {
243
+ for (const t of timers) clearInterval(t)
244
+ timers = []
245
+ clearTimeout(holdNoteTimer)
246
+ if (mounted) mounted.abort()
247
+ mounted = null
248
+ closeTreeMenu()
249
+ closeLocks()
250
+ pendingAssign = null
251
+ unbindChanges()
252
+ current = null
253
+ }
254
+
255
+ // ------------------------------------------------------------ live events --
256
+ let lastAccessState = null
257
+ export function sessionUpdated (id) {
258
+ if (id !== current) return
259
+ const accState = sum().status.access?.state || null
260
+ if (accState !== lastAccessState) { lastAccessState = accState; renderMain(); loadTree() }
261
+ loadNewPeople(id)
262
+ renderTop()
263
+ renderMainBar()
264
+ renderTyping()
265
+ renderRecipients()
266
+ renderComposer()
267
+ if (ws(id).mode === 'ai') renderMain()
268
+ if (ws(id).mode === 'tasks') paintBoard()
269
+ scheduleTree()
270
+ changesChanged()
271
+ }
272
+
273
+ export function sessionMessage (id) {
274
+ if (id !== current) return
275
+ renderMessages(true)
276
+ const w = ws(id)
277
+ if (w.mode === 'ai' && !w.aiSel) renderMain() // the Loom shows chat too
278
+ if (w.drawer !== 'chat' && window.matchMedia('(max-width: 900px)').matches) {
279
+ const b = $('#chat-badge')
280
+ if (b) { b.hidden = false; b.textContent = '•' }
281
+ }
282
+ }
283
+
284
+ export function sessionLog () {}
285
+
286
+ export function sessionFeed (id, entries) {
287
+ const feeds = state.feeds.get(id)
288
+ if (!feeds) return
289
+ const touched = new Set()
290
+ for (const e of entries) {
291
+ if (!feeds.has(e.by)) { if (id === current) loadFeed(e.by); continue } // fetched whole, this entry included
292
+ const list = feeds.get(e.by)
293
+ if (!list.some((x) => x.id === e.id)) list.push(e)
294
+ if (list.length > 300) list.splice(0, list.length - 300)
295
+ touched.add(e.by)
296
+ }
297
+ if (id !== current) return
298
+ const w = ws(id)
299
+ if (w.mode === 'ai' && (!w.aiSel || touched.has(w.aiSel))) renderMain()
300
+ renderMainBar()
301
+ }
302
+
303
+ export function sessionFileChanged (id, { path }) {
304
+ if (id !== current) return
305
+ scheduleTree()
306
+ changesChanged()
307
+ const w = ws(id)
308
+ if (w.mode === 'merge' && shownMerge()?.path === path) refreshFile(path, false)
309
+ if (w.fileTabs.includes(path)) {
310
+ if (w.mode === 'files' && w.fileSel === path) { refreshFile(path, true); scheduleHistory(path) } else { w.stale[path] = true; histories.delete(fileKey(path)); renderMainBar() }
311
+ }
312
+ }
313
+
314
+ /** The Loom shows everyone's AI feed: fetch each person's once (someone new, when they join). */
315
+ function loadNewPeople (id) {
316
+ const feeds = state.feeds.get(id)
317
+ const st = sum().status
318
+ for (const p of [st.me, ...st.peers]) if (p && p.name && !feeds?.has(p.name)) loadFeed(p.name)
319
+ }
320
+
321
+ // --------------------------------------------------------------- top bar --
322
+ // The AI apps installed here, the one from your profile first, so your AI works
323
+ // in the synced folder and its chats reach the feed.
324
+ function editorsByPreference () {
325
+ const all = state.defaults.editors || []
326
+ const mine = all.find((e) => e.tool === state.profile?.tool)
327
+ return mine ? [mine, ...all.filter((e) => e !== mine)] : all
328
+ }
329
+
330
+ /** The merge bar's apps: your AI on its own first when you turned that on (Settings → Your AI), then the apps. */
331
+ function mergeEditors () {
332
+ const a = state.profile?.auto
333
+ const apps = editorsByPreference()
334
+ return a && a.mode === 'auto' && a.supported ? [{ id: 'auto', name: 'your AI', auto: true }, ...apps] : apps
335
+ }
336
+
337
+ function openInMarkup () {
338
+ const [first, ...rest] = editorsByPreference()
339
+ if (!first) return ''
340
+ return `<div class="open-in overflow">
341
+ <button class="btn sm" data-open-in="${esc(first.id)}">Open in ${esc(first.name)}</button>${rest.length ? `
342
+ <button class="btn sm icon" id="open-in-more" title="Open in another app" aria-label="Open in another app" aria-haspopup="true" aria-expanded="false">${I.chevDown}</button>
343
+ <div class="popover more-menu" id="open-in-menu" role="menu" hidden>
344
+ ${rest.map((e) => `<button class="pop-item" role="menuitem" data-open-in="${esc(e.id)}">Open in ${esc(e.name)}</button>`).join('')}
345
+ </div>` : ''}
346
+ </div>`
347
+ }
348
+
349
+ function bindOpenIn () {
350
+ const wrap = $('.open-in')
351
+ if (!wrap) return
352
+ const moreBtn = $('#open-in-more')
353
+ const menu = $('#open-in-menu')
354
+ const setMenu = (open) => { if (menu) { menu.hidden = !open; moreBtn.setAttribute('aria-expanded', String(open)) } }
355
+ if (moreBtn) moreBtn.onclick = () => setMenu(menu.hidden)
356
+ wrap.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setMenu(false); moreBtn?.focus() } })
357
+ document.addEventListener('mousedown', (e) => { if (!wrap.contains(e.target)) setMenu(false) }, { signal: mounted.signal })
358
+ wrap.addEventListener('click', async (e) => {
359
+ const b = e.target.closest('[data-open-in]')
360
+ if (!b) return
361
+ setMenu(false)
362
+ try { await api('POST', `/api/sessions/${current}/open-in`, { app: b.dataset.openIn }) } catch (err) { toast(err.message) }
363
+ })
364
+ }
365
+
366
+ function bindTop () {
367
+ $('#tasks-btn').onclick = () => {
368
+ const w = ws(current)
369
+ if (w.mode === 'tasks') w.mode = w.aiSel ? 'ai' : w.fileSel ? 'files' : 'ai'
370
+ else w.mode = 'tasks'
371
+ saveWs(current)
372
+ renderMainBar()
373
+ renderMain()
374
+ renderTop()
375
+ if (w.mode === 'tasks') $('#task-add')?.focus()
376
+ }
377
+ $('#invite-btn').onclick = () => openInvite(current)
378
+ bindOpenIn()
379
+ $('#ask-commit').onclick = askForCommit
380
+ $('#rename-btn').onclick = renameSession
381
+ $('#move-ws-btn').onclick = moveToWorkspace
382
+ bindCommitChip()
383
+ $('#leave-btn').onclick = async () => {
384
+ if (!await ask({ title: 'Leave this session?', message: 'Quilt stops syncing this folder. Your files stay where they are, and you can rejoin later.', ok: 'Leave', danger: true })) return
385
+ await api('POST', `/api/sessions/${current}/stop`).catch((err) => toast(err.message))
386
+ }
387
+ $('#settings-btn').onclick = () => openSettings()
388
+ bindBranchMenu()
389
+ const moreBtn = $('#more-btn')
390
+ const moreMenu = $('#more-menu')
391
+ const setMore = (open) => { moreMenu.hidden = !open; moreBtn.setAttribute('aria-expanded', String(open)) }
392
+ moreBtn.onclick = () => setMore(moreMenu.hidden)
393
+ moreMenu.addEventListener('click', (e) => { setMore(false); if (e.target.closest('[data-session-settings]')) openSessionSettings(); if (e.target.closest('[data-gather-rules]')) openGatherRules() })
394
+ moreMenu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setMore(false); moreBtn.focus() } })
395
+ document.addEventListener('mousedown', (e) => { if (!moreBtn.parentElement.contains(e.target)) setMore(false) }, { signal: mounted.signal })
396
+ const wrap = $('#people')
397
+ const btn = $('#people-btn')
398
+ const menu = $('#people-menu')
399
+ let hoverTimer
400
+ let openedAt = 0
401
+ const open = () => {
402
+ clearTimeout(hoverTimer)
403
+ if (!menu.hidden) return
404
+ menu.hidden = false; openedAt = Date.now(); btn.setAttribute('aria-expanded', 'true'); renderPeopleMenu()
405
+ }
406
+ const close = () => { clearTimeout(hoverTimer); menu.hidden = true; btn.setAttribute('aria-expanded', 'false') }
407
+ // A click also focuses (and may hover) the button, which already opened the menu; don't toggle it shut.
408
+ btn.onclick = () => (menu.hidden ? open() : Date.now() - openedAt > 400 && close())
409
+ wrap.addEventListener('mouseenter', () => { if (window.matchMedia('(hover: hover)').matches) open() })
410
+ wrap.addEventListener('mouseleave', () => {
411
+ // Don't close while someone's typing in the menu.
412
+ if (menu.contains(document.activeElement) && ['INPUT', 'SELECT'].includes(document.activeElement.tagName)) return
413
+ hoverTimer = setTimeout(close, 250)
414
+ })
415
+ btn.addEventListener('focus', open)
416
+ wrap.addEventListener('keydown', (e) => { if (e.key === 'Escape') { close(); btn.focus() } })
417
+ document.addEventListener('mousedown', (e) => { if (!wrap.contains(e.target)) close() }, { signal: mounted.signal })
418
+
419
+ menu.addEventListener('click', async (e) => {
420
+ if (e.target.closest('[data-session-settings]')) { openSessionSettings(); return }
421
+ const bx = e.target.closest('[data-box-toggle],[data-unpack],[data-ask]')
422
+ if (bx) { boxAction(bx); return }
423
+ const t = e.target.closest('[data-person],[data-dm],[data-sharing]')
424
+ if (!t) return
425
+ if (t.dataset.sharing) {
426
+ const on = t.dataset.sharing === 'on'
427
+ try {
428
+ await api('POST', `/api/sessions/${current}/sharing`, { on })
429
+ toast(on ? 'Sharing your AI chat again' : 'Paused sharing your AI chat')
430
+ } catch (err) { toast(err.message) }
431
+ return
432
+ }
433
+ if (t.dataset.dm) {
434
+ state.to = t.dataset.dm
435
+ renderRecipients()
436
+ openDrawer('chat')
437
+ $('#msg-input').focus()
438
+ close()
439
+ return
440
+ }
441
+ openPerson(t.dataset.person)
442
+ close()
443
+ })
444
+ menu.addEventListener('submit', async (e) => {
445
+ e.preventDefault()
446
+ if (e.target.closest('.pm-member') || e.target.closest('.pm-gh')) return
447
+ const input = menu.querySelector('#focus-input')
448
+ await api('POST', `/api/sessions/${current}/focus`, { text: input.value }).catch((err) => toast(err.message))
449
+ toast(input.value ? 'Focus shared' : 'Focus cleared')
450
+ input.blur()
451
+ })
452
+
453
+ $('#toggle-tree').onclick = () => toggleDrawer('tree')
454
+ $('#toggle-chat').onclick = () => { toggleDrawer('chat'); $('#chat-badge').hidden = true }
455
+ $('#scrim').onclick = () => toggleDrawer(null)
456
+ }
457
+
458
+ /** The Session settings window's controls: your AI chat's sharing, and (for the owner) who can get in and what they may do. */
459
+ function bindSessionSettings (root) {
460
+ root.addEventListener('change', (e) => { if (e.target.matches('[data-github-sync], [data-large-to-relay]')) gitSettingChange(e) })
461
+ root.addEventListener('click', (e) => { if (e.target.closest('[data-gh-connect]')) connectGithub(); else if (e.target.closest('[data-agent-commits]')) gitSettingChange(e) })
462
+ root.addEventListener('change', async (e) => {
463
+ if (e.target.matches('[data-share]')) {
464
+ const on = e.target.checked
465
+ try {
466
+ await api('POST', `/api/sessions/${current}/sharing`, { on })
467
+ toast(on ? 'Sharing your AI chat again' : 'Paused sharing your AI chat')
468
+ } catch (err) { toast(err.message); e.target.checked = !on }
469
+ return
470
+ }
471
+ if (e.target.matches('[data-admit-by]')) {
472
+ const admitBy = e.target.value
473
+ try {
474
+ await api('POST', `/api/sessions/${current}/admit-by`, { admitBy })
475
+ toast(admitBy === 'owner' ? 'Only you can let people in' : admitBy === 'editors' ? 'Anyone who can edit may let people in' : 'Anyone in the session may let people in')
476
+ } catch (err) { toast(err.message); refreshPeople({ force: true }) }
477
+ return
478
+ }
479
+ if (!e.target.matches('[data-summarize]')) return
480
+ const on = e.target.checked
481
+ try {
482
+ await api('POST', `/api/sessions/${current}/summarize`, { on })
483
+ toast(on ? 'Your prompts and replies are summarized before sharing' : 'Sharing your AI chat word for word')
484
+ } catch (err) { toast(err.message); e.target.checked = !on }
485
+ })
486
+ root.addEventListener('change', async (e) => {
487
+ const f = e.target.closest('.pm-member.edit')
488
+ // Access types are saved with Save (below), not on every change; a chat link's time with Extend.
489
+ if (!f || f.classList.contains('pm-access') || f.classList.contains('pm-chat')) return
490
+ try {
491
+ await api('POST', `/api/sessions/${current}/members/set`, { key: f.dataset.key, role: f.role.value, ...(f.scopes ? { scopes: parseScopes(f.scopes.value) } : {}) })
492
+ toast('Access updated')
493
+ } catch (err) { toast(err.message) }
494
+ })
495
+ root.addEventListener('click', (e) => {
496
+ if (!e.target.closest('[data-retry-grants]')) return
497
+ grantLoad = grantsLoading()
498
+ refreshPeople({ force: true })
499
+ loadGrants()
500
+ })
501
+ root.addEventListener('click', async (e) => {
502
+ const b = e.target.closest('[data-extend-chat]')
503
+ if (!b) return
504
+ const f = b.closest('.pm-chat')
505
+ b.disabled = true
506
+ try {
507
+ const r = await api('POST', `/api/sessions/${current}/chat-link/extend`, { key: f.dataset.key, minutes: Number(f.minutes.value) })
508
+ toast(`${r.name}: ${chatTimeLeft(r.expiresAt)}`)
509
+ // The menu doesn't redraw under a focused row: show the new time now, and let it redraw.
510
+ f.querySelector('.pm-now').textContent = chatTimeLeft(r.expiresAt)
511
+ b.blur()
512
+ } catch (err) { toast(err.message) } finally { b.disabled = false }
513
+ })
514
+ root.addEventListener('click', async (e) => {
515
+ const b = e.target.closest('[data-remove]')
516
+ if (!b) return
517
+ const f = b.closest('.pm-member')
518
+ if (!await ask({ title: `Remove ${f.querySelector('.nm').textContent.trim()}?`, message: 'They\'ll need a new invite and your approval to come back.', ok: 'Remove', danger: true })) return
519
+ try { const r = await api('POST', `/api/sessions/${current}/members/remove`, { key: f.dataset.key }); toast(r.warning || 'Removed') } catch (err) { toast(err.message) }
520
+ // In a workspace, a removed agent is no longer invited here (the removal answers once that is written): list it now.
521
+ if (state.workspacesOn && f.dataset.key.startsWith('agent:')) loadSessionAgents()
522
+ })
523
+ // Invited from the workspace: Don't invite (a keep-out) and Invite (which sends it the link now).
524
+ root.addEventListener('click', async (e) => {
525
+ const b = e.target.closest('[data-agent-uninvite],[data-agent-invite]')
526
+ if (!b) return
527
+ const invite = 'agentInvite' in b.dataset
528
+ b.disabled = true
529
+ try {
530
+ const r = await api('POST', `/api/sessions/${current}/agents/${invite ? 'include' : 'exclude'}`, { agentId: b.dataset.agentInvite || b.dataset.agentUninvite })
531
+ toast(!invite ? 'Not invited to this session' : r.notified ? 'Invited: it was sent this session\'s link' : 'Invited. It has no webhook, so send it this session\'s link')
532
+ } catch (err) { toast(err.message); b.disabled = false }
533
+ loadSessionAgents()
534
+ })
535
+ root.addEventListener('click', async (e) => {
536
+ if (e.target.closest('[data-gh-clear]')) {
537
+ try { await api('POST', `/api/sessions/${current}/github-token`, { token: '' }); toast('The relay no longer has a GitHub token') } catch (err) { toast(err.message) }
538
+ refreshPeople({ force: true })
539
+ return
540
+ }
541
+ if (e.target.closest('[data-end-session]')) {
542
+ if (!await ask({ title: 'End this session for everyone?', message: 'Everyone is disconnected, and the session and its stored files are deleted from the relay. Your own folder is not touched.', ok: 'End session', danger: true })) return
543
+ try { await api('POST', `/api/sessions/${current}/end`); toast('Session ended') } catch (err) { toast(err.message) }
544
+ return
545
+ }
546
+ })
547
+ root.addEventListener('submit', async (e) => {
548
+ const f = e.target.closest('.pm-access')
549
+ if (!f) return
550
+ e.preventDefault()
551
+ // Only what was loaded: never a default type saved over a grant we haven't seen.
552
+ const body = accessSaveBody(grantLoad, state.accessTypes, f.dataset.key, { typeId: f.typeId.value, viewOnly: f.viewOnly.checked, noTalk: f.noTalk.checked, foldersRemove: f.foldersRemove.value })
553
+ if (!body) return toast('Their access is still loading. Try again in a moment.')
554
+ const save = f.querySelector('[type=submit]')
555
+ save.disabled = true
556
+ try {
557
+ const r = await api('POST', `/api/sessions/${current}/members/access`, body)
558
+ grantLoad.grants.set(f.dataset.key, r.grant)
559
+ toast('Access updated')
560
+ // The relay's member list may have redrawn the form meanwhile, from the old grant.
561
+ save.blur()
562
+ refreshPeople()
563
+ } catch (err) { toast(err.message) } finally { save.disabled = false }
564
+ })
565
+ root.addEventListener('submit', async (e) => {
566
+ const f = e.target.closest('.pm-gh')
567
+ if (!f) return
568
+ e.preventDefault()
569
+ const token = f.token.value.trim()
570
+ if (!token) return toast('Paste a token first')
571
+ const save = f.querySelector('[type=submit]')
572
+ save.disabled = true
573
+ try {
574
+ await api('POST', `/api/sessions/${current}/github-token`, { token })
575
+ f.token.value = ''
576
+ toast('Token saved: the relay brings in commits while everyone\'s offline')
577
+ refreshPeople({ force: true })
578
+ } catch (err) { toast(err.message) } finally { save.disabled = false }
579
+ })
580
+ }
581
+
582
+ function toggleDrawer (which) {
583
+ const w = ws(current)
584
+ openDrawer(w.drawer === which ? null : which)
585
+ }
586
+
587
+ function openDrawer (which) {
588
+ const w = ws(current)
589
+ w.drawer = which
590
+ $('#ws-body').dataset.drawer = which || ''
591
+ }
592
+
593
+ /** When an agent between turns was last here: "just now", "5m ago". */
594
+ const awayAgo = (ts) => {
595
+ if (!ts) return 'lately'
596
+ const a = ago(ts)
597
+ return a === 'now' ? 'just now' : /^\d+[mh]$/.test(a) ? `${a} ago` : `on ${a}`
598
+ }
599
+
600
+ /** The title and dot before a member's name: green when connected, blue when an agent over HTTP checked in lately, amber when an agent is between turns. */
601
+ function memberDot (m) {
602
+ const [title, color] = m.http ? ['Online over HTTP (checked in within 30 minutes)', 'var(--http)'] : m.online ? ['Online', 'var(--ok)'] : m.away ? [`Away: works in turns, back at its next one (last here ${awayAgo(m.lastSeen)})`, 'var(--away)'] : ['Offline', 'var(--faint)']
603
+ return `title="${title}"><span class="dot" style="background:${color}"></span>`
604
+ }
605
+
606
+ function agentLine (p) {
607
+ if (p.away) return `<span class="ai-state away" title="This agent's machine is cleaned up after every run: it rejoins at the start of each turn and reads what was said to it then">Away between turns · last here ${awayAgo(p.lastSeen)}</span>`
608
+ if (p.hosted) return `<span class="ai-state http" title="Connected over HTTP: shown as here for 30 minutes after each check-in">Over HTTP · ${p.lastSeen ? `checked in ${ago(p.lastSeen) === 'now' ? 'just now' : `${ago(p.lastSeen)} ago`}` : 'checked in lately'}</span>`
609
+ const a = p.agent
610
+ if (!a || (!a.tool && a.status !== 'unavailable' && a.sharing !== false)) return '' // nothing to say: no line
611
+ if (a.sharing === false) return `<span class="ai-state paused">${p.isMe ? 'You paused sharing' : 'Paused sharing'}</span>`
612
+ if (a.status === 'unavailable') return `<span class="ai-state off" title="${esc(a.reason || '')}">${esc(a.tool || 'AI')} feed unavailable</span>`
613
+ if (a.status === 'working') return `<span class="ai-state working"><span class="pulse"></span>${esc(a.tool || 'AI')} is working…</span>`
614
+ return `<span class="ai-state">${a.tool ? `${esc(a.tool)} idle` : 'AI idle'}</span>`
615
+ }
616
+
617
+ // ------------------------------------------------------------- branches --
618
+ let branchSyncing = false
619
+
620
+ function renderBranchMenu () {
621
+ const st = sum().status
622
+ const menu = $('#branch-menu')
623
+ menu.hidden = !st.git && !(st.branches || []).length
624
+ // Inside the git panel the branch list folds, so the changes below stay in reach; kept open across redraws.
625
+ const open = !!menu.querySelector('details.br-all[open]')
626
+ menu.innerHTML = branchMenuHtml({ git: st.git, branches: st.branches || [], me: st.me.name, syncing: branchSyncing, fold: true })
627
+ if (open) menu.querySelector('details.br-all')?.setAttribute('open', '')
628
+ }
629
+
630
+ function bindBranchMenu () {
631
+ const menu = $('#branch-menu')
632
+ menu.addEventListener('click', async (e) => {
633
+ if (!e.target.closest('[data-branch-sync]') || branchSyncing) return
634
+ branchSyncing = true
635
+ renderBranchMenu()
636
+ const id = current
637
+ try { await api('POST', `/api/sessions/${id}/branches/sync`) } catch (err) { toast(err.message) } finally {
638
+ branchSyncing = false
639
+ if (id === current && !$('#git-panel').hidden) renderBranchMenu()
640
+ }
641
+ })
642
+ }
643
+
644
+ function renderTop () {
645
+ if (!current || !$('#people-btn')) return
646
+ renderTabs()
647
+ const st = sum().status
648
+ const g = st.git
649
+ const label = $('#git-branch')
650
+ if (label) {
651
+ label.hidden = !g
652
+ if (g) {
653
+ // Moving to another branch: said at once. Git busy: only once it has lasted a moment.
654
+ const held = g.hold ? Date.now() - g.hold.since : 0
655
+ const note = g.hold && (g.hold.kind === 'switching' || held >= HOLD_NOTE_MS)
656
+ clearTimeout(holdNoteTimer)
657
+ if (g.hold && !note) holdNoteTimer = setTimeout(renderTop, HOLD_NOTE_MS - held + 20)
658
+ const tag = note ? (g.hold.kind === 'switching' ? (g.hold.waiting === 'refused' ? `not syncing · ${esc(g.hold.to || '?')} isn't in the session` : g.hold.waiting === 'retrying' ? `waiting for ${esc(g.hold.to || '?')}…` : `moving to ${esc(g.hold.to || '?')}…`) : g.hold.conflict ? 'paused: resolve the git conflict' : 'syncing paused: git is busy') : ''
659
+ const up = g.upstream
660
+ const behind = !tag && up && (up.behind || up.diverged) ? `<span class="tag${needsHand(up) ? ' warn' : ''}">${needsHand(up) ? 'needs a pull' : `${up.behind} behind`}</span>` : ''
661
+ const full = g.full ? '<span class="tag warn" title="This branch is over the session\'s size limit: new changes aren\'t saved. Start a fresh copy with git checkout -b.">full</span>' : ''
662
+ label.innerHTML = `${I.branch}<span class="branch-name">${esc(g.key)}</span>${tag ? `<span class="tag" title="${tag}">${tag}</span>` : ''}${behind}${full}`
663
+ label.dataset.title = g.hold ? (g.hold.kind === 'switching' ? (g.hold.waiting === 'refused' ? `The session can't take ${g.hold.to || 'this branch'}, so this folder isn't syncing. Check out ${g.key} again in git to keep syncing there.` : `You checked out ${g.hold.to || 'another branch'} in git: this folder is moving to that branch's work in the session. The branch it left keeps its own.`) : g.hold.conflict ? `git left a conflict in ${g.hold.conflict.join(', ')} on this computer. Resolve it and git add it; Quilt then shares your resolution.` : 'Quilt waits for git to finish, then catches up.') : `This folder is on ${g.key}${up ? `, ${upstreamText(up)}` : ''}${g.full ? '. Over the size limit: new changes aren\'t saved.' : ''}.`
664
+ }
665
+ if (!$('#git-panel').hidden) renderBranchMenu()
666
+ }
667
+ const people = [st.me, ...peopleHere(st)]
668
+ const shown = people.slice(0, 4)
669
+ $('#people-btn').innerHTML = `<span class="stack">${shown.map((p, i) => `<span style="z-index:${10 - i}">${avatar(p.name, p.color)}</span>`).join('')}</span>
670
+ <span class="count">${people.length}</span><span class="conn ${st.connected ? 'ok' : 'warn'}" title="${st.connected ? 'Connected' : esc(st.problem || 'Reconnecting…')}"></span>`
671
+ $('#people-btn').setAttribute('aria-label', `${people.length} ${people.length === 1 ? 'person' : 'people'} in this session${st.connected ? '' : ', reconnecting'}`)
672
+ // Why we're offline, when we know: otherwise "Reconnecting…" can go on silently forever.
673
+ const problem = $('#relay-problem')
674
+ if (problem) {
675
+ problem.hidden = st.connected || !st.problem
676
+ problem.textContent = st.connected ? '' : (st.problem || '')
677
+ problem.title = problem.textContent ? `${problem.textContent}. Quilt keeps retrying.` : ''
678
+ }
679
+ refreshPeople()
680
+ renderAccess()
681
+ renderCatchUp($('#catchup'), st.catchUp, { colors: new Map([st.me, ...st.peers].map((p) => [p.name, p.color])) })
682
+ renderGithubCard()
683
+ renderMerges()
684
+ renderGitChip()
685
+ $('#rename-btn').hidden = !st.access?.owner
686
+ $('#move-ws-btn').hidden = !(st.access?.owner && state.workspacesOn && (state.workspaces || []).some((w) => w.access === 'edit'))
687
+ renderTaskButton()
688
+ $('#chat-sub').textContent = st.peers.length ? `with ${peopleHere(st).map((p) => p.sessions && p.mine ? 'your AI' : p.name).join(', ')}` : 'just you so far'
689
+ }
690
+
691
+ // --------------------------------------------------------------- commits --
692
+ // Agents get their own work into git (git from their folders, signed in through the session's GitHub
693
+ // connection; quilt_commit for agents without a folder), within what the owner lets them: picked
694
+ // here. When they can't, they ask (commit.js): in a folder with git each
695
+ // request has a Commit button (exactly its files, then a push). "Not committed yet" shows the
696
+ // uncommitted work here by who made it, so nothing finished sits unshipped unnoticed.
697
+ let commitGithub = null // the owner's GitHub connection (GET /api/github), while the panel is open
698
+ /** Session settings: GitHub sync, large files through the relay, and git for this session. */
699
+ async function gitSettingChange (e) {
700
+ if (e.target.matches('[data-large-to-relay]')) {
701
+ try {
702
+ const r = await api('POST', `/api/sessions/${current}/large-to-relay`, { on: e.target.checked })
703
+ toast(r.largeToRelay ? 'The relay can commit large files on branches that sync with GitHub now.' : 'Large files stay end-to-end encrypted: the relay commits none.')
704
+ } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
705
+ return
706
+ }
707
+ if (e.target.matches('[data-github-sync]')) {
708
+ try {
709
+ const r = await api('POST', `/api/sessions/${current}/github-sync`, { on: e.target.checked })
710
+ toast(r.githubSync === false ? 'Sync with GitHub is off for this session.' : 'This session syncs with GitHub.')
711
+ } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
712
+ return
713
+ }
714
+ const seg = e.target.closest('[data-agent-commits]')
715
+ if (!seg || seg.disabled || seg.classList.contains('on')) return
716
+ seg.closest('.seg').querySelectorAll('button').forEach((b) => { b.classList.toggle('on', b === seg); b.setAttribute('aria-pressed', String(b === seg)) })
717
+ try {
718
+ const r = await api('POST', `/api/sessions/${current}/agent-commits`, { mode: seg.dataset.agentCommits })
719
+ toast(AGENT_COMMIT_WORDS[r.agentCommits])
720
+ } catch (err) { toast(err.message); renderSessionSettings({ force: true }) }
721
+ }
722
+
723
+ /** Opens or closes the git panel: this folder's branch, the session against GitHub, and what changed, in one place. */
724
+ function setGitOpen (open) {
725
+ const panel = $('#git-panel')
726
+ if (!panel) return
727
+ panel.hidden = !open
728
+ $('#git-chip').setAttribute('aria-expanded', String(open))
729
+ if (open) { renderBranchMenu(); renderCommitPanel(); loadCommitWork(); loadChanges() }
730
+ }
731
+
732
+ function bindCommitChip () {
733
+ const wrap = $('#git-wrap')
734
+ const chip = $('#git-chip')
735
+ const panel = $('#commit-panel')
736
+ chip.onclick = () => setGitOpen($('#git-panel').hidden)
737
+ wrap.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setGitOpen(false); chip.focus() } })
738
+ document.addEventListener('mousedown', (e) => { if (!wrap.contains(e.target) && !e.target.closest('.modal, .dialog')) setGitOpen(false) }, { signal: mounted.signal })
739
+ panel.addEventListener('submit', async (e) => {
740
+ const form = e.target.closest('[data-commit-session]')
741
+ if (!form) return
742
+ e.preventDefault()
743
+ const btn = form.querySelector('button')
744
+ const input = form.querySelector('input')
745
+ btn.disabled = true
746
+ btn.textContent = 'Committing…'
747
+ try {
748
+ const r = await api('POST', `/api/sessions/${current}/commit-session`, { message: input.value })
749
+ toast(r.hash ? `Committed ${r.hash.slice(0, 7)}${r.pushed ? ' and pushed' : `; not pushed: ${r.pushError || 'no upstream'}`}` : 'Already in git')
750
+ input.value = ''
751
+ } catch (err) { toast(err.message) } finally {
752
+ btn.disabled = false
753
+ btn.textContent = 'Commit and push'
754
+ }
755
+ })
756
+ panel.addEventListener('click', async (e) => {
757
+ if (e.target.closest('[data-gh-connect]')) { connectGithub(); return }
758
+ const live = e.target.closest('[data-open-live]')
759
+ if (live) { live.disabled = true; openLiveSession(live.dataset.openLive).catch((err) => { toast(err.message); live.disabled = false }); return }
760
+ const b = e.target.closest('[data-done], [data-commit]')
761
+ if (!b) return
762
+ b.disabled = true
763
+ try {
764
+ if (b.dataset.done) {
765
+ const id = b.dataset.done === 'all' ? null : b.dataset.done
766
+ const r = await api('POST', `/api/sessions/${current}/commit-request/done`, id ? { id } : {})
767
+ toast(r.done === 1 ? 'Closed' : `Closed ${r.done}`)
768
+ } else if (b.dataset.commit) {
769
+ b.textContent = 'Committing…'
770
+ const r = await api('POST', `/api/sessions/${current}/commit-request/commit`, { id: b.dataset.commit })
771
+ toast(r.hash ? `Committed ${r.hash.slice(0, 7)}${r.pushed ? ' and pushed' : `; not pushed: ${r.pushError || 'no upstream'}`}` : 'Already in git: closed')
772
+ }
773
+ loadCommitWork()
774
+ } catch (err) { toast(err.message); b.disabled = false; loadCommitWork() }
775
+ })
776
+ }
777
+
778
+ async function loadCommitWork () {
779
+ if ($('#git-panel').hidden) return
780
+ const st = sum().status
781
+ if (st.access?.owner) { try { commitGithub = await api('GET', '/api/github') } catch { commitGithub = null } }
782
+ renderCommitPanel()
783
+ }
784
+
785
+ /**
786
+ * The git chip: this folder's branch, how many files changed in the session, and a dot (with a
787
+ * word when something needs someone) for the session against GitHub. One control for all three,
788
+ * so the top bar has one place for source control.
789
+ */
790
+ function renderGitChip () {
791
+ const chip = $('#git-chip')
792
+ if (!chip || !sum()) return
793
+ const st = sum().status
794
+ const open = (st.commits || []).filter((r) => r.state === 'open')
795
+ const gh = githubStateOf(st)
796
+ const busy = busyPeople(st)
797
+ // A branch that syncs live already has what it needs (a connection, or the owner's token).
798
+ const liveOn = !!(myBranchOf(st)?.relay?.live)
799
+ const off = !!(myBranchOf(st)?.relay?.sync?.state === 'paused' && /owner turned (live )?sync( with GitHub)? off/.test(myBranchOf(st).relay.sync.why || ''))
800
+ const needs = gh && gh.state && gh.state !== 'ok' && gh.state !== 'not-github' && !liveOn && !off
801
+ const live = syncWords(st)
802
+ const warn = !!(live && live.warn && !open.length)
803
+ chip.className = `git-chip${open.length && !busy.length ? ' ready' : ''}${(needs && !open.length) || warn ? ' needs' : ''}${!open.length && !needs && !warn ? ' quiet' : ''}`
804
+ // Words only when someone has to act; otherwise the dot says where the session stands.
805
+ const say = open.length ? `${open.length === 1 ? 'Commit requested' : 'Commits requested'}${busy.length ? ` · ${busy.length === 1 ? 'someone' : 'people'} working` : ''}`
806
+ : needs ? 'Connect GitHub' : warn ? live.chip : ''
807
+ const sayEl = $('#git-say')
808
+ sayEl.hidden = !say
809
+ sayEl.textContent = say
810
+ const dot = $('#git-dot')
811
+ dot.hidden = !!say || !(live && live.dot)
812
+ dot.className = `sd${live && live.dot ? ` ${live.dot}` : ''}`
813
+ const n = changedCount()
814
+ const branch = $('#git-branch')
815
+ $('#git-chg').hidden = !n
816
+ $('#git-sep').hidden = branch.hidden || !n
817
+ const lines = [
818
+ branch.hidden ? '' : branch.dataset.title,
819
+ n ? `${n} file${n === 1 ? '' : 's'} changed in this session.` : '',
820
+ open.length ? open.map((r) => `${r.by}: ${r.message}`).join('\n') : live ? live.line : gh ? githubWords(gh) : ''
821
+ ].filter(Boolean)
822
+ chip.title = [...lines, 'Click for branches, GitHub and every change.'].join('\n')
823
+ chip.setAttribute('aria-label', `Source control${branch.hidden ? '' : `: ${st.git.key}`}${n ? `, ${n} changed` : ''}${say ? `, ${say}` : live ? `, ${live.chip}` : ''}`)
824
+ if (!$('#git-panel').hidden) renderCommitPanel()
825
+ }
826
+
827
+ /** Open requests (Commit, Close), the uncommitted work here by who made it, and committing by itself. */
828
+ function renderCommitPanel () {
829
+ const panel = $('#commit-panel')
830
+ if (!panel) return
831
+ const st = sum().status
832
+ const open = (st.commits || []).filter((r) => r.state === 'open')
833
+ const fileList = (files) => `<details><summary>${files.length} file${files.length === 1 ? '' : 's'}</summary><ul class="commit-files">${files.map((f) => `<li>${esc(f)}</li>`).join('')}</ul></details>`
834
+ const req = (r) => {
835
+ const why = r.error ? `Last try: ${r.error}` : ''
836
+ return `<li><b>${esc(r.by)}</b>${r.task ? ` <span class="hint">· task</span>` : ''}<div>${esc(r.message)}</div>
837
+ ${Array.isArray(r.files) && r.files.length ? fileList(r.files) : ''}
838
+ ${why ? `<p class="commit-why">${esc(why)}</p>` : ''}
839
+ <div class="commit-acts">${st.canCommit ? `<button type="button" class="btn sm" data-commit="${esc(r.id)}">Commit and push</button>` : ''}<button type="button" class="btn sm ghost" data-done="${esc(r.id)}" title="Close it without a commit from Quilt (committed by hand, or not needed)">Close</button></div></li>`
840
+ }
841
+ // Only what needs a person: an agent that may not commit itself asked for one, the session's
842
+ // work not on GitHub yet (and the commit that sends it), or GitHub to connect. Settings
843
+ // (sync, large files, what agents may commit) live in Session settings.
844
+ const html = `
845
+ ${open.length ? `<h4>Asked to be committed</h4><ul class="commit-reqs">${open.map(req).join('')}</ul>` : ''}
846
+ ${gitLineHtml(st)}
847
+ ${connectHtml(st)}
848
+ ${open.length > 1 ? '<div class="commit-foot"><button type="button" class="btn sm ghost" data-done="all">Close all</button></div>' : ''}`.trim()
849
+ panel.hidden = !html
850
+ // Status comes in often: redrawn only when something in it changed, so an open menu or a
851
+ // file list someone expanded stays as it is.
852
+ if (panel.dataset.html === html) return
853
+ panel.dataset.html = html
854
+ // A commit message being typed survives the redraw.
855
+ const typing = panel.querySelector('[data-commit-session] input')
856
+ const kept = typing ? { value: typing.value, focused: document.activeElement === typing, at: typing.selectionStart } : null
857
+ panel.innerHTML = html
858
+ const box = kept && panel.querySelector('[data-commit-session] input')
859
+ if (box) {
860
+ box.value = kept.value
861
+ if (kept.focused) { box.focus(); box.setSelectionRange(kept.at, kept.at) }
862
+ }
863
+ }
864
+
865
+ /** This folder's branch in the relay's branch list (or the default branch), or null. */
866
+ function myBranchOf (st) {
867
+ const list = st.branches || []
868
+ return (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
869
+ }
870
+
871
+ /** Where this branch stands with GitHub (GitHub as a node: relay-sync.js syncStatus), in a few words, with how to read it. */
872
+ function syncWords (st) {
873
+ const b = myBranchOf(st)
874
+ const r = b && b.relay
875
+ const s = r && r.sync
876
+ if (!s) return null
877
+ const name = b.name
878
+ const at = s.revision ? ` at ${s.revision.slice(0, 7)}` : ''
879
+ const t = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
880
+ if (s.state === 'synced') return { chip: 'Committed', dot: 'ok', line: `${name} is in step with GitHub${at}: everything in the session is committed, and pushes and merges there come in within seconds.`, warn: false }
881
+ if (s.state === 'github-pending') return { chip: 'Uncommitted changes', dot: 'edit', line: `${name} has GitHub's latest${at}. The session has changes that aren't committed: everyone here has them, and GitHub gets them when someone commits.`, warn: false, pending: true }
882
+ if (s.state === 'conflict-held') return { chip: 'Merge held', line: `${s.why} on ${name}: the session and GitHub changed the same lines. The merge is on the board; everything else keeps syncing.`, warn: true }
883
+ if (s.state === 'pr-pending') return { chip: s.pr ? `Through pull request #${s.pr.number}` : 'Through a pull request', line: r.leased ? `${name} syncs with GitHub in another session, so this session's commits go through` : `${name} is protected on GitHub, so the session's commits go through`, pr: s.pr || { number: 0, url: '' }, warn: false, leased: !!r.leased, liveIn: r.liveIn || null }
884
+ if (s.state === 'offline') return { chip: 'GitHub unreachable', line: `GitHub unreachable since ${s.since ? t(s.since) : 'a moment ago'}: the session keeps working, and GitHub's changes and the commits asked for go when it answers.`, warn: true }
885
+ if (s.state === 'permission-held') return { chip: 'Sync paused', line: s.why, warn: true }
886
+ if (/owner turned (live )?sync( with GitHub)? off/.test(s.why || '')) return { chip: 'Not syncing', line: `Sync with GitHub is off for this session: files stay in the session, and people commit with git.`, warn: false, off: true }
887
+ return { chip: 'Not syncing', line: `${(s.why || `sync of ${name} with GitHub is off`).replace(/^./, (c) => c.toUpperCase())}.`, warn: false, off: true }
888
+ }
889
+
890
+ /** The session against GitHub in a line (the long words on hover), and the commit that sends what isn't there yet. */
891
+ function gitLineHtml (st) {
892
+ const w = syncWords(st)
893
+ if (!w || w.off) return ''
894
+ const b = myBranchOf(st)
895
+ if (w.pending) {
896
+ const s = b.relay.sync
897
+ const n = s.pending || 0
898
+ const gh = githubStateOf(st)
899
+ const sha = s.revision ? s.revision.slice(0, 7) : ''
900
+ const commit = !sha ? '' : gh ? `<a href="https://github.com/${esc(gh.repo)}/commit/${esc(s.revision)}" target="_blank" rel="noreferrer">${sha}</a>` : `<code>${sha}</code>`
901
+ return `<p class="cp-line" title="${esc(w.line)}"><span class="sd edit"></span><span>${n} file${n === 1 ? '' : 's'} not committed${sha ? ` · GitHub at ${commit}` : ''}</span></p>
902
+ ${st.canCommit ? `<form class="cp-commit" data-commit-session><input class="input" name="message" placeholder="Commit message" aria-label="Commit message" maxlength="500" required autocomplete="off"><button type="submit" class="btn sm primary">Commit and push</button></form>` : ''}`
903
+ }
904
+ const pr = w.pr ? (w.pr.url ? ` · <a href="${esc(w.pr.url)}" target="_blank" rel="noreferrer">pull request #${w.pr.number}</a>` : '') : ''
905
+ const live = w.liveIn ? ` <button type="button" class="btn sm" data-open-live="${esc(w.liveIn)}">Open the live session</button>` : ''
906
+ return `<p class="cp-line${w.warn ? ' warn' : ''}" title="${esc(w.line)}">${w.dot ? `<span class="sd ${w.dot}"></span>` : ''}<span>${esc(w.chip)}</span>${pr}${live}</p>`
907
+ }
908
+
909
+ /** Connect GitHub, while nobody here has (one click, by anyone who can write to the repository). */
910
+ function connectHtml (st) {
911
+ const gh = githubStateOf(st)
912
+ if (!gh || !gh.state || gh.state === 'ok' || gh.state === 'not-github' || myBranchOf(st)?.relay?.live) return ''
913
+ return `<p class="cp-line"><button type="button" class="btn sm" data-gh-connect>${gh.state === 'not-installed' ? 'Add it on GitHub' : 'Connect GitHub'}</button><span class="hint">so git is on for everyone here, agents included</span></p>`
914
+ }
915
+
916
+ /** What else the git card says under the control: the read-only token, or why git isn't working yet. */
917
+ function agentCommitNote (st) {
918
+ const gh = githubStateOf(st)
919
+ if (st.access.githubToken) return 'Agents without a folder commit with the session\'s GitHub token.'
920
+ if (gh && gh.state && gh.state !== 'ok') return `${esc(githubWords(gh))} Connect GitHub from the branch button in the top bar.`
921
+ if (!gh) return 'This session doesn\'t follow a GitHub repository yet.'
922
+ return ''
923
+ }
924
+
925
+ /**
926
+ * Git for this session (mockup option A): one segmented control that fills its card, the rule over the
927
+ * session as a pill (who set it), the choices it doesn't allow greyed with why, and who git works through.
928
+ */
929
+ function gitCardHtml (st) {
930
+ const by = st.agentCommitsBy
931
+ const m = gitControl({ value: st.agentCommits || 'branches', rule: by && by.mode ? by : null })
932
+ const g = st.gitForEveryone
933
+ const line = g && g.words ? gitStatusParts(g.words) : null
934
+ const note = agentCommitNote(st)
935
+ return `<div class="pm-card git-card">
936
+ <div class="git-head"><h4>Git for this session</h4>${m.rulePill ? `<span class="pill rule">${esc(m.rulePill)}</span>` : '<span class="pill">No rule above it</span>'}</div>
937
+ ${gitSegHtml(m, { attr: 'data-agent-commits', label: 'Git for this session' })}
938
+ ${line ? `<p class="git-line ${g.policy === 'off' ? 'off' : g.on ? 'ok' : ''}"><b>${esc(line.strong)}</b>${esc(line.rest)}</p>` : ''}
939
+ ${m.lockedHint ? `<span class="hint">${esc(m.lockedHint)}</span>` : ''}
940
+ ${note ? `<span class="hint">${note}</span>` : ''}
941
+ </div>`
942
+ }
943
+
944
+ /** Session settings, for the owner: syncing with GitHub, large files, and git for this session. */
945
+ function githubSettingsHtml (st) {
946
+ if (!st.access?.owner) return ''
947
+ const sync = st.access.githubSync
948
+ const rows = [
949
+ sync !== undefined ? `<label class="pm-switch"><span><b>Sync with GitHub</b><small>Pushes and merges on GitHub come into the session within seconds; nothing goes to GitHub until someone commits.</small></span><input type="checkbox" role="switch" data-github-sync ${sync !== false ? 'checked' : ''}></label>` : '',
950
+ sync !== undefined && sync !== false ? `<label class="pm-switch"><span><b>Let the relay commit large files</b><small>Large files are end-to-end encrypted. On branches that sync with GitHub this lets the relay read them, so a commit can include them.</small></span><input type="checkbox" role="switch" data-large-to-relay ${st.access.largeToRelay ? 'checked' : ''}></label>` : ''
951
+ ].filter(Boolean).join('')
952
+ return `<div class="pm-section"><div class="pm-title">GitHub</div>${gitCardHtml(st)}${rows ? `<div class="pm-card pm-settings">${rows}</div>` : ''}</div>`
953
+ }
954
+
955
+ /** Opens session `room`: the one already open in this app, else a recent one on this computer. */
956
+ async function openLiveSession (room) {
957
+ for (const [id, s] of state.sessions) if (s && s.status && s.status.room === room) return go(id)
958
+ const r = (state.recent || []).find((x) => x.room === room)
959
+ if (!r) { toast('That session isn\'t on this computer: ask its owner for an invite.'); return }
960
+ const opened = await api('POST', '/api/sessions', { mode: 'rejoin', dir: r.dir })
961
+ state.sessions.set(opened.id, opened)
962
+ await go(opened.id)
963
+ }
964
+
965
+ /** The session's repository on GitHub and whether a member's connection lets agents commit there, from the relay's branch list. */
966
+ function githubStateOf (st) {
967
+ const list = st.branches || []
968
+ const b = (st.git && list.find((x) => x.name === st.git.key)) || list.find((x) => x.default) || null
969
+ const r = b && b.relay
970
+ if (!r || !r.repo || !/^github\.com\//.test(r.repo)) return null
971
+ return { repo: r.repo.replace(/^github\.com\//, ''), state: r.github ? r.github.state : '', login: r.github ? r.github.login : '' }
972
+ }
973
+
974
+ /** The connection, in a sentence. */
975
+ function githubWords (gh) {
976
+ if (!gh) return ''
977
+ if (gh.state === 'ok') return `Git is on for everyone here, through @${gh.login}'s connection: people and agents push to ${gh.repo} with git, nothing to set up (a short-lived token each), and pushes come in within seconds. To keep a branch safe for good, protect it on GitHub.`
978
+ if (gh.state === 'not-installed') return `Quilt isn't installed on ${gh.repo} on GitHub yet.`
979
+ if (gh.state === 'no-access') return `No connected GitHub account (@${gh.login}) may write to ${gh.repo}.`
980
+ if (gh.state === 'not-connected') return `Nobody here has connected GitHub for ${gh.repo} yet.`
981
+ return `Checking ${gh.repo}…`
982
+ }
983
+
984
+ // --------------------------------------------------------- GitHub card --
985
+ // A session on a GitHub repository that no member has connected yet: one card at the top, for
986
+ // people, with the one click that lets agents commit and merge on their own. "Not now" keeps it
987
+ // away for a week; the commit chip still says "Connect GitHub".
988
+ const GH_DISMISS_MS = 7 * 24 * 60 * 60 * 1000
989
+ const ghDismissKey = (repo) => `quilt.github.notnow.${repo}`
990
+ let ghConnecting = null // the repo a Connect click is waiting on (polling the account)
991
+
992
+ function renderGithubCard () {
993
+ const el = $('#github-card')
994
+ if (!el) return
995
+ const st = sum().status
996
+ const gh = githubStateOf(st)
997
+ const dismissed = gh && recall(ghDismissKey(gh.repo))
998
+ const show = gh && ['not-connected', 'not-installed', 'no-access'].includes(gh.state) && !(dismissed && Date.now() - Number(dismissed) < GH_DISMISS_MS)
999
+ el.hidden = !show
1000
+ if (!show) { el._html = ''; return }
1001
+ const action = gh.state === 'not-installed' ? 'Add it on GitHub' : 'Connect GitHub'
1002
+ const html = `<div class="gh-card">
1003
+ <div class="gh-text"><b>${esc(gh.state === 'not-installed' ? `Quilt isn't on ${gh.repo} yet` : gh.state === 'no-access' ? `@${gh.login} can't write to ${gh.repo}` : `This session follows ${gh.repo}`)}</b>
1004
+ <span>${esc(gh.state === 'not-installed' ? 'Add the repository to Quilt on GitHub, and agents here commit and merge on their own; pushes come in within seconds.' : gh.state === 'no-access' ? 'Someone whose GitHub account can write to it connects once, and that covers everyone here, agents included.' : 'Connect GitHub once (one click, by anyone whose GitHub account can write to it): agents here then commit and merge on their own, and pushes come in within seconds. Nobody else has to connect.')}</span></div>
1005
+ <div class="gh-acts">${ghConnecting === gh.repo ? `<span class="hint">${loadingNote('Finish on GitHub, then come back…')}</span>` : `<button type="button" class="btn sm primary" data-gh-connect>${action}</button>`}<button type="button" class="btn sm ghost" data-gh-later>Not now</button></div>
1006
+ </div>`
1007
+ if (el._html === html) return
1008
+ el._html = html
1009
+ el.innerHTML = html
1010
+ }
1011
+
1012
+ function bindGithubCard () {
1013
+ const el = $('#github-card')
1014
+ if (!el) return
1015
+ el.addEventListener('click', (e) => {
1016
+ if (e.target.closest('[data-gh-connect]')) return connectGithub()
1017
+ const later = e.target.closest('[data-gh-later]')
1018
+ if (!later) return
1019
+ const gh = githubStateOf(sum().status)
1020
+ if (gh) remember(ghDismissKey(gh.repo), String(Date.now()))
1021
+ renderGithubCard()
1022
+ })
1023
+ }
1024
+
1025
+ /** Opens GitHub's install page for this session's repository, then watches for the connection and tells the relay to look again. */
1026
+ async function connectGithub () {
1027
+ const gh = githubStateOf(sum().status)
1028
+ try {
1029
+ const { url } = await api('POST', '/api/github/connect', gh ? { repo: gh.repo } : {})
1030
+ window.open(url, '_blank', 'noopener')
1031
+ toast('Finish on GitHub, then come back')
1032
+ } catch (err) { toast(err.message); return }
1033
+ if (!gh) return
1034
+ ghConnecting = gh.repo
1035
+ renderGithubCard()
1036
+ const id = current
1037
+ const until = Date.now() + 10 * 60 * 1000
1038
+ let was = null
1039
+ try { was = (await api('GET', '/api/github')).login || null } catch {}
1040
+ while (Date.now() < until && current === id && ghConnecting === gh.repo) {
1041
+ await new Promise((r) => setTimeout(r, 3000))
1042
+ let now = null
1043
+ try { now = (await api('GET', '/api/github')).login || null } catch {}
1044
+ if (!now || now === was) continue
1045
+ // Connected (or re-connected): the relay checks the repository again, and the card and chip follow.
1046
+ try {
1047
+ const r = await api('POST', `/api/sessions/${id}/github/recheck`)
1048
+ const s = r && r.github && r.github.state
1049
+ toast(s === 'ok' ? `Connected as @${now}: agents can commit here now` : s === 'not-installed' ? `Connected as @${now}, but ${gh.repo} wasn't picked: add it on GitHub` : s === 'no-access' ? `@${now} can't write to ${gh.repo}` : `Connected as @${now}`)
1050
+ } catch (err) { toast(err.message) }
1051
+ break
1052
+ }
1053
+ if (ghConnecting === gh.repo) ghConnecting = null
1054
+ renderGithubCard()
1055
+ }
1056
+
1057
+ async function askForCommit () {
1058
+ const message = await ask({ title: 'Ask for a commit', message: 'Of everything you changed that is not in git yet. A person with git on this branch commits it, and you are told the commit.', ok: 'Ask', input: { label: 'What is the commit for?', placeholder: 'Pricing page and download button' } })
1059
+ if (!message) return
1060
+ try {
1061
+ const r = await api('POST', `/api/sessions/${current}/commit-request`, { message: message.trim() })
1062
+ toast(`Asked for a commit of ${r.files.length} file${r.files.length === 1 ? '' : 's'}`)
1063
+ } catch (err) { toast(err.message) }
1064
+ }
1065
+
1066
+ // ----------------------------------------------------------------- access --
1067
+ const roleLabel = (r) => r === 'owner' ? 'Owner' : r === 'viewer' ? 'View only' : 'Can edit'
1068
+ const scopesText = (scopes) => (scopes || []).join(', ')
1069
+ const parseScopes = (text) => String(text || '').split(',').map((x) => x.trim()).filter(Boolean)
1070
+
1071
+ /** The access pill, the request bar for anyone who may let people in, and the waiting screen. */
1072
+ function renderAccess () {
1073
+ const st = sum().status
1074
+ const acc = st.access || {}
1075
+ const pill = $('#access-pill')
1076
+ if (pill) {
1077
+ const parts = acc.state === 'pending' ? ['Waiting to be let in'] : !acc.controlled ? [] : [
1078
+ ...(acc.role === 'viewer' ? ['View only'] : acc.scopes && acc.scopes.length ? [`Can change ${scopesText(acc.scopes)}`] : []),
1079
+ ...(acc.role !== 'viewer' && acc.scopesExcept && acc.scopesExcept.length ? [`Not ${scopesText(acc.scopesExcept)}`] : []),
1080
+ ...(acc.talk === false ? ["Can't post"] : [])
1081
+ ]
1082
+ const text = parts.join(' · ')
1083
+ pill.hidden = !text
1084
+ pill.textContent = text
1085
+ pill.className = `access-pill${acc.state === 'pending' ? ' wait' : ''}`
1086
+ }
1087
+ const bar = $('#requests')
1088
+ if (!bar) return
1089
+ const waiting = st.waiting || []
1090
+ // Don't redraw while someone is filling in a request (the type picker is a button, see
1091
+ // startDropdowns), only once they've let someone in or denied them.
1092
+ const active = document.activeElement
1093
+ if (bar.contains(active) && !active.closest('[type=submit],[data-deny]')) return
1094
+ bar.hidden = !waiting.length
1095
+ // Access types (and their grants) belong to the owner; others who may admit pick a role.
1096
+ const asType = !!(state.accessTypes && acc.owner)
1097
+ bar.innerHTML = waiting.map((p) => `
1098
+ <form class="request" data-key="${esc(p.key)}">
1099
+ ${avatar(p.name, null)}
1100
+ <div class="rq-main"><b>${esc(p.name)}</b>${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}
1101
+ <span class="hint">wants to join · invited to ${p.invitedAs === 'viewer' ? 'view' : 'edit'}</span></div>
1102
+ ${asType
1103
+ ? `<select class="input" name="typeId" aria-label="Let ${esc(p.name)} in as" title="What they may do: an access type">${typeOptions()}</select>`
1104
+ : `<select class="input" name="role" aria-label="Role for ${esc(p.name)}">
1105
+ <option value="editor" ${p.invitedAs !== 'viewer' ? 'selected' : ''}>Can edit</option>
1106
+ <option value="viewer" ${p.invitedAs === 'viewer' ? 'selected' : ''}>View only</option>
1107
+ </select>
1108
+ ${p.kind === 'agent' ? `<input class="input" name="scopes" placeholder="All folders (or e.g. src, docs)" aria-label="Folders ${esc(p.name)} may change" title="Folders this agent may change, separated by commas">` : ''}`}
1109
+ <button type="button" class="btn sm ghost" data-deny>Deny</button>
1110
+ <button type="submit" class="btn sm primary">Let in</button>
1111
+ </form>`).join('')
1112
+ }
1113
+
1114
+ function bindAccess () {
1115
+ const bar = $('#requests')
1116
+ bar.addEventListener('submit', async (e) => {
1117
+ e.preventDefault()
1118
+ const f = e.target
1119
+ const btn = f.querySelector('[type=submit]')
1120
+ btn.disabled = true
1121
+ try {
1122
+ const body = f.typeId ? { key: f.dataset.key, typeId: f.typeId.value } : { key: f.dataset.key, role: f.role.value, scopes: f.scopes ? parseScopes(f.scopes.value) : [] }
1123
+ const r = await api('POST', `/api/sessions/${current}/members/approve`, body)
1124
+ toast(r.warning || 'Let in')
1125
+ } catch (err) { toast(err.message); btn.disabled = false }
1126
+ })
1127
+ bar.addEventListener('click', async (e) => {
1128
+ if (!e.target.closest('[data-deny]')) return
1129
+ const f = e.target.closest('form')
1130
+ try { await api('POST', `/api/sessions/${current}/members/deny`, { key: f.dataset.key }); toast('Denied') } catch (err) { toast(err.message) }
1131
+ })
1132
+ }
1133
+
1134
+ /** Owner only: renames the session for everyone in it, and on heyquilt.com. */
1135
+ async function renameSession () {
1136
+ const s = sum()
1137
+ const name = await ask({ title: 'Rename this session', message: 'Everyone in it sees the new name, here and on heyquilt.com.', ok: 'Rename', input: { label: 'Name', value: s.status.sessionName || basename(s.dir) } })
1138
+ if (!name) return
1139
+ try { await api('POST', `/api/sessions/${current}/rename`, { name }); toast('Renamed') } catch (err) { toast(err.message) }
1140
+ }
1141
+
1142
+ /**
1143
+ * The owner moves this session into a workspace (or from one to another). Its people and
1144
+ * agents come along when you manage that workspace; the toast says who was added.
1145
+ */
1146
+ async function moveToWorkspace () {
1147
+ const s = sum()
1148
+ const options = (state.workspaces || []).filter((w) => w.access === 'edit' && w.id !== s.workspace)
1149
+ if (!options.length) return toast(s.workspace ? 'No other workspace you can edit.' : 'No workspace you can edit. Make one on Home first.')
1150
+ const now = (state.workspaces || []).find((w) => w.id === s.workspace)
1151
+ const choice = await ask({
1152
+ title: 'Move to a workspace',
1153
+ message: `${now ? `It is in ${now.name} now. ` : ''}Everyone with access to this session (people and agents) is added to the workspace, when you manage it.`,
1154
+ input: { select: options.map((w) => ({ value: w.id, label: w.space?.kind === 'org' ? `${w.name} · ${w.space.name}` : w.name })) },
1155
+ ok: 'Move'
1156
+ })
1157
+ if (!choice) return
1158
+ try {
1159
+ const r = await api('POST', `/api/workspaces/${encodeURIComponent(choice)}/sessions/move`, { session: current })
1160
+ const name = options.find((w) => w.id === choice)?.name || 'the workspace'
1161
+ s.workspace = choice
1162
+ const who = (r.added || []).map((a) => a.name || 'someone')
1163
+ toast(r.peopleNeedAdmin
1164
+ ? `Moved to ${name}. Only its admins can add this session's people to it.`
1165
+ : who.length ? `Moved to ${name}, with ${who.length <= 3 ? who.join(', ') : `${who.slice(0, 3).join(', ')} and ${who.length - 3} more`}` : `Moved to ${name}`)
1166
+ // Workspace counts and members changed: refresh them, and this session's top bar.
1167
+ try { const l = await api('GET', '/api/workspaces'); state.workspaces = l.workspaces || state.workspaces } catch {}
1168
+ renderTop()
1169
+ } catch (err) { toast(err.message) }
1170
+ }
1171
+
1172
+ // ----------------------------------------------------------------- merges --
1173
+ const isViewer = () => { const a = sum().status.access || {}; return !!(a.controlled && a.role === 'viewer') }
1174
+ const shownMerge = () => (sum().status.merges || []).find((m) => m.id === ws(current).mergeSel) || null
1175
+
1176
+ // Status carries merge records without their texts; the compare view fetches
1177
+ // the full record once. Keyed by session, id and state: ours and base never
1178
+ // change for a record, but "edit by hand" and settling change its state.
1179
+ const mergeTexts = new Map() // `${session}\n${id}\n${state}` -> { ours, base } | 'loading'
1180
+ const mergeKey = (sid, m) => `${sid}\n${m.id}\n${m.state}`
1181
+
1182
+ /** The shown merge's ours and base, or undefined while they load. */
1183
+ function fullMerge (m) {
1184
+ const sid = current
1185
+ const got = mergeTexts.get(mergeKey(sid, m))
1186
+ if (got && got !== 'loading') return got
1187
+ if (got) return undefined
1188
+ mergeTexts.set(mergeKey(sid, m), 'loading')
1189
+ api('GET', `/api/sessions/${sid}/merges`).then((r) => {
1190
+ for (const full of r.merges || []) mergeTexts.set(mergeKey(sid, full), { ours: full.ours ?? null, base: full.base ?? null })
1191
+ if (mergeTexts.get(mergeKey(sid, m)) === 'loading') mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // gone meanwhile
1192
+ if (current === sid && ws(sid).mode === 'merge') renderMain()
1193
+ }).catch(() => {
1194
+ mergeTexts.set(mergeKey(sid, m), { ours: null, base: null }) // shown as not available, not refetched on every status
1195
+ if (current === sid && ws(sid).mode === 'merge') renderMain()
1196
+ })
1197
+ return undefined
1198
+ }
1199
+
1200
+ /** Files to merge, marked in the tree, and the compare view when it's showing (it only redraws on a change). */
1201
+ let lastMergeSig = ''
1202
+ function renderMerges () {
1203
+ if (!current || !sum()) return
1204
+ const merges = sum().status.merges || []
1205
+ const w = ws(current)
1206
+ // The tree marks the files to merge: drawn again only when they change.
1207
+ const sig = JSON.stringify(merges.filter((m) => m.state !== 'done').map((m) => [m.id, m.path, m.heldBy || '']))
1208
+ if (sig !== lastMergeSig) { lastMergeSig = sig; renderTreePane() }
1209
+ if (w.mode !== 'merge' || !w.mergeSel) return
1210
+ // The same file can conflict again after its record was settled: the compare
1211
+ // tab then follows the new open record instead of showing the old one.
1212
+ const shown = merges.find((m) => m.id === w.mergeSel)
1213
+ if (shown && shown.state === 'done') {
1214
+ const next = merges.find((m) => m.path === shown.path && m.state !== 'done')
1215
+ if (next) { w.mergeSel = next.id; saveWs(current); renderMainBar() }
1216
+ }
1217
+ renderMain()
1218
+ }
1219
+
1220
+ function openMerge (id) {
1221
+ const w = ws(current)
1222
+ w.mergeSel = id
1223
+ w.mode = 'merge'
1224
+ saveWs(current)
1225
+ renderMainBar()
1226
+ renderMain()
1227
+ renderTreePane()
1228
+ // Always fetch: a cached copy may be from before the session moved on.
1229
+ const m = shownMerge()
1230
+ if (m && !m.binary) refreshFile(m.path, false)
1231
+ }
1232
+
1233
+ /** Is member `m` an agent (a chat link is one too)? */
1234
+ const isAgentMember = (m) => m.kind === 'agent' || !!m.chat
1235
+
1236
+ /**
1237
+ * Owner controls for everyone who has been let in, shown in Session settings: `kind` 'people'
1238
+ * (who can let people in, and each person) or 'agents' (each agent, and the workspace's).
1239
+ */
1240
+ function membersHtml (st, kind = 'people') {
1241
+ const acc = st.access || {}
1242
+ if (!acc.controlled) return kind === 'people' ? '<div class="pm-empty">Anyone with the invite link can join this session.</div>' : ''
1243
+ const mine = (m) => (kind === 'agents') === isAgentMember(m)
1244
+ const list = (st.members || []).filter((m) => m.role !== 'owner' && mine(m))
1245
+ if (!acc.owner) {
1246
+ const all = (st.members || []).filter(mine)
1247
+ return all.length ? `<div class="pm-section"><div class="pm-title">Access</div>
1248
+ ${all.map((m) => m.chat && acc.canAdmit ? chatMemberRow(m, { remove: false }) : `<div class="pm-member"><span class="nm">${esc(m.name)}</span><span class="tag">${roleLabel(m.role)}</span>${m.scopes && m.scopes.length ? `<span class="hint">${esc(scopesText(m.scopes))}</span>` : ''}</div>`).join('')}</div>` : `<div class="pm-empty">No ${kind === 'agents' ? 'agents' : 'people'} here yet.</div>`
1249
+ }
1250
+ const admitBy = st.admitBy || acc.admitBy || 'owner'
1251
+ const admitOpts = [
1252
+ ['owner', 'Only the owner'],
1253
+ ['editors', 'Anyone who can edit'],
1254
+ ['members', 'Anyone in the session']
1255
+ ].map(([v, label]) => `<option value="${v}" ${admitBy === v ? 'selected' : ''}>${label}</option>`).join('')
1256
+ const rows = list.map((m) => m.chat ? chatMemberRow(m) : state.accessTypes && ACCOUNT_KEY.test(m.key) ? accessForm(m) : `
1257
+ <form class="pm-member edit" data-key="${esc(m.key)}">
1258
+ <span class="nm" ${memberDot(m)}${esc(m.name)}</span>
1259
+ <select class="input" name="role" aria-label="Role for ${esc(m.name)}">
1260
+ <option value="editor" ${m.role === 'editor' ? 'selected' : ''}>Can edit</option>
1261
+ <option value="viewer" ${m.role === 'viewer' ? 'selected' : ''}>View only</option>
1262
+ </select>
1263
+ ${m.kind === 'agent' ? `<input class="input" name="scopes" value="${esc(scopesText(m.scopes))}" placeholder="All folders" aria-label="Folders ${esc(m.name)} may change" title="Folders this agent may change, separated by commas">` : ''}
1264
+ <button type="button" class="btn sm ghost icon" data-remove title="Remove ${esc(m.name)}" aria-label="Remove ${esc(m.name)}">${I.x}</button>
1265
+ </form>`).join('')
1266
+ if (kind === 'agents') {
1267
+ return `<div class="pm-section"><div class="pm-title">Agents in this session</div>
1268
+ ${rows || '<div class="pm-empty">No agents yet. Invite one with the Invite button: it shows up here once you let it in.</div>'}</div>
1269
+ ${wsAgents.id === current ? sessionAgentsHtml(wsAgents.agents, st) : ''}`
1270
+ }
1271
+ return `<div class="pm-section"><div class="pm-title">Who can get in</div>
1272
+ <label class="pm-admit"><span>Who can let people in</span>
1273
+ <select class="input" name="admitBy" data-admit-by aria-label="Who can let people into this session">${admitOpts}</select>
1274
+ </label></div>
1275
+ <div class="pm-section"><div class="pm-title">People in this session</div>
1276
+ ${rows || '<div class="pm-empty">Only you so far. People you let in show up here.</div>'}
1277
+ </div>
1278
+ <div class="pm-foot"><button type="button" class="btn sm ghost danger" data-end-session>End session for everyone</button></div>`
1279
+ }
1280
+
1281
+ /**
1282
+ * The owner's GitHub token: the relay brings commits in from a private repository with it while
1283
+ * nobody's folder is online, and commits the work of agents without a folder with it (quilt_commit) when it can write.
1284
+ * Never shown back: only whether one is set.
1285
+ */
1286
+ export function githubTokenHtml (acc) {
1287
+ if (!acc || !acc.owner) return ''
1288
+ const set = !!acc.githubToken
1289
+ return `<div class="pm-section"><div class="pm-title">GitHub token</div>
1290
+ <form class="pm-gh pm-admit" autocomplete="off">
1291
+ <label for="pm-gh-token"><span>GitHub token for this session's repository</span></label>
1292
+ <input class="input" id="pm-gh-token" type="password" name="token" autocomplete="off" spellcheck="false" placeholder="${set ? 'A token is set: paste another to replace it' : 'github_pat_… for this repository'}" aria-label="GitHub token">
1293
+ <div class="pm-gh-row"><button type="submit" class="btn sm">Save token</button>${set ? '<button type="button" class="btn sm ghost" data-gh-clear>Remove token</button>' : ''}</div>
1294
+ <div class="hint">With "Contents: Read" Quilt brings in commits while everyone's offline (private repositories need it). With "Contents: Read and write" (and "Pull requests: Read and write") agents can commit their own work, within what you let them above, without anyone online. It stays on the relay and is never shown to anyone, you included.</div>
1295
+ </form></div>`
1296
+ }
1297
+
1298
+ /** With workspaces on, the agents this session's workspace invites (and the ones not invited), for its People. */
1299
+ async function loadSessionAgents () {
1300
+ const id = current
1301
+ let agents = []
1302
+ if (state.workspacesOn && sum()?.workspace && sum().status.access?.owner) {
1303
+ try { agents = (await api('GET', `/api/sessions/${id}/agents`)).agents } catch { agents = [] }
1304
+ }
1305
+ if (id !== current) return
1306
+ wsAgents = { id, agents }
1307
+ refreshPeople({ force: true })
1308
+ }
1309
+
1310
+ /** How long a chat link still works, e.g. "8 min left (until 14:32)", or "Ran out". */
1311
+ export function chatTimeLeft (expiresAt, now = Date.now()) {
1312
+ const ms = (expiresAt || 0) - now
1313
+ if (ms <= 0) return 'Ran out: make a new link to keep going'
1314
+ const min = Math.ceil(ms / 60000)
1315
+ const left = min < 60 ? `${min} min` : min < 2880 ? `${Math.round(min / 60)} h` : `${Math.round(min / 1440)} days`
1316
+ const until = new Date(expiresAt).toLocaleString(undefined, min < 1440 ? { hour: 'numeric', minute: '2-digit' } : { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
1317
+ return `${left} left (until ${until})`
1318
+ }
1319
+
1320
+ /** A chat AI let in with a chat link: how long it still works, a way to extend it, and (for the owner) remove. */
1321
+ function chatMemberRow (m, { remove = true } = {}) {
1322
+ const name = esc(m.name)
1323
+ const live = (m.expiresAt || 0) > Date.now()
1324
+ return `
1325
+ <form class="pm-member edit pm-chat" data-key="${esc(m.key)}">
1326
+ <span class="nm" ${memberDot(m)}${name} (chat link)</span>
1327
+ <span class="hint pm-now">${esc(chatTimeLeft(m.expiresAt))}</span>
1328
+ ${live ? `<select class="input" name="minutes" aria-label="Keep ${name}'s link working for">
1329
+ <option value="10">10 more minutes</option>
1330
+ <option value="60" selected>1 more hour</option>
1331
+ <option value="480">8 more hours</option>
1332
+ <option value="1440">1 more day</option>
1333
+ <option value="10080">1 more week</option>
1334
+ </select>
1335
+ <button type="button" class="btn sm" data-extend-chat>Extend</button>` : ''}
1336
+ ${remove ? `<button type="button" class="btn sm ghost icon" data-remove title="Remove ${name}" aria-label="Remove ${name}">${I.x}</button>` : ''}
1337
+ </form>`
1338
+ }
1339
+
1340
+ /**
1341
+ * The owner's "Access" section for one person: their access type, and how it's narrowed
1342
+ * for them here (view only, folders taken away, no posting). It never widens the type.
1343
+ */
1344
+ function accessForm (m) {
1345
+ // Disabled until this session's grants are here: Save only sends what was loaded.
1346
+ const v = accessFormValues(grantLoad, m)
1347
+ const ready = v.state === 'ready'
1348
+ const off = ready ? '' : 'disabled'
1349
+ const name = esc(m.name)
1350
+ return `
1351
+ <form class="pm-member edit pm-access" data-key="${esc(m.key)}">
1352
+ <span class="nm" ${memberDot(m)}${name}</span>
1353
+ <span class="hint pm-now">${esc(accessLine(m))}</span>
1354
+ ${ready ? '' : `<span class="hint pm-load ${v.state === 'error' ? 'warn' : ''}" role="status">${v.state === 'loading' ? loadingNote(v.message) : esc(v.message)}</span>`}
1355
+ <select class="input" name="typeId" aria-label="Access type for ${name}" ${off}>${typeOptions(ready ? v.typeId : '')}</select>
1356
+ <label class="pm-check"><input type="checkbox" name="viewOnly" ${ready && v.viewOnly ? 'checked' : ''} ${off}> View only</label>
1357
+ <label class="pm-check"><input type="checkbox" name="noTalk" ${ready && v.noTalk ? 'checked' : ''} ${off}> No posting</label>
1358
+ <input class="input" name="foldersRemove" value="${esc(ready ? v.foldersRemove : '')}" placeholder="Take away folders, e.g. secrets" aria-label="Folders ${name} may not change" title="Folders taken away from this person, separated by commas" ${off}>
1359
+ ${v.state === 'error' ? '<button type="button" class="btn sm" data-retry-grants>Retry</button>' : `<button type="submit" class="btn sm" ${off}>Save</button>`}
1360
+ <button type="button" class="btn sm ghost icon" data-remove title="Remove ${name}" aria-label="Remove ${name}">${I.x}</button>
1361
+ </form>`
1362
+ }
1363
+
1364
+ /** The owner's grants in this session, from the API, for the Access sections. */
1365
+ async function loadGrants () {
1366
+ const id = current
1367
+ let next
1368
+ try {
1369
+ next = grantsLoaded((await api('GET', `/api/sessions/${id}/grants`)).grants)
1370
+ } catch (err) {
1371
+ // The forms say so and offer Retry: saving from grants we couldn't check isn't safe.
1372
+ next = grantsFailed(err)
1373
+ }
1374
+ if (id !== current) return
1375
+ grantLoad = next
1376
+ // Fresh grants replace what the forms show, even one the owner is in.
1377
+ refreshPeople({ force: true })
1378
+ }
1379
+
1380
+ /** Redraws whatever shows people and access: the people menu and the Session settings window, when open. */
1381
+ function refreshPeople (opts = {}) {
1382
+ if (!$('#people-menu').hidden) renderPeopleMenu()
1383
+ if (document.querySelector('.session-settings-back')) renderSessionSettings(opts)
1384
+ }
1385
+
1386
+ /**
1387
+ * The boxes the people panel shows, fetched again when the session's boxed claims change (and, while
1388
+ * the panel is open, every few seconds: a box's change grows with each edit). Redraws when they differ.
1389
+ */
1390
+ function loadBoxes (st, { force = false } = {}) {
1391
+ const id = current
1392
+ const sig = boxesSig(st.claims)
1393
+ if (boxView.loading || (!force && sig === boxView.sig && Date.now() - boxView.at < BOXES_FRESH_MS)) return
1394
+ if (!force && !sig && sig === boxView.sig) return // nothing boxed, as before: nothing to fetch
1395
+ boxView.loading = true
1396
+ api('GET', `/api/sessions/${id}/boxes`).then((r) => {
1397
+ if (current !== id) return
1398
+ const same = JSON.stringify(r) === JSON.stringify(boxView.data)
1399
+ boxView = { ...boxView, sig, at: Date.now(), data: { on: !!r.on, boxes: Array.isArray(r.boxes) ? r.boxes : [] } }
1400
+ if (!same && !$('#people-menu').hidden) renderPeopleMenu()
1401
+ }, () => {}).finally(() => { if (boxView.id === id) boxView.loading = false })
1402
+ }
1403
+
1404
+ /** A box's controls in the people panel: show its change, Unpack your own, ask for someone else's. */
1405
+ async function boxAction (el) {
1406
+ const st = sum().status
1407
+ if (el.dataset.boxToggle) {
1408
+ const p = el.dataset.boxToggle
1409
+ if (boxView.open.has(p)) boxView.open.delete(p)
1410
+ else boxView.open.add(p)
1411
+ renderPeopleMenu()
1412
+ return
1413
+ }
1414
+ el.disabled = true
1415
+ try {
1416
+ if (el.dataset.unpack) {
1417
+ const p = el.dataset.unpack
1418
+ const r = await api('POST', `/api/sessions/${current}/unpack`, { paths: [p] })
1419
+ toast(r.unpacked && r.unpacked.length ? `Unpacked ${basename(p)}: in everyone's folder now` : `${basename(p)} isn't boxed by you anymore`)
1420
+ } else {
1421
+ const p = el.dataset.ask
1422
+ const r = await api('POST', `/api/sessions/${current}/request-file`, { path: p, title: `Needs your change to ${basename(p)}` })
1423
+ toast(`Asked ${r.holder || 'them'} for ${basename(p)}${r.position ? `: ${r.position === 1 ? 'first' : `number ${r.position}`} in line` : ''}`)
1424
+ }
1425
+ } catch (err) { toast(err.message); el.disabled = false }
1426
+ loadBoxes(st, { force: true })
1427
+ }
1428
+
1429
+ /** Who is here and what each is working on, at a glance. Access and sharing live in Session settings. */
1430
+ function renderPeopleMenu () {
1431
+ const st = sum().status
1432
+ const menu = $('#people-menu')
1433
+ loadBoxes(st)
1434
+ const boxes = boxView.data.boxes
1435
+ const placed = []
1436
+ // Under each row: how many of its files are boxed, and the boxes themselves (boxes.js).
1437
+ const boxesFor = (names) => { placed.push(...names); return boxesOf(boxes, names) }
1438
+ const boxSub = (list) => list.length || boxView.data.on ? `<span class="pm-sub pm-boxsum">${esc(boxSummary(list))}</span>` : ''
1439
+ const boxList = (list, holder) => boxListHtml(list, { me: st.me.name, open: boxView.open, holder })
1440
+ const focusEl = menu.querySelector('#focus-input')
1441
+ const typing = focusEl && document.activeElement === focusEl ? focusEl.value : null
1442
+ const self = personInfo(st.me.name)
1443
+ const message = (name) => `<button class="btn sm ghost icon pm-dm" data-dm="${esc(name)}" title="Message ${esc(name)}" aria-label="Message ${esc(name)}">${I.chat}</button>`
1444
+ const row = (p) => {
1445
+ const editing = p.editing && p.editing[0] ? `<span class="pm-sub">Editing <code>${esc(p.editing[0].path)}</code></span>` : ''
1446
+ const mine = boxesFor([p.name])
1447
+ return `<div class="pm-row">
1448
+ <button class="pm-open" data-person="${esc(p.name)}" title="Open ${p.isMe ? 'your' : `${esc(p.name)}'s`} AI chat">${avatar(p.name, p.color, p.online)}
1449
+ <span class="pm-main">
1450
+ <span class="pm-name">${esc(p.isMe ? `${p.name} (you)` : p.name)}${p.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}${toolsOf(p).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
1451
+ ${p.focus && !p.isMe ? `<span class="pm-sub pm-what">${esc(p.focus)}</span>` : ''}
1452
+ ${editing}
1453
+ ${agentLine(p) ? `<span class="pm-sub">${agentLine(p)}</span>` : ''}
1454
+ ${boxSub(mine)}
1455
+ </span></button>
1456
+ ${p.isMe ? '' : message(p.name)}
1457
+ </div>${boxList(mine, p.name)}`
1458
+ }
1459
+ // A person's AI sessions are one row, "Daniel's AI", with what each is working on under it.
1460
+ const aiRow = (g) => {
1461
+ const mine = boxesFor(g.sessions.map((x) => x.name))
1462
+ return `<div class="pm-row">
1463
+ <div class="pm-open" title="${g.sessions.length === 1 ? 'One chat' : `${g.sessions.length} chats`}: a message goes to the one active last">${avatar(g.name, null, true)}
1464
+ <span class="pm-main">
1465
+ <span class="pm-name">${esc(g.mine ? 'Your AI' : g.name)}<span class="tag bot">${I.bot}AI</span>${g.agents.map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>
1466
+ ${g.sessions.map((x) => `<span class="pm-sub pm-what" title="${esc(x.name)}">${esc(x.focus || x.name.split(' · ').slice(1).join(' · ') || x.tool || 'AI')}</span>`).join('')}
1467
+ ${boxSub(mine)}
1468
+ </span></div>
1469
+ ${message(g.name)}
1470
+ </div>${boxList(mine, g.mine ? 'Your AI' : g.name)}`
1471
+ }
1472
+ const here = peopleHere(st)
1473
+ const myAi = here.find((p) => p.sessions && p.mine)
1474
+ const others = here.filter((p) => p !== myAi)
1475
+ const selfRow = row(self)
1476
+ const myAiRow = myAi ? aiRow(myAi) : ''
1477
+ const otherRows = others.length ? others.map((p) => p.sessions ? aiRow(p) : row(personInfo(p.name))).join('') : '<div class="pm-empty">Nobody else is here yet. Use <b>Invite</b> to bring someone in.</div>'
1478
+ const away = (st.away || []).map((a) => row({ ...a, away: true, online: 'away', agent: null }))
1479
+ // Boxes whose holder is in no row (they left the session): kept under their name with the away.
1480
+ const left = unplaced(boxes, placed).map(([who, list]) => row({ name: who, away: true, online: 'away', agent: null })).filter(Boolean)
1481
+ const awayRows = [...away, ...left]
1482
+ menu.innerHTML = `
1483
+ <div class="pm-head"><span>People</span><span class="pm-count">${here.length + 1} here</span></div>
1484
+ <div class="pm-list">
1485
+ ${selfRow}
1486
+ <form class="pm-focus"><input class="input" id="focus-input" placeholder="What are you working on?" aria-label="Your focus" value="${esc(typing ?? st.me.focus ?? '')}"></form>
1487
+ ${myAiRow}
1488
+ <div class="pm-sep"></div>
1489
+ ${otherRows}
1490
+ ${awayRows.length ? `<div class="pm-sep"></div><div class="pm-head"><span>Away</span><span class="pm-count">${awayRows.length}</span></div>${awayRows.join('')}` : ''}
1491
+ </div>
1492
+ <div class="pm-foot"><button type="button" class="btn sm ghost" data-session-settings>${I.gear}<span>Session settings</span></button></div>`
1493
+ if (typing != null) {
1494
+ const el = menu.querySelector('#focus-input')
1495
+ el.focus()
1496
+ el.setSelectionRange(el.value.length, el.value.length)
1497
+ }
1498
+ }
1499
+
1500
+ /** Your AI chat's sharing in this session: two plain switches. */
1501
+ function sharingHtml (st) {
1502
+ const a = st.me.agent || {}
1503
+ // Without posting rights there's nothing to share: the feed is posting too.
1504
+ const muted = mayNotPost()
1505
+ const sharing = a.sharing !== false && !muted
1506
+ const body = a.status === 'unavailable'
1507
+ ? `<div class="hint warn">${esc(a.reason || 'Your AI feed is unavailable')}</div>`
1508
+ : `<label class="pm-switch ${muted ? 'off' : ''}"><span><b>Share my AI chat</b><small>${muted ? NO_POSTING : 'Others see your prompts and your AI\'s replies.'}</small></span><input type="checkbox" role="switch" data-share ${sharing ? 'checked' : ''} ${muted ? 'disabled' : ''}></label>
1509
+ <label class="pm-switch ${sharing ? '' : 'off'}"><span><b>Summarize it first</b><small>Share short summaries instead of every word.</small></span><input type="checkbox" role="switch" data-summarize ${a.summarized ? 'checked' : ''} ${sharing ? '' : 'disabled'}></label>`
1510
+ return `<div class="pm-section"><div class="pm-title">Your AI chat</div><div class="pm-card pm-settings">${body}</div></div>`
1511
+ }
1512
+
1513
+ /** The Session settings window: your sharing, and who can get in and what they may do here. */
1514
+ /**
1515
+ * One set of AI rules: what gathering the project's rules into AGENTS.md would change (Claude,
1516
+ * Gemini and Cursor files), file by file, applied only when the person says so.
1517
+ */
1518
+ async function openGatherRules () {
1519
+ document.querySelector('.gather-back')?.remove()
1520
+ const back = document.createElement('div')
1521
+ back.className = 'modal-back top gather-back'
1522
+ back.innerHTML = `<div class="card modal gather-rules" role="dialog" aria-modal="true" aria-labelledby="gr-title">
1523
+ <div class="ss-head"><h3 id="gr-title">One set of AI rules</h3><p class="lead">Every AI tool reads AGENTS.md. Gather the rules written for one tool into it, so everyone's AI follows the same ones.</p>
1524
+ <button type="button" class="btn sm ghost icon ss-close" data-close-gr aria-label="Close">${I.x}</button></div>
1525
+ <div class="gr-body"><p class="hint">${loadingNote('Looking at the rules…')}</p></div>
1526
+ </div>`
1527
+ document.body.appendChild(back)
1528
+ const close = () => back.remove()
1529
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
1530
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); close() } })
1531
+ back.querySelector('[data-close-gr]').onclick = close
1532
+ back.querySelector('[data-close-gr]').focus()
1533
+ const body = back.querySelector('.gr-body')
1534
+ let plan
1535
+ try { plan = await api('GET', `/api/sessions/${current}/rules/gather`) } catch (err) { body.innerHTML = `<p class="error">${esc(err.message)}</p>`; return }
1536
+ const how = (c) => c.how === 'new' ? 'new file' : c.how === 'removed' ? `removed: its rules move to ${c.to || 'AGENTS.md'}` : 'changed'
1537
+ const manual = plan.manual.length
1538
+ ? `<div class="gr-manual"><b>Place these by hand</b>${plan.manual.map((m) => `<p><code>${esc(m.path)}</code>: ${esc(m.why)}. Copy it into the AGENTS.md of the folder it is for.</p>`).join('')}</div>`
1539
+ : ''
1540
+ if (!plan.changes.length) {
1541
+ body.innerHTML = `<p class="hint">Nothing to gather: the rules are already in AGENTS.md${plan.manual.length ? ', apart from these' : ''}.</p>${manual}`
1542
+ return
1543
+ }
1544
+ body.innerHTML = `${plan.changes.map((c) => `<section class="gr-file"><div class="gr-file-head"><code>${esc(c.path)}</code><span class="hint">${esc(how(c))}</span></div>
1545
+ <pre class="chg-diff">${diffLines(c.diff)}</pre></section>`).join('')}
1546
+ ${manual}
1547
+ <div class="sec-actions"><span class="hint">Writes these files in the session, for everyone. Rules for one person stay in their own tool's settings.</span><button class="btn primary" type="button" data-apply-gr>Gather into AGENTS.md</button></div>`
1548
+ body.querySelector('[data-apply-gr]').onclick = async (e) => {
1549
+ e.target.disabled = true
1550
+ try {
1551
+ const r = await api('POST', `/api/sessions/${current}/rules/gather`, { key: plan.key })
1552
+ toast(`Gathered: ${r.changed.length} file${r.changed.length === 1 ? '' : 's'} changed`)
1553
+ close()
1554
+ } catch (err) { toast(err.message); e.target.disabled = false }
1555
+ }
1556
+ }
1557
+
1558
+ // Session settings: one window, a section at a time, picked from the list on its left.
1559
+ const SS_TABS = [
1560
+ { id: 'chat', label: 'Your AI chat', icon: 'chat' },
1561
+ { id: 'vc', label: 'Version control', icon: 'branch' },
1562
+ { id: 'agents', label: 'Agents', icon: 'bot' },
1563
+ { id: 'people', label: 'People', icon: 'users' }
1564
+ ]
1565
+ let ssTab = 'chat'
1566
+
1567
+ function openSessionSettings (tab) {
1568
+ document.querySelector('.session-settings-back')?.remove()
1569
+ $('#people-menu').hidden = true
1570
+ $('#people-btn').setAttribute('aria-expanded', 'false')
1571
+ if (tab && SS_TABS.some((t) => t.id === tab)) ssTab = tab
1572
+ const back = document.createElement('div')
1573
+ back.className = 'modal-back top session-settings-back'
1574
+ back.innerHTML = `<div class="card modal session-settings" role="dialog" aria-modal="true" aria-labelledby="ss-title">
1575
+ <nav class="ss-nav" aria-label="Session settings">
1576
+ <h3 id="ss-title">Session settings</h3>
1577
+ <p class="lead">For this session only.</p>
1578
+ <div class="ss-tabs" role="tablist" aria-orientation="vertical"></div>
1579
+ </nav>
1580
+ <div class="ss-main">
1581
+ <div class="ss-head"><h4 class="ss-name"></h4><button type="button" class="btn sm ghost icon ss-close" data-close-ss aria-label="Close">${I.x}</button></div>
1582
+ <div class="ss-body" role="tabpanel"></div>
1583
+ </div>
1584
+ </div>`
1585
+ document.body.appendChild(back)
1586
+ const close = () => back.remove()
1587
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
1588
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); close() } })
1589
+ back.querySelector('[data-close-ss]').onclick = close
1590
+ back.querySelector('.ss-tabs').addEventListener('click', (e) => {
1591
+ const b = e.target.closest('[data-ss-tab]')
1592
+ if (!b || b.dataset.ssTab === ssTab) return
1593
+ ssTab = b.dataset.ssTab
1594
+ renderSessionSettings({ force: true })
1595
+ back.querySelector('.ss-body').scrollTop = 0
1596
+ })
1597
+ back.querySelector('.ss-tabs').addEventListener('keydown', (e) => {
1598
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
1599
+ e.preventDefault()
1600
+ const i = SS_TABS.findIndex((t) => t.id === ssTab)
1601
+ ssTab = SS_TABS[(i + (e.key === 'ArrowDown' ? 1 : SS_TABS.length - 1)) % SS_TABS.length].id
1602
+ renderSessionSettings({ force: true })
1603
+ back.querySelector(`[data-ss-tab="${ssTab}"]`)?.focus()
1604
+ })
1605
+ bindSessionSettings(back)
1606
+ renderSessionSettings({ force: true })
1607
+ if (sum().status.access?.owner) { loadGrants(); if (state.workspacesOn) loadSessionAgents() }
1608
+ back.querySelector(`[data-ss-tab="${ssTab}"]`)?.focus()
1609
+ }
1610
+
1611
+ /** A section's count in the list: agents and people let in. */
1612
+ function ssCount (st, id) {
1613
+ if (id !== 'agents' && id !== 'people') return ''
1614
+ return (st.members || []).filter((m) => (id === 'agents') === isAgentMember(m)).length || ''
1615
+ }
1616
+
1617
+ function renderSessionSettings ({ force = false } = {}) {
1618
+ const back = document.querySelector('.session-settings-back')
1619
+ const box = back && back.querySelector('.ss-body')
1620
+ if (!box || !current) return
1621
+ const st = sum().status
1622
+ // The list is drawn once (a redraw under a click would lose it); here only which is on, and the counts.
1623
+ const tabs = back.querySelector('.ss-tabs')
1624
+ if (!tabs.children.length) tabs.innerHTML = SS_TABS.map((t) => `<button type="button" role="tab" class="ss-tab" data-ss-tab="${t.id}">${I[t.icon]}<span>${t.label}</span><span class="ss-count"></span></button>`).join('')
1625
+ for (const b of tabs.children) {
1626
+ const on = b.dataset.ssTab === ssTab
1627
+ b.classList.toggle('on', on)
1628
+ b.setAttribute('aria-selected', String(on))
1629
+ b.tabIndex = on ? 0 : -1
1630
+ b.querySelector('.ss-count').textContent = ssCount(st, b.dataset.ssTab)
1631
+ }
1632
+ box.dataset.tab = ssTab
1633
+ back.querySelector('.ss-name').textContent = SS_TABS.find((t) => t.id === ssTab).label
1634
+ // Don't redraw under the owner while they change someone's access (unless fresh grants
1635
+ // arrived: then the form is redrawn from them, and keeps its focus).
1636
+ const active = box.contains(document.activeElement) && document.activeElement.closest('.pm-member') ? document.activeElement : null
1637
+ if (!force && active && box.querySelector('.pm-member.edit')) return
1638
+ const refocus = active && active.name ? [active.closest('.pm-member').dataset.key, active.name] : null
1639
+ const vc = githubSettingsHtml(st) + githubTokenHtml(st.access)
1640
+ box.innerHTML = ssTab === 'chat' ? sharingHtml(st)
1641
+ : ssTab === 'vc' ? (vc || '<div class="pm-empty">Only the session owner changes how this session works with GitHub.</div>')
1642
+ : membersHtml(st, ssTab)
1643
+ if (refocus) [...box.querySelectorAll('.pm-member')].find((f) => f.dataset.key === refocus[0])?.elements?.[refocus[1]]?.focus()
1644
+ }
1645
+
1646
+ // ------------------------------------------------------ main area (AI/Files) --
1647
+ function bindMain () {
1648
+ $('#main-tabs').addEventListener('click', (e) => {
1649
+ const close = e.target.closest('[data-close]')
1650
+ const tab = e.target.closest('[data-tab]')
1651
+ const w = ws(current)
1652
+ if (close && close.dataset.kind === 'merge') {
1653
+ e.stopPropagation()
1654
+ w.mergeSel = null
1655
+ if (w.mode === 'merge') w.mode = w.fileSel ? 'files' : 'ai'
1656
+ saveWs(current)
1657
+ renderMainBar()
1658
+ renderMain()
1659
+ renderTreePane()
1660
+ return
1661
+ }
1662
+ if (tab && tab.dataset.kind === 'merge') { openMerge(tab.dataset.tab); return }
1663
+ if (tab && tab.dataset.kind === 'loom') { openLoom(); return }
1664
+ if (close) {
1665
+ e.stopPropagation()
1666
+ const key = close.dataset.close
1667
+ const isAi = close.dataset.kind === 'ai'
1668
+ const list = isAi ? w.aiTabs : w.fileTabs
1669
+ const i = list.indexOf(key)
1670
+ if (i !== -1) list.splice(i, 1)
1671
+ if (!isAi && w.fileTab) delete w.fileTab[key]
1672
+ // Closing a person's AI tab goes back to the Loom; a file tab to the file beside it, or the Loom.
1673
+ if (isAi && w.aiSel === key) w.aiSel = null
1674
+ if (!isAi && w.fileSel === key) w.fileSel = list[Math.min(i, list.length - 1)] || null
1675
+ if (!isAi && w.mode === 'files' && !w.fileSel) w.mode = 'ai'
1676
+ saveWs(current)
1677
+ renderMainBar()
1678
+ renderMain()
1679
+ renderTreePane()
1680
+ return
1681
+ }
1682
+ if (tab) {
1683
+ if (tab.dataset.kind === 'ai') openPerson(tab.dataset.tab)
1684
+ else openFile(tab.dataset.tab)
1685
+ }
1686
+ })
1687
+ $('#main').addEventListener('click', async (e) => {
1688
+ const copy = e.target.closest('[data-copy-invite]')
1689
+ if (copy) {
1690
+ const view = copy.dataset.copyInvite === 'view'
1691
+ try { await navigator.clipboard.writeText(view ? sum().viewInvite : sum().invite); toast(view ? 'View-only link copied' : 'Invite link copied') } catch { openInvite(current) }
1692
+ return
1693
+ }
1694
+ const b = e.target.closest('[data-person]')
1695
+ if (b) openPerson(b.dataset.person)
1696
+ const c = e.target.closest('[data-conv]')
1697
+ if (c) pickConv(c.dataset.conv)
1698
+ const t = e.target.closest('[data-fvtab]')
1699
+ if (t) showFileTab(t.dataset.fvtab)
1700
+ const f = e.target.closest('[data-fv]')
1701
+ if (f && f.dataset.fv === 'claim') claimPath(ws(current).fileSel, '')
1702
+ if (f && f.dataset.fv === 'release') releasePattern(f.dataset.pattern)
1703
+ })
1704
+ }
1705
+
1706
+ function openLoom () {
1707
+ const w = ws(current)
1708
+ w.aiSel = null
1709
+ w.mode = 'ai'
1710
+ saveWs(current)
1711
+ renderMainBar()
1712
+ renderMain()
1713
+ renderTreePane()
1714
+ renderTop()
1715
+ }
1716
+
1717
+ function openPerson (name) {
1718
+ const w = ws(current)
1719
+ if (!w.aiTabs.includes(name)) w.aiTabs.push(name)
1720
+ w.aiSel = name
1721
+ w.mode = 'ai'
1722
+ saveWs(current)
1723
+ renderMainBar()
1724
+ renderMain()
1725
+ renderTreePane()
1726
+ if (!state.feeds.get(current)?.has(name)) loadFeed(name)
1727
+ }
1728
+
1729
+ /** Show one of the person's conversations. Picking the newest goes back to following whatever is newest. */
1730
+ function pickConv (conv) {
1731
+ const w = ws(current)
1732
+ if (!w.aiSel) return
1733
+ const entries = state.feeds.get(current)?.get(w.aiSel) || []
1734
+ const newest = conversations(entries)[0]
1735
+ if (!w.convSel) w.convSel = {}
1736
+ if (newest && newest.conv === conv) delete w.convSel[w.aiSel]
1737
+ else w.convSel[w.aiSel] = conv
1738
+ saveWs(current)
1739
+ renderMain()
1740
+ }
1741
+
1742
+ /** Opens a file tab; `tab: 'changes'` shows its Changes tab, otherwise the tab it was on. */
1743
+ function openFile (path, { tab } = {}) {
1744
+ const w = ws(current)
1745
+ if (!w.fileTabs.includes(path)) w.fileTabs.push(path)
1746
+ w.fileSel = path
1747
+ w.mode = 'files'
1748
+ if (!w.fileTab) w.fileTab = {}
1749
+ if (tab === 'changes') w.fileTab[path] = 'changes'
1750
+ else if (tab === 'file') delete w.fileTab[path]
1751
+ loadHistory(path)
1752
+ const wasStale = w.stale[path]
1753
+ delete w.stale[path]
1754
+ saveWs(current)
1755
+ renderMainBar()
1756
+ renderMain()
1757
+ renderTreePane()
1758
+ if (window.matchMedia('(max-width: 900px)').matches) openDrawer(null)
1759
+ if (!state.files.has(fileKey(path)) || wasStale) refreshFile(path, !!wasStale)
1760
+ }
1761
+
1762
+ function renderMainBar () {
1763
+ if (!current || !$('#main-tabs')) return
1764
+ const w = ws(current)
1765
+ const el = $('#main-tabs')
1766
+ const aiOn = (name) => w.mode === 'ai' && w.aiSel === name
1767
+ const fileOn = (path) => w.mode === 'files' && w.fileSel === path
1768
+ const loomOn = w.mode === 'ai' && !w.aiSel
1769
+ const st = sum().status
1770
+ const anyWorking = [st.me, ...st.peers].some((p) => p?.agent && p.agent.sharing !== false && p.agent.status === 'working')
1771
+ const loomTab = `<div class="ws-tab loom-tab${loomOn ? ' on' : ''}" role="tab" aria-selected="${loomOn}" tabindex="0" data-kind="loom" data-tab="loom" title="Everyone's AI conversations, chat and task notes at once">
1772
+ <span class="ico">${I.users}</span><span class="nm">Everyone</span>${anyWorking ? '<span class="pulse" title="An AI is working"></span>' : ''}</div>`
1773
+ el.innerHTML = loomTab + (w.aiTabs.length ? '<span class="ws-tab-sep"></span>' : '') + w.aiTabs.map((name) => {
1774
+ const p = personInfo(name)
1775
+ const working = p.agent && p.agent.sharing !== false && p.agent.status === 'working'
1776
+ return `<div class="ws-tab${aiOn(name) ? ' on' : ''}" role="tab" aria-selected="${aiOn(name)}" tabindex="0" data-kind="ai" data-tab="${esc(name)}" title="${esc(p.isMe ? 'Your AI chat' : `${name}'s AI chat`)}">
1777
+ ${avatar(name, p.color, p.online)}<span class="nm">${esc(p.isMe ? 'Your AI' : `${name}'s AI`)}</span>${working ? '<span class="pulse" title="AI is working"></span>' : ''}
1778
+ <button class="x" data-kind="ai" data-close="${esc(name)}" aria-label="Close ${esc(name)}">${I.x}</button></div>`
1779
+ }).join('') + (w.fileTabs.length ? '<span class="ws-tab-sep"></span>' : '') +
1780
+ w.fileTabs.map((path) => `<div class="ws-tab${fileOn(path) ? ' on' : ''}" role="tab" aria-selected="${fileOn(path)}" tabindex="0" data-kind="file" data-tab="${esc(path)}" title="${esc(path)}">
1781
+ <span class="ico">${I.file}</span><span class="nm">${esc(basename(path))}</span>${w.stale[path] ? '<span class="changed" title="Changed"></span>' : ''}
1782
+ <button class="x" data-kind="file" data-close="${esc(path)}" aria-label="Close ${esc(basename(path))}">${I.x}</button></div>`).join('') +
1783
+ (w.mergeSel ? mergeTabHtml(w) : '')
1784
+ // On Everyone with nothing else open, the row would only name the view you are in: hide it until
1785
+ // there is a tab to switch to (or, on the Tasks board, Everyone to go back to).
1786
+ $('#mainbar').hidden = loomOn && !w.aiTabs.length && !w.fileTabs.length && !w.mergeSel
1787
+ }
1788
+
1789
+ function mergeTabHtml (w) {
1790
+ const m = shownMerge()
1791
+ const on = w.mode === 'merge'
1792
+ const name = m ? `Merge ${basename(m.path)}` : 'Merge'
1793
+ return `<span class="ws-tab-sep"></span><div class="ws-tab${on ? ' on' : ''}" role="tab" aria-selected="${on}" tabindex="0" data-kind="merge" data-tab="${esc(w.mergeSel)}" title="${esc(m ? `Compare the two versions of ${m.path}` : 'Merge')}">
1794
+ <span class="ico">${I.branch}</span><span class="nm">${esc(name)}</span>
1795
+ <button class="x" data-kind="merge" data-close="${esc(w.mergeSel)}" aria-label="Close merge">${I.x}</button></div>`
1796
+ }
1797
+
1798
+ // A card being renamed, or a drag in progress, must not be rebuilt under the pointer.
1799
+ let draggingTask = false
1800
+ let boardDirty = false
1801
+ // Who the next task is for, chosen before Add: `p:name`, `a:name`, or '' for nobody.
1802
+ // null until chosen: the add form then follows the board's Show choice.
1803
+ let pendingAssign = null
1804
+
1805
+ function renderTaskButton () {
1806
+ const btn = $('#tasks-btn')
1807
+ if (!btn || !current) return
1808
+ const open = (sum()?.status.tasks || []).filter((t) => !t.archived && (t.column === 'todo' || t.column === 'doing')).length
1809
+ const n = $('#tasks-count')
1810
+ if (n) { n.hidden = open === 0; n.textContent = String(open) }
1811
+ const on = ws(current).mode === 'tasks'
1812
+ btn.classList.toggle('on', on)
1813
+ btn.setAttribute('aria-pressed', String(on))
1814
+ }
1815
+
1816
+ function editingTask () {
1817
+ const el = document.activeElement
1818
+ if (!el?.closest) return false
1819
+ return !!el.closest('.task-edit, .task-assign, .task-file, .task-file-form, .task-cron, .task-cron-form, .task-add-assign, .task-show')
1820
+ }
1821
+
1822
+ function taskPeople (st) {
1823
+ // A person's AI sessions are assigned as "their AI" (forAi), not one by one.
1824
+ return [st.me, ...(st.peers || [])].filter((p) => p && p.name && !p.persona).map((p) => ({
1825
+ name: p.name,
1826
+ color: p.color,
1827
+ tool: p.tool && p.tool !== 'unknown' ? p.tool : '',
1828
+ agent: p.kind === 'agent'
1829
+ }))
1830
+ }
1831
+
1832
+ function assignmentFromValue (value, st) {
1833
+ if (!value) return { assignee: '', forAi: false, tool: '' }
1834
+ const forAi = value.startsWith('a:')
1835
+ const assignee = value.slice(2)
1836
+ const person = [st.me, ...(st.peers || [])].find((p) => p.name === assignee)
1837
+ const saved = (st.tasks || []).find((t) => t.assignee === assignee && !!t.forAi === forAi)
1838
+ const live = person?.tool && person.tool !== 'unknown' ? person.tool : ''
1839
+ return { assignee, forAi, tool: forAi ? (live || saved?.tool || '') : '' }
1840
+ }
1841
+
1842
+ // Board scroll survives a repaint: the column row's horizontal position and
1843
+ // each column's own vertical position, keyed by column id.
1844
+ let lastBoard = { html: '', el: null }
1845
+ function boardScroll (root) {
1846
+ const cols = root?.querySelector('.board-cols')
1847
+ if (!cols) return null
1848
+ const lists = {}
1849
+ for (const col of cols.querySelectorAll('.board-col')) {
1850
+ const list = col.querySelector('.board-list')
1851
+ if (list) lists[col.dataset.column] = list.scrollTop
1852
+ }
1853
+ return { left: cols.scrollLeft, top: cols.scrollTop, lists }
1854
+ }
1855
+
1856
+ function restoreBoardScroll (root, scroll) {
1857
+ if (!scroll) return
1858
+ const cols = root?.querySelector('.board-cols')
1859
+ if (!cols) return
1860
+ cols.scrollLeft = scroll.left
1861
+ cols.scrollTop = scroll.top
1862
+ for (const col of cols.querySelectorAll('.board-col')) {
1863
+ const list = col.querySelector('.board-list')
1864
+ const top = scroll.lists[col.dataset.column]
1865
+ if (list && typeof top === 'number') list.scrollTop = top
1866
+ }
1867
+ }
1868
+
1869
+ // ------------------------------------------------------ board: folding --
1870
+ // The board always fits: when its columns can't all sit side by side, the ones
1871
+ // you're not using fold to a thin strip (name and count), so nothing is ever off
1872
+ // to the side. foldPlan (board.js) decides which; this lays it out and keeps your
1873
+ // choices per session: the columns you opened (most recent first) and folded.
1874
+ const boardCounts = new Map() // session -> { column: count } at the last layout, to pulse a folded column that grew
1875
+ let unfoldTimer = null // a dragged card resting on a strip opens that column
1876
+
1877
+ function boardChoices () {
1878
+ const w = ws(current)
1879
+ return { picked: w.boardOpen || [], folded: w.boardFolded || [], counts: boardCounts.get(current) || {} }
1880
+ }
1881
+
1882
+ /**
1883
+ * Folds and opens the columns to fit the board's width. `animate` when you opened or folded one:
1884
+ * a fresh render or a resize lands in place, or every status tick would replay the folding.
1885
+ */
1886
+ function layoutBoard (root = $('#main'), { animate = false } = {}) {
1887
+ const board = root?.querySelector('.board')
1888
+ const cols = board?.querySelector('.board-cols')
1889
+ if (!cols) return
1890
+ if (!animate) cols.classList.add('still')
1891
+ const all = [...cols.querySelectorAll('.board-col')]
1892
+ const counts = {}
1893
+ for (const col of all) counts[col.dataset.column] = col.querySelectorAll('.task').length // every card, found by a search or not
1894
+ const pad = parseFloat(getComputedStyle(cols).paddingLeft) || 0
1895
+ const plan = foldPlan({ ...boardChoices(), counts, room: cols.clientWidth - 2 * pad })
1896
+ const open = new Set(plan.open)
1897
+ board.dataset.layout = plan.layout
1898
+ const before = boardCounts.get(current)
1899
+ for (const col of all) {
1900
+ const id = col.dataset.column
1901
+ const isOpen = open.has(id)
1902
+ col.classList.toggle('folded', !isOpen)
1903
+ // Hidden parts can't be tabbed into or read out: the strip when open, the cards when folded.
1904
+ col.querySelector('.board-strip').inert = isOpen
1905
+ for (const part of col.querySelectorAll(':scope > h3, :scope > .board-list')) part.inert = !isOpen
1906
+ // The last open column can't be folded: something is always open.
1907
+ const fold = col.querySelector('.board-fold')
1908
+ if (fold) fold.hidden = isOpen && open.size === 1
1909
+ if (!isOpen && before && counts[id] > (before[id] ?? counts[id])) {
1910
+ const strip = col.querySelector('.board-strip')
1911
+ strip.classList.remove('grew')
1912
+ void strip.offsetWidth // restart the animation
1913
+ strip.classList.add('grew')
1914
+ }
1915
+ }
1916
+ boardCounts.set(current, counts)
1917
+ if (!animate) {
1918
+ void cols.offsetWidth // settle without transitions, then let later changes animate
1919
+ cols.classList.remove('still')
1920
+ }
1921
+ }
1922
+
1923
+ /** Opens a column: it becomes the most recent, so the least recent open one folds to make room. */
1924
+ function openColumn (id, { focus = false } = {}) {
1925
+ const w = ws(current)
1926
+ const { order, folded } = foldPlan({ ...boardChoices(), room: 0 })
1927
+ w.boardOpen = [id, ...order.filter((x) => x !== id)]
1928
+ w.boardFolded = folded.filter((x) => x !== id)
1929
+ saveWs(current)
1930
+ layoutBoard(undefined, { animate: true })
1931
+ if (focus) $(`#main .board-col[data-column="${id}"]`)?.focus({ preventScroll: true }) // the strip clicked is gone: focus its column
1932
+ }
1933
+
1934
+ /** Folds a column to its strip, and keeps it folded until it's opened again. */
1935
+ function foldColumn (id) {
1936
+ const w = ws(current)
1937
+ const { order, folded } = foldPlan({ ...boardChoices(), room: 0 })
1938
+ w.boardFolded = [...new Set([...folded, id])]
1939
+ w.boardOpen = [...order.filter((x) => x !== id), id]
1940
+ saveWs(current)
1941
+ layoutBoard(undefined, { animate: true })
1942
+ $(`#main .board-col[data-column="${id}"] .board-strip`)?.focus()
1943
+ }
1944
+
1945
+ function cancelUnfold () {
1946
+ clearTimeout(unfoldTimer)
1947
+ unfoldTimer = null
1948
+ }
1949
+
1950
+ // -------------------------------------------------------- board: search --
1951
+ // The search box hides the cards that don't hold every word typed (board.js
1952
+ // searchText), in every column. Column and strip counts become the matches, and a
1953
+ // folded column with matches stands out, so nothing found hides in a strip.
1954
+ let boardQuery = ''
1955
+
1956
+ function searchBoard (root = $('#main')) {
1957
+ const input = root?.querySelector('#board-search')
1958
+ if (!input) return
1959
+ if (input.value !== boardQuery) input.value = boardQuery
1960
+ const q = boardQuery.trim()
1961
+ let found = 0
1962
+ let total = 0
1963
+ for (const col of root.querySelectorAll('.board-col')) {
1964
+ let n = 0
1965
+ const cards = col.querySelectorAll('.task')
1966
+ for (const card of cards) {
1967
+ const show = !q || matchesSearch(card.dataset.search || '', q)
1968
+ card.hidden = !show
1969
+ if (show) n++
1970
+ }
1971
+ total += cards.length
1972
+ found += n
1973
+ const shown = q ? n : cards.length
1974
+ const count = col.querySelector('h3 .board-n')
1975
+ if (count) count.textContent = shown
1976
+ const strip = col.querySelector('.board-strip-n')
1977
+ if (strip) { strip.textContent = shown; strip.dataset.n = shown }
1978
+ col.classList.toggle('search-hit', !!q && n > 0)
1979
+ col.classList.toggle('search-miss', !!q && !n)
1980
+ let none = col.querySelector('.board-nomatch')
1981
+ if (q && !n && cards.length) {
1982
+ if (!none) {
1983
+ none = document.createElement('p')
1984
+ none.className = 'board-empty board-nomatch'
1985
+ col.querySelector('.board-list')?.appendChild(none)
1986
+ }
1987
+ none.textContent = 'No matching tickets.'
1988
+ } else none?.remove()
1989
+ }
1990
+ root.querySelector('.board')?.classList.toggle('searching', !!q)
1991
+ const n = root.querySelector('.board-search-n')
1992
+ if (n) n.textContent = q ? `${found} of ${total}` : ''
1993
+ labelFilter(root, { q, found })
1994
+ }
1995
+
1996
+ // ------------------------------------------------------- board: filter --
1997
+ // Search and Show live behind one Filter button (board.js filterHtml). The button
1998
+ // says what's on: "agents" · Sam · 8 of 19, accented, with an × to clear it all.
1999
+ let filterOpen = false
2000
+
2001
+ function labelFilter (root, { q = boardQuery.trim(), found = null } = {}) {
2002
+ const box = root?.querySelector('#board-filter')
2003
+ if (!box) return
2004
+ const showing = box.dataset.showLabel || ''
2005
+ const total = Number(box.dataset.total) || 0
2006
+ const visible = found ?? root.querySelectorAll('.board-col .task:not([hidden])').length
2007
+ const on = !!(q || showing)
2008
+ const parts = [q && `“${q}”`, showing, on && !root.querySelector('.board.archived') && `${visible} of ${total}`].filter(Boolean)
2009
+ const label = box.querySelector('.board-filter-label')
2010
+ if (label) label.textContent = on ? parts.join(' · ') : 'Filter'
2011
+ box.querySelector('[data-filter-toggle]')?.setAttribute('title', on ? `Filtered: ${parts.join(' · ')}` : 'Search and filter tickets (/)')
2012
+ box.classList.toggle('on', on)
2013
+ for (const x of box.querySelectorAll('[data-filter-clear]')) x.hidden = !on
2014
+ }
2015
+
2016
+ /** Opens or closes the Filter panel; it stays open across repaints until closed. */
2017
+ function setFilterOpen (open, { focus } = {}) {
2018
+ filterOpen = open
2019
+ const panel = $('#board-filter-panel')
2020
+ const btn = $('#board-filter [data-filter-toggle]')
2021
+ if (!panel || !btn) return
2022
+ panel.hidden = !open
2023
+ btn.setAttribute('aria-expanded', String(open))
2024
+ if (open && focus === 'search') { const i = $('#board-search'); i?.focus(); i?.select() }
2025
+ if (open && focus === 'first') ($('#board-search') || panel.querySelector('[aria-checked="true"]'))?.focus()
2026
+ if (!open && focus === 'button') btn.focus()
2027
+ }
2028
+
2029
+ function clearFilters () {
2030
+ boardQuery = ''
2031
+ const w = ws(current)
2032
+ if (w.show) { w.show = ''; saveWs(current); paintBoard({ force: true }) } else searchBoard()
2033
+ }
2034
+
2035
+ // ------------------------------------------------------ board: archived --
2036
+ // Typing in the archive's filter hides the rows whose title doesn't match, and
2037
+ // opens the groups so matches show; the groups you folded are kept per session.
2038
+ let archiveQuery = ''
2039
+
2040
+ function filterArchive (root = $('#main')) {
2041
+ const input = root?.querySelector('#archive-filter')
2042
+ if (!input) return
2043
+ if (input.value !== archiveQuery) input.value = archiveQuery
2044
+ const q = archiveQuery.trim().toLowerCase()
2045
+ const closed = ws(current).archiveClosed || []
2046
+ let any = false
2047
+ for (const group of root.querySelectorAll('.archive-group')) {
2048
+ let n = 0
2049
+ for (const row of group.querySelectorAll('.task-archived')) {
2050
+ const show = !q || row.dataset.title.includes(q)
2051
+ row.hidden = !show
2052
+ if (show) n++
2053
+ }
2054
+ group.hidden = !n
2055
+ group.querySelector('[data-group-count]').textContent = n
2056
+ group.open = q ? n > 0 : !closed.includes(group.dataset.group)
2057
+ any ||= n > 0
2058
+ }
2059
+ root.querySelector('.board-archive-none').hidden = any || !q
2060
+ }
2061
+
2062
+ /** Asks before removing an archived task for good: the row turns into a confirmation. */
2063
+ function askRemove (row, ask) {
2064
+ row.classList.toggle('confirming', ask)
2065
+ if (ask) row.querySelector('[data-task-remove-cancel]')?.focus() // the safe choice has focus
2066
+ else row.querySelector('[data-task-remove-ask]')?.focus()
2067
+ }
2068
+
2069
+ // A plain mouse wheel only scrolls up and down. Over the board's headers,
2070
+ // gaps or a column too short to scroll, turn that into sideways movement so
2071
+ // the column row can be reached without a trackpad or the scrollbar.
2072
+ function boardWheel (e) {
2073
+ if (e.ctrlKey || e.defaultPrevented) return
2074
+ const cols = e.target.closest?.('.board-cols')
2075
+ if (!cols || cols.scrollWidth <= cols.clientWidth + 1) return
2076
+ if (getComputedStyle(cols).overflowX === 'visible') return
2077
+ const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY
2078
+ if (!dy || Math.abs(e.deltaX) >= Math.abs(dy) || e.shiftKey) return
2079
+ const list = e.target.closest('.board-list')
2080
+ if (list && list.scrollHeight > list.clientHeight + 1) return
2081
+ // Stacked phone layout scrolls vertically; leave it alone.
2082
+ if (cols.scrollHeight > cols.clientHeight + 1) return
2083
+ e.preventDefault()
2084
+ cols.scrollLeft += dy
2085
+ }
2086
+
2087
+ function paintBoard ({ force = false } = {}) {
2088
+ if (!current || !$('#main')) return
2089
+ renderTaskButton()
2090
+ if (ws(current).mode !== 'tasks') return
2091
+ if (!force && (draggingTask || editingTask())) { boardDirty = true; return }
2092
+ boardDirty = false
2093
+ const add = !force && document.activeElement?.id === 'task-add'
2094
+ ? { value: document.activeElement.value, pos: document.activeElement.selectionStart }
2095
+ : null
2096
+ renderMain()
2097
+ if (!add) return
2098
+ const input = $('#task-add')
2099
+ if (!input) return
2100
+ input.value = add.value
2101
+ input.focus()
2102
+ try { input.setSelectionRange(add.pos, add.pos) } catch {}
2103
+ }
2104
+
2105
+ async function changeTasks (path, body, beforePaint) {
2106
+ const id = current
2107
+ const { tasks } = await api('POST', `/api/sessions/${id}/tasks${path}`, body)
2108
+ if (id !== current) return
2109
+ const s = state.sessions.get(id)
2110
+ if (s) s.status.tasks = tasks
2111
+ beforePaint?.()
2112
+ paintBoard({ force: true })
2113
+ }
2114
+
2115
+ function beginEdit (btn) {
2116
+ const card = btn.closest('.task')
2117
+ if (!card || card.querySelector('.task-edit')) return
2118
+ const input = document.createElement('input')
2119
+ input.className = 'task-edit'
2120
+ input.value = btn.textContent
2121
+ input.maxLength = 200
2122
+ input.setAttribute('aria-label', 'Task')
2123
+ const original = btn.textContent
2124
+ card.draggable = false
2125
+ btn.replaceWith(input)
2126
+ input.focus()
2127
+ input.select()
2128
+ const finish = async (save) => {
2129
+ if (input.dataset.done) return
2130
+ input.dataset.done = '1'
2131
+ const title = input.value.trim()
2132
+ const taskId = card.dataset.task
2133
+ if (!save || !title || title === original) { paintBoard({ force: true }); return }
2134
+ try { await changeTasks('/update', { id: taskId, title }) }
2135
+ catch (err) { toast(err.message); paintBoard({ force: true }) }
2136
+ }
2137
+ input.addEventListener('keydown', (e) => {
2138
+ if (e.key === 'Enter') { e.preventDefault(); finish(true) }
2139
+ if (e.key === 'Escape') { e.preventDefault(); finish(false) }
2140
+ })
2141
+ input.addEventListener('blur', () => finish(true))
2142
+ }
2143
+
2144
+ function bindBoard () {
2145
+ const el = $('#main')
2146
+ el.addEventListener('wheel', boardWheel, { passive: false })
2147
+ el.addEventListener('input', (e) => {
2148
+ if (e.target.id === 'board-search') { boardQuery = e.target.value; searchBoard(el); return }
2149
+ if (e.target.id !== 'archive-filter') return
2150
+ archiveQuery = e.target.value
2151
+ filterArchive(el)
2152
+ })
2153
+ // "/" from anywhere on the board (not while typing) goes to the search.
2154
+ document.addEventListener('keydown', (e) => {
2155
+ if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey || !current || ws(current).mode !== 'tasks') return
2156
+ if (e.target.closest?.('input, textarea, select, [contenteditable="true"]')) return
2157
+ if (!$('#board-filter')) return
2158
+ e.preventDefault()
2159
+ setFilterOpen(true, { focus: $('#board-search') ? 'search' : 'first' })
2160
+ }, { signal: mounted.signal })
2161
+ // A click outside the Filter panel closes it.
2162
+ document.addEventListener('mousedown', (e) => {
2163
+ if (filterOpen && !e.target.closest?.('#board-filter')) setFilterOpen(false)
2164
+ }, { signal: mounted.signal })
2165
+ // Folding a group is remembered, but not the opening that filtering does.
2166
+ el.addEventListener('toggle', (e) => {
2167
+ const group = e.target.closest?.('.archive-group')
2168
+ if (!group || archiveQuery.trim()) return
2169
+ const w = ws(current)
2170
+ const closed = new Set(w.archiveClosed || [])
2171
+ if (group.open) closed.delete(group.dataset.group)
2172
+ else closed.add(group.dataset.group)
2173
+ w.archiveClosed = [...closed]
2174
+ saveWs(current)
2175
+ }, true)
2176
+ el.addEventListener('keydown', (e) => {
2177
+ if (e.key !== 'Escape') return
2178
+ const row = e.target.closest?.('.task-archived.confirming')
2179
+ if (row) { e.preventDefault(); askRemove(row, false); return }
2180
+ if (e.target.id === 'archive-filter' && archiveQuery) { e.preventDefault(); archiveQuery = ''; filterArchive(el) }
2181
+ if (filterOpen && e.target.closest?.('#board-filter')) { e.preventDefault(); setFilterOpen(false, { focus: 'button' }); return }
2182
+ if (e.target.closest?.('[data-filter-toggle]') && boardQuery) { e.preventDefault(); boardQuery = ''; searchBoard(el) }
2183
+ })
2184
+ // The board's width changes with the window and with the file tree and chat beside it.
2185
+ const resized = new ResizeObserver(() => { if (current && ws(current).mode === 'tasks') layoutBoard(el) })
2186
+ resized.observe(el)
2187
+ mounted.signal.addEventListener('abort', () => resized.disconnect())
2188
+ el.addEventListener('click', (e) => {
2189
+ if (e.target.closest?.('[data-filter-toggle]')) { setFilterOpen(!filterOpen, { focus: filterOpen ? null : 'first' }); return }
2190
+ if (e.target.closest?.('[data-filter-clear]')) { clearFilters(); return }
2191
+ const pick = e.target.closest?.('[data-show-pick]')
2192
+ if (pick) {
2193
+ // Kept per session, like the rest of the workspace. The panel stays open to try another.
2194
+ const value = pick.dataset.showPick
2195
+ ws(current).show = value
2196
+ saveWs(current)
2197
+ paintBoard({ force: true })
2198
+ const again = [...document.querySelectorAll('#board-filter [data-show-pick]')].find((b) => b.dataset.showPick === value)
2199
+ again?.focus()
2200
+ return
2201
+ }
2202
+ const unfold = e.target.closest?.('[data-unfold]')
2203
+ if (unfold) { openColumn(unfold.dataset.unfold, { focus: true }); return }
2204
+ const fold = e.target.closest?.('[data-fold]')
2205
+ if (fold) foldColumn(fold.dataset.fold)
2206
+ })
2207
+ el.addEventListener('focusout', () => {
2208
+ setTimeout(() => { if (!editingTask() && boardDirty) paintBoard() }, 0)
2209
+ })
2210
+ el.addEventListener('change', async (e) => {
2211
+ const addAssign = e.target.closest?.('#task-add-assign')
2212
+ if (addAssign) { pendingAssign = addAssign.value; return }
2213
+ const sel = e.target.closest?.('select.task-assign')
2214
+ if (!sel) return
2215
+ const id = sel.closest('.task')?.dataset.task
2216
+ if (!id) return
2217
+ try { await changeTasks('/update', { id, ...assignmentFromValue(sel.value, sum().status) }) }
2218
+ catch (err) { toast(err.message); paintBoard({ force: true }) }
2219
+ })
2220
+ el.addEventListener('submit', async (e) => {
2221
+ if (e.target.classList.contains('task-cron-form')) {
2222
+ e.preventDefault()
2223
+ const input = e.target.querySelector('input')
2224
+ const id = e.target.closest('.task')?.dataset.task
2225
+ if (!id || !input) return
2226
+ try { await changeTasks('/update', { id, cron: input.value }) }
2227
+ catch (err) { toast(err.message) }
2228
+ return
2229
+ }
2230
+ if (e.target.classList.contains('task-file-form')) {
2231
+ e.preventDefault()
2232
+ const input = e.target.querySelector('input')
2233
+ const path = input.value.trim()
2234
+ const id = e.target.closest('.task')?.dataset.task
2235
+ if (!path || !id) return
2236
+ const task = (sum()?.status.tasks || []).find((t) => t.id === id)
2237
+ const files = [...(task?.files || [])]
2238
+ if (!files.includes(path)) files.push(path)
2239
+ try { await changeTasks('/update', { id, files }) }
2240
+ catch (err) { toast(err.message); input.focus() }
2241
+ return
2242
+ }
2243
+ if (e.target.id !== 'task-add-form') return
2244
+ e.preventDefault()
2245
+ const input = e.target.querySelector('input')
2246
+ const title = input.value.trim()
2247
+ if (!title) return
2248
+ const button = e.target.querySelector('button[type="submit"]')
2249
+ button.disabled = true
2250
+ const who = assignmentFromValue(e.target.querySelector('#task-add-assign')?.value || pendingAssign, sum().status)
2251
+ try {
2252
+ await changeTasks('', { title, ...who }, () => { pendingAssign = null })
2253
+ $('#task-add')?.focus()
2254
+ } catch (err) { toast(err.message); button.disabled = false }
2255
+ })
2256
+ el.addEventListener('click', async (e) => {
2257
+ const move = e.target.closest('[data-move]')
2258
+ if (move) {
2259
+ const id = move.closest('.task')?.dataset.task
2260
+ if (!id) return
2261
+ try { await changeTasks('/update', { id, column: move.dataset.move }) }
2262
+ catch (err) { toast(err.message) }
2263
+ return
2264
+ }
2265
+ const removeFile = e.target.closest('[data-file-remove]')
2266
+ if (removeFile) {
2267
+ const id = removeFile.closest('.task')?.dataset.task
2268
+ const path = removeFile.getAttribute('data-file-remove')
2269
+ const task = (sum()?.status.tasks || []).find((t) => t.id === id)
2270
+ if (!id || !task) return
2271
+ try { await changeTasks('/update', { id, files: (task.files || []).filter((f) => f !== path) }) }
2272
+ catch (err) { toast(err.message) }
2273
+ return
2274
+ }
2275
+ if (e.target.closest('[data-archived-toggle]')) {
2276
+ // The board shows the archived tasks in place of its columns.
2277
+ ws(current).archived = !ws(current).archived
2278
+ archiveQuery = ''
2279
+ paintBoard({ force: true })
2280
+ if (ws(current).archived) $('#archive-filter')?.focus()
2281
+ return
2282
+ }
2283
+ const ask = e.target.closest('[data-task-remove-ask], [data-task-remove-cancel]')
2284
+ if (ask) { askRemove(ask.closest('.task-archived'), ask.matches('[data-task-remove-ask]')); return }
2285
+ const more = e.target.closest('[data-task-more]')
2286
+ if (more) {
2287
+ const id = more.closest('.task')?.dataset.task
2288
+ const task = (sum()?.status.tasks || []).find((t) => t.id === id)
2289
+ if (!task) return
2290
+ const update = async (path, body) => {
2291
+ try { await changeTasks(path, body) } catch (err) { toast(err.message) }
2292
+ }
2293
+ openTaskMenu(more, task, {
2294
+ // The board may have been redrawn since the menu opened: find the card again.
2295
+ edit: () => {
2296
+ const title = [...document.querySelectorAll('.task')].find((c) => c.dataset.task === id)?.querySelector('.task-title')
2297
+ if (title) beginEdit(title)
2298
+ },
2299
+ recur: () => update('/update', { id, recurring: !task.recurring }),
2300
+ archive: () => update('/update', { id, archived: true }),
2301
+ remove: () => update('/delete', { id })
2302
+ })
2303
+ return
2304
+ }
2305
+ const unarchive = e.target.closest('[data-task-unarchive]')
2306
+ if (unarchive) {
2307
+ const id = unarchive.closest('[data-task]')?.dataset.task
2308
+ if (!id) return
2309
+ try { await changeTasks('/update', { id, archived: false }) }
2310
+ catch (err) { toast(err.message) }
2311
+ return
2312
+ }
2313
+ const notes = e.target.closest('[data-task-notes]')
2314
+ if (notes) {
2315
+ const id = notes.closest('[data-task]')?.dataset.task
2316
+ const task = (sum()?.status.tasks || []).find((t) => t.id === id)
2317
+ if (task) openTaskNotes(task)
2318
+ return
2319
+ }
2320
+ const recur = e.target.closest('[data-task-recur]')
2321
+ if (recur) {
2322
+ const id = recur.closest('.task')?.dataset.task
2323
+ const task = (sum()?.status.tasks || []).find((t) => t.id === id)
2324
+ if (!id || !task) return
2325
+ try { await changeTasks('/update', { id, recurring: !task.recurring }) }
2326
+ catch (err) { toast(err.message) }
2327
+ return
2328
+ }
2329
+ if (e.target.closest('[data-task-delete]')) {
2330
+ const id = e.target.closest('[data-task]')?.dataset.task
2331
+ if (!id) return
2332
+ try { await changeTasks('/delete', { id }) }
2333
+ catch (err) { toast(err.message) }
2334
+ return
2335
+ }
2336
+ const title = e.target.closest('.task-title')
2337
+ if (title) beginEdit(title)
2338
+ })
2339
+ el.addEventListener('dragstart', (e) => {
2340
+ const card = e.target.closest?.('.task')
2341
+ if (!card || e.target.closest('.task-icon, .task-move, .task-assign, .task-files, .task-file-form, .task-cron-form, .task-file-x, input, select')) { e.preventDefault(); return }
2342
+ draggingTask = true
2343
+ e.dataTransfer.setData('text/plain', card.dataset.task)
2344
+ e.dataTransfer.effectAllowed = 'move'
2345
+ card.classList.add('dragging')
2346
+ })
2347
+ el.addEventListener('dragover', (e) => {
2348
+ const col = e.target.closest?.('.board-col')
2349
+ if (!col) { cancelUnfold(); return }
2350
+ e.preventDefault()
2351
+ e.dataTransfer.dropEffect = 'move'
2352
+ for (const n of el.querySelectorAll('.board-col.over')) if (n !== col) { n.classList.remove('over'); cancelUnfold() }
2353
+ col.classList.add('over')
2354
+ // Resting on a folded column opens it, so the card can go where it belongs in the list.
2355
+ // Dropping on the strip right away puts it at the end.
2356
+ if (col.classList.contains('folded') && !unfoldTimer) {
2357
+ unfoldTimer = setTimeout(() => { unfoldTimer = null; if (col.classList.contains('over')) openColumn(col.dataset.column) }, 500)
2358
+ }
2359
+ })
2360
+ el.addEventListener('drop', async (e) => {
2361
+ cancelUnfold()
2362
+ const col = e.target.closest?.('.board-col')
2363
+ if (!col) return
2364
+ e.preventDefault()
2365
+ const id = e.dataTransfer.getData('text/plain')
2366
+ const before = e.target.closest('.task')?.dataset.task
2367
+ col.classList.remove('over')
2368
+ draggingTask = false
2369
+ if (!id || before === id) { if (boardDirty) paintBoard(); return }
2370
+ try { await changeTasks('/update', { id, column: col.dataset.column, ...(before ? { before } : {}) }) }
2371
+ catch (err) { toast(err.message); paintBoard({ force: true }) }
2372
+ })
2373
+ el.addEventListener('dragend', () => {
2374
+ cancelUnfold()
2375
+ draggingTask = false
2376
+ for (const n of el.querySelectorAll('.over, .dragging')) n.classList.remove('over', 'dragging')
2377
+ if (boardDirty) paintBoard()
2378
+ })
2379
+ }
2380
+
2381
+ function renderMain () {
2382
+ const el = $('#main')
2383
+ if (!current || !el) return
2384
+ const w = ws(current)
2385
+ const st = sum().status
2386
+ if (w.mode === 'tasks') {
2387
+ // Restoring the last archived task goes back to the board, and stays there.
2388
+ if (w.archived && !(st.tasks || []).some((t) => t.archived)) w.archived = false
2389
+ // Showing one person's tasks: a new task is theirs too, unless the add form says otherwise.
2390
+ const show = typeof w.show === 'string' ? w.show : ''
2391
+ const assignTo = pendingAssign ?? (/^[pa]:/.test(show) ? show : '')
2392
+ const html = renderBoard(st.tasks || [], me(), taskPeople(st), assignTo, { archived: !!w.archived, show, closedGroups: w.archiveClosed || [] })
2393
+ // Status ticks arrive every few seconds. Re-rendering an unchanged board
2394
+ // would throw away hover, selection and scroll for nothing.
2395
+ if (html === lastBoard.html && lastBoard.el && el.firstElementChild === lastBoard.el) return
2396
+ const scroll = boardScroll(el)
2397
+ const box = ['archive-filter', 'board-search'].includes(document.activeElement?.id) ? document.activeElement : null
2398
+ const typing = box ? { id: box.id, at: box.selectionStart } : null
2399
+ el.innerHTML = html
2400
+ lastBoard = { html, el: el.firstElementChild }
2401
+ layoutBoard(el)
2402
+ restoreBoardScroll(el, scroll)
2403
+ filterArchive(el)
2404
+ searchBoard(el)
2405
+ if (filterOpen) setFilterOpen(true)
2406
+ labelFilter(el)
2407
+ if (typing) {
2408
+ const input = $(`#${typing.id}`)
2409
+ input?.focus()
2410
+ try { input?.setSelectionRange(typing.at, typing.at) } catch {}
2411
+ }
2412
+ return
2413
+ }
2414
+ lastBoard = { html: '', el: null }
2415
+ if (st.access && st.access.state === 'pending') {
2416
+ el.innerHTML = `<div class="main-empty">
2417
+ <div class="ill">${I.lock}</div>
2418
+ <h3>Waiting to be let in</h3>
2419
+ <p class="hint">The person who started this session needs to approve you. Files will appear here as soon as they do. You can leave this open.</p>
2420
+ </div>`
2421
+ return
2422
+ }
2423
+ if (w.mode === 'merge' && !w.mergeSel) w.mode = 'ai'
2424
+ if (w.mode === 'merge') {
2425
+ const m = shownMerge()
2426
+ const cached = m ? state.files.get(fileKey(m.path)) : null
2427
+ const f = cached && cached.file
2428
+ const theirs = !cached ? undefined : f.missing || f.binary ? null : f.text
2429
+ const texts = m && !m.binary ? fullMerge(m) : { ours: null, base: null }
2430
+ renderMergeView(el, { merge: m, texts, theirs, me: me(), editors: mergeEditors(), viewer: isViewer() })
2431
+ if (m && !m.binary && !cached) refreshFile(m.path, false)
2432
+ return
2433
+ }
2434
+ if (w.mode === 'ai') {
2435
+ if (!w.aiSel) {
2436
+ const people = st.peers
2437
+ if (!people.length && !state.feeds.get(current)?.get(st.me.name)?.length) {
2438
+ const { invite, viewInvite } = sum()
2439
+ el.innerHTML = `<div class="main-empty invite-empty">
2440
+ <div class="ill">${I.link}</div>
2441
+ <h3>Invite someone to code with you</h3>
2442
+ <p class="hint">Send a link. Clicking it opens this session in Quilt${viewInvite ? ', and you approve them before they get in' : ''}. To add an AI, use <b>Invite</b> above.</p>
2443
+ ${viewInvite ? `
2444
+ <div class="invite-pair">
2445
+ <div><div class="label">Can edit</div><div class="codebox"><code>${esc(invite)}</code></div><button class="btn primary" data-copy-invite="edit">${I.copy}<span>Copy edit link</span></button></div>
2446
+ <div><div class="label">View only</div><div class="codebox"><code>${esc(viewInvite)}</code></div><button class="btn" data-copy-invite="view">${I.copy}<span>Copy view link</span></button></div>
2447
+ </div>` : `
2448
+ <div class="codebox"><code id="empty-invite">${esc(invite)}</code></div>
2449
+ <button class="btn primary" data-copy-invite="edit">${I.copy}<span>Copy invite link</span></button>`}
2450
+ <p class="hint small">Once they join, you'll see their AI chat here as it happens.</p>
2451
+ </div>`
2452
+ return
2453
+ }
2454
+ renderLoomView(el)
2455
+ return
2456
+ }
2457
+ const feeds = state.feeds.get(current)
2458
+ const entries = feeds && feeds.get(w.aiSel)
2459
+ if (!entries) { el.innerHTML = `<div class="main-empty"><p class="hint">${loadingNote()}</p></div>`; return }
2460
+ const p = personInfo(w.aiSel)
2461
+ const convSel = w.convSel && w.convSel[w.aiSel]
2462
+ renderFeed(el, { entries, person: w.aiSel, isMe: !!p.isMe, color: p.color, agent: p.agent, online: p.online, convSel })
2463
+ } else {
2464
+ if (!w.fileSel) {
2465
+ el.innerHTML = `<div class="main-empty"><div class="ill">${I.file}</div><h3>Open a file</h3>
2466
+ <p class="hint">Pick a file in the tree to see it live. Lines your partners change light up as they happen.</p></div>`
2467
+ return
2468
+ }
2469
+ const cached = state.files.get(fileKey(w.fileSel))
2470
+ renderFileView(el, {
2471
+ path: w.fileSel,
2472
+ file: cached ? cached.file : null,
2473
+ meta: treeMeta(w.fileSel),
2474
+ me: me(),
2475
+ prevText: cached ? cached.prevText : null,
2476
+ tab: fileTabOf(w.fileSel),
2477
+ history: histories.get(fileKey(w.fileSel)) || null,
2478
+ summary: fileChanges(w.fileSel),
2479
+ people: { who: (name) => name === me() ? 'you' : name, colorOf: (name) => personInfo(name).color }
2480
+ })
2481
+ if (cached && fileTabOf(w.fileSel) === 'file') cached.prevText = null // highlight once, on the File tab
2482
+ }
2483
+ }
2484
+
2485
+ const fileKey = (path) => `${current}\n${path}`
2486
+ const fileTabOf = (path) => (ws(current).fileTab || {})[path] === 'changes' ? 'changes' : 'file'
2487
+
2488
+ function showFileTab (tab) {
2489
+ const w = ws(current)
2490
+ if (!w.fileSel) return
2491
+ if (!w.fileTab) w.fileTab = {}
2492
+ if (tab === 'changes') w.fileTab[w.fileSel] = 'changes'
2493
+ else delete w.fileTab[w.fileSel]
2494
+ saveWs(current)
2495
+ renderMain()
2496
+ $('#main .fv-tabs [aria-selected="true"]')?.focus()
2497
+ if (tab === 'changes') loadHistory(w.fileSel)
2498
+ }
2499
+
2500
+ // ------------------------------------------------------- a file's changes --
2501
+ const histories = new Map() // fileKey -> history entries, newest first
2502
+ const historyTimers = new Map()
2503
+
2504
+ /** A save is recorded in the history alongside the file: read it again once that has landed. */
2505
+ function scheduleHistory (path) {
2506
+ clearTimeout(historyTimers.get(path))
2507
+ historyTimers.set(path, setTimeout(() => { historyTimers.delete(path); loadHistory(path) }, 500))
2508
+ }
2509
+
2510
+ async function loadHistory (path) {
2511
+ const id = current
2512
+ try {
2513
+ const { entries } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
2514
+ if (id !== current) return
2515
+ histories.set(`${id}\n${path}`, entries)
2516
+ const w = ws(id)
2517
+ if (w.mode !== 'files' || w.fileSel !== path) return
2518
+ if (fileTabOf(path) === 'changes') renderMain()
2519
+ else renderFileTabs($('#main'), { tab: 'file', history: entries, summary: fileChanges(path) }) // the count only: freshly changed lines stay lit
2520
+ } catch {}
2521
+ }
2522
+
2523
+ function treeMeta (path) {
2524
+ const t = state.trees.get(current)
2525
+ const f = t && t.files.find((x) => x.path === path)
2526
+ if (f) return f
2527
+ // Deleted or unknown: still show a claim covering it.
2528
+ return { path, edited: null, claim: null }
2529
+ }
2530
+
2531
+ async function refreshFile (path, highlight) {
2532
+ const id = current
2533
+ const key = fileKey(path)
2534
+ const before = state.files.get(key)
2535
+ const prevText = highlight && before && before.file && !before.file.missing && !before.file.binary ? before.file.text : null
2536
+ let file
2537
+ try {
2538
+ file = await api('GET', `/api/sessions/${id}/file?path=${encodeURIComponent(path)}`)
2539
+ } catch {
2540
+ file = { missing: true, deleted: !!(before && before.file && !before.file.missing) }
2541
+ }
2542
+ if (id !== current) return
2543
+ state.files.set(key, { file, prevText })
2544
+ const w = ws(current)
2545
+ if ((w.mode === 'files' && w.fileSel === path) || (w.mode === 'merge' && shownMerge()?.path === path)) renderMain()
2546
+ }
2547
+
2548
+ // ------------------------------------------------------------------ Loom --
2549
+ function loomPrefs (w) {
2550
+ const p = w.loom && typeof w.loom === 'object' ? w.loom : {}
2551
+ const names = (list) => Array.isArray(list) ? list.filter((n) => typeof n === 'string') : []
2552
+ w.loom = {
2553
+ chat: p.chat !== false,
2554
+ tasks: p.tasks !== false,
2555
+ density: p.density === 'compact' ? 'compact' : 'detailed',
2556
+ layout: p.layout === 'merged' ? 'merged' : 'lanes',
2557
+ hidden: names(p.hidden),
2558
+ pinned: names(p.pinned), // lanes kept open when they don't all fit
2559
+ opened: names(p.opened).slice(0, 20), // lanes opened from a strip, latest first
2560
+ convNames: convNames(p.convNames) // conversation id -> the name you gave it (on this computer)
2561
+ }
2562
+ return w.loom
2563
+ }
2564
+
2565
+ /** The names you gave conversations in the Loom: strings only, the newest 200. */
2566
+ function convNames (o) {
2567
+ if (!o || typeof o !== 'object') return {}
2568
+ return Object.fromEntries(Object.entries(o).filter(([k, v]) => k && typeof v === 'string' && v.trim()).slice(-200).map(([k, v]) => [k, v.trim().slice(0, 60)]))
2569
+ }
2570
+
2571
+ function renderLoomView (el) {
2572
+ const s = sum()
2573
+ const st = s.status
2574
+ // Each person's AI sessions share one lane, "<person>'s AI", as in chat.
2575
+ const { personOf, ...data } = foldAiLanes({
2576
+ people: [
2577
+ { ...st.me, isMe: true, online: st.connected },
2578
+ ...st.peers.map((p) => ({ ...p, online: p.hosted ? 'http' : true, optional: !!p.persona }))
2579
+ ],
2580
+ owners: owners(s),
2581
+ feeds: state.feeds.get(current) || new Map(),
2582
+ messages: renderable(state.messages.get(current)),
2583
+ tasks: st.tasks || [],
2584
+ claims: st.claims || []
2585
+ })
2586
+ loomPersonOf = personOf
2587
+ const prefs = loomPrefs(ws(current))
2588
+ renderLoom(el, {
2589
+ build: (merged, width) => buildLoom({ ...data, show: prefs, hidden: prefs.hidden, merged, width, pinned: prefs.pinned, opened: prefs.opened, convNames: prefs.convNames }),
2590
+ prefs,
2591
+ me: st.me.name,
2592
+ names: mentionNames(s),
2593
+ meAgent: st.me.kind === 'agent',
2594
+ onAction: loomAction
2595
+ })
2596
+ }
2597
+
2598
+ let loomPersonOf = new Map() // a "<person>'s AI" lane -> the person whose AI chat it opens
2599
+
2600
+ function loomAction (kind, value) {
2601
+ const w = ws(current)
2602
+ const prefs = loomPrefs(w)
2603
+ if (kind === 'person') return openPerson(loomPersonOf.get(value) || value)
2604
+ if (kind === 'conv') { openPerson(loomPersonOf.get(value.name) || value.name); pickConv(value.conv); return }
2605
+ if (kind === 'file') return openFile(value)
2606
+ if (kind === 'holds') return openLocks(value.anchor, { who: value.name }) // the same lock list as the Files rail
2607
+ if (kind === 'task') {
2608
+ const task = (sum().status.tasks || []).find((t) => t.id === value)
2609
+ if (task) openTaskNotes(task)
2610
+ return
2611
+ }
2612
+ if (kind === 'hide') prefs.hidden = [...new Set([...prefs.hidden, value])]
2613
+ else if (kind === 'unhide') prefs.hidden = prefs.hidden.filter((n) => n !== value)
2614
+ else if (kind === 'toggle') prefs[value] = prefs[value] === false
2615
+ else if (kind === 'density') prefs.density = value
2616
+ else if (kind === 'layout') prefs.layout = value
2617
+ else if (kind === 'pin') prefs.pinned = prefs.pinned.includes(value) ? prefs.pinned.filter((n) => n !== value) : [...prefs.pinned, value]
2618
+ else if (kind === 'open-lane') { prefs.opened = [value, ...prefs.opened.filter((n) => n !== value)].slice(0, 20); prefs.layout = 'lanes' }
2619
+ else if (kind === 'conv-name') {
2620
+ // Empty: back to the conversation's own name (its tool's title, or its first prompt).
2621
+ const rest = Object.fromEntries(Object.entries(prefs.convNames).filter(([k]) => k !== value.conv))
2622
+ prefs.convNames = value.name ? { ...rest, [value.conv]: value.name.slice(0, 60) } : rest
2623
+ }
2624
+ saveWs(current)
2625
+ renderMain()
2626
+ }
2627
+
2628
+ const feedsLoading = new Set()
2629
+ async function loadFeed (name) {
2630
+ const id = current
2631
+ const key = `${id}|${name}`
2632
+ if (feedsLoading.has(key)) return
2633
+ feedsLoading.add(key)
2634
+ try {
2635
+ const { entries } = await api('GET', `/api/sessions/${id}/feed?who=${encodeURIComponent(name)}`)
2636
+ if (!state.feeds.has(id)) state.feeds.set(id, new Map())
2637
+ state.feeds.get(id).set(name, entries)
2638
+ feedsLoading.delete(key)
2639
+ if (id === current && ws(id).mode === 'ai' && (!ws(id).aiSel || ws(id).aiSel === name)) renderMain()
2640
+ } catch (err) {
2641
+ setTimeout(() => feedsLoading.delete(key), 30000) // not again on every status tick
2642
+ if (ws(id).aiSel === name) toast(err.message)
2643
+ }
2644
+ }
2645
+
2646
+ function loadFeeds () {
2647
+ if (!state.feeds.has(current)) state.feeds.set(current, new Map())
2648
+ return Promise.all(ws(current).aiTabs.map((name) => loadFeed(name)))
2649
+ }
2650
+
2651
+ // -------------------------------------------------------------- file tree --
2652
+ let treeTimer = null
2653
+ function scheduleTree () {
2654
+ clearTimeout(treeTimer)
2655
+ treeTimer = setTimeout(loadTree, 300)
2656
+ }
2657
+
2658
+ async function loadTree () {
2659
+ const id = current
2660
+ if (!id) return
2661
+ try {
2662
+ const tree = await api('GET', `/api/sessions/${id}/tree`)
2663
+ if (id !== current) return
2664
+ state.trees.set(id, tree)
2665
+ renderTreePane()
2666
+ const w = ws(id)
2667
+ if (w.mode === 'files' && w.fileSel) {
2668
+ // Keep the banner (editor, claim) fresh without re-highlighting.
2669
+ const cached = state.files.get(fileKey(w.fileSel))
2670
+ if (cached) renderFileView($('#main'), { path: w.fileSel, file: cached.file, meta: treeMeta(w.fileSel), me: me(), bannerOnly: true })
2671
+ }
2672
+ } catch {}
2673
+ }
2674
+
2675
+ function renderTreePane () {
2676
+ const el = $('#tree')
2677
+ if (!current || !el) return
2678
+ const w = ws(current)
2679
+ const tree = state.trees.get(current)
2680
+ const scroll = el.scrollTop
2681
+ const merges = (sum()?.status.merges || []).filter((m) => m.state !== 'done')
2682
+ if (!merges.length) w.mergesOnly = false
2683
+ const shown = w.mode === 'merge' ? merges.find((m) => m.id === w.mergeSel)?.path : null
2684
+ renderTree(el, tree, { me: me(), expanded: w.expanded, selected: w.mode === 'files' ? w.fileSel : shown || null, merges, mergesOnly: !!w.mergesOnly })
2685
+ el.scrollTop = scroll
2686
+ $('#file-count').textContent = tree ? `${tree.files.length}` : ''
2687
+ const pill = $('#merge-filter')
2688
+ if (pill) {
2689
+ pill.hidden = !merges.length
2690
+ pill.innerHTML = `<span class="mp-n">${merges.length}</span><span class="mp-l">to merge</span>`
2691
+ pill.setAttribute('aria-pressed', String(!!w.mergesOnly))
2692
+ pill.title = w.mergesOnly ? 'Show every file' : 'Show only the files to merge'
2693
+ }
2694
+ renderRailPane(tree, merges)
2695
+ // The rows were replaced: keep the card on the one still under the pointer, with fresh numbers.
2696
+ if (cardPath) {
2697
+ const row = treeRow(cardPath)
2698
+ const under = cardPointer && document.elementFromPoint(cardPointer.x, cardPointer.y)?.closest('[data-file]')
2699
+ if (row && (under === row || row.matches(':focus-visible'))) showTreeCard(row)
2700
+ else hideTreeCard()
2701
+ }
2702
+ }
2703
+
2704
+ // ------------------------------------------------------------- change card --
2705
+ // Hovering a file the session changed shows its +/−, who made them and when
2706
+ // (tree.js draws it). It waits a moment so a pointer passing over the tree
2707
+ // doesn't flash cards, then follows from row to row at once. Arrowing to a row
2708
+ // shows it too; a click doesn't, it opens the file.
2709
+ const CARD_DELAY = 300
2710
+ let cardPath = null // the file whose card is showing
2711
+ let cardTimer = null
2712
+ let cardWarmUntil = 0 // just closed: the next row's card shows at once
2713
+ let cardPointer = null // where the pointer last was over the tree, to find its row after a re-render
2714
+ let cardQuiet = null // the file just clicked: no card for it until the pointer leaves its row
2715
+
2716
+ const treeRow = (path) => [...document.querySelectorAll('#tree [data-file]')].find((r) => r.dataset.file === path)
2717
+
2718
+ function restoreRowTitle (path) {
2719
+ const row = path && treeRow(path)
2720
+ if (row?.dataset.title) { row.title = row.dataset.title; delete row.dataset.title }
2721
+ }
2722
+
2723
+ /** How much of a file's changes the history still has a diff for: 'all', 'some', 'none', or null until read. */
2724
+ function keptOf (path) {
2725
+ const entries = histories.get(fileKey(path))
2726
+ if (!entries) return null
2727
+ if (!entries.length) return 'none'
2728
+ return rolledOff(entries, fileChanges(path)).length ? 'some' : 'all'
2729
+ }
2730
+
2731
+ // The card reads the file's history to say whether the Changes tab has a diff; it may have
2732
+ // changed since, so it's read again each time a card opens (it's local and small).
2733
+ const keptReading = new Set()
2734
+ async function readKept (path) {
2735
+ if (keptReading.has(path)) return
2736
+ keptReading.add(path)
2737
+ const id = current
2738
+ try {
2739
+ const { entries } = await api('GET', `/api/sessions/${id}/history?path=${encodeURIComponent(path)}`)
2740
+ if (id !== current) return
2741
+ histories.set(fileKey(path), entries)
2742
+ if (cardPath === path) { const row = treeRow(path); if (row) showTreeCard(row, { fresh: false }) }
2743
+ } catch {} finally { keptReading.delete(path) }
2744
+ }
2745
+
2746
+ function showTreeCard (row, { fresh = true } = {}) {
2747
+ const f = fileChanges(row.dataset.file)
2748
+ if (!f) { hideTreeCard(); return }
2749
+ if (fresh && row.dataset.file !== cardPath) readKept(row.dataset.file)
2750
+ if (cardPath !== row.dataset.file) restoreRowTitle(cardPath) // moving row to row: the card follows
2751
+ cardPath = row.dataset.file
2752
+ // The row's own tooltip (its full path) would pop up over the card: the card shows the path.
2753
+ if (row.title) { row.dataset.title = row.title; row.removeAttribute('title') }
2754
+ showChangeCard(row, changeCardHtml(f, {
2755
+ who: (name) => name === me() ? 'you' : name,
2756
+ colorOf: (name) => personInfo(name).color,
2757
+ kept: keptOf(row.dataset.file)
2758
+ }))
2759
+ }
2760
+
2761
+ function hideTreeCard ({ warm = true } = {}) {
2762
+ clearTimeout(cardTimer)
2763
+ if (!cardPath) return
2764
+ restoreRowTitle(cardPath)
2765
+ cardPath = null
2766
+ if (warm) cardWarmUntil = Date.now() + CARD_DELAY
2767
+ hideChangeCard()
2768
+ }
2769
+
2770
+ function cardSoon (row) {
2771
+ clearTimeout(cardTimer)
2772
+ if (!fileChanges(row.dataset.file)) { hideTreeCard(); return }
2773
+ readKept(row.dataset.file) // during the wait, so the card opens knowing
2774
+ const now = cardPath || Date.now() < cardWarmUntil
2775
+ cardTimer = setTimeout(() => showTreeCard(row), now ? 0 : CARD_DELAY)
2776
+ }
2777
+
2778
+
2779
+ function openTaskNotes (task, { instant = false } = {}) {
2780
+ document.querySelector('.task-notes-back')?.remove()
2781
+ const back = document.createElement('div')
2782
+ back.className = 'modal-back task-notes-back'
2783
+ back.innerHTML = taskNotesModalHtml(task)
2784
+ document.body.appendChild(back)
2785
+ const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
2786
+ const close = () => {
2787
+ if (back.dataset.closing) return
2788
+ back.dataset.closing = '1'
2789
+ back.classList.remove('is-open')
2790
+ if (reduce) { back.remove(); return }
2791
+ let finished = false
2792
+ const finish = () => { if (finished) return; finished = true; back.remove() }
2793
+ back.addEventListener('transitionend', (e) => { if (e.target === back) finish() })
2794
+ setTimeout(finish, 280)
2795
+ }
2796
+ back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
2797
+ back.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); close() } })
2798
+ back.querySelector('[data-close-notes]').onclick = close
2799
+ const form = back.querySelector('.task-comment-form')
2800
+ const box = back.querySelector('.task-comment-text')
2801
+ box.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); form.requestSubmit() } })
2802
+ form.addEventListener('submit', async (e) => {
2803
+ e.preventDefault()
2804
+ const text = box.value.trim()
2805
+ if (!text) return
2806
+ try {
2807
+ await changeTasks('/comment', { id: task.id, text })
2808
+ const now = (sum()?.status.tasks || []).find((t) => t.id === task.id)
2809
+ box.value = ''
2810
+ // Drawn again with the new comment, the box ready for another.
2811
+ if (now) openTaskNotes(now, { instant: true })
2812
+ } catch (err) { toast(err.message) }
2813
+ })
2814
+ ;(task.comments?.length || task.qaNotes || task.verified ? back.querySelector('[data-close-notes]') : box).focus()
2815
+ if (reduce || instant) back.classList.add('is-open')
2816
+ else requestAnimationFrame(() => requestAnimationFrame(() => back.classList.add('is-open')))
2817
+ if (instant) back.querySelector('.task-comment-text').focus()
2818
+ }
2819
+
2820
+ /** The folded Files pane: locks and who holds them (rail.js); a merge in a file you have open marks the merge pill. */
2821
+ function renderRailPane (tree = state.trees.get(current), merges = sum()?.status.merges || []) {
2822
+ const el = $('#tree-rail')
2823
+ const s = sum()
2824
+ if (!el || !s) return
2825
+ const st = s.status
2826
+ const w = ws(current)
2827
+ const model = railModel({
2828
+ claims: st.claims || [],
2829
+ merges,
2830
+ me: st.me.name,
2831
+ owners: owners(s),
2832
+ aiName,
2833
+ files: tree ? tree.files.map((f) => f.path) : [],
2834
+ open: [...w.fileTabs, ...(w.mergeSel ? [merges.find((m) => m.id === w.mergeSel)?.path] : [])].filter(Boolean),
2835
+ focusOf: (name) => (name === st.me.name ? st.me.focus : st.peers.find((p) => p.name === name)?.focus) || '',
2836
+ kindOf: (name) => personInfo(name).kind
2837
+ })
2838
+ $('#merge-filter')?.classList.toggle('urgent', model.merges.urgent)
2839
+ renderRail(el, model, { me: st.me.name, colorOf: (name) => personInfo(name).color, onFile: (path) => openFile(path) })
2840
+ }
2841
+
2842
+ function applyTreeCollapsed () {
2843
+ const body = $('#ws-body')
2844
+ const btn = $('#collapse-tree')
2845
+ if (!body || !current) return
2846
+ const collapsed = ws(current).treeCollapsed !== false
2847
+ body.classList.toggle('tree-collapsed', collapsed)
2848
+ const rail = $('#tree-rail')
2849
+ if (rail) rail.toggleAttribute('inert', !collapsed)
2850
+ if (!collapsed) closeLocks()
2851
+ const scroll = $('#tree')
2852
+ if (scroll) {
2853
+ scroll.toggleAttribute('inert', collapsed)
2854
+ scroll.setAttribute('aria-hidden', collapsed ? 'true' : 'false')
2855
+ }
2856
+ if (!btn) return
2857
+ btn.setAttribute('aria-expanded', String(!collapsed))
2858
+ btn.title = collapsed ? 'Expand files' : 'Collapse files'
2859
+ btn.setAttribute('aria-label', collapsed ? 'Expand files' : 'Collapse files')
2860
+ btn.innerHTML = collapsed ? I.arrowRight : I.arrowLeft
2861
+ }
2862
+
2863
+ function toggleTreeCollapsed () {
2864
+ if (!current) return
2865
+ const w = ws(current)
2866
+ w.treeCollapsed = w.treeCollapsed === false
2867
+ saveWs(current)
2868
+ applyTreeCollapsed()
2869
+ }
2870
+
2871
+ // The chat pane: drag its left edge to widen it (up to CHAT_MAX, leaving the middle room), or
2872
+ // past CHAT_SNAP to fold it into a thin rail that counts new messages. Kept for every session.
2873
+ const CHAT_MIN = 300
2874
+ const CHAT_MAX = 640
2875
+ const CHAT_SNAP = 220
2876
+ let chatSeenTs = Date.now() // messages after this count on the folded rail
2877
+
2878
+ const chatCollapsed = () => recall('chat-collapsed', '') === '1'
2879
+ const chatWidthFor = (body, w) => Math.round(Math.min(Math.max(w, CHAT_MIN), Math.max(CHAT_MIN, Math.min(CHAT_MAX, body.clientWidth - 560))))
2880
+
2881
+ function applyChatLayout () {
2882
+ const body = $('#ws-body')
2883
+ if (!body) return
2884
+ const w = Number(recall('chat-width', '')) || 0
2885
+ if (w) body.style.setProperty('--chat-open', `${chatWidthFor(body, w)}px`)
2886
+ const collapsed = chatCollapsed()
2887
+ body.classList.toggle('chat-collapsed', collapsed)
2888
+ const btn = $('#chat-resize')
2889
+ if (btn) btn.setAttribute('aria-valuenow', String(collapsed ? 0 : w || 380))
2890
+ renderChatBadge()
2891
+ }
2892
+
2893
+ function setChatCollapsed (collapsed) {
2894
+ remember('chat-collapsed', collapsed ? '1' : '0')
2895
+ chatSeenTs = Date.now()
2896
+ applyChatLayout()
2897
+ if (!collapsed) { renderMessages(false, true); $('#msg-input')?.focus({ preventScroll: true }) }
2898
+ }
2899
+
2900
+ /** The folded chat's count of messages from others since it was folded. */
2901
+ function renderChatBadge () {
2902
+ const badge = $('#chat-rail-badge')
2903
+ const rail = $('#chat-rail')
2904
+ const s = sum()
2905
+ if (!badge || !rail) return
2906
+ const n = s && chatCollapsed()
2907
+ ? renderable(state.messages.get(current)).filter((m) => (m.ts || 0) > chatSeenTs && m.by !== s.status.me?.name && !ownAiChatter(m, owners(s))).length
2908
+ : 0
2909
+ badge.hidden = !n
2910
+ badge.textContent = n > 99 ? '99+' : String(n)
2911
+ rail.title = n ? `Open chat · ${n} new message${n === 1 ? '' : 's'}` : 'Open chat'
2912
+ }
2913
+
2914
+ function bindChatPane () {
2915
+ const body = $('#ws-body')
2916
+ const handle = $('#chat-resize')
2917
+ if (!body || !handle) return
2918
+ $('#collapse-chat').onclick = () => setChatCollapsed(true)
2919
+ $('#chat-rail').onclick = () => setChatCollapsed(false)
2920
+ handle.ondblclick = () => setChatCollapsed(!chatCollapsed())
2921
+ handle.onkeydown = (e) => {
2922
+ if (e.key === 'Enter') { e.preventDefault(); return setChatCollapsed(!chatCollapsed()) }
2923
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
2924
+ e.preventDefault()
2925
+ const now = chatCollapsed() ? CHAT_MIN : (Number(recall('chat-width', '')) || $('#chat-pane').offsetWidth)
2926
+ remember('chat-width', String(chatWidthFor(body, now + (e.key === 'ArrowLeft' ? 32 : -32))))
2927
+ if (chatCollapsed()) setChatCollapsed(false)
2928
+ else applyChatLayout()
2929
+ }
2930
+ handle.onpointerdown = (e) => {
2931
+ if (e.button !== 0) return
2932
+ e.preventDefault()
2933
+ handle.setPointerCapture(e.pointerId)
2934
+ body.classList.add('resizing')
2935
+ const right = body.getBoundingClientRect().right
2936
+ let w = 0
2937
+ const move = (ev) => {
2938
+ w = right - ev.clientX
2939
+ const fold = w < CHAT_SNAP
2940
+ body.classList.toggle('chat-collapsed', fold)
2941
+ if (!fold) body.style.setProperty('--chat-open', `${chatWidthFor(body, w)}px`)
2942
+ }
2943
+ const up = () => {
2944
+ handle.onpointermove = handle.onpointerup = handle.onpointercancel = null
2945
+ body.classList.remove('resizing')
2946
+ if (!w) return
2947
+ if (w < CHAT_SNAP) return setChatCollapsed(true)
2948
+ remember('chat-width', String(chatWidthFor(body, w)))
2949
+ if (chatCollapsed()) setChatCollapsed(false)
2950
+ else applyChatLayout()
2951
+ }
2952
+ handle.onpointermove = move
2953
+ handle.onpointerup = up
2954
+ handle.onpointercancel = up
2955
+ }
2956
+ }
2957
+
2958
+ function bindTreeEvents () {
2959
+ const collapse = $('#collapse-tree')
2960
+ if (collapse) collapse.onclick = () => toggleTreeCollapsed()
2961
+ const filter = $('#merge-filter')
2962
+ if (filter) {
2963
+ filter.onclick = () => {
2964
+ const w = ws(current)
2965
+ if (w.treeCollapsed) { w.mergesOnly = true; toggleTreeCollapsed() } else w.mergesOnly = !w.mergesOnly
2966
+ saveWs(current)
2967
+ renderTreePane()
2968
+ }
2969
+ }
2970
+ const el = $('#tree')
2971
+ el.addEventListener('click', (e) => {
2972
+ const more = e.target.closest('[data-more]')
2973
+ if (more) { e.stopPropagation(); showTreeMenu(more, more.dataset.more, more.dataset.kind); return }
2974
+ const changes = e.target.closest('[data-changes]')
2975
+ if (changes) { e.stopPropagation(); openFile(changes.dataset.changes, { tab: 'changes' }); return }
2976
+ const dir = e.target.closest('[data-dir]')
2977
+ if (dir) {
2978
+ const w = ws(current)
2979
+ const depth0 = !dir.dataset.dir.includes('/')
2980
+ const open = w.expanded[dir.dataset.dir] ?? depth0
2981
+ w.expanded[dir.dataset.dir] = !open
2982
+ saveWs(current)
2983
+ renderTreePane()
2984
+ return
2985
+ }
2986
+ const file = e.target.closest('[data-file]')
2987
+ if (file && file.dataset.merge) openMerge(file.dataset.merge) // changed in two places: both versions, side by side
2988
+ else if (file) openFile(file.dataset.file)
2989
+ })
2990
+ el.addEventListener('contextmenu', (e) => {
2991
+ const row = e.target.closest('[data-file],[data-dir]')
2992
+ if (!row) return
2993
+ e.preventDefault()
2994
+ const path = row.dataset.file || row.dataset.dir
2995
+ showTreeMenu(row.querySelector('.t-more') || row, path, row.dataset.file ? 'file' : 'dir')
2996
+ })
2997
+ el.addEventListener('mousemove', (e) => { cardPointer = { x: e.clientX, y: e.clientY } }, { passive: true })
2998
+ el.addEventListener('mouseover', (e) => {
2999
+ const row = e.target.closest?.('[data-file]')
3000
+ if (row && showsLoom($('#main'))) focusFile($('#main'), row.dataset.file) // the Loom lights who worked on it
3001
+ if (row && row.dataset.file !== cardPath && row.dataset.file !== cardQuiet) cardSoon(row)
3002
+ })
3003
+ el.addEventListener('mouseout', (e) => {
3004
+ const row = e.target.closest?.('[data-file]')
3005
+ if (!row || row.contains(e.relatedTarget)) return
3006
+ // Forgotten even while the Loom isn't showing (the row was clicked open), or the Loom lights it again on return.
3007
+ focusFile($('#main'), null)
3008
+ if (e.relatedTarget?.closest?.('[data-file]')?.dataset.file !== cardQuiet) cardQuiet = null
3009
+ clearTimeout(cardTimer)
3010
+ cardTimer = setTimeout(hideTreeCard, 80) // the next row's mouseover cancels this
3011
+ })
3012
+ // Rows redrawn under the pointer (opening a file redraws the tree) never say they were left: leaving the tree does.
3013
+ el.addEventListener('mouseleave', () => {
3014
+ cardPointer = null
3015
+ cardQuiet = null
3016
+ focusFile($('#main'), null)
3017
+ hideTreeCard({ warm: false })
3018
+ })
3019
+ // Keyboard focus shows it; a click's focus doesn't (the click opens the file).
3020
+ el.addEventListener('focusin', (e) => {
3021
+ const row = e.target.closest?.('[data-file]')
3022
+ if (row && row.matches(':focus-visible')) showTreeCard(row)
3023
+ })
3024
+ el.addEventListener('focusout', (e) => { if (!el.contains(e.relatedTarget)) hideTreeCard({ warm: false }) })
3025
+ el.addEventListener('mousedown', (e) => {
3026
+ cardQuiet = e.target.closest?.('[data-file]')?.dataset.file || null // clicked open: its card stays away until the pointer leaves it
3027
+ hideTreeCard({ warm: false })
3028
+ })
3029
+ el.addEventListener('scroll', () => hideTreeCard({ warm: false }), { passive: true })
3030
+ window.addEventListener('blur', () => hideTreeCard({ warm: false }), { signal: mounted.signal })
3031
+ mounted.signal.addEventListener('abort', () => hideTreeCard({ warm: false }))
3032
+ el.addEventListener('keydown', (e) => {
3033
+ if (e.key === 'Escape' && cardPath) { hideTreeCard({ warm: false }); return }
3034
+ const row = e.target.closest('.t-row')
3035
+ if (!row) return
3036
+ const rows = [...el.querySelectorAll('.t-row')]
3037
+ const i = rows.indexOf(row)
3038
+ if (e.key === 'ArrowDown') { e.preventDefault(); rows[i + 1]?.focus() }
3039
+ if (e.key === 'ArrowUp') { e.preventDefault(); rows[i - 1]?.focus() }
3040
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); row.click() }
3041
+ })
3042
+ // Make the tree reachable by keyboard: first row takes focus.
3043
+ el.tabIndex = 0
3044
+ el.addEventListener('focus', () => { if (document.activeElement === el) el.querySelector('.t-row')?.focus() })
3045
+ }
3046
+
3047
+ function claimCovering (path, kind) {
3048
+ const tree = state.trees.get(current)
3049
+ if (!tree) return null
3050
+ if (kind === 'dir') return tree.claims.find((c) => claimFolder(c.pattern) === path) || null
3051
+ const f = tree.files.find((x) => x.path === path)
3052
+ return f ? f.claim : null
3053
+ }
3054
+
3055
+ function showTreeMenu (anchor, path, kind) {
3056
+ openTreeMenu(anchor, {
3057
+ path,
3058
+ kind,
3059
+ me: me(),
3060
+ owner: !!sum()?.status.access?.owner,
3061
+ claim: claimCovering(path, kind),
3062
+ onOpen: () => openFile(path),
3063
+ onClaim: (note) => claimPath(path, note),
3064
+ onRelease: () => releasePattern(claimCovering(path, kind).pattern),
3065
+ onClearAway: clearAwayClaims,
3066
+ onRequest: (req) => fileQueue('request-file', { path, ...req }, `Asked for ${path}`),
3067
+ onWithdraw: (request) => fileQueue('withdraw-request', { request }, 'Request withdrawn'),
3068
+ onHandoff: (h) => fileQueue('handoff', { path: claimCovering(path, kind).pattern, ...h }, `Handed off ${path}`)
3069
+ })
3070
+ }
3071
+
3072
+ /** The file queue from the ⋯ menu: ask for a file, take a request back, or hand a file on. */
3073
+ async function fileQueue (route, body, done) {
3074
+ try {
3075
+ await api('POST', `/api/sessions/${current}/${route}`, body)
3076
+ toast(done)
3077
+ loadTree()
3078
+ } catch (err) { toast(err.message) }
3079
+ }
3080
+
3081
+ async function clearAwayClaims () {
3082
+ try {
3083
+ const { released } = await api('POST', `/api/sessions/${current}/clear-claims`, {})
3084
+ toast(released ? `Released ${released} claim${released === 1 ? '' : 's'}` : 'No claims to release')
3085
+ loadTree()
3086
+ } catch (err) { toast(err.message) }
3087
+ }
3088
+
3089
+ async function claimPath (path, note) {
3090
+ try {
3091
+ await api('POST', `/api/sessions/${current}/claim`, { pattern: path, note })
3092
+ toast(`Claimed ${path}`)
3093
+ loadTree()
3094
+ } catch (err) { toast(err.message) }
3095
+ }
3096
+
3097
+ async function releasePattern (pattern) {
3098
+ try {
3099
+ await api('POST', `/api/sessions/${current}/release`, { pattern })
3100
+ toast(`Released ${pattern}`)
3101
+ loadTree()
3102
+ } catch (err) { toast(err.message) }
3103
+ }
3104
+
3105
+ // ------------------------------------------------------------------- chat --
3106
+ function bindChat () {
3107
+ const id = current
3108
+ const input = $('#msg-input')
3109
+ const grow = () => { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight, 160)}px` }
3110
+ input.addEventListener('input', grow)
3111
+ // Partners see "… is typing" while there is text in the box: said again every few seconds
3112
+ // while keys come, taken back when the box is cleared or loses focus (sending clears it too).
3113
+ let typingSaid = 0
3114
+ const sayTyping = (on) => {
3115
+ if (mayNotPost()) return
3116
+ if (on && Date.now() - typingSaid < TYPING_MS / 2) return
3117
+ if (!on && !typingSaid) return
3118
+ typingSaid = on ? Date.now() : 0
3119
+ api('POST', `/api/sessions/${id}/typing`, { on, to: state.to || null }).catch(() => {})
3120
+ }
3121
+ input.addEventListener('input', () => sayTyping(!!input.value.trim()))
3122
+ input.addEventListener('blur', () => sayTyping(false))
3123
+ bindMentions(input)
3124
+ input.addEventListener('keydown', (e) => {
3125
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !e.defaultPrevented) { e.preventDefault(); $('#composer').requestSubmit() }
3126
+ })
3127
+ $('#to-select').onchange = (e) => { state.to = e.target.value; updatePlaceholder() }
3128
+ $('#attach-btn').onclick = () => $('#file-input').click()
3129
+ $('#file-input').onchange = (e) => { addFiles(e.target.files); e.target.value = '' }
3130
+ $('[data-attach-workspace]')?.addEventListener('click', async () => {
3131
+ if (mayNotPost()) return toast(NO_POSTING)
3132
+ const workspace = sum()?.workspace
3133
+ const pick = await workspaceFilePicker(workspace)
3134
+ if (!pick) return
3135
+ try {
3136
+ await api('POST', `/api/sessions/${id}/attach-from-workspace`, { fileId: pick.id, to: state.to || null, text: input.value.trim() })
3137
+ input.value = ''
3138
+ grow()
3139
+ } catch (err) {
3140
+ toast(err.message)
3141
+ } finally {
3142
+ input.focus()
3143
+ }
3144
+ })
3145
+ $('#attachments').onclick = (e) => {
3146
+ const b = e.target.closest('[data-unqueue]')
3147
+ if (b) { state.pending.splice(Number(b.dataset.unqueue), 1); renderAttachments() }
3148
+ }
3149
+ $('#composer').onsubmit = async (e) => {
3150
+ e.preventDefault()
3151
+ const text = input.value.trim()
3152
+ const files = state.pending.slice()
3153
+ if (!text && !files.length) return
3154
+ $('#send-btn').disabled = true
3155
+ try {
3156
+ if (files.length) {
3157
+ for (let i = 0; i < files.length; i++) {
3158
+ const f = files[i]
3159
+ await api('POST', `/api/sessions/${id}/send`, f, {
3160
+ 'x-filename': encodeURIComponent(f.name),
3161
+ 'x-to': encodeURIComponent(state.to),
3162
+ 'x-text': encodeURIComponent(i === 0 ? text : '')
3163
+ })
3164
+ }
3165
+ } else {
3166
+ const r = await api('POST', `/api/sessions/${id}/say`, { text, to: state.to || null })
3167
+ if (state.to && r.recipientAway) toast(`${state.to} is away between turns. It reads this at the start of its next one.`)
3168
+ else if (state.to && !r.recipientOnline) toast(`${state.to} is offline. They'll see it when they’re back.`)
3169
+ }
3170
+ input.value = ''
3171
+ typingSaid = 0 // sending cleared it
3172
+ state.pending = []
3173
+ renderAttachments()
3174
+ grow()
3175
+ } catch (err) {
3176
+ toast(err.message)
3177
+ } finally {
3178
+ $('#send-btn').disabled = mayNotPost()
3179
+ input.focus()
3180
+ }
3181
+ }
3182
+
3183
+ const pane = $('#chat-pane')
3184
+ let depth = 0
3185
+ pane.addEventListener('dragenter', (e) => { if (e.dataTransfer?.types.includes('Files')) { depth++; pane.classList.add('dragging') } })
3186
+ pane.addEventListener('dragleave', () => { depth = Math.max(0, depth - 1); if (!depth) pane.classList.remove('dragging') })
3187
+ pane.addEventListener('dragover', (e) => e.preventDefault())
3188
+ pane.addEventListener('drop', (e) => {
3189
+ e.preventDefault()
3190
+ depth = 0
3191
+ pane.classList.remove('dragging')
3192
+ addFiles(e.dataTransfer.files)
3193
+ })
3194
+ input.addEventListener('paste', (e) => {
3195
+ if (e.clipboardData?.files?.length) { e.preventDefault(); addFiles(e.clipboardData.files) }
3196
+ })
3197
+ pane.addEventListener('focusin', () => markRead(id))
3198
+ }
3199
+
3200
+ function addFiles (list) {
3201
+ if (mayNotPost()) return toast(NO_POSTING)
3202
+ for (const f of list) {
3203
+ if (state.maxFileBytes && f.size > state.maxFileBytes) { toast(`${f.name} is larger than ${bytes(state.maxFileBytes)}`); continue }
3204
+ state.pending.push(f)
3205
+ }
3206
+ renderAttachments()
3207
+ $('#msg-input').focus()
3208
+ }
3209
+
3210
+ function renderAttachments () {
3211
+ const el = $('#attachments')
3212
+ if (!el) return
3213
+ el.innerHTML = state.pending.map((f, i) => `<span class="tag">${esc(f.name)} · ${bytes(f.size)}<button type="button" data-unqueue="${i}" aria-label="Remove">×</button></span>`).join('')
3214
+ updatePlaceholder()
3215
+ }
3216
+
3217
+ /** Someone who may not post sees the chat, but not a way to post to it. */
3218
+ function renderComposer () {
3219
+ const input = $('#msg-input')
3220
+ const s = sum()
3221
+ if (!input || !s) return
3222
+ const muted = mayNotPost()
3223
+ input.disabled = muted
3224
+ $('#attach-btn').disabled = muted
3225
+ const wsBtn = $('[data-attach-workspace]')
3226
+ if (wsBtn) wsBtn.disabled = muted
3227
+ $('#send-btn').disabled = muted
3228
+ $('#composer').classList.toggle('muted', muted)
3229
+ updatePlaceholder()
3230
+ }
3231
+
3232
+ function mayNotPost () {
3233
+ const acc = sum()?.status.access
3234
+ return !!(acc && acc.state === 'approved' && acc.talk === false)
3235
+ }
3236
+
3237
+ function updatePlaceholder () {
3238
+ const input = $('#msg-input')
3239
+ if (!input) return
3240
+ if (mayNotPost()) { input.placeholder = NO_POSTING; return }
3241
+ const who = state.to ? (state.to === `${me()}'s AI` ? 'your AI' : state.to) : 'everyone'
3242
+ input.placeholder = state.pending.length ? `Add a note for ${who} (optional)…` : `Message ${who}…`
3243
+ }
3244
+
3245
+ function renderRecipients () {
3246
+ const s = sum()
3247
+ const sel = $('#to-select')
3248
+ if (!s || !sel) return
3249
+ const who = owners(s)
3250
+ const names = new Set(peopleHere(s.status).map((p) => p.name))
3251
+ for (const m of renderable(state.messages.get(current))) {
3252
+ if (m.by !== s.status.me.name) names.add(shownName(m.by, who))
3253
+ if (m.to && m.to !== s.status.me.name) names.add(shownName(m.to, who))
3254
+ }
3255
+ if (state.to) names.add(state.to)
3256
+ const online = new Set(peopleHere(s.status).map((p) => p.name))
3257
+ const away = new Set((s.status.away || []).map((a) => a.name))
3258
+ sel.innerHTML = '<option value="">Everyone</option>' + [...names].sort().map((n) =>
3259
+ `<option value="${esc(n)}" ${n === state.to ? 'selected' : ''}>${esc(n === `${s.status.me.name}'s AI` ? 'Your AI' : n)} (direct${online.has(n) ? '' : away.has(n) ? ', away' : ', offline'})</option>`).join('')
3260
+ sel.value = state.to
3261
+ sel.closest('.to').hidden = names.size === 0
3262
+ updatePlaceholder()
3263
+ }
3264
+
3265
+ /**
3266
+ * Everyone who can be mentioned in this session: members, people online, and whoever has written,
3267
+ * and @Agents (every agent at once) while an agent other than me is connected.
3268
+ */
3269
+ function mentionNames (s) {
3270
+ const names = new Set()
3271
+ const who = owners(s)
3272
+ if (s.status.me?.name) names.add(s.status.me.name)
3273
+ for (const p of peopleHere(s.status)) if (p.name) names.add(p.name)
3274
+ for (const m of s.status.members || []) if (m.name) names.add(shownName(m.name, who))
3275
+ for (const m of renderable(state.messages.get(current))) { if (m.by) names.add(shownName(m.by, who)); if (m.to) names.add(shownName(m.to, who)) }
3276
+ if ((s.status.peers || []).some((p) => p.kind === 'agent' && p.name !== s.status.me?.name)) names.add(ALL_AGENTS)
3277
+ names.add(RELAY_NAME)
3278
+ return [...names]
3279
+ }
3280
+
3281
+ /**
3282
+ * Typing @ in the composer offers the session's members; arrows pick, Enter or Tab
3283
+ * completes, Escape closes. Agents wake on a mention of their name, so it has to be exact.
3284
+ * After "@Quilt" it offers the questions the relay answers: picking one sends it.
3285
+ */
3286
+ function bindMentions (input) {
3287
+ const menu = $('#mention-menu')
3288
+ let items = [] // names, or { question } after @Quilt
3289
+ let at = null
3290
+ let on = 0
3291
+ const close = () => { items = []; at = null; menu.hidden = true; menu.innerHTML = '' }
3292
+ const render = () => {
3293
+ menu.innerHTML = items.map((n, i) => {
3294
+ const attrs = `type="button" role="option" aria-selected="${i === on}" class="${i === on ? 'on' : ''}"`
3295
+ if (n.question) return `<button ${attrs} data-question="${i}"><span class="relay-q">${I.chat}</span><span>${esc(n.question)}</span></button>`
3296
+ const face = isRelay(n) ? `<span class="relay-q">${I.globe}</span>` : avatar(n, sum()?.status.peers.find((p) => p.name === n)?.color)
3297
+ return `<button ${attrs} data-name="${esc(n)}">${face}<span>${esc(n)}</span></button>`
3298
+ }).join('')
3299
+ menu.hidden = false
3300
+ }
3301
+ const pick = (item) => {
3302
+ if (item && item.question) {
3303
+ input.value = `@${RELAY_NAME} ${item.question}`
3304
+ close()
3305
+ $('#composer').requestSubmit()
3306
+ return
3307
+ }
3308
+ if (!at) return
3309
+ const r = completeMention(input.value, at, item)
3310
+ input.value = r.text
3311
+ input.setSelectionRange(r.caret, r.caret)
3312
+ close()
3313
+ input.dispatchEvent(new Event('input', { bubbles: true }))
3314
+ input.focus()
3315
+ }
3316
+ const show = (list) => {
3317
+ if (JSON.stringify(list) !== JSON.stringify(items)) on = 0
3318
+ items = list
3319
+ render()
3320
+ }
3321
+ const update = () => {
3322
+ const s = sum()
3323
+ if (!s) return close()
3324
+ const asks = relayPrompts(input.value, { owner: !!s.status.access?.owner })
3325
+ if (asks.length) { at = null; return show(asks.map((q) => ({ question: q.text }))) }
3326
+ const found = mentionAt(input.value, input.selectionStart)
3327
+ if (!found) return close()
3328
+ const me = s.status.me?.name
3329
+ const list = mentionCandidates(mentionNames(s).filter((n) => n !== me), found.query)
3330
+ if (!list.length) return close()
3331
+ at = found
3332
+ show(list)
3333
+ }
3334
+ input.addEventListener('input', update)
3335
+ input.addEventListener('click', update)
3336
+ input.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) update() })
3337
+ input.addEventListener('keydown', (e) => {
3338
+ if (!items.length) return
3339
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); on = (on + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length; render() } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); pick(items[on]) } else if (e.key === 'Escape') { e.preventDefault(); close() }
3340
+ }, true)
3341
+ input.addEventListener('blur', () => setTimeout(close, 150))
3342
+ menu.addEventListener('mousedown', (e) => e.preventDefault()) // keep the textarea focused
3343
+ menu.addEventListener('click', (e) => {
3344
+ const q = e.target.closest('[data-question]')
3345
+ if (q) return pick(items[Number(q.dataset.question)])
3346
+ const b = e.target.closest('[data-name]')
3347
+ if (b) pick(b.dataset.name)
3348
+ })
3349
+ }
3350
+
3351
+ /** "Dana is typing…" under the messages, with a small chatting animation, while partners type. */
3352
+ function renderTyping () {
3353
+ const el = $('#typing')
3354
+ const s = sum()
3355
+ if (!el || !s) return
3356
+ const html = typingHtml(typingNames(s.status.peers, s.status.me?.name))
3357
+ if (el.innerHTML === html) return
3358
+ const list = $('#messages')
3359
+ const nearBottom = list && list.scrollHeight - list.scrollTop - list.clientHeight < 80
3360
+ el.innerHTML = html
3361
+ el.hidden = !html
3362
+ if (nearBottom) list.scrollTop = list.scrollHeight
3363
+ }
3364
+
3365
+ function renderMessages (incoming = false, force = false) {
3366
+ const el = $('#messages')
3367
+ const s = sum()
3368
+ if (!el || !s) return
3369
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80
3370
+ const who = owners(s)
3371
+ // A person's AI sessions working things out among themselves isn't shown: only what they say to people.
3372
+ const list = renderable(state.messages.get(current)).filter((m) => !ownAiChatter(m, who)) // peers can push anything into the room
3373
+ const name = s.status.me.name
3374
+ const colors = new Map(s.status.peers.map((p) => [p.name, p.color]))
3375
+ colors.set(name, s.status.me.color)
3376
+ const names = mentionNames(s)
3377
+ el.innerHTML = list.length
3378
+ ? list.map((m) => {
3379
+ const mine = m.by === name
3380
+ const relay = isRelay(m.by)
3381
+ const by = relay ? RELAY_NAME : shownName(m.by, who)
3382
+ const dm = m.to ? `<span class="dm">${mine || who.has(m.by) ? `to ${esc(m.to === name ? 'you' : m.to === `${name}'s AI` ? 'your AI' : shownName(m.to, who))}` : 'direct'}</span>` : ''
3383
+ const file = m.file ? `<a class="file-card" href="${fileCardHref(current, m.id, TOKEN)}" download="${esc(m.file.name)}">
3384
+ <span class="fi">${I.file}</span><span style="min-width:0"><div class="fn">${esc(m.file.name)}</div><div class="fs">${bytes(m.file.size)} · ${mine ? 'sent' : 'download'}</div></span></a>` : ''
3385
+ // The sender's face sits in the name line above the bubble, so the bubble has the pane's width.
3386
+ const face = mine ? '' : relay ? `<span class="relay-q avatar">${I.globe}</span>` : avatar(by, colors.get(m.by))
3387
+ return `<div class="msg${mine ? ' mine' : ''}">
3388
+ <div style="min-width:0"><div class="head">${face}<b${by !== m.by ? ` title="${esc(m.by)}"` : ''}>${mine ? 'You' : esc(by === `${name}'s AI` ? 'Your AI' : by)}</b>${dm}<span>${esc(clock(m.ts))}</span></div>
3389
+ <div class="bubble">${m.text ? `<div class="text">${textHtml(m.text, names, name, { meAgent: s.status.me.kind === 'agent' })}</div>` : ''}${file}</div></div></div>`
3390
+ }).join('')
3391
+ : '<div class="day-empty"><div><b>Say hi.</b></div><div class="hint">Messages, direct messages and files you share appear here. Drop a file on this panel to send it.</div></div>'
3392
+ if (force || nearBottom || (incoming && list[list.length - 1]?.by === name)) el.scrollTop = el.scrollHeight
3393
+ renderChatBadge()
3394
+ }