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.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- 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
|
+
}
|