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/common.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Shared state and helpers for the Quilt app (native ES modules, no build step).
|
|
2
|
+
import { quiltMark } from './mark.js'
|
|
3
|
+
import { parseInvite } from './invite.js'
|
|
4
|
+
|
|
5
|
+
// The app only ever talks to Quilt's own relay, so an old-style relay link that happens
|
|
6
|
+
// to be this address resolves the same way a join.heyquilt.com link does.
|
|
7
|
+
const HOSTED_RELAY = 'wss://relay.heyquilt.com'
|
|
8
|
+
// Same list as settings.js: the hosted relay under its current and older address.
|
|
9
|
+
const HOSTED_ALIASES = [HOSTED_RELAY, 'wss://cowove-relay.fly.dev']
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------- token --
|
|
12
|
+
const params = new URLSearchParams(location.search)
|
|
13
|
+
if (params.get('t')) {
|
|
14
|
+
try { sessionStorage.setItem('quilt-token', params.get('t')) } catch {}
|
|
15
|
+
history.replaceState(null, '', '/')
|
|
16
|
+
}
|
|
17
|
+
export let TOKEN = params.get('t')
|
|
18
|
+
try { TOKEN = TOKEN || sessionStorage.getItem('quilt-token') } catch {}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------- icons --
|
|
21
|
+
export const I = {
|
|
22
|
+
github: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.4 5.4 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/><path d="M9 18c-4.51 2-5-2-7-2"/></svg>',
|
|
23
|
+
folder: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
|
|
24
|
+
clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
|
|
25
|
+
send: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4z"/></svg>',
|
|
26
|
+
copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
|
|
27
|
+
plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
|
|
28
|
+
x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
|
|
29
|
+
up: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>',
|
|
30
|
+
file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>',
|
|
31
|
+
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
|
32
|
+
power: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/></svg>',
|
|
33
|
+
bot: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="3"/><path d="M12 8V4"/><circle cx="12" cy="3" r="1"/><path d="M9 14h.01M15 14h.01"/></svg>',
|
|
34
|
+
users: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/></svg>',
|
|
35
|
+
tree: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h6M3 12h18M3 19h18M13 5h8"/></svg>',
|
|
36
|
+
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
|
37
|
+
menu: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
|
|
38
|
+
more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
|
|
39
|
+
chevDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>',
|
|
40
|
+
caret: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>',
|
|
41
|
+
sparkle: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.9 5.6L19.5 9.5l-5.6 1.9L12 17l-1.9-5.6L4.5 9.5l5.6-1.9z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/></svg>',
|
|
42
|
+
// A box: someone's unfinished work on a file (boxes.js). Unpack: the same box, opened upwards.
|
|
43
|
+
box: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="m3 8 9 5 9-5"/><path d="M12 13v8"/></svg>',
|
|
44
|
+
unpack: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8 12 3 3 8v8l9 5 9-5z"/><path d="M12 13V7"/><path d="m9 10 3-3 3 3"/></svg>',
|
|
45
|
+
lock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>',
|
|
46
|
+
down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>',
|
|
47
|
+
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
|
48
|
+
home: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 10 9-7 9 7v10a2 2 0 0 1-2 2h-4v-7H9v7H5a2 2 0 0 1-2-2z"/></svg>',
|
|
49
|
+
gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>',
|
|
50
|
+
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
|
|
51
|
+
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/></svg>',
|
|
52
|
+
diff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v14"/><path d="M5 10h14"/><path d="M5 21h14"/></svg>',
|
|
53
|
+
branch: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10M18 9a6 6 0 0 1-6 6H6"/></svg>',
|
|
54
|
+
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>',
|
|
55
|
+
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>',
|
|
56
|
+
board: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="7.5"/><path d="m8.4 12.1 2.4 2.4 4.8-5"/></svg>',
|
|
57
|
+
pencil: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
|
|
58
|
+
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
59
|
+
repeat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9 9 0 0 0-8 5"/><path d="M3 4v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9 9 0 0 0 8-5"/><path d="M21 20v-5h-5"/></svg>',
|
|
60
|
+
arrowRight: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>',
|
|
61
|
+
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.9l10.4-6.5a1 1 0 0 0 0-1.8L9.5 4.6A1 1 0 0 0 8 5.5z"/></svg>',
|
|
62
|
+
upload: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>',
|
|
63
|
+
chevrons: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>',
|
|
64
|
+
arrowLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m11 18-6-6 6-6"/></svg>',
|
|
65
|
+
grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="3.5" width="7" height="7" rx="1.5"/><rect x="3.5" y="13.5" width="7" height="7" rx="1.5"/><rect x="13.5" y="13.5" width="7" height="7" rx="1.5"/></svg>',
|
|
66
|
+
external: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6"/><path d="M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/></svg>'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const TOOLS = ['Claude Code', 'Cursor', 'Codex', 'xAI', 'Windsurf', 'GitHub Copilot', 'Zed', 'Aider', 'Other']
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------- state --
|
|
72
|
+
export const state = {
|
|
73
|
+
loaded: false,
|
|
74
|
+
account: null, // { id, name, email }: who this computer is signed in as
|
|
75
|
+
sessions: new Map(), // id -> summary
|
|
76
|
+
messages: new Map(), // id -> [message]
|
|
77
|
+
recent: [],
|
|
78
|
+
defaults: {},
|
|
79
|
+
profile: {}, // name, tool, color, joinDir, shareAgent, summarize, preferLocal
|
|
80
|
+
maxFileBytes: 0,
|
|
81
|
+
view: 'home', // 'home' | session id
|
|
82
|
+
pane: 'chat', // mobile pane
|
|
83
|
+
to: '', // chat recipient ('' = everyone)
|
|
84
|
+
pending: [], // files queued in the composer
|
|
85
|
+
error: null,
|
|
86
|
+
feeds: new Map(), // session id -> Map(person -> entries[])
|
|
87
|
+
trees: new Map(), // session id -> { files, claims }
|
|
88
|
+
files: new Map(), // `${id}\n${path}` -> file contents from /file
|
|
89
|
+
ws: new Map(), // session id -> workspace layout (mode, tabs, expanded folders)
|
|
90
|
+
accessTypes: null, // the account's access types (built-ins first), once loaded; null if the API can't be reached
|
|
91
|
+
workspaces: null, // [] once /api/workspaces answered; null before
|
|
92
|
+
orgs: [], // the account's orgs ({ slug, name }), for the Add workspace form and the filter
|
|
93
|
+
addingWorkspace: false, // the Add workspace card is showing its form
|
|
94
|
+
workspacesOn: false, // the accounts API has workspaces on
|
|
95
|
+
workspace: null, // the open workspace page's data
|
|
96
|
+
spaceFilter: 'all', // the home grid's Personal / org switch
|
|
97
|
+
filesView: { folder: '', picked: null, mode: 'tiles' }, // the All files view: folder shown, file previewed, tiles or list
|
|
98
|
+
pollTimer: null // refreshes the open workspace (ws: or wsfiles:) every 20 seconds
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -------------------------------------------------------------- helpers --
|
|
102
|
+
export const $ = (sel, root = document) => root.querySelector(sel)
|
|
103
|
+
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
104
|
+
export const basename = (p) => String(p).split(/[\\/]/).filter(Boolean).pop() || p
|
|
105
|
+
export const bytes = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(1)} GB`
|
|
106
|
+
export const ago = (ts) => {
|
|
107
|
+
const s = Math.max(0, Math.round((Date.now() - ts) / 1000))
|
|
108
|
+
if (s < 45) return 'now'
|
|
109
|
+
if (s < 3600) return `${Math.round(s / 60)}m`
|
|
110
|
+
if (s < 86400) return `${Math.round(s / 3600)}h`
|
|
111
|
+
return new Date(ts).toLocaleDateString()
|
|
112
|
+
}
|
|
113
|
+
export const clock = (ts) => new Date(ts).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
114
|
+
/** Git for this session (git for everyone): the toast that confirms a pick in its control (git-rule.js). */
|
|
115
|
+
export const AGENT_COMMIT_WORDS = { off: 'Git is off for everyone here', branches: 'Git is on for everyone here: agents push to branches of their own, with pull requests', any: 'Git is on for everyone here: agents may push to any branch' }
|
|
116
|
+
|
|
117
|
+
// People's colours. None sits on the vermilion accent, so nobody's lane looks live or alarming; src/session.js keeps the same list.
|
|
118
|
+
export const PALETTE = ['#4f4fa0', '#3b6a9a', '#4a7a45', '#855a9c', '#a8701c', '#2e7a80', '#9c4f6b']
|
|
119
|
+
export const colorFor = (name, given) => given || PALETTE[Math.abs([...String(name)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)) % PALETTE.length]
|
|
120
|
+
/** A workspace's colours (its cover, its dot in the sidebar). */
|
|
121
|
+
export const COLORS = { lilac: '#d9c6ea', mint: '#cfe6d4', peach: '#f6dcc0', rose: '#f3d3d0', periwinkle: '#e0dcf0', sky: '#cfe0ee' }
|
|
122
|
+
|
|
123
|
+
/** `online`: true (a live connection) or 'http' (an agent over HTTP that checked in lately: a blue dot). */
|
|
124
|
+
export const avatar = (name, color, online = false) =>
|
|
125
|
+
`<div class="avatar${online ? ' online' : ''}${online === 'http' ? ' http' : ''}${online === 'away' ? ' away' : ''}" style="background:${esc(colorFor(name, color))}">${esc(String(name || '?').slice(0, 1))}</div>`
|
|
126
|
+
|
|
127
|
+
export function toast (msg) {
|
|
128
|
+
const t = $('#toast')
|
|
129
|
+
t.textContent = msg
|
|
130
|
+
t.classList.add('show')
|
|
131
|
+
clearTimeout(toast.timer)
|
|
132
|
+
toast.timer = setTimeout(() => t.classList.remove('show'), 2400)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* An in-app replacement for confirm()/prompt(): browser dialogs are blocked in
|
|
137
|
+
* some embedded views (a preview pane answers "no" without showing anything).
|
|
138
|
+
* Resolves true/false, or with `input` the trimmed text (null when cancelled).
|
|
139
|
+
* `input.select` ([{ value, label }]) shows a dropdown instead and resolves with the chosen value.
|
|
140
|
+
*/
|
|
141
|
+
export function ask ({ title, message = '', ok = 'OK', danger = false, input = null }) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
const back = document.createElement('div')
|
|
144
|
+
back.className = 'modal-back ask-back' // above the Settings pop-up, which can ask too
|
|
145
|
+
back.innerHTML = `<form class="card modal" role="dialog" aria-modal="true" aria-labelledby="ask-title" autocomplete="off">
|
|
146
|
+
<h3 id="ask-title">${esc(title)}</h3>
|
|
147
|
+
${message ? `<p class="lead">${esc(message)}</p>` : ''}
|
|
148
|
+
${input ? `<div class="field"><label for="ask-input">${esc(input.label || '')}</label>${Array.isArray(input.select)
|
|
149
|
+
? `<select class="input" id="ask-input">${input.select.map((o) => `<option value="${esc(o.value)}" ${o.value === input.value ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>`
|
|
150
|
+
: `<input class="input" id="ask-input" placeholder="${esc(input.placeholder || '')}" value="${esc(input.value || '')}">`}</div>` : ''}
|
|
151
|
+
<div class="actions"><button type="button" class="btn" data-no>Cancel</button><button type="submit" class="btn ${danger ? 'danger' : 'primary'}">${esc(ok)}</button></div>
|
|
152
|
+
</form>`
|
|
153
|
+
document.body.appendChild(back)
|
|
154
|
+
const form = back.querySelector('form')
|
|
155
|
+
const field = back.querySelector('#ask-input')
|
|
156
|
+
const done = (v) => { back.remove(); resolve(v) }
|
|
157
|
+
const cancel = () => done(input ? null : false)
|
|
158
|
+
back.addEventListener('mousedown', (e) => { if (e.target === back) cancel() })
|
|
159
|
+
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') cancel() })
|
|
160
|
+
back.querySelector('[data-no]').onclick = cancel
|
|
161
|
+
form.onsubmit = (e) => {
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
if (!input) return done(true)
|
|
164
|
+
const v = field.value.trim()
|
|
165
|
+
if (v) done(v)
|
|
166
|
+
else field.focus()
|
|
167
|
+
}
|
|
168
|
+
;(field || back.querySelector('[type=submit]')).focus()
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function api (method, path, body, headers = {}) {
|
|
173
|
+
const res = await fetch(path, {
|
|
174
|
+
method,
|
|
175
|
+
headers: { 'x-quilt-token': TOKEN || '', ...(body && !(body instanceof Blob) ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
176
|
+
body: body instanceof Blob ? body : body ? JSON.stringify(body) : undefined
|
|
177
|
+
})
|
|
178
|
+
const data = await res.json().catch(() => ({}))
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const err = new Error(data.error || `Request failed (${res.status})`)
|
|
181
|
+
// This computer isn't signed in (any more): the app goes back to the sign-in screen.
|
|
182
|
+
if (res.status === 401 && data.signedOut) {
|
|
183
|
+
err.signedOut = true
|
|
184
|
+
window.dispatchEvent(new Event('quilt-signed-out'))
|
|
185
|
+
}
|
|
186
|
+
throw err
|
|
187
|
+
}
|
|
188
|
+
return data
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ------------------------------------------------------------ access types --
|
|
192
|
+
export const NO_POSTING = "You can't post in this session."
|
|
193
|
+
export const ACCOUNT_KEY = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
|
|
194
|
+
|
|
195
|
+
/** Loads this account's access types into state.accessTypes (null when the accounts API can't be reached). */
|
|
196
|
+
export async function loadAccessTypes () {
|
|
197
|
+
try { state.accessTypes = (await api('GET', '/api/access-types')).types } catch { state.accessTypes = null }
|
|
198
|
+
return state.accessTypes
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** <option>s for an access type picker, "Can edit" selected unless `selected` says otherwise. */
|
|
202
|
+
export function typeOptions (selected = 'builtin:edit') {
|
|
203
|
+
return (state.accessTypes || []).map((t) => `<option value="${esc(t.id)}" ${t.id === selected ? 'selected' : ''}>${esc(t.name)}</option>`).join('')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** "Can edit · src · except src/keys · no posting": what someone's access comes to, from the relay's member list. */
|
|
207
|
+
export function accessLine (m) {
|
|
208
|
+
if (m.role === 'viewer') return m.talk === false ? 'View only · no posting' : 'View only'
|
|
209
|
+
const parts = ['Can edit', m.scopes && m.scopes.length ? m.scopes.join(', ') : 'all folders']
|
|
210
|
+
if (m.scopesExcept && m.scopesExcept.length) parts.push(`except ${m.scopesExcept.join(', ')}`)
|
|
211
|
+
if (m.talk === false) parts.push('no posting')
|
|
212
|
+
return parts.join(' · ')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ------------------------------------------------------------- reports --
|
|
216
|
+
// Errors nothing caught (a bug in the page) go to the app, which tells Quilt. The same
|
|
217
|
+
// message within ten seconds is sent once. api() failures are not sent from here: the
|
|
218
|
+
// handler that failed already recorded them.
|
|
219
|
+
const recentReports = new Map()
|
|
220
|
+
function reportRendererError (message) {
|
|
221
|
+
const text = String(message || '').slice(0, 500)
|
|
222
|
+
if (!text) return
|
|
223
|
+
const t = Date.now()
|
|
224
|
+
if (recentReports.get(text) > t - 10_000) return
|
|
225
|
+
recentReports.set(text, t)
|
|
226
|
+
for (const [k, v] of recentReports) if (v < t - 60_000) recentReports.delete(k)
|
|
227
|
+
fetch('/api/report', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'x-quilt-token': TOKEN || '', 'content-type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({ name: 'renderer', message: text, context: { view: String(state.view || '') } })
|
|
231
|
+
}).catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
window.addEventListener('error', (e) => reportRendererError(e.message || (e.error && e.error.message) || ''))
|
|
234
|
+
window.addEventListener('unhandledrejection', (e) => reportRendererError((e.reason && e.reason.message) || String(e.reason || '')))
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Same as decodeInvite in runner.js (room and relay only): an invite link, or an older base64 code.
|
|
238
|
+
* A link naming its own relay must name Quilt's relay or the one this app uses (state.defaults.relay).
|
|
239
|
+
*/
|
|
240
|
+
export function decodeInvite (code) {
|
|
241
|
+
const allowRelay = (s) => HOSTED_ALIASES.includes(String(s).replace(/\/+$/, '')) || (!!state.defaults.relay && s === state.defaults.relay)
|
|
242
|
+
try {
|
|
243
|
+
const r = parseInvite(code, { allowRelay })
|
|
244
|
+
return { server: r.relay || HOSTED_RELAY, room: r.room }
|
|
245
|
+
} catch {
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function remember (key, value) { try { localStorage.setItem(`quilt-${key}`, value) } catch {} }
|
|
251
|
+
export function recall (key, fallback = '') { try { return localStorage.getItem(`quilt-${key}`) ?? fallback } catch { return fallback } }
|
|
252
|
+
|
|
253
|
+
/** Whose AI is still working, from a session status (for commit timing). */
|
|
254
|
+
export function busyPeople (st, { includeMe = true } = {}) {
|
|
255
|
+
const people = [...(includeMe ? [{ ...st.me, isMe: true }] : []), ...st.peers]
|
|
256
|
+
const out = []
|
|
257
|
+
for (const p of people) {
|
|
258
|
+
const a = p.agent
|
|
259
|
+
if (a && a.sharing !== false && a.status === 'working') out.push(`${p.isMe ? 'your' : `${p.name}'s`} ${a.tool || 'AI'}`)
|
|
260
|
+
else if (p.work && p.work.state === 'working') out.push(p.isMe ? 'you' : p.name)
|
|
261
|
+
}
|
|
262
|
+
return out
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Tools someone is using, for badges. */
|
|
266
|
+
export const toolsOf = (p) => [...new Set([p.tool, ...(p.agents || [])].filter((t) => t && t !== 'unknown'))]
|
|
267
|
+
|
|
268
|
+
// Native <select> menus look out of place, so every select gets a styled button
|
|
269
|
+
// and menu. The real select stays (hidden) in the form, so .value, form data
|
|
270
|
+
// and change events work exactly as before.
|
|
271
|
+
let openMenu = null
|
|
272
|
+
function closeMenu (refocus) {
|
|
273
|
+
if (!openMenu) return
|
|
274
|
+
const { menu, btn } = openMenu
|
|
275
|
+
openMenu = null
|
|
276
|
+
menu.remove()
|
|
277
|
+
btn.setAttribute('aria-expanded', 'false')
|
|
278
|
+
if (refocus) btn.focus()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function syncDropdown (sel, btn) {
|
|
282
|
+
const o = sel.options[sel.selectedIndex]
|
|
283
|
+
btn.querySelector('.dd-label').textContent = o ? o.textContent : ''
|
|
284
|
+
btn.disabled = sel.disabled
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function openDropdown (sel, btn) {
|
|
288
|
+
closeMenu()
|
|
289
|
+
const menu = document.createElement('div')
|
|
290
|
+
menu.className = 'dd-menu'
|
|
291
|
+
menu.setAttribute('role', 'listbox')
|
|
292
|
+
;[...sel.options].forEach((o, i) => {
|
|
293
|
+
const item = document.createElement('div')
|
|
294
|
+
item.className = 'dd-item' + (i === sel.selectedIndex ? ' on' : '')
|
|
295
|
+
item.setAttribute('role', 'option')
|
|
296
|
+
item.setAttribute('aria-selected', String(i === sel.selectedIndex))
|
|
297
|
+
if (o.disabled) item.setAttribute('aria-disabled', 'true')
|
|
298
|
+
item.dataset.i = i
|
|
299
|
+
item.innerHTML = `<span>${esc(o.textContent)}</span>${I.check || ''}`
|
|
300
|
+
menu.append(item)
|
|
301
|
+
})
|
|
302
|
+
document.body.append(menu)
|
|
303
|
+
const r = btn.getBoundingClientRect()
|
|
304
|
+
menu.style.minWidth = `${r.width}px`
|
|
305
|
+
const below = window.innerHeight - r.bottom
|
|
306
|
+
const h = Math.min(menu.scrollHeight, 280)
|
|
307
|
+
menu.style.left = `${Math.min(r.left, window.innerWidth - menu.offsetWidth - 8)}px`
|
|
308
|
+
menu.style.top = below < h + 12 && r.top > below ? `${r.top - h - 4}px` : `${r.bottom + 4}px`
|
|
309
|
+
btn.setAttribute('aria-expanded', 'true')
|
|
310
|
+
openMenu = { menu, btn, sel, active: Math.max(0, sel.selectedIndex) }
|
|
311
|
+
highlight(openMenu.active)
|
|
312
|
+
|
|
313
|
+
menu.addEventListener('mousedown', (e) => e.preventDefault())
|
|
314
|
+
menu.addEventListener('click', (e) => {
|
|
315
|
+
const item = e.target.closest('.dd-item')
|
|
316
|
+
if (item && item.getAttribute('aria-disabled') !== 'true') choose(Number(item.dataset.i))
|
|
317
|
+
})
|
|
318
|
+
menu.addEventListener('mousemove', (e) => {
|
|
319
|
+
const item = e.target.closest('.dd-item')
|
|
320
|
+
if (item) highlight(Number(item.dataset.i), false)
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function highlight (i, scroll = true) {
|
|
325
|
+
if (!openMenu) return
|
|
326
|
+
const items = openMenu.menu.children
|
|
327
|
+
if (!items.length) return
|
|
328
|
+
openMenu.active = Math.max(0, Math.min(items.length - 1, i))
|
|
329
|
+
;[...items].forEach((x, j) => x.classList.toggle('active', j === openMenu.active))
|
|
330
|
+
if (!scroll) return
|
|
331
|
+
// Scroll only the menu: scrollIntoView can scroll the page, which closes the menu.
|
|
332
|
+
const { menu } = openMenu
|
|
333
|
+
const it = items[openMenu.active]
|
|
334
|
+
if (it.offsetTop < menu.scrollTop) menu.scrollTop = it.offsetTop
|
|
335
|
+
else if (it.offsetTop + it.offsetHeight > menu.scrollTop + menu.clientHeight) menu.scrollTop = it.offsetTop + it.offsetHeight - menu.clientHeight
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function choose (i) {
|
|
339
|
+
const { sel } = openMenu
|
|
340
|
+
closeMenu(true)
|
|
341
|
+
if (i === sel.selectedIndex || sel.options[i]?.disabled) return
|
|
342
|
+
sel.selectedIndex = i
|
|
343
|
+
sel.dispatchEvent(new Event('input', { bubbles: true }))
|
|
344
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function enhanceSelect (sel) {
|
|
348
|
+
if (sel.dataset.dd) return
|
|
349
|
+
sel.dataset.dd = '1'
|
|
350
|
+
const btn = document.createElement('button')
|
|
351
|
+
btn.type = 'button'
|
|
352
|
+
btn.className = `dd-btn ${sel.className}`.trim()
|
|
353
|
+
btn.setAttribute('aria-haspopup', 'listbox')
|
|
354
|
+
btn.setAttribute('aria-expanded', 'false')
|
|
355
|
+
const label = sel.getAttribute('aria-label') || (sel.id && document.querySelector(`label[for="${CSS.escape(sel.id)}"]`)?.textContent)
|
|
356
|
+
if (label) btn.setAttribute('aria-label', label)
|
|
357
|
+
btn.innerHTML = `<span class="dd-label"></span>${I.chevDown}`
|
|
358
|
+
sel.classList.add('dd-native')
|
|
359
|
+
sel.tabIndex = -1
|
|
360
|
+
sel.setAttribute('aria-hidden', 'true')
|
|
361
|
+
sel.after(btn)
|
|
362
|
+
syncDropdown(sel, btn)
|
|
363
|
+
|
|
364
|
+
// A <label for> click focuses the hidden select; send it to the button.
|
|
365
|
+
sel.addEventListener('focus', () => btn.focus())
|
|
366
|
+
sel.addEventListener('change', () => syncDropdown(sel, btn))
|
|
367
|
+
new MutationObserver(() => syncDropdown(sel, btn)).observe(sel, { childList: true, subtree: true, attributes: true, characterData: true })
|
|
368
|
+
btn.addEventListener('click', () => {
|
|
369
|
+
if (openMenu?.btn === btn) closeMenu()
|
|
370
|
+
else openDropdown(sel, btn)
|
|
371
|
+
})
|
|
372
|
+
btn.addEventListener('keydown', (e) => {
|
|
373
|
+
const open = openMenu?.btn === btn
|
|
374
|
+
if (!open && ['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) { e.preventDefault(); openDropdown(sel, btn); return }
|
|
375
|
+
if (!open) return
|
|
376
|
+
const keys = {
|
|
377
|
+
ArrowDown: () => highlight(openMenu.active + 1),
|
|
378
|
+
ArrowUp: () => highlight(openMenu.active - 1),
|
|
379
|
+
Home: () => highlight(0),
|
|
380
|
+
End: () => highlight(Infinity),
|
|
381
|
+
Enter: () => choose(openMenu.active),
|
|
382
|
+
' ': () => choose(openMenu.active),
|
|
383
|
+
Escape: () => { e.stopPropagation(); closeMenu(true) }
|
|
384
|
+
}
|
|
385
|
+
if (keys[e.key]) { e.preventDefault(); keys[e.key](); return }
|
|
386
|
+
if (e.key === 'Tab') { closeMenu(); return }
|
|
387
|
+
if (e.key.length === 1) {
|
|
388
|
+
const k = e.key.toLowerCase()
|
|
389
|
+
const opts = [...sel.options]
|
|
390
|
+
const n = opts.length
|
|
391
|
+
for (let j = 1; j <= n; j++) {
|
|
392
|
+
const idx = (openMenu.active + j) % n
|
|
393
|
+
if (opts[idx].textContent.trim().toLowerCase().startsWith(k)) { highlight(idx); break }
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function startDropdowns (root = document.body) {
|
|
400
|
+
root.querySelectorAll('select').forEach(enhanceSelect)
|
|
401
|
+
new MutationObserver((muts) => {
|
|
402
|
+
for (const m of muts) {
|
|
403
|
+
for (const n of m.addedNodes) {
|
|
404
|
+
if (n.nodeType !== 1) continue
|
|
405
|
+
if (n.tagName === 'SELECT') enhanceSelect(n)
|
|
406
|
+
else n.querySelectorAll?.('select').forEach(enhanceSelect)
|
|
407
|
+
}
|
|
408
|
+
// Remove a menu whose button left the page (e.g. a re-render).
|
|
409
|
+
if (openMenu && !openMenu.btn.isConnected) closeMenu()
|
|
410
|
+
}
|
|
411
|
+
}).observe(root, { childList: true, subtree: true })
|
|
412
|
+
document.addEventListener('mousedown', (e) => {
|
|
413
|
+
if (openMenu && !openMenu.menu.contains(e.target) && !openMenu.btn.contains(e.target)) closeMenu()
|
|
414
|
+
})
|
|
415
|
+
window.addEventListener('resize', () => closeMenu())
|
|
416
|
+
document.addEventListener('scroll', (e) => { if (openMenu && !openMenu.menu.contains(e.target)) closeMenu() }, true)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A loading screen: the Quilt mark piecing itself together over "Loading workspace" (or a
|
|
421
|
+
* session) and its name. `full` fills the window (a session); otherwise it fills the page.
|
|
422
|
+
* `steady`: drawn again while still loading, so it doesn't fade in a second time.
|
|
423
|
+
*/
|
|
424
|
+
export function loadingHtml ({ label, name = '', full = false, steady = false } = {}) {
|
|
425
|
+
return `<div class="loading-screen${full ? ' full' : ''}${steady ? ' steady' : ''}" role="status" aria-live="polite">${quiltMark({ word: false, loop: true })}<p class="ls-label">${esc(label)}</p>${name ? `<p class="ls-name">${esc(name)}</p>` : ''}</div>`
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A short wait inside a panel or list ("Loading…", "Checking GitHub…"): the small mark piecing itself together, then the words. */
|
|
429
|
+
export function loadingNote (label = 'Loading…') {
|
|
430
|
+
return `<span class="ql" role="status">${quiltMark({ word: false, loop: true, cls: 'qm-inline' })}<span>${esc(label)}</span></span>`
|
|
431
|
+
}
|
package/src/ui/csv.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// CSV for the file preview: no DOM, so it loads under node for its test.
|
|
2
|
+
|
|
3
|
+
/** Splits CSV text into rows of cells: commas, quoted fields, "" for a quote inside one. */
|
|
4
|
+
export function parseCsv (text, maxRows = 200) {
|
|
5
|
+
const rows = []
|
|
6
|
+
let row = []; let cell = ''; let quoted = false
|
|
7
|
+
for (let i = 0; i < text.length; i++) {
|
|
8
|
+
const c = text[i]
|
|
9
|
+
if (quoted) {
|
|
10
|
+
if (c !== '"') cell += c
|
|
11
|
+
else if (text[i + 1] === '"') { cell += '"'; i++ } else quoted = false
|
|
12
|
+
} else if (c === '"' && cell === '') quoted = true
|
|
13
|
+
else if (c === ',') { row.push(cell); cell = '' } else if (c === '\n' || c === '\r') {
|
|
14
|
+
if (c === '\r' && text[i + 1] === '\n') i++
|
|
15
|
+
row.push(cell); rows.push(row); row = []; cell = ''
|
|
16
|
+
if (rows.length >= maxRows) return rows
|
|
17
|
+
} else cell += c
|
|
18
|
+
}
|
|
19
|
+
if (cell !== '' || row.length) { row.push(cell); rows.push(row) }
|
|
20
|
+
return rows
|
|
21
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What changed in one file: its history entries (src/history.js), newest first,
|
|
2
|
+
// each with who made it, when, the task it was for and the diff in green and red.
|
|
3
|
+
// The file view's Changes tab shows it. The history keeps a session's latest
|
|
4
|
+
// changes only, while the per-file totals (changes.js) count everything: anyone
|
|
5
|
+
// whose changes are counted but no longer kept is listed after the diffs.
|
|
6
|
+
import { esc, ago, avatar, colorFor } from './common.js'
|
|
7
|
+
|
|
8
|
+
const kindTag = (k) => k === 'created' ? '<span class="chg-kind new">new</span>' : k === 'deleted' ? '<span class="chg-kind gone">deleted</span>' : ''
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Who changed a file (its totals, from changes.js fileChanges) but has no history left for it:
|
|
12
|
+
* their changes rolled off. Only who is compared, not how much: totals add up every save, the
|
|
13
|
+
* history keeps a burst of saves as one net diff, so their line counts differ anyway.
|
|
14
|
+
* @param {object[]} entries the file's kept history
|
|
15
|
+
* @param {{ by?: { name: string, pulled?: boolean }[] }|null} summary
|
|
16
|
+
*/
|
|
17
|
+
export function rolledOff (entries, summary) {
|
|
18
|
+
if (!summary) return []
|
|
19
|
+
const kept = new Set(entries.map((e) => `${e.by}\0${e.pulled ? 1 : 0}`))
|
|
20
|
+
return (summary.by || []).filter((b) => !kept.has(`${b.name}\0${b.pulled ? 1 : 0}`))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How many items the Changes tab lists: each kept change, and each person whose changes rolled off. */
|
|
24
|
+
export const changeCount = (entries, summary) => entries.length + rolledOff(entries, summary).length
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object[]} entries history entries for one file, newest first
|
|
28
|
+
* @param {object} o
|
|
29
|
+
* @param {(name: string) => string} o.who how to show a name ("you" for me)
|
|
30
|
+
* @param {(name: string) => string} [o.colorOf]
|
|
31
|
+
* @param {object|null} [o.summary] the file's totals (changes.js fileChanges), to list what rolled off
|
|
32
|
+
*/
|
|
33
|
+
export function historyMarkup (entries, { who = (n) => n, colorOf = () => null, summary = null } = {}) {
|
|
34
|
+
const gone = rolledOff(entries, summary)
|
|
35
|
+
if (!entries.length && !gone.length) return '<div class="fv-note">No changes to this file are kept. A session keeps its latest 2,000 changes, so older ones may have rolled off.</div>'
|
|
36
|
+
return entries.map((e) => {
|
|
37
|
+
const span = e.from && e.ts - e.from > 60000 ? ` · over ${Math.round((e.ts - e.from) / 60000)}m` : ''
|
|
38
|
+
const tag = e.pulled ? '<span class="chg-kind pulled">pulled from git</span>' : kindTag(e.kind)
|
|
39
|
+
const body = e.diff
|
|
40
|
+
? `<pre class="chg-diff">${diffLines(e.diff)}</pre>`
|
|
41
|
+
: `<p class="hint">${e.detail && !/^\+\d+ -\d+$/.test(e.detail) ? `Not a text file (${esc(e.detail)}).` : 'No lines changed.'}</p>`
|
|
42
|
+
return `
|
|
43
|
+
<article class="chg-entry">
|
|
44
|
+
<div class="chg-row">${avatar(e.by, colorFor(e.by, colorOf(e.by)))}<b>${esc(who(e.by))}</b>${tag}
|
|
45
|
+
<span class="hint">${esc(ago(e.ts))}${span}</span><span class="spacer"></span>${e.diff ? `<span class="chg-delta"><span class="add">+${e.added}</span> <span class="del">−${e.removed}</span></span>` : ''}</div>
|
|
46
|
+
${e.task ? `<p class="hint chg-task">For “${esc(e.task.title)}”</p>` : ''}
|
|
47
|
+
${body}
|
|
48
|
+
</article>`
|
|
49
|
+
}).join('') + (gone.length ? goneMarkup(gone, { who, colorOf, alone: !entries.length }) : '')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Counted in the totals, no longer in the history: who, their +/- and when, without a diff.
|
|
53
|
+
function goneMarkup (gone, { who, colorOf, alone }) {
|
|
54
|
+
return `
|
|
55
|
+
<section class="chg-gone">
|
|
56
|
+
<h4>${alone ? 'Changes' : 'Earlier changes'} without a diff</h4>
|
|
57
|
+
<ul>${gone.map((b) => `
|
|
58
|
+
<li>${avatar(b.name, colorFor(b.name, colorOf(b.name)))}<b>${esc(who(b.name))}</b>${b.pulled ? '<span class="chg-kind pulled">pulled from git</span>' : ''}
|
|
59
|
+
<span class="hint">${esc(ago(b.ts))}</span><span class="spacer"></span><span class="chg-delta"><span class="add">+${b.added}</span> <span class="del">−${b.removed}</span></span></li>`).join('')}
|
|
60
|
+
</ul>
|
|
61
|
+
<p class="hint">Their line-by-line diff is no longer kept. A session keeps its latest 2,000 changes, and a big one, like moving many files, can push older ones out. The totals still count them.</p>
|
|
62
|
+
</section>`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A unified diff as coloured lines, each hunk headed "Line N". */
|
|
66
|
+
export function diffLines (diff) {
|
|
67
|
+
return String(diff).split('\n').map((line) => {
|
|
68
|
+
const h = /^@@ -(\d+),\d+ \+(\d+),\d+ @@/.exec(line)
|
|
69
|
+
if (h) return `<span class="ln hunk">Line ${+h[2] || +h[1] || 1}</span>`
|
|
70
|
+
if (line.startsWith('… ')) return `<span class="ln hunk">${esc(line)}</span>`
|
|
71
|
+
const cls = line[0] === '+' ? 'add' : line[0] === '-' ? 'del' : 'ctx'
|
|
72
|
+
return `<span class="ln ${cls}"><i>${cls === 'add' ? '+' : cls === 'del' ? '−' : ' '}</i>${esc(line.slice(1)) || ' '}</span>`
|
|
73
|
+
}).join('')
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Which agent conversations a feed holds, and which one to show. Pure, so it
|
|
2
|
+
// can be tested without a browser.
|
|
3
|
+
const LABEL_CHARS = 48
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The conversations in a feed, newest activity first:
|
|
7
|
+
* [{ conv, tool, label, title, ts, count }]. Named after the AI tool's own title for it
|
|
8
|
+
* (the newest `title` an entry carries: the name the person sees in their tool), else the
|
|
9
|
+
* first prompt, else the tool. Entries without a conversation id share one bucket.
|
|
10
|
+
*/
|
|
11
|
+
export function conversations (entries) {
|
|
12
|
+
const byConv = new Map()
|
|
13
|
+
for (const e of entries) {
|
|
14
|
+
if (!e || e.kind === 'paused' || e.kind === 'resumed') continue
|
|
15
|
+
const conv = e.conv || ''
|
|
16
|
+
let c = byConv.get(conv)
|
|
17
|
+
if (!c) { c = { conv, tool: e.tool || null, label: null, title: '', titleTs: -1, ts: e.ts || 0, count: 0 }; byConv.set(conv, c) }
|
|
18
|
+
if (e.kind === 'prompt' && c.label === null && e.text) c.label = shortLabel(e.text)
|
|
19
|
+
if (e.title && typeof e.title === 'string' && (e.ts || 0) >= c.titleTs) { c.title = shortLabel(e.title); c.titleTs = e.ts || 0 }
|
|
20
|
+
if (!c.tool && e.tool) c.tool = e.tool
|
|
21
|
+
if ((e.ts || 0) > c.ts) c.ts = e.ts
|
|
22
|
+
c.count++
|
|
23
|
+
}
|
|
24
|
+
return [...byConv.values()]
|
|
25
|
+
.map(({ titleTs, ...c }) => ({ ...c, label: c.title || c.label || c.tool || 'Conversation' }))
|
|
26
|
+
.sort((a, b) => b.ts - a.ts)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shortLabel (text) {
|
|
30
|
+
const line = String(text).split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
31
|
+
return line.length > LABEL_CHARS ? line.slice(0, LABEL_CHARS) + '…' : line
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The conversation to show: the pinned one if it still exists, else the newest. */
|
|
35
|
+
export function pickConversation (list, pinned) {
|
|
36
|
+
if (!list.length) return null
|
|
37
|
+
if (pinned !== undefined && list.some((c) => c.conv === pinned)) return pinned
|
|
38
|
+
return list[0].conv
|
|
39
|
+
}
|