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
package/src/ui/home.js
ADDED
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
// Home and Settings: a sidebar with your profile and open sessions, next to
|
|
2
|
+
// either the start/join page or your settings. Sessions themselves live in session.js.
|
|
3
|
+
import { I, state, $, esc, basename, ago, toast, api, ask, decodeInvite, avatar, PALETTE, AGENT_COMMIT_WORDS, loadingNote } from './common.js'
|
|
4
|
+
import { go, pickFolder, signedOutNow, agentInviteHtml } from './app.js'
|
|
5
|
+
import { agentPaste } from './invite.js'
|
|
6
|
+
import { openKindMenu, kindLineHtml } from './agent-kinds.js'
|
|
7
|
+
import { quiltMark } from './mark.js'
|
|
8
|
+
import { updateControl } from './releases.js'
|
|
9
|
+
import { workspacesHtml, bindWorkspaces, workspacePageHtml, bindWorkspacePage, loadWorkspaces, openWorkspace, COLORS } from './workspaces.js'
|
|
10
|
+
import { allFilesHtml, bindAllFiles } from './files.js'
|
|
11
|
+
import { invitesHtml, bindInvites } from './invites.js'
|
|
12
|
+
import { agentCardHtml, bindPlacement, placeableWorkspaces, agentMiniHtml, openAgentMenu } from './agent-place.js'
|
|
13
|
+
import { SOUND_EVENTS, playSound } from './sounds.js'
|
|
14
|
+
import { gitControl, gitSegHtml } from './git-rule.js'
|
|
15
|
+
import { cloudAiCardHtml, bindCloudAi } from './cloud-ai.js'
|
|
16
|
+
|
|
17
|
+
export const tildify = (p) => state.defaults.home && String(p).startsWith(state.defaults.home) ? `~${String(p).slice(state.defaults.home.length)}` : p
|
|
18
|
+
const hostOf = (url) => { try { return new URL(String(url).replace(/^ws/, 'http')).host } catch { return url } }
|
|
19
|
+
const firstName = () => String(state.profile.name || '').split(/[\s._-]/)[0] || state.profile.name
|
|
20
|
+
|
|
21
|
+
function greeting () {
|
|
22
|
+
const h = new Date().getHours()
|
|
23
|
+
return h < 5 ? 'Working late' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------- shell --
|
|
27
|
+
/** Whether the sidebar is collapsed to a rail of icons (wide windows only; kept on this computer). */
|
|
28
|
+
function sideCollapsed () {
|
|
29
|
+
if (state.sideCollapsed === undefined) { try { state.sideCollapsed = localStorage.getItem('quilt.sideCollapsed') === '1' } catch { state.sideCollapsed = false } }
|
|
30
|
+
return state.sideCollapsed
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderShell (view) {
|
|
34
|
+
const page = $('#page')
|
|
35
|
+
const scroll = page ? page.scrollTop : 0
|
|
36
|
+
$('#app').innerHTML = `
|
|
37
|
+
<div class="app-shell${sideCollapsed() ? ' side-collapsed' : ''}">
|
|
38
|
+
${mobileBarHtml(view)}
|
|
39
|
+
<div class="side-scrim" data-side-close></div>
|
|
40
|
+
${sidebarHtml(view)}
|
|
41
|
+
<main class="page" id="page">${view === 'settings' ? settingsHtml() : view === 'agents' ? agentsPageHtml() : view.startsWith('wsfiles:') ? allFilesHtml(state.workspace) : view.startsWith('ws:') ? workspacePageHtml() : homeHtml()}</main>
|
|
42
|
+
</div>`
|
|
43
|
+
if (page && view === state.shellView) $('#page').scrollTop = scroll
|
|
44
|
+
state.shellView = view
|
|
45
|
+
bindSidebar()
|
|
46
|
+
if (view === 'settings') bindSettings($('#page'), () => renderShell('settings'))
|
|
47
|
+
else if (view === 'agents') bindAgents($('#page'))
|
|
48
|
+
else if (view.startsWith('wsfiles:')) {
|
|
49
|
+
const id = view.slice('wsfiles:'.length)
|
|
50
|
+
const rerender = () => { if (state.view === view) renderShell(view) }
|
|
51
|
+
bindAllFiles($('#page'), { id, go, rerender, reload: async () => { await openWorkspace(id).catch((err) => toast(err.message)); rerender() } })
|
|
52
|
+
} else if (view.startsWith('ws:')) {
|
|
53
|
+
bindWorkspacePage($('#page'), { go, rerender: () => renderShell(view), newSessionDialog, inviteDialog: workspaceInviteDialog, dialog })
|
|
54
|
+
bindSessionActions($('#page'))
|
|
55
|
+
} else bindHome()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Narrow windows: a slim bar with the logo, where you are, your avatar and a menu
|
|
59
|
+
* button; the full sidebar slides in from the left as a drawer. Hidden on wide windows. */
|
|
60
|
+
function placeName (view) {
|
|
61
|
+
if (view === 'settings') return 'Settings'
|
|
62
|
+
if (view === 'agents') return 'Agents'
|
|
63
|
+
const id = view.startsWith('wsfiles:') ? view.slice(8) : view.startsWith('ws:') ? view.slice(3) : ''
|
|
64
|
+
if (!id) return 'Home'
|
|
65
|
+
const name = (state.workspaces || []).find((w) => w.id === id)?.name || state.workspace?.workspace?.name || 'Workspace'
|
|
66
|
+
return view.startsWith('wsfiles:') ? `${name} · Files` : name
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mobileBarHtml (view) {
|
|
70
|
+
const p = state.profile
|
|
71
|
+
return `
|
|
72
|
+
<header class="mbar">
|
|
73
|
+
<button type="button" class="btn ghost icon" data-side-open aria-label="Open the menu" aria-expanded="false">${I.menu}</button>
|
|
74
|
+
<button type="button" class="mbar-brand" data-view="home" aria-label="Home">${quiltMark({ word: false, px: 24 })}</button>
|
|
75
|
+
<span class="mbar-title">${esc(placeName(view))}</span>
|
|
76
|
+
<button type="button" class="mbar-me" data-view="settings" title="Your profile and settings">${avatar(p.name, p.color)}</button>
|
|
77
|
+
</header>`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sidebarHtml (view) {
|
|
81
|
+
const p = state.profile
|
|
82
|
+
const running = [...state.sessions.values()]
|
|
83
|
+
const reopenable = state.recent.filter((r) => !r.unsupported)
|
|
84
|
+
const slim = sideCollapsed()
|
|
85
|
+
return `
|
|
86
|
+
<aside class="side">
|
|
87
|
+
<div class="side-top">
|
|
88
|
+
<button class="brand" data-view="home" aria-label="Home" title="Home">${slim ? quiltMark({ word: false, px: 32 }) : quiltMark({ sew: 'first' })}</button>
|
|
89
|
+
<button type="button" class="btn icon ghost side-toggle" data-side-collapse aria-label="${slim ? 'Expand the sidebar' : 'Collapse the sidebar'}" title="${slim ? 'Expand' : 'Collapse'}" aria-expanded="${!slim}">${I.chevrons}</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<button class="me-card" data-view="settings" title="${slim ? esc(p.name) + ' · your profile and settings' : 'Edit your profile'}">
|
|
93
|
+
${avatar(p.name, p.color)}
|
|
94
|
+
<span class="me-text"><b>${esc(p.name)}</b><span>${esc(p.tool)}</span></span>
|
|
95
|
+
<span class="me-edit">Edit</span>
|
|
96
|
+
</button>
|
|
97
|
+
|
|
98
|
+
${state.workspacesOn
|
|
99
|
+
? `<button class="btn primary full sessions-btn" type="button" data-join-session title="Join a session">${I.link}<span>Join a session</span></button>`
|
|
100
|
+
: `<div class="menu-wrap" id="sessions-menu-wrap">
|
|
101
|
+
<button class="btn primary full sessions-btn" id="sessions-btn" aria-haspopup="true" aria-expanded="false">${I.folder}<span>Sessions</span>${running.length ? `<span class="badge-count">${running.length}</span>` : ''}<span class="caret">${I.caret}</span></button>
|
|
102
|
+
<div class="popover menu" id="sessions-menu" role="menu" hidden>
|
|
103
|
+
<button class="pop-item" role="menuitem" data-new-session>${I.plus}<span>New session…</span></button>
|
|
104
|
+
<button class="pop-item" role="menuitem" data-join-session>${I.link}<span>Join with an invite…</span></button>
|
|
105
|
+
${running.length ? `<div class="pop-sep"></div><div class="pop-label">Open now</div>${running.map((s) => `
|
|
106
|
+
<button class="pop-item" role="menuitem" data-go="${s.id}"><span class="dot" style="background:${s.status.connected ? 'var(--ok)' : 'var(--warn)'}"></span><span class="grow">${esc(s.status.sessionName || basename(s.dir))}</span><span class="hint">${s.status.peers.length + 1} here</span></button>`).join('')}` : ''}
|
|
107
|
+
${reopenable.length ? `<div class="pop-sep"></div><div class="pop-label">Recent</div>${reopenable.slice(0, 5).map((r) => `
|
|
108
|
+
<button class="pop-item" role="menuitem" data-rejoin="${esc(r.dir)}"><span class="dot"></span><span class="grow">${esc(basename(r.dir))}</span><span class="hint">${esc(ago(r.lastUsed))}</span></button>`).join('')}` : ''}
|
|
109
|
+
</div>
|
|
110
|
+
</div>`}
|
|
111
|
+
|
|
112
|
+
<nav class="side-nav" aria-label="Main">
|
|
113
|
+
<button data-view="home" class="${view === 'home' ? 'on' : ''}" title="Home">${I.home}<span>Home</span></button>
|
|
114
|
+
${state.workspacesOn ? `<button data-view="agents" class="${view === 'agents' ? 'on' : ''}" title="Agents">${I.bot}<span>Agents</span></button>` : ''}
|
|
115
|
+
</nav>
|
|
116
|
+
${sideWorkspacesHtml(view)}
|
|
117
|
+
|
|
118
|
+
<nav class="side-nav side-foot" aria-label="App">
|
|
119
|
+
<button data-view="settings" class="${view === 'settings' ? 'on' : ''}" title="Settings">${I.gear}<span>Settings</span></button>
|
|
120
|
+
</nav>
|
|
121
|
+
</aside>`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The sidebar's Workspaces list, with the open one marked. Only once the API has workspaces on. */
|
|
125
|
+
function sideWorkspacesHtml (view) {
|
|
126
|
+
view = String(view).replace(/^wsfiles:/, 'ws:') // All files marks its workspace too
|
|
127
|
+
const list = state.workspacesOn ? (state.workspaces || []) : []
|
|
128
|
+
if (!list.length) return ''
|
|
129
|
+
return `
|
|
130
|
+
<div class="side-label">Workspaces</div>
|
|
131
|
+
<nav class="side-nav side-workspaces" aria-label="Workspaces">${list.map((w) => `
|
|
132
|
+
<button data-view="ws:${esc(w.id)}" class="${view === `ws:${w.id}` ? 'on' : ''}" title="${esc(w.name)}"><span class="sw" style="background:${COLORS[w.color] || COLORS.lilac}"></span><span class="nm">${esc(w.name)}${w.space?.kind === 'org' ? `<span class="org"> · ${esc(w.space.name)}</span>` : ''}</span>${w.counts?.open ? `<span class="ct">${w.counts.open}</span>` : ''}</button>`).join('')}
|
|
133
|
+
</nav>`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function bindSidebar () {
|
|
137
|
+
document.querySelectorAll('[data-view]').forEach((b) => {
|
|
138
|
+
b.onclick = () => go(b.dataset.view)
|
|
139
|
+
})
|
|
140
|
+
// The narrow drawer: open from the bar's menu button; the scrim, Escape or going anywhere closes it.
|
|
141
|
+
const shell = $('.app-shell')
|
|
142
|
+
const toggle = $('[data-side-open]')
|
|
143
|
+
const setDrawer = (open) => { shell.classList.toggle('side-open', open); toggle?.setAttribute('aria-expanded', String(open)) }
|
|
144
|
+
if (toggle) toggle.onclick = () => setDrawer(!shell.classList.contains('side-open'))
|
|
145
|
+
$('[data-side-close]')?.addEventListener('click', () => setDrawer(false))
|
|
146
|
+
// Collapse to a rail of icons (wide windows): kept on this computer; the shell is drawn again.
|
|
147
|
+
const collapse = $('[data-side-collapse]')
|
|
148
|
+
if (collapse) collapse.onclick = () => {
|
|
149
|
+
state.sideCollapsed = !sideCollapsed()
|
|
150
|
+
try { localStorage.setItem('quilt.sideCollapsed', state.sideCollapsed ? '1' : '0') } catch {}
|
|
151
|
+
renderShell(state.shellView || 'home')
|
|
152
|
+
$('[data-side-collapse]')?.focus()
|
|
153
|
+
}
|
|
154
|
+
shell.addEventListener('keydown', (e) => { if (e.key === 'Escape' && shell.classList.contains('side-open')) { setDrawer(false); toggle?.focus() } })
|
|
155
|
+
const btn = $('#sessions-btn')
|
|
156
|
+
const menu = $('#sessions-menu')
|
|
157
|
+
// With workspaces on there is no Sessions menu (sessions live in their workspaces), only Join a session.
|
|
158
|
+
if (btn && menu) {
|
|
159
|
+
const setOpen = (open) => { menu.hidden = !open; btn.setAttribute('aria-expanded', String(open)) }
|
|
160
|
+
btn.onclick = () => setOpen(menu.hidden)
|
|
161
|
+
menu.addEventListener('click', () => setOpen(false))
|
|
162
|
+
menu.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen(false); btn.focus() } })
|
|
163
|
+
}
|
|
164
|
+
if (!bindSidebar.listening) {
|
|
165
|
+
bindSidebar.listening = true
|
|
166
|
+
document.addEventListener('mousedown', (e) => {
|
|
167
|
+
const wrap = $('#sessions-menu-wrap')
|
|
168
|
+
if (wrap && !wrap.contains(e.target)) { $('#sessions-menu').hidden = true; $('#sessions-btn').setAttribute('aria-expanded', 'false') }
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
document.querySelectorAll('.side [data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
|
|
172
|
+
bindSessionActions(document.querySelector('.side'))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** New / join / rejoin buttons, wherever they appear. */
|
|
176
|
+
function bindSessionActions (root) {
|
|
177
|
+
root.querySelectorAll('[data-new-session]').forEach((b) => { b.onclick = () => newSessionDialog() })
|
|
178
|
+
root.querySelectorAll('[data-join-session]').forEach((b) => { b.onclick = () => joinSessionDialog() })
|
|
179
|
+
root.querySelectorAll('[data-rejoin]').forEach((b) => {
|
|
180
|
+
b.onclick = async () => {
|
|
181
|
+
const label = b.querySelector('.grow') ? null : b.textContent
|
|
182
|
+
b.disabled = true
|
|
183
|
+
if (label) b.innerHTML = `${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>Connecting…</span>`
|
|
184
|
+
try {
|
|
185
|
+
const sum = await api('POST', '/api/sessions', { mode: 'rejoin', dir: b.dataset.rejoin })
|
|
186
|
+
state.sessions.set(sum.id, sum)
|
|
187
|
+
await go(sum.id)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
toast(err.message)
|
|
190
|
+
b.disabled = false
|
|
191
|
+
if (label) b.textContent = label
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ------------------------------------------------------------------ home --
|
|
198
|
+
function sessionRows () {
|
|
199
|
+
const running = [...state.sessions.values()].map((s) => ({
|
|
200
|
+
live: true, id: s.id, dir: s.dir, name: s.status.me.name, tool: s.status.me.tool,
|
|
201
|
+
server: s.status.server, peers: s.status.peers.length
|
|
202
|
+
}))
|
|
203
|
+
const recent = state.recent.map((r) => ({ live: false, dir: r.dir, name: r.name, tool: r.tool, server: r.server, lastUsed: r.lastUsed, unsupported: !!r.unsupported }))
|
|
204
|
+
return [...running, ...recent]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const relayLabel = (server) => server ? hostOf(server) : ''
|
|
208
|
+
|
|
209
|
+
function sessionRowHtml (r) {
|
|
210
|
+
if (r.unsupported) {
|
|
211
|
+
return `
|
|
212
|
+
<div class="session-row gone">
|
|
213
|
+
<div class="folder-ico">${I.folder}</div>
|
|
214
|
+
<div class="meta">
|
|
215
|
+
<div class="name">${esc(basename(r.dir))}</div>
|
|
216
|
+
<div class="sub">This session ran on your computer's own relay, which Quilt no longer supports. Your files are untouched.</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="facts"><span>${esc(ago(r.lastUsed))}</span></div>
|
|
219
|
+
<div class="acts"><button class="btn sm" data-forget="${esc(r.dir)}">Remove from list</button></div>
|
|
220
|
+
</div>`
|
|
221
|
+
}
|
|
222
|
+
return `
|
|
223
|
+
<div class="session-row${r.live ? ' live' : ''}">
|
|
224
|
+
<div class="folder-ico${r.live ? ' live' : ''}">${I.folder}</div>
|
|
225
|
+
<div class="meta">
|
|
226
|
+
<div class="name">${esc(basename(r.dir))}${r.live ? '<span class="pill ok"><span class="dot"></span>Open</span>' : ''}</div>
|
|
227
|
+
<div class="sub">${esc(tildify(r.dir))}</div>
|
|
228
|
+
</div>
|
|
229
|
+
<div class="facts">
|
|
230
|
+
<span title="Your name there">${I.user}${esc(r.name || '')}</span>
|
|
231
|
+
${r.server ? `<span title="Relay">${I.globe}${esc(relayLabel(r.server))}</span>` : ''}
|
|
232
|
+
<span>${r.live ? `${r.peers ? `${r.peers} other${r.peers === 1 ? '' : 's'} here` : 'Just you'}` : esc(ago(r.lastUsed))}</span>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="acts">
|
|
235
|
+
${r.live
|
|
236
|
+
? `<button class="btn sm primary" data-go="${r.id}">Open</button>`
|
|
237
|
+
: `<button class="btn sm" data-rejoin="${esc(r.dir)}">Rejoin</button>
|
|
238
|
+
<button class="btn sm ghost icon" data-forget="${esc(r.dir)}" title="Remove from this list" aria-label="Remove ${esc(basename(r.dir))} from this list">${I.x}</button>`}
|
|
239
|
+
</div>
|
|
240
|
+
</div>`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function homeHtml () {
|
|
244
|
+
const rows = sessionRows()
|
|
245
|
+
return `
|
|
246
|
+
<header class="page-head">
|
|
247
|
+
<h1>${greeting()}, ${esc(firstName())}</h1>
|
|
248
|
+
<p>${state.workspacesOn ? 'Your workspaces. Open one, or add a new one.' : rows.some((r) => r.live) ? 'Pick up a session, or start something new from the Sessions menu.' : 'Start a session on one of your folders, or join one a partner shared with you.'}</p>
|
|
249
|
+
</header>
|
|
250
|
+
<section class="invites" id="invites-slot"${(state.invites || []).length ? '' : ' hidden'}>${invitesHtml()}</section>
|
|
251
|
+
|
|
252
|
+
${state.workspacesOn ? workspacesHtml() : rows.length ? `
|
|
253
|
+
<section class="sessions">
|
|
254
|
+
<div class="sec-head"><h2>Your sessions</h2><span class="count">${rows.length}</span>
|
|
255
|
+
<span class="spacer"></span>
|
|
256
|
+
<button class="btn sm" data-join-session>${I.link}<span>Join</span></button>
|
|
257
|
+
<button class="btn sm primary" data-new-session>${I.plus}<span>New session</span></button>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="card session-list">${rows.map(sessionRowHtml).join('')}</div>
|
|
260
|
+
</section>` : `
|
|
261
|
+
<section class="card welcome">
|
|
262
|
+
<div class="welcome-steps">
|
|
263
|
+
<div><span class="n">1</span><b>Start a session</b><p class="hint">Pick a folder on your computer to work on together.</p></div>
|
|
264
|
+
<div><span class="n">2</span><b>Send an invite link</b><p class="hint">Partners click it or paste it into Quilt. You approve who gets in.</p></div>
|
|
265
|
+
<div><span class="n">3</span><b>Code together</b><p class="hint">Files sync live, and you can watch each other's AI work.</p></div>
|
|
266
|
+
</div>
|
|
267
|
+
<div class="welcome-actions">
|
|
268
|
+
<button class="btn primary" data-new-session>${I.plus}<span>New session</span></button>
|
|
269
|
+
<button class="btn" data-join-session>${I.link}<span>Join with an invite</span></button>
|
|
270
|
+
</div>
|
|
271
|
+
</section>`}`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function bindHome () {
|
|
275
|
+
const page = $('#page')
|
|
276
|
+
bindSessionActions(page)
|
|
277
|
+
page.querySelectorAll('.session-list [data-go]').forEach((b) => { b.onclick = () => go(b.dataset.go) })
|
|
278
|
+
if (state.workspacesOn) bindWorkspaces(page, { go, rerender: () => renderShell('home') })
|
|
279
|
+
bindInvites(page, { joinDialog: joinSessionDialog, after: async () => { await loadWorkspaces(); if (state.view === 'home') renderShell('home') } })
|
|
280
|
+
page.querySelectorAll('[data-forget]').forEach((b) => {
|
|
281
|
+
b.onclick = async () => {
|
|
282
|
+
try {
|
|
283
|
+
state.recent = (await api('POST', '/api/recent/forget', { dir: b.dataset.forget })).recent
|
|
284
|
+
renderShell('home')
|
|
285
|
+
toast('Removed from the list. The folder is untouched.')
|
|
286
|
+
} catch (err) { toast(err.message) }
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --------------------------------------------------------------- dialogs --
|
|
292
|
+
function dialog (html) {
|
|
293
|
+
const back = document.createElement('div')
|
|
294
|
+
back.className = 'modal-back'
|
|
295
|
+
back.innerHTML = `<form class="card modal" role="dialog" aria-modal="true" autocomplete="off">${html}</form>`
|
|
296
|
+
document.body.appendChild(back)
|
|
297
|
+
const form = back.querySelector('form')
|
|
298
|
+
const close = () => back.remove()
|
|
299
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
|
|
300
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
301
|
+
form.querySelector('[data-cancel]').onclick = close
|
|
302
|
+
return { back, form, close }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function newSessionDialog (workspace = '') {
|
|
306
|
+
const { form, close } = dialog(`
|
|
307
|
+
<h3>New session${workspace ? ` in ${esc(state.workspaces?.find((w) => w.id === workspace)?.name || 'this workspace')}` : ''}</h3>
|
|
308
|
+
<p class="lead">Pick what you want to work on together. You'll get invites to send once it starts.</p>
|
|
309
|
+
<div class="segmented src-switch" role="tablist" aria-label="Start from">
|
|
310
|
+
<button type="button" role="tab" data-src="folder" class="on" aria-selected="true">${I.folder}<span>Folder</span></button>
|
|
311
|
+
<button type="button" role="tab" data-src="github" aria-selected="false">${I.branch}<span>GitHub</span></button>
|
|
312
|
+
</div>
|
|
313
|
+
<div id="src-folder">
|
|
314
|
+
<div class="field">
|
|
315
|
+
<label for="n-dir">Project folder</label>
|
|
316
|
+
<div class="row"><input class="input grow" id="n-dir" name="dir" placeholder="~/code/my-app" value="${esc(tildify(state.lastCreateDir || state.defaults.cwd || ''))}" required>
|
|
317
|
+
<button type="button" class="btn icon" id="n-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
<div id="src-github" hidden></div>
|
|
321
|
+
<p class="error" id="n-error"></p>
|
|
322
|
+
<div class="actions"><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Start session</button></div>`)
|
|
323
|
+
let src = 'folder'
|
|
324
|
+
const gh = githubPicker(form.querySelector('#src-github'))
|
|
325
|
+
form.querySelectorAll('[data-src]').forEach((b) => {
|
|
326
|
+
b.onclick = () => {
|
|
327
|
+
src = b.dataset.src
|
|
328
|
+
form.querySelectorAll('[data-src]').forEach((x) => { x.classList.toggle('on', x === b); x.setAttribute('aria-selected', String(x === b)) })
|
|
329
|
+
form.querySelector('#src-folder').hidden = src !== 'folder'
|
|
330
|
+
form.querySelector('#src-github').hidden = src !== 'github'
|
|
331
|
+
form.querySelector('#n-dir').required = src === 'folder'
|
|
332
|
+
form.querySelector('button[type=submit]').textContent = src === 'github' ? 'Clone and start' : 'Start session'
|
|
333
|
+
form.querySelector('#n-error').textContent = ''
|
|
334
|
+
if (src === 'github') gh.open()
|
|
335
|
+
else form.querySelector('#n-dir').focus()
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
form.querySelector('#n-browse').onclick = () => pickFolder(form.querySelector('#n-dir'))
|
|
339
|
+
form.onsubmit = async (e) => {
|
|
340
|
+
e.preventDefault()
|
|
341
|
+
if (src === 'github') {
|
|
342
|
+
let body
|
|
343
|
+
try { body = gh.value() } catch (err) { form.querySelector('#n-error').textContent = err.message; return }
|
|
344
|
+
if (await submit(form, '#n-error', { mode: 'github', ...body, workspace }, 'Cloning…')) close()
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
const dir = form.querySelector('#n-dir').value
|
|
348
|
+
state.lastCreateDir = dir
|
|
349
|
+
if (await submit(form, '#n-error', { mode: 'create', dir, workspace })) close()
|
|
350
|
+
}
|
|
351
|
+
form.querySelector('#n-dir').focus()
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Invite someone to a workspace, at the access picked above: people you've worked with (an
|
|
356
|
+
* Invite button) or anyone by email get an invite in their Quilt (Home, and heyquilt.com) and
|
|
357
|
+
* by email, and are in once they accept; the agents that can be added (your own in a personal
|
|
358
|
+
* workspace, the org's in an org's) are added at once. Agents can also join every session here
|
|
359
|
+
* as it starts, and Invite a new agent makes a link for an agent that joins once it registers.
|
|
360
|
+
*/
|
|
361
|
+
function workspaceInviteDialog (id) {
|
|
362
|
+
const d = state.workspace
|
|
363
|
+
// An org's workspace offers the org's agents only (never your own: they can't join it).
|
|
364
|
+
const isOrg = !!d?.workspace?.orgId
|
|
365
|
+
const org = isOrg ? (state.workspaces || []).find((w) => w.id === id)?.space : null
|
|
366
|
+
const orgName = org?.name || d?.owner?.name || 'the org'
|
|
367
|
+
const { back, form, close } = dialog(`
|
|
368
|
+
<h3>Invite to ${esc(d?.workspace?.name || 'this workspace')}</h3>
|
|
369
|
+
<p class="lead">People get the invite in their Quilt and by email, and are in once they accept. Agents are added straight away.</p>
|
|
370
|
+
<div class="field"><label for="wi-access">Access</label>
|
|
371
|
+
<select class="input" id="wi-access"><option value="edit">Can edit</option><option value="view">View only</option></select></div>
|
|
372
|
+
${toggle('wiEvery', false, 'Also invite to every new session in this workspace', 'For your own agents. Each new session sends them its link, and its owner lets them in. Off: the agent is in the workspace and sees its files, and joins a session only when someone invites it there.')}
|
|
373
|
+
<div class="inv-sub-row"><div class="label inv-sub">People you've worked with, and ${isOrg ? `${esc(orgName)}'s agents` : 'your agents'}</div>
|
|
374
|
+
<label class="search-box"><span class="sr-only">Search people and agents</span><input class="input sm" id="wi-search" type="search" placeholder="Search…" autocomplete="off"></label></div>
|
|
375
|
+
<div id="wi-people"><p class="hint">${loadingNote()}</p></div>
|
|
376
|
+
<div class="ag-pages" id="wi-pages" hidden></div>
|
|
377
|
+
<div class="field wi-email"><label for="wi-email">Invite by email</label>
|
|
378
|
+
<div class="row"><input class="input grow" id="wi-email" type="email" placeholder="name@example.com" autocomplete="off"><button class="btn" type="button" data-wi-email>Send invite</button></div></div>
|
|
379
|
+
<div class="label inv-sub">Waiting to accept</div>
|
|
380
|
+
<div class="inv-list" id="wi-pending"><p class="hint">${loadingNote()}</p></div>
|
|
381
|
+
<div class="inv-agent wi-new-agent" id="wi-new-agent"><button class="btn sm" type="button" data-wi-invite-agent aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite a new agent</span></button><span class="hint">You'll get a link to paste into your AI.</span></div>
|
|
382
|
+
<p class="error" id="wi-error"></p>
|
|
383
|
+
<div class="actions"><button type="button" class="btn primary" data-cancel>Done</button></div>`)
|
|
384
|
+
// Anchored at the top: it grows downward as the list and an agent invite come in, never jumps.
|
|
385
|
+
back.classList.add('top')
|
|
386
|
+
form.onsubmit = (e) => e.preventDefault()
|
|
387
|
+
const joins = () => (form.querySelector('[name=wiEvery]').checked ? 'all' : 'invited')
|
|
388
|
+
const already = new Set([d?.owner?.account, ...(d?.members || []).map((m) => m.account), ...(d?.agents || []).filter((a) => !a.excluded).map((a) => a.account)].filter(Boolean))
|
|
389
|
+
const list = $('#wi-people', back)
|
|
390
|
+
// Only an agent's owner can make it join every session: someone else's agent joins when invited.
|
|
391
|
+
const ownAgents = new Set()
|
|
392
|
+
let ownKnown = false // when your agents couldn't be listed, the API decides (it refuses someone else's)
|
|
393
|
+
// { list, note }: a note says why no agents could be listed (an org's need Agents: Read).
|
|
394
|
+
const agents = !isOrg
|
|
395
|
+
? api('GET', '/api/agents').then((r) => ({ list: r.agents }), () => ({ list: [], failed: true }))
|
|
396
|
+
: !org
|
|
397
|
+
? Promise.resolve({ list: [], note: `${orgName}'s agents can't be listed here.` })
|
|
398
|
+
: api('GET', `/api/orgs/${encodeURIComponent(org.slug)}/agents`).then((r) => ({ list: r.agents }), (err) => ({ list: [], note: `${orgName}'s agents can't be listed: ${err.message}` }))
|
|
399
|
+
// People with an open invite show as Invited (inviting them again would be refused).
|
|
400
|
+
const invited = api('GET', `/api/workspaces/${encodeURIComponent(id)}/invites`).then((r) => new Set(r.invites.filter((i) => i.status === 'waiting' && i.account).map((i) => i.account)), () => new Set())
|
|
401
|
+
Promise.all([
|
|
402
|
+
api('GET', '/api/collaborators').then((r) => r.collaborators).catch(() => []),
|
|
403
|
+
agents,
|
|
404
|
+
invited
|
|
405
|
+
]).then(([people, { list: mine, note, failed }, waiting]) => {
|
|
406
|
+
for (const a of mine) ownAgents.add(`agent:${a.id}`)
|
|
407
|
+
ownKnown = !note && !failed
|
|
408
|
+
const seen = new Set()
|
|
409
|
+
// The people you were with most recently first, then your agents by when they were last used.
|
|
410
|
+
const rows = [...people.filter((c) => c.kind !== 'agent' || !isOrg), ...mine.map((a) => ({ account: `agent:${a.id}`, name: a.name, kind: 'agent', provider: a.provider, type: a.type, lastTogetherAt: a.lastUsedAt || 0 }))]
|
|
411
|
+
.filter((c) => c.account && !seen.has(c.account) && seen.add(c.account))
|
|
412
|
+
.sort((a, b) => (b.lastTogetherAt || 0) - (a.lastTogetherAt || 0))
|
|
413
|
+
const why = note ? `<p class="hint wi-note">${esc(note)}</p>` : ''
|
|
414
|
+
const action = (c) => already.has(c.account) ? '<span class="pill">In</span>' : waiting.has(c.account) ? '<span class="pill">Invited</span>' : c.kind === 'agent'
|
|
415
|
+
? `<button class="btn sm" type="button" data-add-account="${esc(c.account)}" data-name="${esc(c.name)}">Add</button>`
|
|
416
|
+
: `<button class="btn sm" type="button" data-invite-person="${esc(c.account)}" data-name="${esc(c.name)}">Invite</button>`
|
|
417
|
+
const card = (c) => `<div class="ag-mini member${c.kind === 'agent' ? ' bot' : ''}" title="${esc(c.name)}">
|
|
418
|
+
<span class="ag-av">${avatar(c.name, null, false)}${c.kind === 'agent' ? `<span class="kind-badge" title="Agent">${I.bot}</span>` : ''}</span>
|
|
419
|
+
<span class="ag-t"><b>${esc(c.name)}</b><span class="hint">${esc(c.kind === 'agent' ? (c.type || c.provider || 'agent') : 'person')}${c.lastTogetherAt ? ` · ${esc(ago(c.lastTogetherAt))}` : ''}</span></span>
|
|
420
|
+
${action(c)}
|
|
421
|
+
</div>`
|
|
422
|
+
// Six a page, with a search: the list grows with everyone you have ever worked with.
|
|
423
|
+
const PAGE = 6
|
|
424
|
+
const search = $('#wi-search', back)
|
|
425
|
+
const pages = $('#wi-pages', back)
|
|
426
|
+
let page = 0
|
|
427
|
+
const draw = () => {
|
|
428
|
+
const q = (search.value || '').trim().toLowerCase()
|
|
429
|
+
const hits = q ? rows.filter((c) => [c.name, c.kind, c.provider, c.type].some((x) => String(x || '').toLowerCase().includes(q))) : rows
|
|
430
|
+
const last = Math.max(0, Math.ceil(hits.length / PAGE) - 1)
|
|
431
|
+
page = Math.min(page, last)
|
|
432
|
+
const from = page * PAGE
|
|
433
|
+
const shown = hits.slice(from, from + PAGE)
|
|
434
|
+
list.innerHTML = (shown.length ? `<div class="ag-grid wi-grid">${shown.map(card).join('')}</div>` : rows.length ? '<p class="hint">Nobody matches that.</p>' : "<p class=\"hint\">Nobody yet. People and agents you've been in a session with show up here.</p>") + why
|
|
435
|
+
pages.hidden = hits.length <= PAGE
|
|
436
|
+
pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
|
|
437
|
+
<button type="button" class="btn sm icon" data-page="-1" aria-label="Previous page" ${page === 0 ? 'disabled' : ''}>${I.arrowLeft}</button>
|
|
438
|
+
<button type="button" class="btn sm icon" data-page="1" aria-label="Next page" ${page >= last ? 'disabled' : ''}>${I.arrowRight}</button>`
|
|
439
|
+
}
|
|
440
|
+
draw()
|
|
441
|
+
search.addEventListener('input', () => { page = 0; draw() })
|
|
442
|
+
pages.addEventListener('click', (e) => {
|
|
443
|
+
const b = e.target.closest('[data-page]')
|
|
444
|
+
if (!b || b.disabled) return
|
|
445
|
+
page += Number(b.dataset.page)
|
|
446
|
+
draw()
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
const wsInvites = `/api/workspaces/${encodeURIComponent(id)}/invites`
|
|
450
|
+
const pendingEl = $('#wi-pending', back)
|
|
451
|
+
async function pending () {
|
|
452
|
+
try {
|
|
453
|
+
const open = (await api('GET', wsInvites)).invites.filter((i) => i.status === 'waiting')
|
|
454
|
+
pendingEl.innerHTML = open.length
|
|
455
|
+
? open.map((i) => `<div class="inv-row"><span class="grow">${esc(i.email || i.name)}<span class="hint"> · ${i.access === 'view' ? 'View only' : 'Can edit'}</span></span><button class="btn sm ghost" type="button" data-wi-cancel="${esc(i.id)}">Cancel</button></div>`).join('')
|
|
456
|
+
: '<p class="hint">Nobody yet.</p>'
|
|
457
|
+
} catch (err) { pendingEl.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
|
|
458
|
+
}
|
|
459
|
+
pending()
|
|
460
|
+
const sendInvite = async (to) => {
|
|
461
|
+
$('#wi-error', back).textContent = ''
|
|
462
|
+
await api('POST', wsInvites, { to, access: $('#wi-access', back).value })
|
|
463
|
+
await pending()
|
|
464
|
+
}
|
|
465
|
+
pendingEl.addEventListener('click', async (e) => {
|
|
466
|
+
const b = e.target.closest('[data-wi-cancel]')
|
|
467
|
+
if (!b) return
|
|
468
|
+
b.disabled = true
|
|
469
|
+
try { await api('POST', `${wsInvites}/${encodeURIComponent(b.dataset.wiCancel)}/cancel`, {}); toast('Invite cancelled'); await pending() } catch (err) { $('#wi-error', back).textContent = err.message; b.disabled = false }
|
|
470
|
+
})
|
|
471
|
+
const emailBtn = $('[data-wi-email]', back)
|
|
472
|
+
const emailInput = $('#wi-email', back)
|
|
473
|
+
const byEmail = async () => {
|
|
474
|
+
const email = emailInput.value.trim()
|
|
475
|
+
if (!email) return emailInput.focus()
|
|
476
|
+
emailBtn.disabled = true
|
|
477
|
+
try { await sendInvite({ email }); emailInput.value = ''; toast(`Invite sent to ${email}`) } catch (err) { $('#wi-error', back).textContent = err.message }
|
|
478
|
+
emailBtn.disabled = false
|
|
479
|
+
}
|
|
480
|
+
emailBtn.onclick = byEmail
|
|
481
|
+
emailInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); byEmail() } })
|
|
482
|
+
list.addEventListener('click', async (e) => {
|
|
483
|
+
const p = e.target.closest('[data-invite-person]')
|
|
484
|
+
if (p) {
|
|
485
|
+
p.disabled = true
|
|
486
|
+
try {
|
|
487
|
+
await sendInvite({ account: p.dataset.invitePerson })
|
|
488
|
+
p.outerHTML = '<span class="pill">Invited</span>'
|
|
489
|
+
toast(`Invited ${p.dataset.name}`)
|
|
490
|
+
} catch (err) { $('#wi-error', back).textContent = err.message; p.disabled = false }
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
const b = e.target.closest('[data-add-account]')
|
|
494
|
+
if (!b) return
|
|
495
|
+
b.disabled = true
|
|
496
|
+
$('#wi-error', back).textContent = ''
|
|
497
|
+
const account = b.dataset.addAccount
|
|
498
|
+
try {
|
|
499
|
+
await api('POST', `/api/workspaces/${encodeURIComponent(id)}/members`, { account, access: $('#wi-access', back).value, ...(account.startsWith('agent:') ? { sessions: ownAgents.has(account) || !ownKnown ? joins() : 'invited' } : {}) })
|
|
500
|
+
b.outerHTML = '<span class="pill">Added</span>'
|
|
501
|
+
toast(`Added ${b.dataset.name}`)
|
|
502
|
+
await openWorkspace(id)
|
|
503
|
+
await loadWorkspaces()
|
|
504
|
+
if (state.view === `ws:${id}` || state.view === `wsfiles:${id}`) renderShell(state.view)
|
|
505
|
+
} catch (err) {
|
|
506
|
+
$('#wi-error', back).textContent = err.message
|
|
507
|
+
b.disabled = false
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
const invite = $('[data-wi-invite-agent]', back)
|
|
511
|
+
invite.onclick = async () => {
|
|
512
|
+
// Workspaces are on here: pick a global, workspace (this one first) or session agent. A
|
|
513
|
+
// workspace agent gets the access and every-session choice above.
|
|
514
|
+
if (state.workspacesOn) {
|
|
515
|
+
return openKindMenu(invite, {
|
|
516
|
+
workspaceId: id,
|
|
517
|
+
workspaceBody: () => ({ access: $('#wi-access', back).value, sessions: joins() }),
|
|
518
|
+
onInvite: ({ kind, text, where }) => { $('#wi-new-agent', back).innerHTML = agentInviteHtml(text, 'wi-paste', kindLineHtml(kind, where)) }
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
invite.disabled = true
|
|
522
|
+
$('#wi-error', back).textContent = ''
|
|
523
|
+
try {
|
|
524
|
+
const { link } = await api('POST', `/api/workspaces/${encodeURIComponent(id)}/agent-invites`, { access: $('#wi-access', back).value, sessions: joins() })
|
|
525
|
+
$('#wi-new-agent', back).innerHTML = agentInviteHtml(agentPaste({ link, invitedToSessions: joins() === 'all' }), 'wi-paste')
|
|
526
|
+
} catch (err) {
|
|
527
|
+
$('#wi-error', back).textContent = err.message
|
|
528
|
+
invite.disabled = false
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
form.addEventListener('click', async (e) => {
|
|
532
|
+
const b = e.target.closest('[data-copy]')
|
|
533
|
+
if (!b) return
|
|
534
|
+
e.preventDefault()
|
|
535
|
+
try { await navigator.clipboard.writeText($(`#${b.dataset.copy}`, form).textContent); toast('Copied') } catch { toast('Select the text and press ⌘/Ctrl+C to copy') }
|
|
536
|
+
})
|
|
537
|
+
return close
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The GitHub side of the New session dialog: repo, branch, and where to clone it. */
|
|
541
|
+
function githubPicker (root) {
|
|
542
|
+
let repos = null
|
|
543
|
+
let repo = null // { name, defaultBranch }
|
|
544
|
+
let branches = null // { branches, defaultBranch }
|
|
545
|
+
let loaded = false
|
|
546
|
+
|
|
547
|
+
async function open () {
|
|
548
|
+
if (loaded) return root.querySelector('#gh-search')?.focus()
|
|
549
|
+
loaded = true
|
|
550
|
+
root.innerHTML = `<p class="hint gh-wait">${loadingNote('Checking GitHub…')}</p>`
|
|
551
|
+
let st
|
|
552
|
+
try { st = await api('GET', '/api/github/status') } catch (err) { st = { authenticated: false, message: err.message } }
|
|
553
|
+
if (!st.authenticated) {
|
|
554
|
+
loaded = false
|
|
555
|
+
root.innerHTML = `<div class="note gh-setup">${I.branch}<span>Quilt can't list your GitHub repositories on this computer. Start from a folder instead: a clone of the repository works the same, and once anyone connects GitHub for the session, git is on for everyone in it.</span></div>`
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
root.innerHTML = `
|
|
559
|
+
<div class="field">
|
|
560
|
+
<label for="gh-search">Repository${st.user ? ` <span class="hint">· signed in as ${esc(st.user)}</span>` : ''}</label>
|
|
561
|
+
<input class="input" id="gh-search" placeholder="Search your repositories" spellcheck="false" autocomplete="off">
|
|
562
|
+
<div class="gh-list" id="gh-list" role="listbox" aria-label="Repositories"><p class="hint">${loadingNote('Loading repositories…')}</p></div>
|
|
563
|
+
</div>
|
|
564
|
+
<div id="gh-branch" hidden>
|
|
565
|
+
<div class="field">
|
|
566
|
+
<span class="label">Branch</span>
|
|
567
|
+
<div class="choice-cards">
|
|
568
|
+
<label class="choice-card"><input type="radio" name="ghb" value="existing" checked><span><b>Use an existing branch</b></span></label>
|
|
569
|
+
<label class="choice-card"><input type="radio" name="ghb" value="new"><span><b>Create a new branch</b></span></label>
|
|
570
|
+
</div>
|
|
571
|
+
<div id="gh-existing"><select class="input" id="gh-branch-select" aria-label="Branch"></select></div>
|
|
572
|
+
<div id="gh-new" class="row" hidden>
|
|
573
|
+
<input class="input grow" id="gh-new-name" placeholder="my-feature" spellcheck="false" aria-label="New branch name">
|
|
574
|
+
<span class="hint">from</span>
|
|
575
|
+
<select class="input gh-base" id="gh-base" aria-label="Start from branch"></select>
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
<div class="field">
|
|
579
|
+
<label for="gh-dir">Clone into</label>
|
|
580
|
+
<div class="row"><input class="input grow" id="gh-dir" spellcheck="false">
|
|
581
|
+
<button type="button" class="btn icon" id="gh-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
|
|
582
|
+
<span class="hint">Leave empty to use a new folder in ${esc(state.profile.joinDir)}.</span>
|
|
583
|
+
</div>
|
|
584
|
+
</div>`
|
|
585
|
+
const search = root.querySelector('#gh-search')
|
|
586
|
+
search.addEventListener('input', paintRepos)
|
|
587
|
+
search.addEventListener('keydown', (e) => {
|
|
588
|
+
if (e.key !== 'Enter') return
|
|
589
|
+
e.preventDefault() // pick the first match rather than submitting
|
|
590
|
+
root.querySelector('#gh-list [data-repo]')?.click()
|
|
591
|
+
})
|
|
592
|
+
root.querySelector('#gh-browse').onclick = () => pickFolder(root.querySelector('#gh-dir'))
|
|
593
|
+
root.querySelectorAll('[name=ghb]').forEach((r) => {
|
|
594
|
+
r.onchange = () => {
|
|
595
|
+
root.querySelector('#gh-existing').hidden = r.value !== 'existing'
|
|
596
|
+
root.querySelector('#gh-new').hidden = r.value !== 'new'
|
|
597
|
+
if (r.value === 'new') root.querySelector('#gh-new-name').focus()
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
search.focus()
|
|
601
|
+
try {
|
|
602
|
+
repos = (await api('GET', '/api/github/repos')).repos
|
|
603
|
+
} catch (err) {
|
|
604
|
+
root.querySelector('#gh-list').innerHTML = `<p class="hint warn">${esc(err.message)}</p>`
|
|
605
|
+
return
|
|
606
|
+
}
|
|
607
|
+
paintRepos()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function paintRepos () {
|
|
611
|
+
const list = root.querySelector('#gh-list')
|
|
612
|
+
if (!list || !repos) return
|
|
613
|
+
const q = root.querySelector('#gh-search').value.trim().toLowerCase()
|
|
614
|
+
const shown = repos.filter((r) => !q || r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)).slice(0, 60)
|
|
615
|
+
list.innerHTML = shown.length
|
|
616
|
+
? shown.map((r) => `<button type="button" class="gh-repo${repo && repo.name === r.name ? ' on' : ''}" role="option" aria-selected="${repo && repo.name === r.name}" data-repo="${esc(r.name)}">
|
|
617
|
+
<span class="grow"><b>${esc(r.name)}</b>${r.description ? `<span class="hint">${esc(r.description)}</span>` : ''}</span>
|
|
618
|
+
${r.updatedAt ? `<span class="hint">${esc(ago(Date.parse(r.updatedAt)))}</span>` : ''}</button>`).join('')
|
|
619
|
+
: `<p class="hint">${repos.length ? 'No repositories match.' : 'You have no repositories on GitHub yet.'}</p>`
|
|
620
|
+
list.querySelectorAll('[data-repo]').forEach((b) => { b.onclick = () => pickRepo(repos.find((r) => r.name === b.dataset.repo)) })
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function pickRepo (r) {
|
|
624
|
+
repo = r
|
|
625
|
+
branches = null
|
|
626
|
+
paintRepos()
|
|
627
|
+
root.querySelector('#gh-branch').hidden = false
|
|
628
|
+
root.querySelector('#gh-dir').placeholder = `${state.profile.joinDir}/${r.name.split('/').pop()}`
|
|
629
|
+
const sel = root.querySelector('#gh-branch-select')
|
|
630
|
+
const baseSel = root.querySelector('#gh-base')
|
|
631
|
+
sel.innerHTML = baseSel.innerHTML = '<option>Loading…</option>'
|
|
632
|
+
sel.disabled = baseSel.disabled = true
|
|
633
|
+
try {
|
|
634
|
+
const b = await api('GET', `/api/github/branches?repo=${encodeURIComponent(r.name)}`)
|
|
635
|
+
if (repo !== r) return // picked another meanwhile
|
|
636
|
+
branches = b
|
|
637
|
+
const opts = b.branches.map((n) => `<option value="${esc(n)}" ${n === b.defaultBranch ? 'selected' : ''}>${esc(n)}${n === b.defaultBranch ? ' (default)' : ''}</option>`).join('')
|
|
638
|
+
sel.innerHTML = baseSel.innerHTML = opts || '<option value="">No branches</option>'
|
|
639
|
+
sel.disabled = baseSel.disabled = false
|
|
640
|
+
} catch (err) {
|
|
641
|
+
sel.innerHTML = baseSel.innerHTML = '<option value="">Couldn’t load branches</option>'
|
|
642
|
+
toast(err.message)
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function value () {
|
|
647
|
+
if (!repo) throw new Error('Pick a repository.')
|
|
648
|
+
if (!branches) throw new Error('Wait for the branches to load.')
|
|
649
|
+
const dir = root.querySelector('#gh-dir').value.trim() || undefined
|
|
650
|
+
if (root.querySelector('[name=ghb]:checked').value === 'new') {
|
|
651
|
+
const newBranch = root.querySelector('#gh-new-name').value.trim()
|
|
652
|
+
if (!newBranch) throw new Error('Name your new branch.')
|
|
653
|
+
return { repo: repo.name, newBranch, base: root.querySelector('#gh-base').value || undefined, dir }
|
|
654
|
+
}
|
|
655
|
+
return { repo: repo.name, branch: root.querySelector('#gh-branch-select').value || undefined, dir }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return { open, value }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function joinSessionDialog (invite = '') {
|
|
662
|
+
const p = state.profile
|
|
663
|
+
const { form, close } = dialog(`
|
|
664
|
+
<h3>Join a session</h3>
|
|
665
|
+
<p class="lead">${invite ? 'You were invited to a session. Choose where the files go, then join.' : 'Paste the invite link your partner sent you.'} They'll be asked to let you in.</p>
|
|
666
|
+
<div class="field">
|
|
667
|
+
<label for="j-invite">Invite link</label>
|
|
668
|
+
<textarea class="input mono" id="j-invite" rows="2" spellcheck="false" placeholder="https://join.heyquilt.com/…" required></textarea>
|
|
669
|
+
<span class="hint warn" id="invite-hint" hidden>That doesn’t look like a quilt invite link. Copy the whole link they sent.</span>
|
|
670
|
+
</div>
|
|
671
|
+
<div class="field">
|
|
672
|
+
<label for="j-dir">Put the files in</label>
|
|
673
|
+
<div class="row"><input class="input grow" id="j-dir" placeholder="${esc(p.joinDir)}/<room>">
|
|
674
|
+
<button type="button" class="btn icon" id="j-browse" title="Browse" aria-label="Browse">${I.folder}</button></div>
|
|
675
|
+
<span class="hint">Leave empty to use a new folder in ${esc(p.joinDir)}.</span>
|
|
676
|
+
</div>
|
|
677
|
+
<p class="error" id="j-error"></p>
|
|
678
|
+
<div class="actions"><button type="button" class="btn ghost" data-cancel>Cancel</button><button class="btn primary" type="submit">Join session</button></div>`)
|
|
679
|
+
const inv = form.querySelector('#j-invite')
|
|
680
|
+
inv.addEventListener('input', () => {
|
|
681
|
+
const d = decodeInvite(inv.value)
|
|
682
|
+
form.querySelector('#invite-hint').hidden = !inv.value.trim() || !!d
|
|
683
|
+
form.querySelector('#j-dir').placeholder = `${p.joinDir}/${d ? d.room : '<room>'}`
|
|
684
|
+
})
|
|
685
|
+
if (invite) { inv.value = invite; inv.dispatchEvent(new Event('input')) }
|
|
686
|
+
form.querySelector('#j-browse').onclick = () => pickFolder(form.querySelector('#j-dir'))
|
|
687
|
+
form.onsubmit = async (e) => {
|
|
688
|
+
e.preventDefault()
|
|
689
|
+
if (!decodeInvite(inv.value)) { form.querySelector('#j-error').textContent = 'Paste the invite link your partner sent you.'; return }
|
|
690
|
+
if (await submit(form, '#j-error', { mode: 'join', invite: inv.value, dir: form.querySelector('#j-dir').value.trim() || undefined })) close()
|
|
691
|
+
}
|
|
692
|
+
if (invite) form.querySelector('button[type=submit]').focus()
|
|
693
|
+
else inv.focus()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function submit (form, errSel, body, busy = 'Connecting…') {
|
|
697
|
+
const btn = form.querySelector('button[type=submit]')
|
|
698
|
+
const label = btn.textContent
|
|
699
|
+
btn.disabled = true
|
|
700
|
+
btn.innerHTML = `${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>${esc(busy)}</span>`
|
|
701
|
+
form.querySelector(errSel).textContent = ''
|
|
702
|
+
try {
|
|
703
|
+
const sum = await api('POST', '/api/sessions', body)
|
|
704
|
+
state.sessions.set(sum.id, sum)
|
|
705
|
+
await go(sum.id)
|
|
706
|
+
return true
|
|
707
|
+
} catch (err) {
|
|
708
|
+
form.querySelector(errSel).textContent = err.message
|
|
709
|
+
btn.disabled = false
|
|
710
|
+
btn.textContent = label
|
|
711
|
+
return false
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// -------------------------------------------------------------- settings --
|
|
716
|
+
const THEMES = [['light', 'Light'], ['dark', 'Dark'], ['system', 'System']]
|
|
717
|
+
|
|
718
|
+
function toggle (name, checked, label, hint) {
|
|
719
|
+
return `<label class="toggle"><input type="checkbox" name="${name}" ${checked ? 'checked' : ''}><span class="track"><span class="knob"></span></span>
|
|
720
|
+
<span class="tg-text"><b>${label}</b><span class="hint">${hint}</span></span></label>`
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The Agents page (workspaces on): your agents as cards, nine a page; a card opens where it works. */
|
|
724
|
+
function agentsPageHtml () {
|
|
725
|
+
return `
|
|
726
|
+
<div id="agents-sec">
|
|
727
|
+
<header class="page-head ag-page-head">
|
|
728
|
+
<h1>Agents</h1><span class="pill count" id="agents-count" hidden></span>
|
|
729
|
+
<label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label>
|
|
730
|
+
<button class="btn primary" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button>
|
|
731
|
+
</header>
|
|
732
|
+
<p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card to choose where it works.</p>
|
|
733
|
+
<div id="agents-invite"><p class="error" id="agents-error"></p></div>
|
|
734
|
+
<div id="agents-list"><p class="hint">${loadingNote()}</p></div>
|
|
735
|
+
<div class="ag-pages" id="agents-pages" hidden></div>
|
|
736
|
+
<p class="hint ag-foot"><a href="https://heyquilt.com/docs/agents#kinds" target="_blank" rel="noopener">How agent kinds work</a> · Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a></p>
|
|
737
|
+
</div>`
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Where an agent works, in a dialog: the card with Works in (all workspaces, chosen ones, or
|
|
742
|
+
* only where added) and Invited to new sessions; each change saves. `onChange` is told after a save.
|
|
743
|
+
*/
|
|
744
|
+
async function openPlacementDialog (a, { onChange = () => {} } = {}) {
|
|
745
|
+
let place
|
|
746
|
+
try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) { toast(err.message); return }
|
|
747
|
+
if (!place) { toast('This agent has no placement yet.'); return }
|
|
748
|
+
$('#agent-place-back')?.remove()
|
|
749
|
+
const back = document.createElement('div')
|
|
750
|
+
back.id = 'agent-place-back'
|
|
751
|
+
back.className = 'modal-back settings-back'
|
|
752
|
+
back.innerHTML = `
|
|
753
|
+
<div class="settings-modal agent-modal" role="dialog" aria-modal="true" aria-labelledby="agent-place-title">
|
|
754
|
+
<header class="settings-head">
|
|
755
|
+
<h2 id="agent-place-title">${I.bot}<span>${esc(a.name)}</span></h2>
|
|
756
|
+
<p>Where it works, and whether it is invited to new sessions. Changes save at once.</p>
|
|
757
|
+
<button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
|
|
758
|
+
</header>
|
|
759
|
+
<div class="settings-body">${agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })}</div>
|
|
760
|
+
</div>`
|
|
761
|
+
document.body.appendChild(back)
|
|
762
|
+
bindPlacement(back.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
|
|
763
|
+
back.addEventListener('change', onChange)
|
|
764
|
+
back.addEventListener('click', (e) => { if (e.target.closest('[data-placement-reach], [data-placement-sessions], [data-placement-ws]')) onChange() })
|
|
765
|
+
const close = () => back.remove()
|
|
766
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
|
|
767
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
768
|
+
back.querySelector('[data-settings-close]').onclick = close
|
|
769
|
+
back.querySelector('[data-settings-close]').focus()
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Settings come in tabs: You (profile, appearance, sounds), Sessions (defaults for starting and
|
|
773
|
+
// joining), Agents (your AI, your agents, agents and git), Workspaces (with workspaces on), Account
|
|
774
|
+
// (sign-in, this computer, the version).
|
|
775
|
+
const SETTINGS_TABS = [['you', 'You'], ['sessions', 'Sessions'], ['agents', 'Agents'], ['workspaces', 'Workspaces'], ['account', 'Account']]
|
|
776
|
+
const settingsTabIcons = () => ({ you: I.user || '', sessions: I.folder || '', agents: I.bot || '', workspaces: I.grid || I.folder || '', account: I.gear || '' })
|
|
777
|
+
export function settingsTab () {
|
|
778
|
+
if (!state.settingsTab) { try { state.settingsTab = localStorage.getItem('quilt.settingsTab') || 'you' } catch { state.settingsTab = 'you' } }
|
|
779
|
+
const tabs = SETTINGS_TABS.filter(([id]) => id !== 'workspaces' || state.workspacesOn)
|
|
780
|
+
return tabs.some(([id]) => id === state.settingsTab) ? state.settingsTab : 'you'
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function settingsTabsHtml (tab) {
|
|
784
|
+
const icons = settingsTabIcons()
|
|
785
|
+
return `<nav class="settings-tabs" role="tablist" aria-label="Settings">${SETTINGS_TABS.filter(([id]) => id !== 'workspaces' || state.workspacesOn).map(([id, label]) => `<button type="button" role="tab" class="tab ${tab === id ? 'on' : ''}" data-settings-tab="${id}" aria-selected="${tab === id}">${icons[id] || ''}<span>${label}</span></button>`).join('')}</nav>`
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function workspacesSettingsHtml () {
|
|
789
|
+
const list = state.workspaces || []
|
|
790
|
+
return `
|
|
791
|
+
<section class="card settings-sec" id="workspaces-sec">
|
|
792
|
+
<div class="sec-intro"><h2>Workspaces</h2><p>The workspaces you are in, and the organizations behind them. Each one's own settings (people, agents, GitHub) are on its page.</p></div>
|
|
793
|
+
<div class="sec-body">
|
|
794
|
+
${list.length ? list.map((w) => `<div class="ws-settings-row"><span class="sw" style="background:${esc(w.color || 'var(--accent)')}"></span><span class="nm"><b>${esc(w.name)}</b>${w.org && w.org.name ? `<span class="org">${esc(w.org.name)}</span>` : ''}</span><button class="btn sm" type="button" data-view="ws:${esc(w.id)}">Open</button></div>`).join('') : '<p class="hint">You are not in a workspace yet. Make one from Home.</p>'}
|
|
795
|
+
</div>
|
|
796
|
+
</section>`
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const AUTO_CHOICES = [['chat', 'Leave it for my chat'], ['open', 'Open it in my tool'], ['auto', 'Work on its own']]
|
|
800
|
+
|
|
801
|
+
/** "When work comes to my AI" (src/autowork.js): leave it, open it, or let the AI work on its own. */
|
|
802
|
+
function autoSettingsHtml (p) {
|
|
803
|
+
const a = p.auto || { mode: 'chat', supported: false, models: [], model: '', changeFiles: false, limitsFiles: true, cap: 0, tool: p.tool }
|
|
804
|
+
const tool = esc(a.tool || p.tool || 'your AI tool')
|
|
805
|
+
return `<form class="card settings-sec" id="auto-sec" autocomplete="off">
|
|
806
|
+
<div class="sec-intro"><h2>When work comes to your AI</h2><p>A task given to your AI, or a merge sent to it, while none of your AI sessions is open.</p></div>
|
|
807
|
+
<div class="sec-body">
|
|
808
|
+
<div class="field">
|
|
809
|
+
<label for="s-autowork">Do this</label>
|
|
810
|
+
<select class="input" id="s-autowork" name="autoWork">${AUTO_CHOICES.map(([v, l]) => {
|
|
811
|
+
const off = v === 'auto' && !a.supported && a.mode !== 'auto'
|
|
812
|
+
return `<option value="${v}" ${a.mode === v ? 'selected' : ''} ${off ? 'disabled' : ''}>${l}${off ? ` (not available for ${tool})` : ''}</option>`
|
|
813
|
+
}).join('')}</select>
|
|
814
|
+
<span class="hint" id="s-autowork-hint">${autoHint(a.mode, tool)}</span>
|
|
815
|
+
</div>
|
|
816
|
+
<div class="auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>
|
|
817
|
+
${a.supported ? '' : `<p class="error">${tool} can't work on its own. Pick Claude Code, Cursor, Codex or GitHub Copilot in your profile, or choose another option.</p>`}
|
|
818
|
+
<div class="field">
|
|
819
|
+
<label for="s-automodel">Model</label>
|
|
820
|
+
<input class="input" id="s-automodel" name="autoModel" list="s-automodels" value="${esc(a.model || '')}" placeholder="${esc(a.models[0] || '')}">
|
|
821
|
+
<datalist id="s-automodels">${a.models.map((m) => `<option value="${esc(m)}">`).join('')}</datalist>
|
|
822
|
+
<span class="hint">${a.models.length ? `Suggested: ${a.models.map(esc).join(', ')}. ${esc(a.models[0])} is the default; type any model ${tool} knows.` : 'The model your tool uses by default.'}</span>
|
|
823
|
+
</div>
|
|
824
|
+
${toggle('autoFiles', a.changeFiles, 'Let it change files', a.limitsFiles ? 'Off: it reads, plans, comments and asks, and leaves changes to you. File claims apply either way.' : `${tool} can change files either way; this also lets it run commands. File claims apply either way.`)}
|
|
825
|
+
<div class="field">
|
|
826
|
+
<label for="s-autocap">Daily limit (tokens)</label>
|
|
827
|
+
<input class="input" id="s-autocap" name="autoCap" type="number" min="0" step="1000" value="${a.cap || ''}" placeholder="No limit">
|
|
828
|
+
<span class="hint">Past it, work waits for your chat until tomorrow, and you're told once.</span>
|
|
829
|
+
</div>
|
|
830
|
+
</div>
|
|
831
|
+
<div class="sec-actions"><span><span class="hint auto-only" ${a.mode === 'auto' ? '' : 'hidden'}>Runs on this computer, with your own ${tool} account, while Quilt is open. It asks questions in Quilt chat; a reply carries the same chat on.</span></span><button class="btn primary" type="submit">Save</button></div>
|
|
832
|
+
<div id="auto-usage"></div>
|
|
833
|
+
</div>
|
|
834
|
+
</form>`
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function autoHint (mode, tool) {
|
|
838
|
+
if (mode === 'auto') return `${tool} starts on the work by itself, in the session's folder. You see what it does in the feed and its questions in chat.`
|
|
839
|
+
if (mode === 'open') return `${tool} opens on the work in the session's folder, for you to drive.`
|
|
840
|
+
return 'Nothing opens: your AI picks it up from its inbox the next time you work with it.'
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/** Today's runs and tokens, and the latest runs with a way to carry each chat on. */
|
|
844
|
+
function autoUsageHtml (u) {
|
|
845
|
+
const tools = Object.entries(u.today || {})
|
|
846
|
+
const n = (x) => Number(x || 0).toLocaleString()
|
|
847
|
+
const today = tools.length
|
|
848
|
+
? tools.map(([t, v]) => `<div class="kv"><span>${esc(t)}</span><b>${n(v.input + v.output)} tokens</b><span class="hint">${v.runs} run${v.runs === 1 ? '' : 's'} today${v.costUsd ? ` · $${v.costUsd.toFixed(2)}` : ''}</span></div>`).join('')
|
|
849
|
+
: '<p class="hint">No runs today.</p>'
|
|
850
|
+
const runs = (u.runs || []).slice(0, 8).map((r) => `<div class="auto-run">
|
|
851
|
+
<span class="nm"><b>${esc(r.title || r.kind)}</b><span class="hint">${esc(r.tool)} · ${r.reported ? `${n(r.input + r.output)} tokens` : 'tokens not reported'} · ${r.outcome === 'ok' ? 'finished' : 'failed'} · ${ago(r.ts)}</span></span>
|
|
852
|
+
${r.chatId ? `<button class="btn sm" type="button" data-take-over="${esc(r.run)}">Open chat</button>` : ''}
|
|
853
|
+
</div>`).join('')
|
|
854
|
+
return `<h3 class="auto-usage-head">Today</h3>${today}${runs ? `<h3 class="auto-usage-head">Latest runs</h3><div class="auto-runs">${runs}</div>` : ''}`
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function loadAutoUsage (root) {
|
|
858
|
+
const box = $('#auto-usage', root)
|
|
859
|
+
if (!box) return
|
|
860
|
+
try { box.innerHTML = autoUsageHtml(await api('GET', '/api/autowork')) } catch (err) { box.innerHTML = `<p class="error">${esc(err.message)}</p>` }
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function settingsHtml ({ head = true } = {}) {
|
|
864
|
+
const p = state.profile
|
|
865
|
+
const a = state.account || { name: p.name, email: '' }
|
|
866
|
+
const theme = p.theme || document.documentElement.dataset.theme || 'dark'
|
|
867
|
+
const tab = settingsTab()
|
|
868
|
+
const show = (id) => ({ account: ['account', 'about', 'computer'], you: ['profile', 'appearance', 'sounds'], sessions: ['sessions'], agents: ['your-ai', 'auto', 'cloud-ai', 'agents', 'agents-git'], workspaces: ['workspaces'] })[tab].includes(id)
|
|
869
|
+
return `
|
|
870
|
+
${head ? `<header class="page-head">
|
|
871
|
+
<h1>Settings</h1>
|
|
872
|
+
<p>Saved on this computer and used for every new session.</p>
|
|
873
|
+
</header>` : ''}
|
|
874
|
+
${settingsTabsHtml(tab)}
|
|
875
|
+
${tab === 'workspaces' ? workspacesSettingsHtml() : ''}
|
|
876
|
+
${show('account') ? `<section class="card settings-sec" id="account-sec">
|
|
877
|
+
<div class="sec-intro"><h2>Account</h2><p>This computer is signed in to your heyquilt.com account.</p></div>
|
|
878
|
+
<div class="sec-body">
|
|
879
|
+
<div class="kv"><span>Signed in as</span><b>${esc(a.name)}</b><span class="hint">${esc(a.email)}</span></div>
|
|
880
|
+
<div class="sec-actions"><span class="hint">Signing out stops your sessions on this computer. Your files stay put.</span><button class="btn" type="button" id="sign-out">Sign out</button></div>
|
|
881
|
+
</div>
|
|
882
|
+
</section>` : ''}
|
|
883
|
+
|
|
884
|
+
${show('your-ai') ? `<form class="card settings-sec" id="your-ai-sec" autocomplete="off">
|
|
885
|
+
<div class="sec-intro"><h2>Your AI</h2><p>The AI tool you code with, in every session you are in.</p></div>
|
|
886
|
+
<div class="sec-body">
|
|
887
|
+
<div class="field">
|
|
888
|
+
<label for="s-aitasks">Let my AI pick up tasks by itself</label>
|
|
889
|
+
<select class="input" id="s-aitasks" name="aiTasks">${[['off', 'Off'], ['mine', 'Tasks assigned to it'], ['any', 'Assigned to it, then unassigned ones']].map(([v, l]) => `<option value="${v}" ${p.aiTasks === v ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
|
890
|
+
<span class="hint">When your AI finishes a piece of work, Quilt hands it the next To do task from the board, in whichever AI tool you use. Applies at once.</span>
|
|
891
|
+
</div>
|
|
892
|
+
${toggle('shareAgent', p.shareAgent, 'Share my AI chat', 'Partners see your prompts, the replies and which files it touches. You can pause it inside any session.')}
|
|
893
|
+
${toggle('summarize', p.summarize, 'Summarize my chats', 'Your prompts and your AI’s replies are shortened to a sentence or two on this computer before they’re shared. Uses your claude CLI (a few Haiku tokens each); if it isn’t available, the text is just shortened.')}
|
|
894
|
+
<div class="sec-actions"><span></span><button class="btn primary" type="submit">Save</button></div>
|
|
895
|
+
</div>
|
|
896
|
+
</form>` : ''}
|
|
897
|
+
|
|
898
|
+
${show('auto') ? autoSettingsHtml(p) : ''}
|
|
899
|
+
|
|
900
|
+
${show('cloud-ai') ? cloudAiCardHtml() : ''}
|
|
901
|
+
|
|
902
|
+
${show('agents') ? `<section class="card settings-sec" id="agents-sec">
|
|
903
|
+
<div class="sec-intro"><h2>Agents</h2><span class="pill count" id="agents-count" hidden></span><label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label><button class="btn primary sec-head-btn" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button></div>
|
|
904
|
+
<div class="sec-body">
|
|
905
|
+
<div id="agents-invite"><p class="error" id="agents-error"></p></div>
|
|
906
|
+
<div id="agents-list"><p class="hint">${loadingNote()}</p></div>
|
|
907
|
+
<div class="ag-pages" id="agents-pages" hidden></div>
|
|
908
|
+
<div class="sec-actions"><span class="hint">${state.workspacesOn ? 'Open a card to choose where the agent works and whether it joins new sessions.' : 'An invite is pasted into the AI once; from then on it joins any session you send it.'} Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a>.</span>${state.workspacesOn ? `<button class="btn sm" type="button" data-view="agents">Open Agents</button>` : ''}</div>
|
|
909
|
+
</div>
|
|
910
|
+
</section>` : ''}
|
|
911
|
+
|
|
912
|
+
${show('agents-git') ? `<section class="card settings-sec" id="agents-git-sec">
|
|
913
|
+
<div class="sec-intro"><h2>Git for everyone</h2><p>People and agents use git from their own folders, as themselves, with short-lived credentials from Quilt's GitHub app bound to the session's repository: nobody else connects or sets anything up.</p></div>
|
|
914
|
+
<div class="sec-body">
|
|
915
|
+
<div class="field">
|
|
916
|
+
<span class="label" id="s-agentcommits-l">In sessions I start, git is</span>
|
|
917
|
+
<div id="s-agentcommits">${gitSegHtml(gitControl({ value: p.agentCommits || 'branches' }), { attr: 'data-git-default', label: 'In sessions I start, git is' })}</div>
|
|
918
|
+
<span class="hint">Git is for everyone in the session, people and agents, through one GitHub connection. Off for everyone: nobody pushes through Quilt, and agents ask a person to commit. Branches of their own: a task puts the agent on quilt/<agent>/<task>, its push opens a pull request, and a person merges by moving the task to Done. Any branch: agents may push to the session's branch and merge too. Saved at once, for sessions you start from now on; a running session's owner changes it in its settings (GitHub).</span>
|
|
919
|
+
</div>
|
|
920
|
+
<div class="kv"><span>GitHub</span><span id="agents-gh"><span class="hint">${loadingNote('Checking…')}</span></span></div>
|
|
921
|
+
<div class="sec-actions"><span class="hint">Connect once, with a GitHub account that can write to your repositories. Git is then on for everyone in your sessions, people and agents; pushes come in within seconds.</span><button class="btn" type="button" id="agents-gh-connect" hidden>${I.link}<span>Connect GitHub</span></button></div>
|
|
922
|
+
</div>
|
|
923
|
+
</section>` : ''}
|
|
924
|
+
|
|
925
|
+
${show('profile') ? `<form class="card settings-sec" id="profile-sec" autocomplete="off">
|
|
926
|
+
<div class="sec-intro"><h2>Profile</h2><p>How you show up to the people you code with.</p></div>
|
|
927
|
+
<div class="sec-body">
|
|
928
|
+
<div class="profile-preview" id="pv">${avatar(p.name, p.color)}<div><b id="pv-name">${esc(p.name)}</b><span id="pv-tool">coding with ${esc(p.tool)}</span></div></div>
|
|
929
|
+
<div class="field">
|
|
930
|
+
<label for="s-name">Name</label>
|
|
931
|
+
<input class="input" id="s-name" value="${esc(p.name)}" readonly>
|
|
932
|
+
<span class="hint"><a href="https://heyquilt.com/settings" target="_blank" rel="noopener">Change it on heyquilt.com</a></span>
|
|
933
|
+
</div>
|
|
934
|
+
<div class="field">
|
|
935
|
+
<span class="label">Color</span>
|
|
936
|
+
<div class="swatches" role="radiogroup" aria-label="Color">
|
|
937
|
+
<label class="swatch auto" title="Automatic"><input type="radio" name="color" value="" ${p.color ? '' : 'checked'}><span>Auto</span></label>
|
|
938
|
+
${PALETTE.map((c) => `<label class="swatch" title="${c}"><input type="radio" name="color" value="${c}" ${p.color === c ? 'checked' : ''}><span style="background:${c}"></span></label>`).join('')}
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
<div class="field">
|
|
942
|
+
<label for="s-tool">AI tool you use</label>
|
|
943
|
+
<select class="input" id="s-tool" name="tool">${state.defaults.tools.map((t) => `<option ${t === p.tool ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
|
|
944
|
+
<span class="hint">Shown next to your name, and used to find your AI chat so partners can follow along.</span>
|
|
945
|
+
</div>
|
|
946
|
+
<div class="sec-actions"><span class="hint">New sessions use this. Rejoin a running session to update it there.</span><button class="btn primary" type="submit">Save profile</button></div>
|
|
947
|
+
</div>
|
|
948
|
+
</form>` : ''}
|
|
949
|
+
|
|
950
|
+
${show('appearance') ? `<section class="card settings-sec" id="appearance-sec">
|
|
951
|
+
<div class="sec-intro"><h2>Appearance</h2><p>How Quilt looks on this computer.</p></div>
|
|
952
|
+
<div class="sec-body">
|
|
953
|
+
<div class="segmented theme-switch" role="radiogroup" aria-label="Theme">
|
|
954
|
+
${THEMES.map(([v, label]) => `<button type="button" role="radio" data-theme-pick="${v}" class="${theme === v ? 'on' : ''}" aria-checked="${theme === v}">${theme === v ? I.check : ''}<span>${label}</span></button>`).join('')}
|
|
955
|
+
</div>
|
|
956
|
+
<span class="hint">System follows your computer's light or dark setting.</span>
|
|
957
|
+
</div>
|
|
958
|
+
</section>` : ''}
|
|
959
|
+
|
|
960
|
+
${show('sounds') ? `<section class="card settings-sec" id="sounds-sec">
|
|
961
|
+
<div class="sec-intro"><h2>Sounds</h2><p>A short chime when something needs you, in any open session.</p></div>
|
|
962
|
+
<div class="sec-body">
|
|
963
|
+
${SOUND_EVENTS.map((ev) => `<div class="row sound-row">${toggle(ev.setting, p[ev.setting] !== false, ev.label, ev.hint)}
|
|
964
|
+
<button type="button" class="btn sm ghost" data-sound-test="${ev.kind}" title="Play this sound" aria-label="Play the ${esc(ev.label.toLowerCase())} sound">Play</button></div>`).join('')}
|
|
965
|
+
<span class="hint">Changes apply at once.</span>
|
|
966
|
+
</div>
|
|
967
|
+
</section>` : ''}
|
|
968
|
+
|
|
969
|
+
${show('sessions') ? `<form class="card settings-sec" id="sessions-sec" autocomplete="off">
|
|
970
|
+
<div class="sec-intro"><h2>Sessions</h2><p>Defaults for starting and joining.</p></div>
|
|
971
|
+
<div class="sec-body">
|
|
972
|
+
<div class="field">
|
|
973
|
+
<label for="s-joindir">Put projects you join in</label>
|
|
974
|
+
<div class="row"><input class="input grow" id="s-joindir" name="joinDir" value="${esc(p.joinDir)}">
|
|
975
|
+
<button type="button" class="btn icon" data-browse-settings="s-joindir" title="Browse" aria-label="Browse">${I.folder}</button></div>
|
|
976
|
+
<span class="hint">Each session gets its own folder in here.</span>
|
|
977
|
+
</div>
|
|
978
|
+
${toggle('preferLocal', p.preferLocal, 'Keep my files when joining a folder that has some', 'When off, their versions of the same files win.')}
|
|
979
|
+
${toggle('pushLocalCommits', p.pushLocalCommits, 'Push my local commits for me', 'When you commit in a folder whose branch syncs with GitHub, Quilt pushes the commit for you (through the relay if your git can\'t). When off, you push with git yourself.')}
|
|
980
|
+
${toggle('report', p.report, 'Send problem reports to Quilt', 'The app tells Quilt which actions you take (not what you read), whether they worked and how long they took, with the error message when something fails, your app version and OS. Never your files, your chats or your links.')}
|
|
981
|
+
<div class="sec-actions"><span></span><button class="btn primary" type="submit">Save</button></div>
|
|
982
|
+
</div>
|
|
983
|
+
</form>` : ''}
|
|
984
|
+
|
|
985
|
+
${show('about') ? `<section class="card settings-sec" id="about-sec">
|
|
986
|
+
<div class="sec-intro"><h2>About Quilt</h2><p>${state.release?.outOfDate ? `Quilt ${esc(state.release.latest.version)} is out.` : 'The version on this computer.'}</p></div>
|
|
987
|
+
<div class="sec-body">
|
|
988
|
+
<div class="kv"><span>Version</span><span>${esc(state.release?.version || '…')}${state.release?.outOfDate ? ` <span class="pill warn">update available</span>` : state.release ? ' <span class="pill">up to date</span>' : ''}</span></div>
|
|
989
|
+
<div class="sec-actions"><span class="hint">Everything that changed, release by release.</span><span class="row">
|
|
990
|
+
${state.release?.outOfDate ? updateControl() : ''}
|
|
991
|
+
<button class="btn" type="button" data-release-notes>${I.sparkle}<span>What's new</span></button></span></div>
|
|
992
|
+
</div>
|
|
993
|
+
</section>` : ''}
|
|
994
|
+
|
|
995
|
+
${show('computer') ? `<section class="card settings-sec" id="computer-sec">
|
|
996
|
+
<div class="sec-intro"><h2>This computer</h2><p>Where Quilt keeps things.</p></div>
|
|
997
|
+
<div class="sec-body">
|
|
998
|
+
<div class="kv"><span>Identity key</span><code>~/.quilt/identity.json</code><span class="hint">Proves your name is yours. Copy it to another computer to keep your name there.</span></div>
|
|
999
|
+
<div class="kv"><span>Sign-in</span><code>~/.quilt/account.json</code><span class="hint">This computer's sign-in. Only you can read it.</span></div>
|
|
1000
|
+
<div class="kv"><span>Settings</span><code>~/.quilt/settings.json</code></div>
|
|
1001
|
+
<div class="sec-actions"><span class="hint">Stops every session and this app. Your files stay put.</span><button class="btn" type="button" data-shutdown>${I.power}<span>Shut down Quilt</span></button></div>
|
|
1002
|
+
</div>
|
|
1003
|
+
</section>` : ''}`
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* The Agents card: your agents from the accounts API, and a one-time invite for a new one.
|
|
1008
|
+
* With workspaces on, each agent's placement loads first, so its row (Available in and Joins
|
|
1009
|
+
* included) draws once.
|
|
1010
|
+
*/
|
|
1011
|
+
const AGENTS_PAGE = 9
|
|
1012
|
+
|
|
1013
|
+
/** The agents as small cards, a page at a time, with a search (Settings and the Agents page); with workspaces on, a card opens where it works. */
|
|
1014
|
+
function bindAgentsGrid (root, list) {
|
|
1015
|
+
const count = $('#agents-count', root)
|
|
1016
|
+
const pages = $('#agents-pages', root)
|
|
1017
|
+
const search = $('#agents-search', root)
|
|
1018
|
+
let all = []
|
|
1019
|
+
let page = 0
|
|
1020
|
+
const matching = () => {
|
|
1021
|
+
const q = (search.value || '').trim().toLowerCase()
|
|
1022
|
+
return q ? all.filter((a) => [a.name, a.type, a.provider].some((x) => String(x || '').toLowerCase().includes(q))) : all
|
|
1023
|
+
}
|
|
1024
|
+
const draw = () => {
|
|
1025
|
+
const hits = matching()
|
|
1026
|
+
const last = Math.max(0, Math.ceil(hits.length / AGENTS_PAGE) - 1)
|
|
1027
|
+
page = Math.min(page, last)
|
|
1028
|
+
const from = page * AGENTS_PAGE
|
|
1029
|
+
const shown = hits.slice(from, from + AGENTS_PAGE)
|
|
1030
|
+
list.innerHTML = shown.length
|
|
1031
|
+
? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens: state.workspacesOn })).join('')}</div>`
|
|
1032
|
+
: `<p class="hint">${all.length ? 'No agent matches that.' : 'No agents yet. Invite one above.'}</p>`
|
|
1033
|
+
pages.hidden = hits.length <= AGENTS_PAGE
|
|
1034
|
+
pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
|
|
1035
|
+
<button type="button" class="btn sm icon" data-page="-1" aria-label="Previous page" ${page === 0 ? 'disabled' : ''}>${I.arrowLeft}</button>
|
|
1036
|
+
<button type="button" class="btn sm icon" data-page="1" aria-label="Next page" ${page >= last ? 'disabled' : ''}>${I.arrowRight}</button>`
|
|
1037
|
+
}
|
|
1038
|
+
pages.addEventListener('click', (e) => {
|
|
1039
|
+
const b = e.target.closest('[data-page]')
|
|
1040
|
+
if (!b || b.disabled) return
|
|
1041
|
+
page += Number(b.dataset.page)
|
|
1042
|
+
draw()
|
|
1043
|
+
})
|
|
1044
|
+
search.addEventListener('input', () => { page = 0; draw() })
|
|
1045
|
+
// A card is a button with workspaces on (Enter or Space opens it, like a click).
|
|
1046
|
+
list.addEventListener('keydown', (e) => {
|
|
1047
|
+
const card = e.target.closest && e.target.closest('.ag-mini.open')
|
|
1048
|
+
if (!card || e.target !== card || (e.key !== 'Enter' && e.key !== ' ')) return
|
|
1049
|
+
e.preventDefault()
|
|
1050
|
+
const a = all.find((x) => x.id === card.dataset.agent)
|
|
1051
|
+
if (a) openPlacementDialog(a)
|
|
1052
|
+
})
|
|
1053
|
+
list.addEventListener('click', (e) => {
|
|
1054
|
+
const b = e.target.closest('[data-agent-menu]')
|
|
1055
|
+
const card = e.target.closest('[data-agent]')
|
|
1056
|
+
const a = all.find((x) => x.id === (b ? b.dataset.agentMenu : card && card.dataset.agent))
|
|
1057
|
+
if (!a) return
|
|
1058
|
+
if (!b) { if (state.workspacesOn) openPlacementDialog(a); return }
|
|
1059
|
+
const items = [
|
|
1060
|
+
...(state.workspacesOn ? [{ act: 'place', label: 'Where it works', icon: I.grid }] : []),
|
|
1061
|
+
{ act: 'revoke', label: 'Revoke on heyquilt.com', icon: I.external, danger: true }
|
|
1062
|
+
]
|
|
1063
|
+
openAgentMenu(b, {
|
|
1064
|
+
label: `Actions for ${a.name}`,
|
|
1065
|
+
items,
|
|
1066
|
+
on: (act) => {
|
|
1067
|
+
if (act === 'place') openPlacementDialog(a)
|
|
1068
|
+
if (act === 'revoke') window.open('https://heyquilt.com/dashboard/agents', '_blank', 'noopener')
|
|
1069
|
+
}
|
|
1070
|
+
})
|
|
1071
|
+
})
|
|
1072
|
+
api('GET', '/api/agents').then(({ agents }) => {
|
|
1073
|
+
all = agents
|
|
1074
|
+
count.textContent = String(agents.length)
|
|
1075
|
+
count.hidden = !agents.length
|
|
1076
|
+
draw()
|
|
1077
|
+
}).catch((err) => { list.innerHTML = `<p class="hint warn">${esc(err.message)}</p>` })
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function bindAgents (root) {
|
|
1081
|
+
const list = $('#agents-list', root)
|
|
1082
|
+
const sec = $('#agents-sec', root)
|
|
1083
|
+
bindAgentsGrid(root, list)
|
|
1084
|
+
sec.addEventListener('click', async (e) => {
|
|
1085
|
+
const b = e.target.closest('[data-copy]')
|
|
1086
|
+
if (!b) return
|
|
1087
|
+
try { await navigator.clipboard.writeText($(`#${b.dataset.copy}`, sec).textContent); toast('Copied') } catch { toast('Select the text and press ⌘/Ctrl+C to copy') }
|
|
1088
|
+
})
|
|
1089
|
+
const btn = $('#agents-make', root)
|
|
1090
|
+
btn.onclick = async () => {
|
|
1091
|
+
// With workspaces on: pick a global, workspace or session agent first.
|
|
1092
|
+
if (state.workspacesOn) {
|
|
1093
|
+
return openKindMenu(btn, {
|
|
1094
|
+
onInvite: ({ kind, text, where }) => {
|
|
1095
|
+
$('#agents-invite', root).innerHTML = agentInviteHtml(text, 'agents-paste', kindLineHtml(kind, where))
|
|
1096
|
+
btn.innerHTML = `${I.bot}<span>Invite another</span>`
|
|
1097
|
+
}
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
btn.disabled = true
|
|
1101
|
+
try {
|
|
1102
|
+
const inv = await api('POST', '/api/agent-invites')
|
|
1103
|
+
$('#agents-invite', root).innerHTML = agentInviteHtml(agentPaste({ link: inv.link }), 'agents-paste')
|
|
1104
|
+
btn.innerHTML = `${I.bot}<span>Invite another</span>`
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
$('#agents-error', root).textContent = err.message
|
|
1107
|
+
}
|
|
1108
|
+
btn.disabled = false
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* Agents and git: your default for what agents may commit in sessions you start (saved at once),
|
|
1114
|
+
* and this account's GitHub connection. Connect opens GitHub's install page; the card then watches
|
|
1115
|
+
* for the connection for up to ten minutes.
|
|
1116
|
+
*/
|
|
1117
|
+
function bindAgentsGit (sec) {
|
|
1118
|
+
const box = $('#s-agentcommits', sec)
|
|
1119
|
+
const drawSeg = (v) => { box.innerHTML = gitSegHtml(gitControl({ value: v }), { attr: 'data-git-default', label: 'In sessions I start, git is' }) }
|
|
1120
|
+
box.addEventListener('click', async (e) => {
|
|
1121
|
+
const b = e.target.closest('[data-git-default]')
|
|
1122
|
+
if (!b || b.classList.contains('on')) return
|
|
1123
|
+
const v = b.dataset.gitDefault
|
|
1124
|
+
drawSeg(v)
|
|
1125
|
+
try {
|
|
1126
|
+
state.profile = await api('POST', '/api/settings', { agentCommits: v })
|
|
1127
|
+
toast(AGENT_COMMIT_WORDS[v] || 'Saved')
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
drawSeg(state.profile.agentCommits || 'branches')
|
|
1130
|
+
toast(err.message)
|
|
1131
|
+
}
|
|
1132
|
+
})
|
|
1133
|
+
const out = $('#agents-gh', sec)
|
|
1134
|
+
const btn = $('#agents-gh-connect', sec)
|
|
1135
|
+
const draw = (gh) => {
|
|
1136
|
+
if (!gh.available) { out.innerHTML = '<span class="hint">GitHub is not set up on this server.</span>'; btn.hidden = true; return }
|
|
1137
|
+
out.innerHTML = gh.connected ? `<b>Connected as @${esc(gh.login)}</b>` : '<span class="hint warn">Not connected</span>'
|
|
1138
|
+
btn.hidden = false
|
|
1139
|
+
btn.innerHTML = `${I.link}<span>${gh.connected ? 'Add repositories' : 'Connect GitHub'}</span>`
|
|
1140
|
+
}
|
|
1141
|
+
const load = () => api('GET', '/api/github').then(draw, (err) => { out.innerHTML = `<span class="hint warn">${esc(err.message)}</span>` })
|
|
1142
|
+
load()
|
|
1143
|
+
btn.onclick = async () => {
|
|
1144
|
+
let was = null
|
|
1145
|
+
try { was = (await api('GET', '/api/github')).login || null } catch {}
|
|
1146
|
+
btn.disabled = true
|
|
1147
|
+
try {
|
|
1148
|
+
const { url } = await api('POST', '/api/github/connect', {})
|
|
1149
|
+
window.open(url, '_blank', 'noopener')
|
|
1150
|
+
toast('Finish on GitHub, then come back')
|
|
1151
|
+
} catch (err) { btn.disabled = false; toast(err.message); return }
|
|
1152
|
+
out.innerHTML = `<span class="hint">${loadingNote('Finish on GitHub, then come back…')}</span>`
|
|
1153
|
+
const until = Date.now() + 10 * 60 * 1000
|
|
1154
|
+
while (sec.isConnected && Date.now() < until) {
|
|
1155
|
+
await new Promise((r) => setTimeout(r, 3000))
|
|
1156
|
+
let gh = null
|
|
1157
|
+
try { gh = await api('GET', '/api/github') } catch { continue }
|
|
1158
|
+
if (!gh.login || gh.login === was) continue
|
|
1159
|
+
btn.disabled = false
|
|
1160
|
+
draw(gh)
|
|
1161
|
+
toast(`Connected as @${gh.login}: agents can commit in your sessions now`)
|
|
1162
|
+
return
|
|
1163
|
+
}
|
|
1164
|
+
btn.disabled = false
|
|
1165
|
+
if (sec.isConnected) load()
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** Wires the settings cards inside `root`; `refresh()` redraws them after a save. */
|
|
1170
|
+
function bindSettings (root, refresh) {
|
|
1171
|
+
// Tabs: the pick is kept on this computer, and the cards are drawn again.
|
|
1172
|
+
root.querySelector('.settings-tabs')?.addEventListener('click', (e) => {
|
|
1173
|
+
const b = e.target.closest('[data-settings-tab]')
|
|
1174
|
+
if (!b || b.classList.contains('on')) return
|
|
1175
|
+
state.settingsTab = b.dataset.settingsTab
|
|
1176
|
+
try { localStorage.setItem('quilt.settingsTab', state.settingsTab) } catch {}
|
|
1177
|
+
refresh()
|
|
1178
|
+
})
|
|
1179
|
+
const saveForm = (form, pick, done) => {
|
|
1180
|
+
form.onsubmit = async (e) => {
|
|
1181
|
+
e.preventDefault()
|
|
1182
|
+
const btn = form.querySelector('button[type=submit]')
|
|
1183
|
+
btn.disabled = true
|
|
1184
|
+
try {
|
|
1185
|
+
state.profile = await api('POST', '/api/settings', pick(new FormData(form)))
|
|
1186
|
+
toast('Saved')
|
|
1187
|
+
refresh()
|
|
1188
|
+
done && done()
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
toast(err.message)
|
|
1191
|
+
btn.disabled = false
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Profile: live preview while picking. The name comes from the account.
|
|
1197
|
+
const prof = $('#profile-sec', root)
|
|
1198
|
+
if (prof) {
|
|
1199
|
+
const preview = () => {
|
|
1200
|
+
const f = new FormData(prof)
|
|
1201
|
+
$('#pv', root).querySelector('.avatar').outerHTML = avatar(state.profile.name, f.get('color') || null)
|
|
1202
|
+
$('#pv-tool', root).textContent = `coding with ${f.get('tool')}`
|
|
1203
|
+
}
|
|
1204
|
+
prof.addEventListener('input', preview)
|
|
1205
|
+
prof.addEventListener('change', preview)
|
|
1206
|
+
saveForm(prof, (f) => ({ color: f.get('color'), tool: f.get('tool') }))
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const sess = $('#sessions-sec', root)
|
|
1210
|
+
if (sess) sess.querySelector('[data-browse-settings]').onclick = () => pickFolder($('#s-joindir', root))
|
|
1211
|
+
if (sess) saveForm(sess, (f) => ({ joinDir: f.get('joinDir'), preferLocal: !!f.get('preferLocal'), pushLocalCommits: !!f.get('pushLocalCommits'), report: !!f.get('report') }))
|
|
1212
|
+
|
|
1213
|
+
const ai = $('#your-ai-sec', root)
|
|
1214
|
+
if (ai) saveForm(ai, (f) => ({ shareAgent: !!f.get('shareAgent'), summarize: !!f.get('summarize'), aiTasks: f.get('aiTasks') }))
|
|
1215
|
+
|
|
1216
|
+
const auto = $('#auto-sec', root)
|
|
1217
|
+
if (auto) {
|
|
1218
|
+
saveForm(auto, (f) => ({ autoWork: f.get('autoWork'), ...(f.get('autoWork') === 'auto' ? { autoModel: f.get('autoModel'), autoFiles: !!f.get('autoFiles'), autoCap: f.get('autoCap') } : {}) }))
|
|
1219
|
+
const sel = $('#s-autowork', auto)
|
|
1220
|
+
sel.onchange = () => {
|
|
1221
|
+
for (const el of auto.querySelectorAll('.auto-only')) el.hidden = sel.value !== 'auto'
|
|
1222
|
+
$('#s-autowork-hint', auto).textContent = autoHint(sel.value, state.profile.auto?.tool || state.profile.tool)
|
|
1223
|
+
}
|
|
1224
|
+
auto.addEventListener('click', async (e) => {
|
|
1225
|
+
const b = e.target.closest('[data-take-over]')
|
|
1226
|
+
if (!b) return
|
|
1227
|
+
b.disabled = true
|
|
1228
|
+
try {
|
|
1229
|
+
const r = await api('POST', '/api/autowork/take-over', { run: b.dataset.takeOver })
|
|
1230
|
+
toast(r.opened ? 'Opened the chat in your AI tool' : r.copied ? `Copied: ${r.command}. Paste it in a terminal.` : r.command)
|
|
1231
|
+
} catch (err) { toast(err.message) }
|
|
1232
|
+
b.disabled = false
|
|
1233
|
+
})
|
|
1234
|
+
loadAutoUsage(root)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const cloud = $('#cloud-ai-sec', root)
|
|
1238
|
+
if (cloud) bindCloudAi(cloud)
|
|
1239
|
+
|
|
1240
|
+
const agit = $('#agents-git-sec', root)
|
|
1241
|
+
if (agit) bindAgentsGit(agit)
|
|
1242
|
+
|
|
1243
|
+
// Appearance applies at once; the server puts it on <html> at the next launch.
|
|
1244
|
+
$('#appearance-sec', root)?.addEventListener('click', async (e) => {
|
|
1245
|
+
const pick = e.target.closest('[data-theme-pick]')
|
|
1246
|
+
if (!pick || pick.classList.contains('on')) return
|
|
1247
|
+
const before = document.documentElement.dataset.theme
|
|
1248
|
+
document.documentElement.dataset.theme = pick.dataset.themePick
|
|
1249
|
+
try {
|
|
1250
|
+
state.profile = await api('POST', '/api/settings', { theme: pick.dataset.themePick })
|
|
1251
|
+
refresh()
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
document.documentElement.dataset.theme = before
|
|
1254
|
+
toast(err.message)
|
|
1255
|
+
}
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
// Sounds apply at once, like the theme; Play previews one.
|
|
1259
|
+
const sounds = $('#sounds-sec', root)
|
|
1260
|
+
sounds?.addEventListener('click', (e) => {
|
|
1261
|
+
const b = e.target.closest('[data-sound-test]')
|
|
1262
|
+
if (b) playSound(b.dataset.soundTest, { force: true })
|
|
1263
|
+
})
|
|
1264
|
+
sounds?.addEventListener('change', async (e) => {
|
|
1265
|
+
const box = e.target
|
|
1266
|
+
if (!SOUND_EVENTS.some((ev) => ev.setting === box.name)) return
|
|
1267
|
+
try {
|
|
1268
|
+
state.profile = await api('POST', '/api/settings', { [box.name]: box.checked })
|
|
1269
|
+
} catch (err) {
|
|
1270
|
+
box.checked = !box.checked
|
|
1271
|
+
toast(err.message)
|
|
1272
|
+
}
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
if ($('#agents-sec', root)) bindAgents(root)
|
|
1276
|
+
|
|
1277
|
+
const signOut = $('#sign-out', root)
|
|
1278
|
+
if (signOut) signOut.onclick = async () => {
|
|
1279
|
+
if (!await ask({ title: 'Sign out of Quilt?', message: 'This stops your sessions on this computer. Your files stay where they are.', ok: 'Sign out', danger: true })) return
|
|
1280
|
+
try {
|
|
1281
|
+
await api('POST', '/api/account/signout')
|
|
1282
|
+
signedOutNow()
|
|
1283
|
+
} catch (err) {
|
|
1284
|
+
toast(err.message)
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Settings in a pop-up, for inside a session: the same cards as the Settings page, so you
|
|
1291
|
+
* can change your profile without leaving. Saving redraws the cards in place.
|
|
1292
|
+
*/
|
|
1293
|
+
export function openSettings () {
|
|
1294
|
+
$('#settings-back')?.remove()
|
|
1295
|
+
const back = document.createElement('div')
|
|
1296
|
+
back.id = 'settings-back'
|
|
1297
|
+
back.className = 'modal-back settings-back'
|
|
1298
|
+
back.innerHTML = `
|
|
1299
|
+
<div class="settings-modal" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
|
1300
|
+
<header class="settings-head">
|
|
1301
|
+
<h2 id="settings-title">${I.gear}<span>Settings</span></h2>
|
|
1302
|
+
<p>Saved on this computer and used for every new session.</p>
|
|
1303
|
+
<button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
|
|
1304
|
+
</header>
|
|
1305
|
+
<div class="settings-body" id="settings-body"></div>
|
|
1306
|
+
</div>`
|
|
1307
|
+
document.body.appendChild(back)
|
|
1308
|
+
const body = $('#settings-body', back)
|
|
1309
|
+
const draw = () => {
|
|
1310
|
+
const scroll = body.scrollTop
|
|
1311
|
+
body.innerHTML = settingsHtml({ head: false })
|
|
1312
|
+
bindSettings(body, draw)
|
|
1313
|
+
body.scrollTop = scroll
|
|
1314
|
+
// The sidebar's profile card, if it is showing, follows the saved profile.
|
|
1315
|
+
if (state.shellView && $('#app .side')) renderShell(state.shellView)
|
|
1316
|
+
}
|
|
1317
|
+
draw()
|
|
1318
|
+
const close = () => back.remove()
|
|
1319
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
|
|
1320
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
1321
|
+
back.querySelector('[data-settings-close]').onclick = close
|
|
1322
|
+
back.querySelector('[data-settings-close]').focus()
|
|
1323
|
+
}
|