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/app.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// Quilt app: boot, live events, home screen, folder picker and invites.
|
|
2
|
+
// The session workspace lives in session.js. Plain ES modules, no build step.
|
|
3
|
+
import { TOKEN, I, state, $, esc, basename, toast, api, ask, decodeInvite, remember, recall, startDropdowns, avatar, loadAccessTypes, typeOptions, loadingHtml, loadingNote } from './common.js'
|
|
4
|
+
import { renderShell, joinSessionDialog } from './home.js'
|
|
5
|
+
import { mountSession, sessionUpdated, sessionMessage, sessionFeed, sessionFileChanged, sessionLog, sessionUnmount } from './session.js'
|
|
6
|
+
import { quiltMark } from './mark.js'
|
|
7
|
+
import { renderSignIn } from './signin.js'
|
|
8
|
+
import { checkRelease, openReleaseNotes } from './releases.js'
|
|
9
|
+
import { loadWorkspaces, openWorkspace, wsIdOf } from './workspaces.js'
|
|
10
|
+
import { previewBusy } from './files.js'
|
|
11
|
+
import { agentPaste } from './invite.js'
|
|
12
|
+
import { openKindMenu, kindLineHtml } from './agent-kinds.js'
|
|
13
|
+
import { messageSound, letInSound, taskSound, soundOn, playSound, unlockSounds } from './sounds.js'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------- boot --
|
|
16
|
+
const SIGNED_OUT = 'This computer was signed out. Sign in again.'
|
|
17
|
+
|
|
18
|
+
// Once per page, not per boot: signing in again calls boot() and mustn't add listeners twice.
|
|
19
|
+
startDropdowns()
|
|
20
|
+
unlockSounds()
|
|
21
|
+
|
|
22
|
+
/** Plays the sound for `kind` if it's switched on in Settings. */
|
|
23
|
+
function chime (kind) {
|
|
24
|
+
if (kind && soundOn(state.profile, kind)) playSound(kind)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function boot () {
|
|
28
|
+
if (!TOKEN) return renderLocked()
|
|
29
|
+
// Shown only if loading takes a moment: the Q pieces itself together while we wait.
|
|
30
|
+
const waiting = setTimeout(() => { if (!state.loaded) $('#app').innerHTML = `<div class="booting">${quiltMark({ word: false, loop: true })}</div>` }, 250)
|
|
31
|
+
try {
|
|
32
|
+
const acc = await api('GET', '/api/account')
|
|
33
|
+
if (!acc.signedIn) {
|
|
34
|
+
clearTimeout(waiting)
|
|
35
|
+
return renderSignIn(acc.reason === 'revoked' ? SIGNED_OUT : '', boot)
|
|
36
|
+
}
|
|
37
|
+
state.account = acc.account
|
|
38
|
+
const s = await api('GET', '/api/state')
|
|
39
|
+
state.recent = s.recent
|
|
40
|
+
state.defaults = s.defaults
|
|
41
|
+
state.profile = s.profile
|
|
42
|
+
state.maxFileBytes = s.maxFileBytes
|
|
43
|
+
for (const sum of s.sessions) state.sessions.set(sum.id, sum)
|
|
44
|
+
state.loaded = true
|
|
45
|
+
await loadWorkspaces()
|
|
46
|
+
clearTimeout(waiting)
|
|
47
|
+
const last = recall('view')
|
|
48
|
+
state.view = state.sessions.has(last) || last === 'settings' || (last === 'agents' && state.workspacesOn) || isWorkspace(last) ? last : (state.sessions.size ? [...state.sessions.keys()][0] : 'home')
|
|
49
|
+
if (isWorkspace(state.view)) await openWorkspace(wsIdOf(state.view)).catch(() => { state.view = 'home' })
|
|
50
|
+
if (isSession(state.view)) await loadMessages(state.view)
|
|
51
|
+
if (!state.events) connectEvents()
|
|
52
|
+
render()
|
|
53
|
+
pollWorkspace()
|
|
54
|
+
// Invite links that opened the desktop app wait until you're signed in.
|
|
55
|
+
if (!boot.invites) { boot.invites = true; window.quiltDesktop?.onInvite(openInviteLink); window.quiltDesktop?.onReleaseNotes?.(openReleaseNotes) }
|
|
56
|
+
// Is a newer Quilt out, and has this version's "what's new" been shown? Never blocks the app.
|
|
57
|
+
checkRelease()
|
|
58
|
+
} catch (err) {
|
|
59
|
+
clearTimeout(waiting)
|
|
60
|
+
if (!err.signedOut) renderLocked(err.message)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Back to the sign-in screen: after Sign out, or when the API turned this computer away. */
|
|
65
|
+
export function signedOutNow (message = '') {
|
|
66
|
+
sessionUnmount() // stops an open session view's timers and bindings
|
|
67
|
+
clearInterval(state.pollTimer)
|
|
68
|
+
state.pollTimer = null
|
|
69
|
+
state.events?.close()
|
|
70
|
+
state.events = null
|
|
71
|
+
state.loaded = false
|
|
72
|
+
state.account = null
|
|
73
|
+
state.workspaces = null
|
|
74
|
+
state.workspacesOn = false
|
|
75
|
+
state.workspace = null
|
|
76
|
+
state.orgs = []
|
|
77
|
+
state.addingWorkspace = false
|
|
78
|
+
for (const m of [state.sessions, state.messages, state.feeds, state.trees, state.files]) m.clear()
|
|
79
|
+
document.querySelectorAll('.modal-back').forEach((m) => m.remove())
|
|
80
|
+
renderSignIn(message, boot)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
window.addEventListener('quilt-signed-out', () => signedOutNow(SIGNED_OUT))
|
|
84
|
+
|
|
85
|
+
function connectEvents () {
|
|
86
|
+
const es = state.events = new EventSource(`/api/events?t=${encodeURIComponent(TOKEN)}`)
|
|
87
|
+
// The browser reopens a dropped stream by itself, but whatever was pushed meanwhile is
|
|
88
|
+
// gone (a partner back after a blip, say), and nothing pushes again until something
|
|
89
|
+
// changes. So after a reopen, fetch the state over again.
|
|
90
|
+
let opened = false
|
|
91
|
+
es.addEventListener('open', () => {
|
|
92
|
+
if (!opened) { opened = true; return }
|
|
93
|
+
resync()
|
|
94
|
+
})
|
|
95
|
+
es.addEventListener('session', (e) => {
|
|
96
|
+
const sum = JSON.parse(e.data)
|
|
97
|
+
const prev = state.sessions.get(sum.id)
|
|
98
|
+
// Something new for you since the last summary: someone asking in, or a task handed to you.
|
|
99
|
+
if (prev && prev.status && sum.status) {
|
|
100
|
+
chime(letInSound(prev.status.waiting, sum.status.waiting) || taskSound(prev.status.tasks, sum.status.tasks, sum.status.me?.name))
|
|
101
|
+
}
|
|
102
|
+
// Session summaries don't carry the full log; keep what we have.
|
|
103
|
+
state.sessions.set(sum.id, prev ? { ...sum, logs: prev.logs } : sum)
|
|
104
|
+
if (state.view === sum.id) sessionUpdated(sum.id)
|
|
105
|
+
else renderTabs()
|
|
106
|
+
// A session's status changes its card on the open workspace page (All files shows no sessions).
|
|
107
|
+
if (isWorkspace(state.view) && !isFiles(state.view) && state.workspace?.running.includes(sum.id)) renderShell(state.view)
|
|
108
|
+
})
|
|
109
|
+
es.addEventListener('feed', (e) => {
|
|
110
|
+
const { id, entries } = JSON.parse(e.data)
|
|
111
|
+
sessionFeed(id, entries)
|
|
112
|
+
})
|
|
113
|
+
es.addEventListener('file-changed', (e) => {
|
|
114
|
+
const { id, ...change } = JSON.parse(e.data)
|
|
115
|
+
sessionFileChanged(id, change)
|
|
116
|
+
})
|
|
117
|
+
es.addEventListener('message', (e) => {
|
|
118
|
+
const { id, message } = JSON.parse(e.data)
|
|
119
|
+
const list = state.messages.get(id)
|
|
120
|
+
if (!list || !list.some((m) => m.id === message.id)) chime(messageSound(message, state.sessions.get(id)?.status.me.name))
|
|
121
|
+
if (list && !list.some((m) => m.id === message.id)) list.push(message)
|
|
122
|
+
if (state.view === id) {
|
|
123
|
+
sessionMessage(id, message)
|
|
124
|
+
if (document.visibilityState === 'visible') markRead(id)
|
|
125
|
+
} else renderTabs()
|
|
126
|
+
if (message.by !== state.sessions.get(id)?.status.me.name && document.visibilityState !== 'visible') {
|
|
127
|
+
document.title = `• ${message.by}: ${message.text || message.file?.name || ''}`.slice(0, 60)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
es.addEventListener('log', (e) => {
|
|
131
|
+
const { id, ts, line } = JSON.parse(e.data)
|
|
132
|
+
const s = state.sessions.get(id)
|
|
133
|
+
if (s) { s.logs.push({ ts, line }); if (s.logs.length > 200) s.logs.shift() }
|
|
134
|
+
if (state.view === id) sessionLog(id)
|
|
135
|
+
})
|
|
136
|
+
es.addEventListener('stopped', (e) => {
|
|
137
|
+
const { id } = JSON.parse(e.data)
|
|
138
|
+
state.sessions.delete(id)
|
|
139
|
+
state.messages.delete(id)
|
|
140
|
+
state.feeds.delete(id)
|
|
141
|
+
state.trees.delete(id)
|
|
142
|
+
if (state.view === id) state.view = 'home'
|
|
143
|
+
render()
|
|
144
|
+
// On a workspace page the stopped session moves from running to recent.
|
|
145
|
+
if (isWorkspace(state.view)) {
|
|
146
|
+
const view = state.view
|
|
147
|
+
refreshRecent().then(() => openWorkspace(wsIdOf(view))).then(() => { if (state.view === view) render() }).catch(() => {})
|
|
148
|
+
}
|
|
149
|
+
// The folder moves from "open" to "recent", but the recent list is the
|
|
150
|
+
// server's: draw Home again once the fresh one arrives, or the session you
|
|
151
|
+
// just left is missing from it until the next visit.
|
|
152
|
+
refreshRecent().then(() => { if (state.view === 'home') render() })
|
|
153
|
+
})
|
|
154
|
+
es.addEventListener('signed-out', (e) => {
|
|
155
|
+
const { reason } = JSON.parse(e.data)
|
|
156
|
+
signedOutNow(reason === 'revoked' ? SIGNED_OUT : '')
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A workspace's page (ws:<id>) or its All files view (wsfiles:<id>).
|
|
161
|
+
const isFiles = (v) => typeof v === 'string' && v.startsWith('wsfiles:')
|
|
162
|
+
const isWorkspace = (v) => typeof v === 'string' && (v.startsWith('ws:') || isFiles(v))
|
|
163
|
+
|
|
164
|
+
/** While a workspace is open, fetch it again every 20 seconds and redraw if it changed
|
|
165
|
+
* (someone else's upload shows up). go() and signing out stop it. */
|
|
166
|
+
function pollWorkspace () {
|
|
167
|
+
clearInterval(state.pollTimer)
|
|
168
|
+
state.pollTimer = null
|
|
169
|
+
if (!isWorkspace(state.view)) return
|
|
170
|
+
const view = state.view
|
|
171
|
+
state.pollTimer = setInterval(async () => {
|
|
172
|
+
if (state.view !== view) { clearInterval(state.pollTimer); state.pollTimer = null; return }
|
|
173
|
+
if (document.querySelector('.modal-back')) return // a dialog is open: leave the page under it alone
|
|
174
|
+
const before = JSON.stringify(state.workspace)
|
|
175
|
+
try { await openWorkspace(wsIdOf(view)) } catch { return }
|
|
176
|
+
if (state.view === view && JSON.stringify(state.workspace) !== before && !previewBusy()) render()
|
|
177
|
+
}, 20000)
|
|
178
|
+
}
|
|
179
|
+
const isSession = (view) => view !== 'home' && view !== 'settings' && view !== 'agents' && !isWorkspace(view)
|
|
180
|
+
|
|
181
|
+
document.addEventListener('visibilitychange', () => {
|
|
182
|
+
if (document.visibilityState === 'visible') {
|
|
183
|
+
document.title = 'quilt'
|
|
184
|
+
if (isSession(state.view)) markRead(state.view)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
export async function loadMessages (id) {
|
|
189
|
+
const { messages } = await api('GET', `/api/sessions/${id}/messages`)
|
|
190
|
+
state.messages.set(id, messages)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function markRead (id) {
|
|
194
|
+
const list = state.messages.get(id) || []
|
|
195
|
+
if (!list.some((m) => m.unread)) return
|
|
196
|
+
for (const m of list) m.unread = false
|
|
197
|
+
await api('POST', `/api/sessions/${id}/read`).catch(() => {})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function refreshRecent () {
|
|
201
|
+
try {
|
|
202
|
+
const s = await api('GET', '/api/state')
|
|
203
|
+
state.recent = s.recent
|
|
204
|
+
state.profile = s.profile
|
|
205
|
+
} catch {}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Brings every session's summary (and the recent list) up to date with the server. */
|
|
209
|
+
async function resync () {
|
|
210
|
+
let s
|
|
211
|
+
try { s = await api('GET', '/api/state') } catch { return }
|
|
212
|
+
state.recent = s.recent
|
|
213
|
+
state.profile = s.profile
|
|
214
|
+
const live = new Set()
|
|
215
|
+
for (const sum of s.sessions) {
|
|
216
|
+
live.add(sum.id)
|
|
217
|
+
const prev = state.sessions.get(sum.id)
|
|
218
|
+
state.sessions.set(sum.id, prev ? { ...sum, logs: prev.logs } : sum)
|
|
219
|
+
}
|
|
220
|
+
for (const id of [...state.sessions.keys()]) if (!live.has(id)) state.sessions.delete(id)
|
|
221
|
+
if (isSession(state.view)) {
|
|
222
|
+
if (state.sessions.has(state.view)) sessionUpdated(state.view)
|
|
223
|
+
else { state.view = 'home'; render() }
|
|
224
|
+
} else if (state.view === 'home' || isWorkspace(state.view)) render()
|
|
225
|
+
else renderTabs()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function go (view) {
|
|
229
|
+
clearInterval(state.pollTimer)
|
|
230
|
+
state.pollTimer = null
|
|
231
|
+
state.view = view
|
|
232
|
+
state.pending = []
|
|
233
|
+
state.to = ''
|
|
234
|
+
if (view !== 'home') state.addingWorkspace = false
|
|
235
|
+
if (isWorkspace(view)) {
|
|
236
|
+
if (state.workspace?.workspace?.id !== wsIdOf(view)) {
|
|
237
|
+
// Another workspace: its loading screen right away, not a wait on the last page.
|
|
238
|
+
state.workspace = null
|
|
239
|
+
state.filesView = { folder: '', picked: null, mode: state.filesView.mode }
|
|
240
|
+
render()
|
|
241
|
+
}
|
|
242
|
+
await refreshRecent()
|
|
243
|
+
const loaded = await openWorkspace(wsIdOf(view)).then(() => true, (err) => { toast(err.message); return false })
|
|
244
|
+
if (state.view !== view) return // somewhere else was opened while this one loaded
|
|
245
|
+
if (loaded) state.wsEnter = wsIdOf(view) // its page fades in once, now that all of it is here
|
|
246
|
+
else view = 'home'
|
|
247
|
+
state.view = view
|
|
248
|
+
}
|
|
249
|
+
remember('view', view)
|
|
250
|
+
if (view === 'home') { await refreshRecent(); if (state.workspacesOn) await loadWorkspaces() }
|
|
251
|
+
if (isSession(view) && !state.messages.has(view)) {
|
|
252
|
+
// A session opened for the first time: its loading screen until its chat is here; the
|
|
253
|
+
// session view keeps it up until the files and the AI feeds are in too (mountSession).
|
|
254
|
+
sessionUnmount()
|
|
255
|
+
const sum = state.sessions.get(view)
|
|
256
|
+
$('#app').innerHTML = loadingHtml({ label: 'Loading session', name: sum?.status?.sessionName || basename(sum?.dir || ''), full: true })
|
|
257
|
+
await loadMessages(view).catch(() => {})
|
|
258
|
+
if (state.view !== view) return
|
|
259
|
+
}
|
|
260
|
+
render()
|
|
261
|
+
pollWorkspace()
|
|
262
|
+
if (isSession(view)) markRead(view)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// --------------------------------------------------------------- render --
|
|
266
|
+
let shuttingDown = false // one "Shut down Quilt?" at a time, however often the button is clicked
|
|
267
|
+
export async function shutdown () {
|
|
268
|
+
if (shuttingDown) return
|
|
269
|
+
shuttingDown = true
|
|
270
|
+
try {
|
|
271
|
+
if (!await ask({ title: 'Shut down Quilt?', message: 'This stops every session and this app. Your files stay where they are.', ok: 'Shut down', danger: true })) return
|
|
272
|
+
$('#settings-back')?.remove()
|
|
273
|
+
await api('POST', '/api/shutdown')
|
|
274
|
+
state.events?.close() // don't re-render or reconnect as sessions stop
|
|
275
|
+
renderLocked('quilt is shut down. You can close this tab.')
|
|
276
|
+
} catch (err) {
|
|
277
|
+
toast(err.message)
|
|
278
|
+
} finally {
|
|
279
|
+
shuttingDown = false
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
document.addEventListener('click', (e) => { if (e.target.closest('[data-shutdown]')) shutdown() })
|
|
284
|
+
|
|
285
|
+
export function render () {
|
|
286
|
+
sessionUnmount()
|
|
287
|
+
if (!isSession(state.view)) {
|
|
288
|
+
renderShell(state.view)
|
|
289
|
+
} else {
|
|
290
|
+
mountSession(state.view)
|
|
291
|
+
bindTopbar()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function renderLocked (msg) {
|
|
296
|
+
$('#app').innerHTML = `
|
|
297
|
+
<div class="home"><div class="hero">
|
|
298
|
+
<div class="wordmark">${quiltMark({ sew: 'first' })}</div>
|
|
299
|
+
<p class="tagline">${esc(msg || 'Open Quilt using the link printed in your terminal by')} ${msg ? '' : '<code>quilt ui</code>.'}</p>
|
|
300
|
+
</div></div>`
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function renderTabs () {
|
|
304
|
+
const el = $('#tabs')
|
|
305
|
+
if (!el) return
|
|
306
|
+
// One session is the normal case; tabs only help when there are several.
|
|
307
|
+
el.hidden = state.sessions.size < 2 && state.view !== 'home'
|
|
308
|
+
el.innerHTML = [...state.sessions.values()].map((s) => {
|
|
309
|
+
const unread = s.status.unread
|
|
310
|
+
return `<button class="tab${state.view === s.id ? ' on' : ''}" data-go="${s.id}">
|
|
311
|
+
<span class="dot" style="background:${s.status.connected ? 'var(--ok)' : 'var(--warn)'}"></span>
|
|
312
|
+
${esc(s.status.sessionName || basename(s.dir))}
|
|
313
|
+
${unread && state.view !== s.id ? `<span class="badge">${unread}</span>` : ''}
|
|
314
|
+
</button>`
|
|
315
|
+
}).join('')
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function bindTopbar () {
|
|
319
|
+
renderTabs()
|
|
320
|
+
document.querySelectorAll('[data-go]').forEach((b) => { if (!b.closest('#tabs')) b.onclick = () => go(b.dataset.go) })
|
|
321
|
+
$('#tabs')?.addEventListener('click', (e) => {
|
|
322
|
+
const b = e.target.closest('[data-go]')
|
|
323
|
+
if (b) go(b.dataset.go)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// In the desktop app, clicking an invite link opens the join dialog with it filled in.
|
|
328
|
+
async function openInviteLink (link) {
|
|
329
|
+
document.querySelectorAll('.modal-back').forEach((m) => m.remove())
|
|
330
|
+
if (state.view !== 'home') await go('home')
|
|
331
|
+
joinSessionDialog(link)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// -------------------------------------------------------- folder picker --
|
|
335
|
+
export async function pickFolder (input) {
|
|
336
|
+
if (window.quiltDesktop) {
|
|
337
|
+
const dir = await window.quiltDesktop.pickFolder(input.value)
|
|
338
|
+
if (dir) { input.value = dir; input.dispatchEvent(new Event('input', { bubbles: true })) }
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
const back = document.createElement('div')
|
|
342
|
+
back.className = 'modal-back'
|
|
343
|
+
back.innerHTML = `<div class="card modal" role="dialog" aria-modal="true" aria-labelledby="pick-title">
|
|
344
|
+
<h3 id="pick-title">Choose a folder</h3>
|
|
345
|
+
<p class="lead">Pick the project folder, or type a path. New folders are created for you.</p>
|
|
346
|
+
<div class="row"><button class="btn icon" id="pick-up" title="Up" aria-label="Up">${I.up}</button><input class="input grow mono" id="pick-path"><button class="btn" id="pick-go">Go</button></div>
|
|
347
|
+
<div class="dirlist" id="pick-list"></div>
|
|
348
|
+
<div class="hint" id="pick-note"></div>
|
|
349
|
+
<div class="actions"><button class="btn ghost" id="pick-cancel">Cancel</button><button class="btn primary" id="pick-use">Use this folder</button></div>
|
|
350
|
+
</div>`
|
|
351
|
+
document.body.appendChild(back)
|
|
352
|
+
let done
|
|
353
|
+
const closed = new Promise((resolve) => { done = resolve })
|
|
354
|
+
const close = () => { back.remove(); done() }
|
|
355
|
+
let current = null
|
|
356
|
+
const load = async (p) => {
|
|
357
|
+
try {
|
|
358
|
+
const d = await api('GET', `/api/fs?path=${encodeURIComponent(p)}`)
|
|
359
|
+
current = d
|
|
360
|
+
$('#pick-path', back).value = d.path
|
|
361
|
+
$('#pick-up', back).disabled = !d.parent
|
|
362
|
+
$('#pick-list', back).innerHTML = d.dirs.length
|
|
363
|
+
? d.dirs.map((n) => `<button data-dir="${esc(n)}">${I.folder}<span>${esc(n)}</span></button>`).join('')
|
|
364
|
+
: '<div class="empty-note" style="padding:14px">No subfolders</div>'
|
|
365
|
+
$('#pick-note', back).textContent = d.hasSession ? 'This folder has been used with Quilt before.' : d.isEmpty ? 'This folder is empty.' : ''
|
|
366
|
+
$('#pick-list', back).querySelectorAll('[data-dir]').forEach((b) => {
|
|
367
|
+
b.onclick = () => load(`${d.path.replace(/[\\/]$/, '')}/${b.dataset.dir}`)
|
|
368
|
+
})
|
|
369
|
+
} catch (err) {
|
|
370
|
+
$('#pick-note', back).textContent = err.message
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
$('#pick-up', back).onclick = () => current?.parent && load(current.parent)
|
|
374
|
+
$('#pick-go', back).onclick = () => load($('#pick-path', back).value)
|
|
375
|
+
$('#pick-path', back).onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); load(e.target.value) } }
|
|
376
|
+
$('#pick-cancel', back).onclick = close
|
|
377
|
+
$('#pick-use', back).onclick = () => { input.value = $('#pick-path', back).value; close() }
|
|
378
|
+
back.onclick = (e) => { if (e.target === back) close() }
|
|
379
|
+
back.onkeydown = (e) => { if (e.key === 'Escape') close() }
|
|
380
|
+
await load(input.value || state.defaults.home)
|
|
381
|
+
$('#pick-path', back).focus()
|
|
382
|
+
return closed
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
// --------------------------------------------------------------- invite --
|
|
387
|
+
export function openInvite (id) {
|
|
388
|
+
const s = state.sessions.get(id)
|
|
389
|
+
if (!s) return
|
|
390
|
+
const d = decodeInvite(s.invite)
|
|
391
|
+
// The owner of a session with approvals can invite people as an access type.
|
|
392
|
+
const owner = !!(s.viewInvite && s.status.access?.owner)
|
|
393
|
+
const back = document.createElement('div')
|
|
394
|
+
back.className = 'modal-back'
|
|
395
|
+
back.innerHTML = `<div class="card modal" role="dialog" aria-modal="true" aria-labelledby="inv-title">
|
|
396
|
+
<h3 id="inv-title">Invite someone</h3>
|
|
397
|
+
<p class="lead">Send them a link. Clicking it opens the session in Quilt (after signing in). It also works with <code>quilt join <link></code>.</p>
|
|
398
|
+
<div class="label" style="margin-bottom:6px">${s.viewInvite ? 'Can edit' : 'Invite link'}</div>
|
|
399
|
+
<div class="codebox"><code id="inv-code">${esc(s.invite)}</code><button class="btn icon" data-copy="inv-code" title="Copy" aria-label="Copy invite link">${I.copy}</button></div>
|
|
400
|
+
${s.viewInvite ? `<div class="label" style="margin-bottom:6px">View only</div>
|
|
401
|
+
<div class="codebox"><code id="inv-view">${esc(s.viewInvite)}</code><button class="btn icon" data-copy="inv-view" title="Copy" aria-label="Copy view-only link">${I.copy}</button></div>` : ''}
|
|
402
|
+
${d ? `<p class="hint">Room <code>${esc(d.room)}</code> via <code>${esc(d.server)}</code></p>` : ''}
|
|
403
|
+
<p class="hint">${s.viewInvite ? 'Everyone who uses a link waits until you let them in, and you can change what they may do later in Session settings.' : 'Anyone with this link can edit the project. Only share it with people you trust.'}</p>
|
|
404
|
+
${owner ? `<div class="inv-section" id="inv-access">
|
|
405
|
+
<div class="label inv-agent-label">Invite as</div>
|
|
406
|
+
<select class="input" id="inv-type" aria-label="Invite as"></select>
|
|
407
|
+
<p class="hint">People you invite here get straight in with this access once they sign in. No waiting for you.</p>
|
|
408
|
+
<div class="label inv-sub">People you've worked with</div>
|
|
409
|
+
<div class="inv-list" id="inv-people"><p class="hint">${loadingNote()}</p></div>
|
|
410
|
+
<div class="label inv-sub">Invite by email</div>
|
|
411
|
+
<form class="row" id="inv-email-form">
|
|
412
|
+
<input class="input grow" type="email" id="inv-email" placeholder="name@example.com" aria-label="Email address" required>
|
|
413
|
+
<button class="btn primary" type="submit">Send invite</button>
|
|
414
|
+
</form>
|
|
415
|
+
<div class="label inv-sub">Pending invites</div>
|
|
416
|
+
<div class="inv-list" id="inv-pending"></div>
|
|
417
|
+
<p class="error" id="inv-error"></p>
|
|
418
|
+
</div>` : ''}
|
|
419
|
+
<div class="label inv-agent-label">Your AI</div>
|
|
420
|
+
<div id="inv-agent" class="inv-agent">
|
|
421
|
+
<p class="hint">An AI that already has the Quilt command just needs the link above: tell it "Join my Quilt session: <link>". To add an AI that isn't registered with Quilt yet, make it an agent invite and paste the text into it: it carries the link above too. It joins as your own agent, listed in Settings.</p>
|
|
422
|
+
<button class="btn" type="button" id="inv-agent-make"${state.workspacesOn ? ' aria-haspopup="menu" aria-expanded="false"' : ''}>${I.bot}<span>Invite an AI agent</span></button>
|
|
423
|
+
<p class="error" id="inv-agent-error"></p>
|
|
424
|
+
</div>
|
|
425
|
+
${s.status.access?.owner || s.status.access?.canAdmit ? `<div class="label inv-agent-label">A chat AI</div>
|
|
426
|
+
<div id="inv-chat" class="inv-agent">
|
|
427
|
+
<p class="hint">For ChatGPT, claude.ai, Grok and other AIs you use in a chat window: make a chat link and paste it into the chat. It can read and send messages, add, assign, move and comment on tasks, read files, and add pictures, documents and notes. It can't change existing files. It shows up in the session as its own member and works for 10 minutes: extend it in Session settings while it works, or make a new one after.</p>
|
|
428
|
+
<button class="btn" type="button" id="inv-chat-make">${I.link}<span>Make a chat link</span></button>
|
|
429
|
+
<p class="error" id="inv-chat-error"></p>
|
|
430
|
+
</div>` : ''}
|
|
431
|
+
<div class="actions"><button class="btn primary" id="inv-done">Done</button></div>
|
|
432
|
+
</div>`
|
|
433
|
+
document.body.appendChild(back)
|
|
434
|
+
const close = () => back.remove()
|
|
435
|
+
back.addEventListener('click', async (e) => {
|
|
436
|
+
const b = e.target.closest('[data-copy]')
|
|
437
|
+
if (!b) return
|
|
438
|
+
const el = $(`#${b.dataset.copy}`, back)
|
|
439
|
+
try { await navigator.clipboard.writeText(el.textContent); toast('Copied') } catch {
|
|
440
|
+
const r = document.createRange(); r.selectNodeContents(el)
|
|
441
|
+
getSelection().removeAllRanges(); getSelection().addRange(r); toast('Press ⌘/Ctrl+C to copy')
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
$('#inv-agent-make', back).onclick = async () => {
|
|
445
|
+
const btn = $('#inv-agent-make', back)
|
|
446
|
+
// With workspaces on: pick a global, workspace or session agent (this session first).
|
|
447
|
+
if (state.workspacesOn) {
|
|
448
|
+
return openKindMenu(btn, {
|
|
449
|
+
sessionId: id,
|
|
450
|
+
workspaceId: s.workspace || '',
|
|
451
|
+
onInvite: ({ kind, text, where }) => { $('#inv-agent', back).innerHTML = agentInviteHtml(text, 'inv-agent-text', kindLineHtml(kind, where)) }
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
btn.disabled = true
|
|
455
|
+
try {
|
|
456
|
+
const inv = await api('POST', '/api/agent-invites')
|
|
457
|
+
$('#inv-agent', back).innerHTML = agentInviteHtml(agentPaste({ link: inv.link, session: s.invite }), 'inv-agent-text')
|
|
458
|
+
} catch (err) {
|
|
459
|
+
btn.disabled = false
|
|
460
|
+
$('#inv-agent-error', back).textContent = err.message
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const chatMake = $('#inv-chat-make', back)
|
|
464
|
+
if (chatMake) {
|
|
465
|
+
chatMake.onclick = async () => {
|
|
466
|
+
chatMake.disabled = true
|
|
467
|
+
try {
|
|
468
|
+
const r = await api('POST', `/api/sessions/${id}/chat-link`, {})
|
|
469
|
+
const until = new Date(r.expiresAt).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
|
|
470
|
+
$('#inv-chat', back).innerHTML = `<p class="hint">Paste this into the chat. It works until ${esc(until)} (extend it in Session settings) and shows in the session as <b>${esc(r.name)}</b>. Anyone with it can act as ${esc(r.name)}, so only paste it there.</p>
|
|
471
|
+
<div class="codebox"><code id="inv-chat-text">${esc(`Open this link and follow it to join our Quilt session: ${r.url}`)}</code><button class="btn icon" data-copy="inv-chat-text" title="Copy" aria-label="Copy chat link">${I.copy}</button></div>`
|
|
472
|
+
} catch (err) {
|
|
473
|
+
chatMake.disabled = false
|
|
474
|
+
$('#inv-chat-error', back).textContent = err.message
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
$('#inv-done', back).onclick = close
|
|
479
|
+
back.onclick = (e) => { if (e.target === back) close() }
|
|
480
|
+
if (owner) bindInviteAs(id, back)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** The owner's invite panel: "Invite as" a type, people you've worked with, by email, and pending invites. */
|
|
484
|
+
function bindInviteAs (id, back) {
|
|
485
|
+
const error = (msg) => { $('#inv-error', back).textContent = msg || '' }
|
|
486
|
+
const typeId = () => $('#inv-type', back).value
|
|
487
|
+
const invite = async (to, done) => {
|
|
488
|
+
error('')
|
|
489
|
+
try {
|
|
490
|
+
await api('POST', `/api/sessions/${id}/invites`, { typeId: typeId(), to })
|
|
491
|
+
toast(done)
|
|
492
|
+
await pending()
|
|
493
|
+
} catch (err) { error(err.message) }
|
|
494
|
+
}
|
|
495
|
+
async function pending () {
|
|
496
|
+
const el = $('#inv-pending', back)
|
|
497
|
+
try {
|
|
498
|
+
const open = (await api('GET', `/api/sessions/${id}/invites`)).invites.filter((i) => i.status === 'waiting')
|
|
499
|
+
el.innerHTML = open.length
|
|
500
|
+
? open.map((i) => `<div class="inv-row"><span class="grow">${esc(i.email || i.name)}<span class="hint"> · ${esc(i.typeName)}</span></span><button class="btn sm ghost" type="button" data-cancel-invite="${esc(i.id)}">Cancel</button></div>`).join('')
|
|
501
|
+
: '<p class="hint">No pending invites.</p>'
|
|
502
|
+
} catch (err) { el.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
|
|
503
|
+
}
|
|
504
|
+
async function people () {
|
|
505
|
+
const el = $('#inv-people', back)
|
|
506
|
+
try {
|
|
507
|
+
const list = (await api('GET', '/api/collaborators')).collaborators
|
|
508
|
+
el.innerHTML = list.length
|
|
509
|
+
? list.map((c) => `<div class="inv-row">${avatar(c.name, null)}<span class="grow">${esc(c.name)}${c.kind === 'agent' ? `<span class="tag bot">${I.bot}agent</span>` : ''}</span><button class="btn sm" type="button" data-invite-account="${esc(c.account)}" data-name="${esc(c.name)}">Invite</button></div>`).join('')
|
|
510
|
+
: "<p class=\"hint\">Nobody yet. People and agents you've been in a session with show up here.</p>"
|
|
511
|
+
} catch (err) { el.innerHTML = `<p class="hint">${esc(err.message)}</p>` }
|
|
512
|
+
}
|
|
513
|
+
;(state.accessTypes ? Promise.resolve(state.accessTypes) : loadAccessTypes()).then((types) => {
|
|
514
|
+
const sel = $('#inv-type', back)
|
|
515
|
+
if (types) sel.innerHTML = typeOptions()
|
|
516
|
+
else { sel.innerHTML = '<option value="builtin:edit">Can edit</option><option value="builtin:view">View only</option>' }
|
|
517
|
+
})
|
|
518
|
+
people()
|
|
519
|
+
pending()
|
|
520
|
+
back.addEventListener('click', async (e) => {
|
|
521
|
+
const inv = e.target.closest('[data-invite-account]')
|
|
522
|
+
if (inv) { inv.disabled = true; await invite({ account: inv.dataset.inviteAccount }, `Invited ${inv.dataset.name}`); inv.disabled = false; return }
|
|
523
|
+
const cancel = e.target.closest('[data-cancel-invite]')
|
|
524
|
+
if (cancel) {
|
|
525
|
+
try { await api('POST', `/api/sessions/${id}/invites/cancel`, { inviteId: cancel.dataset.cancelInvite }); toast('Invite cancelled'); await pending() } catch (err) { error(err.message) }
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
$('#inv-email-form', back).onsubmit = async (e) => {
|
|
529
|
+
e.preventDefault()
|
|
530
|
+
const input = $('#inv-email', back)
|
|
531
|
+
await invite({ email: input.value.trim() }, 'Invite sent')
|
|
532
|
+
if (!$('#inv-error', back).textContent) input.value = ''
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** A fresh agent invite, as the text to paste into an AI, with Copy; `kind` (workspaces on) says which kind of agent it makes. */
|
|
537
|
+
export function agentInviteHtml (text, id, kind = '') {
|
|
538
|
+
return `${kind}<p class="hint"><b>Paste this into your AI.</b> It works once, within an hour. Anyone with it can join as your agent, so only give it to your own AI.</p>
|
|
539
|
+
<div class="codebox"><code id="${id}" class="paste">${esc(text)}</code><button class="btn icon" data-copy="${id}" title="Copy" aria-label="Copy agent invite">${I.copy}</button></div>`
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
boot()
|