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,175 @@
|
|
|
1
|
+
// Invite an agent, with workspaces on: a small menu anchored to the button, with the three
|
|
2
|
+
// kinds of agent (global, workspace, session), then where it goes for the last two, then the
|
|
3
|
+
// text to paste into the AI. With workspaces off the buttons never open it (see home.js and
|
|
4
|
+
// app.js). The markup is plain functions, so tests can draw it.
|
|
5
|
+
import { I, esc, api, state, basename, COLORS } from './common.js'
|
|
6
|
+
import { agentPaste } from './invite.js'
|
|
7
|
+
|
|
8
|
+
export const DOCS_URL = 'https://heyquilt.com/docs/agents#kinds'
|
|
9
|
+
export const KINDS = [
|
|
10
|
+
['global', 'Global agent', 'In all your workspaces, invited to their sessions', 'globe'],
|
|
11
|
+
['workspace', 'Workspace agent', 'In one workspace, invited to its sessions', 'grid'],
|
|
12
|
+
['session', 'Session agent', 'Invited to one session', 'link']
|
|
13
|
+
]
|
|
14
|
+
const NAMES = Object.fromEntries(KINDS.map(([k, name]) => [k, name]))
|
|
15
|
+
|
|
16
|
+
/** The workspaces a workspace agent can be invited to: your own, and org ones you admin. */
|
|
17
|
+
export const invitableWorkspaces = (list) => (list || []).filter((w) => w.admin && !w.archivedAt && (w.via === 'owner' || w.space?.kind === 'org'))
|
|
18
|
+
|
|
19
|
+
/** The sessions running on this computer that a session agent can be invited to. */
|
|
20
|
+
export const invitableSessions = (sessions) => [...(sessions || [])].filter((s) => s.invite)
|
|
21
|
+
|
|
22
|
+
/** The menu's first step: the kinds, and How agent kinds work. */
|
|
23
|
+
export function kindMenuHtml () {
|
|
24
|
+
return `${KINDS.map(([kind, name, line, icon]) => `
|
|
25
|
+
<button type="button" class="pop-item km-item" role="menuitem" data-kind="${kind}"><span class="km-ic">${I[icon]}</span><span class="km-t"><b>${name}</b><span>${line}</span></span></button>`).join('')}
|
|
26
|
+
<div class="pop-sep"></div>
|
|
27
|
+
<a class="pop-item km-docs" role="menuitem" href="${DOCS_URL}" target="_blank" rel="noopener">How agent kinds work${I.external}</a>
|
|
28
|
+
<p class="error km-error" role="alert"></p>`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The second step for a workspace or session agent: where it goes. `items` are
|
|
33
|
+
* [{ id, name, color, current }]; the current one (where the menu was opened) comes first.
|
|
34
|
+
*/
|
|
35
|
+
export function pickHtml (kind, items) {
|
|
36
|
+
const what = kind === 'workspace' ? 'workspace' : 'session'
|
|
37
|
+
const list = [...items].sort((a, b) => (b.current ? 1 : 0) - (a.current ? 1 : 0))
|
|
38
|
+
const empty = kind === 'workspace' ? 'No workspace you can invite an agent to yet.' : 'No session is running. Open one, then invite from there.'
|
|
39
|
+
return `
|
|
40
|
+
<div class="km-head"><button type="button" class="btn sm ghost icon" data-km-back aria-label="Back to the kinds of agent">${I.arrowLeft}</button><b>${NAMES[kind]}</b></div>
|
|
41
|
+
<div class="pop-label">Which ${what}</div>
|
|
42
|
+
<div class="km-list">${list.length
|
|
43
|
+
? list.map((x) => `<button type="button" class="pop-item km-pick" role="menuitem" data-pick="${esc(x.id)}"><i class="km-dot" style="background:${esc(x.color)}"></i><span class="grow">${esc(x.name)}</span>${x.current ? `<span class="hint">This ${what}</span>` : ''}</button>`).join('')
|
|
44
|
+
: `<p class="hint km-empty">${empty}</p>`}</div>
|
|
45
|
+
<p class="error km-error" role="alert"></p>`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The line above the paste text: which kind of agent it makes, and where. */
|
|
49
|
+
export function kindLineHtml (kind, where = '') {
|
|
50
|
+
const icon = I[KINDS.find(([k]) => k === kind)?.[3] || 'bot']
|
|
51
|
+
const tail = kind === 'global' ? 'In all your workspaces, invited to their sessions.' : kind === 'workspace' ? `In ${esc(where)}, invited to its sessions.` : `Invited to ${esc(where)}.`
|
|
52
|
+
return `<p class="hint km-kind"><span class="pill km-pill">${icon}${NAMES[kind]}</span>${tail}</p>`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The workspaces and sessions to pick from, the current ones marked. */
|
|
56
|
+
function choices (kind, { workspaceId, sessionId }) {
|
|
57
|
+
if (kind === 'workspace') return invitableWorkspaces(state.workspaces).map((w) => ({ id: w.id, name: w.name, color: COLORS[w.color] || COLORS.lilac, current: w.id === workspaceId }))
|
|
58
|
+
return invitableSessions(state.sessions?.values?.()).map((s) => ({ id: s.id, name: s.status?.sessionName || basename(s.dir), color: s.status?.connected ? 'var(--ok)' : 'var(--warn)', current: s.id === sessionId }))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Makes the invite for `kind` (at `id` for a workspace or session): { text, where }. */
|
|
62
|
+
export async function makeAgentInvite (kind, id, workspaceBody) {
|
|
63
|
+
if (kind === 'global') return { text: agentPaste({ link: (await api('POST', '/api/agent-invites', { global: true })).link, invitedToSessions: true }), where: '' }
|
|
64
|
+
if (kind === 'workspace') {
|
|
65
|
+
const w = (state.workspaces || []).find((x) => x.id === id)
|
|
66
|
+
const { link } = await api('POST', `/api/workspaces/${encodeURIComponent(id)}/agent-invites`, workspaceBody ? workspaceBody() : { access: 'edit', sessions: 'all' })
|
|
67
|
+
return { text: agentPaste({ link, invitedToSessions: true }), where: w?.name || 'this workspace' }
|
|
68
|
+
}
|
|
69
|
+
const s = state.sessions.get(id)
|
|
70
|
+
if (!s) throw new Error('That session is not running any more.')
|
|
71
|
+
const { link } = await api('POST', '/api/agent-invites')
|
|
72
|
+
return { text: agentPaste({ link, session: s.invite }), where: s.status?.sessionName || basename(s.dir) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let open = null
|
|
76
|
+
|
|
77
|
+
/** Closes the menu (and gives the button its focus back when `refocus`). */
|
|
78
|
+
export function closeKindMenu (refocus = false) {
|
|
79
|
+
if (!open) return
|
|
80
|
+
const { menu, btn, off } = open
|
|
81
|
+
open = null
|
|
82
|
+
off.abort()
|
|
83
|
+
menu.remove()
|
|
84
|
+
btn.setAttribute('aria-expanded', 'false')
|
|
85
|
+
if (refocus) btn.focus()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Puts the menu under the button (or above it when there's no room), inside the window, and
|
|
90
|
+
* answers the side it took. Later steps pass that side back, so the menu never jumps across
|
|
91
|
+
* the button when a step is shorter or taller.
|
|
92
|
+
*/
|
|
93
|
+
function place (menu, btn, side = null) {
|
|
94
|
+
const r = btn.getBoundingClientRect()
|
|
95
|
+
const w = menu.offsetWidth
|
|
96
|
+
// Its left edge under the button's; or, when that runs off the window, its right edge under the button's.
|
|
97
|
+
const left = r.left + w > window.innerWidth - 8 ? r.right - w : r.left
|
|
98
|
+
menu.style.left = `${Math.max(8, Math.min(left, window.innerWidth - w - 8))}px`
|
|
99
|
+
const below = window.innerHeight - r.bottom
|
|
100
|
+
const at = side || (below < menu.offsetHeight + 12 && r.top > below ? 'above' : 'below')
|
|
101
|
+
menu.style.top = at === 'below' ? `${r.bottom + 4}px` : ''
|
|
102
|
+
menu.style.bottom = at === 'above' ? `${window.innerHeight - r.top + 4}px` : ''
|
|
103
|
+
// A taller step scrolls inside the room on its side rather than running off the window.
|
|
104
|
+
menu.style.maxHeight = `${Math.max(160, Math.min(420, (at === 'below' ? below : r.top) - 12))}px`
|
|
105
|
+
return at
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Opens the menu at `btn`. opts: workspaceId / sessionId (where it was opened, picked first),
|
|
110
|
+
* workspaceBody() (a workspace agent's { access, sessions }; Edit, every session otherwise),
|
|
111
|
+
* onInvite({ kind, text, where }) once the invite is made.
|
|
112
|
+
*/
|
|
113
|
+
export function openKindMenu (btn, { workspaceId = '', sessionId = '', workspaceBody = null, onInvite = () => {} } = {}) {
|
|
114
|
+
if (open?.btn === btn) { closeKindMenu(true); return }
|
|
115
|
+
closeKindMenu()
|
|
116
|
+
const menu = document.createElement('div')
|
|
117
|
+
menu.className = 'popover kind-menu'
|
|
118
|
+
menu.setAttribute('role', 'menu')
|
|
119
|
+
menu.setAttribute('aria-label', 'Invite an agent')
|
|
120
|
+
const off = new AbortController()
|
|
121
|
+
open = { menu, btn, off }
|
|
122
|
+
btn.setAttribute('aria-expanded', 'true')
|
|
123
|
+
const items = () => [...menu.querySelectorAll('[role=menuitem]:not(:disabled)')]
|
|
124
|
+
let side = null
|
|
125
|
+
const show = (html) => {
|
|
126
|
+
menu.innerHTML = html
|
|
127
|
+
side = place(menu, btn, side)
|
|
128
|
+
items()[0]?.focus()
|
|
129
|
+
}
|
|
130
|
+
const busy = (on) => { for (const b of menu.querySelectorAll('button')) b.disabled = on; menu.setAttribute('aria-busy', String(on)) }
|
|
131
|
+
const make = async (kind, id) => {
|
|
132
|
+
busy(true)
|
|
133
|
+
try {
|
|
134
|
+
const made = await makeAgentInvite(kind, id, workspaceBody)
|
|
135
|
+
closeKindMenu()
|
|
136
|
+
onInvite({ kind, ...made })
|
|
137
|
+
} catch (err) {
|
|
138
|
+
busy(false)
|
|
139
|
+
const e = menu.querySelector('.km-error')
|
|
140
|
+
if (e) e.textContent = err.message
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
let kind = null
|
|
144
|
+
menu.addEventListener('click', (e) => {
|
|
145
|
+
if (e.target.closest('.km-docs')) { closeKindMenu(); return }
|
|
146
|
+
if (e.target.closest('[data-km-back]')) { kind = null; show(kindMenuHtml()); return }
|
|
147
|
+
const k = e.target.closest('[data-kind]')
|
|
148
|
+
if (k) {
|
|
149
|
+
kind = k.dataset.kind
|
|
150
|
+
if (kind === 'global') make('global')
|
|
151
|
+
else show(pickHtml(kind, choices(kind, { workspaceId, sessionId })))
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const p = e.target.closest('[data-pick]')
|
|
155
|
+
if (p && kind) make(kind, p.dataset.pick)
|
|
156
|
+
})
|
|
157
|
+
menu.addEventListener('keydown', (e) => {
|
|
158
|
+
const list = items()
|
|
159
|
+
const i = list.indexOf(document.activeElement)
|
|
160
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeKindMenu(true); return }
|
|
161
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
list[(i + (e.key === 'ArrowDown' ? 1 : list.length - 1)) % list.length]?.focus()
|
|
164
|
+
}
|
|
165
|
+
if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); list[e.key === 'Home' ? 0 : list.length - 1]?.focus() }
|
|
166
|
+
if (e.key === 'Tab') closeKindMenu()
|
|
167
|
+
})
|
|
168
|
+
document.body.append(menu)
|
|
169
|
+
show(kindMenuHtml())
|
|
170
|
+
const outside = (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) closeKindMenu() }
|
|
171
|
+
document.addEventListener('mousedown', outside, { signal: off.signal })
|
|
172
|
+
btn.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeKindMenu(true) } }, { signal: off.signal })
|
|
173
|
+
window.addEventListener('resize', () => closeKindMenu(), { signal: off.signal })
|
|
174
|
+
document.addEventListener('scroll', (e) => { if (!menu.contains(e.target)) closeKindMenu() }, { capture: true, signal: off.signal })
|
|
175
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Where agents work, in the app: an agent's row in Settings › Agents (with Works in and
|
|
2
|
+
// Invited to new sessions once workspaces are on), an agent's card on a workspace's page, and
|
|
3
|
+
// the agents a session's workspace invites, in its People. A workspace invites its agents to
|
|
4
|
+
// a new session (sends them the link); the session's owner lets them in. The markup is plain
|
|
5
|
+
// functions, so tests can draw it; home.js, workspaces.js and session.js put it on the page.
|
|
6
|
+
import { I, esc, ago, avatar, colorFor, api, toast, COLORS } from './common.js'
|
|
7
|
+
|
|
8
|
+
export const REACH_OPTIONS = [['all', 'All'], ['workspaces', 'Chosen'], ['manual', 'Where added']]
|
|
9
|
+
/** What Works in means, under the choice (when no workspaces are picked by hand). */
|
|
10
|
+
export const REACH_HINTS = { all: 'Global: in every workspace you own, and any you make later.', manual: 'Only in workspaces it is added to, from their page.' }
|
|
11
|
+
export const JOINS_OPTIONS = [['all', 'Every session'], ['invited', 'Not automatically']]
|
|
12
|
+
/** A workspace card's Invite to new sessions: each choice reads as the line a non-admin sees. */
|
|
13
|
+
export const INVITE_OPTIONS = [['all', 'Invited to new sessions'], ['invited', 'Not invited automatically']]
|
|
14
|
+
const JOINS_HINTS = { all: 'Gets each new session\'s link; the owner lets it in.', invited: 'Joins a session only when someone invites it.', manual: 'Set on each workspace it is added to.' }
|
|
15
|
+
const options = (list, value) => list.map(([v, label]) => `<option value="${v}"${v === value ? ' selected' : ''}>${label}</option>`).join('')
|
|
16
|
+
const segs = (list, value, attr, label, disabled = false) =>
|
|
17
|
+
`<div class="segmented sm" role="radiogroup" ${attr} aria-label="${esc(label)}"${disabled ? ' aria-disabled="true"' : ''}>${list.map(([v, l]) =>
|
|
18
|
+
`<button type="button" role="radio" data-v="${v}" aria-checked="${v === value}" class="${v === value ? 'on' : ''}"${disabled ? ' disabled' : ''}>${l}</button>`).join('')}</div>`
|
|
19
|
+
|
|
20
|
+
/** Your own personal workspaces: the only ones one of your agents can be placed in. */
|
|
21
|
+
export const placeableWorkspaces = (list) => (list || []).filter((w) => w.space?.kind === 'personal' && w.via === 'owner')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One of your agents in Settings › Agents. Without a placement (workspaces off) it is the row
|
|
25
|
+
* Quilt has always shown; with one, a card with where it works and when it joins sessions.
|
|
26
|
+
*/
|
|
27
|
+
export function agentRow (a, place = null, workspaces = []) {
|
|
28
|
+
const signedOut = a.status === 'reused' || a.status === 'expired'
|
|
29
|
+
const state = signedOut ? '<span class="pill warn">signed out</span>' : a.canJoinSessions ? '' : '<span class="pill">registered only</span>'
|
|
30
|
+
const when = a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}`
|
|
31
|
+
if (place) return agentCardHtml(a, place, workspaces, { state, when })
|
|
32
|
+
return `<div class="kv agent-row"><span>${I.bot}</span><b>${esc(a.name)} ${state}</b><span class="hint">${esc(a.provider)} · ${esc(a.type)} · ${when}</span></div>`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A small card (Settings): a dot for whether it can join sessions now, its name, what it is, and a ⋮ menu.
|
|
37
|
+
* Like a team's members page; the choices (where it works) are on the Agents page.
|
|
38
|
+
*/
|
|
39
|
+
export function agentMiniHtml (a, { opens = false } = {}) {
|
|
40
|
+
const signedOut = a.status === 'reused' || a.status === 'expired'
|
|
41
|
+
const on = !signedOut && a.canJoinSessions
|
|
42
|
+
const why = signedOut ? 'Signed out: invite it again' : a.canJoinSessions ? 'Can join your sessions' : 'Registered only'
|
|
43
|
+
const when = a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}`
|
|
44
|
+
return `<div class="ag-mini${opens ? ' open' : ''}" data-agent="${esc(a.id)}" title="${esc(`${a.provider} · ${a.type} · ${when}${opens ? ' · open to choose where it works' : ''}`)}"${opens ? ' role="button" tabindex="0"' : ''}>
|
|
45
|
+
<span class="dot ${on ? 'on' : ''}" title="${esc(why)}"></span>
|
|
46
|
+
<span class="ag-t"><b>${esc(a.name)}</b><span class="hint">${esc(signedOut ? 'Signed out' : a.type)}</span></span>
|
|
47
|
+
<button type="button" class="btn icon ghost ag-more" data-agent-menu="${esc(a.id)}" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ${esc(a.name)}">${I.more}</button>
|
|
48
|
+
</div>`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The ⋮ menu on a small card: `items` are [{ act, label, icon }]; `on(act)` runs the choice. Closes on a choice, a click elsewhere, Escape, or ⋮ again. */
|
|
52
|
+
export function openAgentMenu (anchor, { label, items, on }) {
|
|
53
|
+
const again = closeAgentMenu.anchor === anchor
|
|
54
|
+
closeAgentMenu()
|
|
55
|
+
if (again) return
|
|
56
|
+
const menu = document.createElement('div')
|
|
57
|
+
menu.className = 'popover more-menu agent-menu'
|
|
58
|
+
menu.setAttribute('role', 'menu')
|
|
59
|
+
menu.setAttribute('aria-label', label)
|
|
60
|
+
menu.innerHTML = items.map((it) => it.sep
|
|
61
|
+
? '<div class="pop-sep"></div>'
|
|
62
|
+
: `<button type="button" class="pop-item${it.danger ? ' danger' : ''}${it.on ? ' on' : ''}" data-act="${esc(it.act)}" role="menuitem"${it.on !== undefined ? ` aria-checked="${!!it.on}" role="menuitemradio"` : ''}>${it.icon || (it.on !== undefined ? `<span class="pop-check">${it.on ? I.check : ''}</span>` : '')}${esc(it.label)}</button>`).join('')
|
|
63
|
+
document.body.appendChild(menu)
|
|
64
|
+
const r = anchor.getBoundingClientRect()
|
|
65
|
+
const w = menu.offsetWidth
|
|
66
|
+
menu.style.left = `${Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))}px`
|
|
67
|
+
menu.style.top = `${Math.max(8, Math.min(r.bottom + 4, window.innerHeight - menu.offsetHeight - 8))}px`
|
|
68
|
+
anchor.setAttribute('aria-expanded', 'true')
|
|
69
|
+
const buttons = [...menu.querySelectorAll('.pop-item')]
|
|
70
|
+
buttons[0]?.focus()
|
|
71
|
+
menu.addEventListener('click', (e) => {
|
|
72
|
+
const act = e.target.closest('[data-act]')?.dataset.act
|
|
73
|
+
if (!act) return
|
|
74
|
+
closeAgentMenu()
|
|
75
|
+
on(act)
|
|
76
|
+
})
|
|
77
|
+
const away = (e) => { if (!menu.contains(e.target) && !anchor.contains(e.target)) closeAgentMenu() }
|
|
78
|
+
const keys = (e) => {
|
|
79
|
+
if (e.key === 'Escape') { closeAgentMenu(); anchor.focus?.() }
|
|
80
|
+
const i = buttons.indexOf(document.activeElement)
|
|
81
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); buttons[(i + (e.key === 'ArrowDown' ? 1 : buttons.length - 1)) % buttons.length].focus() }
|
|
82
|
+
}
|
|
83
|
+
setTimeout(() => document.addEventListener('mousedown', away), 0)
|
|
84
|
+
document.addEventListener('keydown', keys)
|
|
85
|
+
const scroll = (e) => { if (!menu.contains(e.target)) closeAgentMenu() }
|
|
86
|
+
document.addEventListener('scroll', scroll, true)
|
|
87
|
+
closeAgentMenu.cleanup = () => {
|
|
88
|
+
document.removeEventListener('mousedown', away)
|
|
89
|
+
document.removeEventListener('keydown', keys)
|
|
90
|
+
document.removeEventListener('scroll', scroll, true)
|
|
91
|
+
anchor.setAttribute('aria-expanded', 'false')
|
|
92
|
+
menu.remove()
|
|
93
|
+
closeAgentMenu.anchor = null
|
|
94
|
+
}
|
|
95
|
+
closeAgentMenu.anchor = anchor
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function closeAgentMenu () {
|
|
99
|
+
if (closeAgentMenu.cleanup) { closeAgentMenu.cleanup(); closeAgentMenu.cleanup = null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The chosen-workspace chips: each of your workspaces, pressed when the agent is in it. */
|
|
103
|
+
function chipsHtml (ids, workspaces) {
|
|
104
|
+
if (!workspaces.length) return '<span class="ag-why">Make a workspace first.</span>'
|
|
105
|
+
return workspaces.map((w) => `<button type="button" class="ag-chip" data-placement-ws value="${esc(w.id)}" aria-pressed="${ids.includes(w.id)}"><i style="background:${COLORS[w.color] || COLORS.lilac}"></i>${esc(w.name)}</button>`).join('')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An agent's card: who it is (with a Global badge when it is in every workspace), Works in
|
|
110
|
+
* (all workspaces, chosen ones as chips, or only where added) and Invited to new sessions. The line under each
|
|
111
|
+
* choice has a fixed height, so changing a choice never moves the card.
|
|
112
|
+
*/
|
|
113
|
+
export function agentCardHtml (a, place, workspaces, { state = '', when = '' } = {}) {
|
|
114
|
+
const ids = place.workspaceIds || []
|
|
115
|
+
const manual = place.reach === 'manual'
|
|
116
|
+
return `
|
|
117
|
+
<div class="ag-card" data-agent-place="${esc(a.id)}">
|
|
118
|
+
<div class="ag-head">${avatar(a.name, colorFor(a.name), false)}
|
|
119
|
+
<div class="ag-t"><b>${esc(a.name)}</b>${state ? ` ${state}` : ''}<span class="hint">${esc(a.provider)} · ${esc(a.type)} · ${when}</span></div>
|
|
120
|
+
<span class="pill violet ag-global" data-ag-global${place.reach === 'all' ? '' : ' hidden'} title="In every workspace you own">${I.globe}Global</span>
|
|
121
|
+
</div>
|
|
122
|
+
<div class="ag-set">
|
|
123
|
+
<div class="ag-g"><span class="ag-l">Works in</span>${segs(REACH_OPTIONS, place.reach, 'data-placement-reach', `Where ${a.name} works`)}
|
|
124
|
+
<div class="ag-detail" data-ag-detail>${place.reach === 'workspaces' ? chipsHtml(ids, workspaces) : `<span class="ag-why">${esc(REACH_HINTS[place.reach])}</span>`}</div></div>
|
|
125
|
+
<div class="ag-g"><span class="ag-l">Invited to new sessions</span>${segs(JOINS_OPTIONS, place.sessions, 'data-placement-sessions', `Whether ${a.name} is invited to new sessions`, manual)}
|
|
126
|
+
<div class="ag-detail"><span class="ag-why" data-ag-joins-why>${esc(JOINS_HINTS[manual ? 'manual' : place.sessions])}</span></div></div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The placement to save from what the card shows; access and folder limits stay as they were. */
|
|
132
|
+
export function placementBody (saved, { reach, sessions, workspaceIds }, workspaces) {
|
|
133
|
+
const mine = new Set(workspaces.map((w) => w.id))
|
|
134
|
+
return { reach, sessions, access: saved.access || 'edit', scopes: saved.scopes || [], workspaceIds: reach === 'workspaces' ? workspaceIds.filter((id) => mine.has(id)) : [] }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Saves each card's Works in, chosen workspaces and Invited to new sessions as they change. */
|
|
138
|
+
export function bindPlacements (root, agents, places, workspaces) {
|
|
139
|
+
root.querySelectorAll('[data-agent-place]').forEach((el) => {
|
|
140
|
+
const i = agents.findIndex((a) => a.id === el.dataset.agentPlace)
|
|
141
|
+
if (i >= 0 && places[i]) bindPlacement(el, agents[i], places[i], workspaces)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One card: what it shows follows the choices, each change saves, and a failed save puts the card back. */
|
|
146
|
+
export function bindPlacement (el, a, place, workspaces) {
|
|
147
|
+
let saved = place
|
|
148
|
+
let shown = { reach: place.reach, sessions: place.sessions, workspaceIds: [...(place.workspaceIds || [])] }
|
|
149
|
+
const reachEl = el.querySelector('[data-placement-reach]')
|
|
150
|
+
const joinsEl = el.querySelector('[data-placement-sessions]')
|
|
151
|
+
const paint = () => {
|
|
152
|
+
const manual = shown.reach === 'manual'
|
|
153
|
+
for (const b of reachEl.querySelectorAll('[data-v]')) { const on = b.dataset.v === shown.reach; b.classList.toggle('on', on); b.setAttribute('aria-checked', String(on)) }
|
|
154
|
+
for (const b of joinsEl.querySelectorAll('[data-v]')) { const on = b.dataset.v === shown.sessions; b.classList.toggle('on', on); b.setAttribute('aria-checked', String(on)); b.disabled = manual }
|
|
155
|
+
if (manual) joinsEl.setAttribute('aria-disabled', 'true'); else joinsEl.removeAttribute('aria-disabled')
|
|
156
|
+
el.querySelector('[data-ag-global]').hidden = shown.reach !== 'all'
|
|
157
|
+
el.querySelector('[data-ag-detail]').innerHTML = shown.reach === 'workspaces' ? chipsHtml(shown.workspaceIds, workspaces) : `<span class="ag-why">${esc(REACH_HINTS[shown.reach])}</span>`
|
|
158
|
+
el.querySelector('[data-ag-joins-why]').textContent = JOINS_HINTS[manual ? 'manual' : shown.sessions]
|
|
159
|
+
}
|
|
160
|
+
const save = async () => {
|
|
161
|
+
paint()
|
|
162
|
+
try {
|
|
163
|
+
saved = (await api('POST', `/api/agents/${encodeURIComponent(a.id)}/placement`, placementBody(saved, shown, workspaces))).placement
|
|
164
|
+
toast('Saved')
|
|
165
|
+
} catch (err) {
|
|
166
|
+
toast(err.message)
|
|
167
|
+
shown = { reach: saved.reach, sessions: saved.sessions, workspaceIds: [...(saved.workspaceIds || [])] }
|
|
168
|
+
paint()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
el.addEventListener('click', (e) => {
|
|
172
|
+
const r = e.target.closest('[data-placement-reach] [data-v]')
|
|
173
|
+
const j = e.target.closest('[data-placement-sessions] [data-v]')
|
|
174
|
+
const c = e.target.closest('[data-placement-ws]')
|
|
175
|
+
if (r && r.dataset.v !== shown.reach) { shown.reach = r.dataset.v; return save() }
|
|
176
|
+
if (j && !j.disabled && j.dataset.v !== shown.sessions) { shown.sessions = j.dataset.v; return save() }
|
|
177
|
+
if (c) {
|
|
178
|
+
shown.workspaceIds = shown.workspaceIds.includes(c.value) ? shown.workspaceIds.filter((id) => id !== c.value) : [...shown.workspaceIds, c.value]
|
|
179
|
+
return save()
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Why someone else's agent in a workspace is never invited to every session there (the API's words). */
|
|
185
|
+
export const FOREIGN_JOINS = 'Only its owner can have an agent invited to every session.'
|
|
186
|
+
|
|
187
|
+
/** The pill on a workspace's agent card: why it is there. */
|
|
188
|
+
export function viaLabel (a, orgName = '') {
|
|
189
|
+
if (a.via === 'member') return 'This workspace'
|
|
190
|
+
if (a.managedBy === 'org') return `Added by ${orgName || 'the org'}`
|
|
191
|
+
return a.via === 'global' ? 'Global' : 'Placed'
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A person or agent on a workspace's page, as a small card: the avatar (agents wear a bot badge, so
|
|
196
|
+
* who is human shows at a glance), the name, and one line with what it is and its access. Admins
|
|
197
|
+
* get a ⋮ menu (memberMenuItems); everyone else just sees it. `m` is a member ({ account, name,
|
|
198
|
+
* kind, access, owner }) or a workspace agent ({ agentId, account, name, provider, via, access,
|
|
199
|
+
* sessions, excluded, foreign }).
|
|
200
|
+
*/
|
|
201
|
+
export function memberMiniHtml (m, { admin = false, orgName = '' } = {}) {
|
|
202
|
+
const bot = m.kind === 'agent' || !!m.agentId
|
|
203
|
+
const name = m.name || m.account || 'Agent'
|
|
204
|
+
const role = m.owner ? 'Owner' : m.access === 'edit' ? 'Can edit' : 'View only'
|
|
205
|
+
const what = bot ? `${m.provider ? `${m.provider} · ` : ''}agent` : 'person'
|
|
206
|
+
const more = bot && m.agentId ? ` · ${m.excluded ? 'not in this workspace' : m.sessions === 'all' ? 'invited to new sessions' : 'not invited automatically'}${m.via ? ` · ${viaLabel(m, orgName).toLowerCase()}` : ''}` : ''
|
|
207
|
+
const items = admin ? memberMenuItems(m) : []
|
|
208
|
+
return `<div class="ag-mini member${bot ? ' bot' : ''}${m.excluded ? ' out' : ''}" data-member="${esc(m.account)}" title="${esc(`${name} · ${what} · ${role}${more}`)}">
|
|
209
|
+
<span class="ag-av">${avatar(name, colorFor(name), false)}${bot ? `<span class="kind-badge" title="Agent">${I.bot}</span>` : ''}</span>
|
|
210
|
+
<span class="ag-t"><b>${esc(name)}</b><span class="hint">${esc(what)} · ${esc(role)}</span></span>
|
|
211
|
+
${items.length ? `<button type="button" class="btn icon ghost ag-more" data-member-menu="${esc(m.account)}" aria-haspopup="menu" aria-expanded="false" aria-label="Actions for ${esc(name)}">${I.more}</button>` : ''}
|
|
212
|
+
</div>`
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What an admin may do with a member, for its ⋮ menu: [{ act, label, icon?, on?, danger? }].
|
|
217
|
+
* `act` is access:edit | access:view | joins:all | joins:invited | remove | exclude | include;
|
|
218
|
+
* `on` marks the current choice. The owner has no menu; a placed agent's access is set where it
|
|
219
|
+
* was placed; someone else's agent is invited by hand only (FOREIGN_JOINS).
|
|
220
|
+
*/
|
|
221
|
+
export function memberMenuItems (m) {
|
|
222
|
+
if (m.owner) return []
|
|
223
|
+
const bot = m.kind === 'agent' || !!m.agentId
|
|
224
|
+
const member = !m.agentId || m.via === 'member'
|
|
225
|
+
const items = []
|
|
226
|
+
if (bot && m.agentId && m.excluded) return [{ act: 'include', label: 'Let back in', icon: I.plus }]
|
|
227
|
+
if (member) {
|
|
228
|
+
items.push({ act: 'access:edit', label: 'Can edit', on: m.access === 'edit' }, { act: 'access:view', label: 'View only', on: m.access === 'view' })
|
|
229
|
+
}
|
|
230
|
+
if (bot && m.agentId && !m.foreign) {
|
|
231
|
+
items.push({ sep: true }, { act: 'joins:all', label: 'Invited to new sessions', on: m.sessions === 'all' }, { act: 'joins:invited', label: 'Not invited automatically', on: m.sessions !== 'all' })
|
|
232
|
+
}
|
|
233
|
+
items.push({ sep: true })
|
|
234
|
+
if (member) items.push({ act: 'remove', label: 'Remove', icon: I.x, danger: true })
|
|
235
|
+
else items.push({ act: 'exclude', label: 'Not in this workspace', icon: I.x, danger: true })
|
|
236
|
+
return items
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Where a session's agent stands: in it, waiting for the owner, sent the link, or not invited. */
|
|
240
|
+
export function sessionAgentState (a, st = {}) {
|
|
241
|
+
const key = `agent:${a.agentId}`
|
|
242
|
+
if (a.excluded) return 'Not invited'
|
|
243
|
+
if ((st.members || []).some((m) => m.key === key)) return 'In this session'
|
|
244
|
+
if ((st.waiting || []).some((p) => p.key === key)) return 'Waiting for you to let it in'
|
|
245
|
+
return 'Invited'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A session's People (owner, workspaces on, session in a workspace): the agents its workspace
|
|
250
|
+
* invites, each with why and where it stands, and Don't invite (unless it is in already); the
|
|
251
|
+
* ones its owner said Don't invite to, with Invite. Nothing at all when there are none.
|
|
252
|
+
*/
|
|
253
|
+
export function sessionAgentsHtml (agents, st = {}) {
|
|
254
|
+
if (!agents?.length) return ''
|
|
255
|
+
const row = (a) => {
|
|
256
|
+
const name = a.name || 'Agent'
|
|
257
|
+
const now = sessionAgentState(a, st)
|
|
258
|
+
const action = a.excluded
|
|
259
|
+
? `<button type="button" class="btn sm" data-agent-invite="${esc(a.agentId)}" aria-label="Invite ${esc(name)} to this session">Invite</button>`
|
|
260
|
+
: now === 'In this session' ? '' : `<button type="button" class="btn sm ghost" data-agent-uninvite="${esc(a.agentId)}" aria-label="Don't invite ${esc(name)} to this session">Don't invite</button>`
|
|
261
|
+
return `<div class="pm-member pm-inv${a.excluded ? ' out' : ''}"><span class="nm"><b>${esc(name)}</b><small>${esc(viaLabel(a))} · ${esc(now)}</small></span>${action}</div>`
|
|
262
|
+
}
|
|
263
|
+
return `<div class="pm-section pm-invited"><div class="pm-title">Invited from the workspace</div>
|
|
264
|
+
${agents.map(row).join('')}</div>`
|
|
265
|
+
}
|