heyquilt 0.0.1 → 0.3.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// The Loom: every AI conversation in the session at once. One lane per person
|
|
2
|
+
// (their thread), time running down like a feed. Each AI turn (a prompt, what
|
|
3
|
+
// the AI did, its reply) is one card in its person's lane, and so are chat
|
|
4
|
+
// messages and task notes. Cards in different lanes that touched the same file
|
|
5
|
+
// are stitched together. When the lanes don't all fit, the quiet ones fold into
|
|
6
|
+
// thin strips (and past a few strips, into one "crowd" column) where each thing
|
|
7
|
+
// they did is a knot in the same rows. Pure, so it can be tested without a browser.
|
|
8
|
+
import { conversations } from './feed-convs.js'
|
|
9
|
+
import { aiName } from './chat.js'
|
|
10
|
+
|
|
11
|
+
export const GAP_MS = 20 * 60 * 1000 // a pause this long between two things gets a "later" divider
|
|
12
|
+
export const TURN_GAP_MS = 10 * 60 * 1000 // a conversation quiet this long starts a new turn without a prompt
|
|
13
|
+
export const COLLIDE_MS = 15 * 60 * 1000 // two lanes on one file this close together: shown as a collision
|
|
14
|
+
export const MAX_ITEMS = 400 // newest first; older ones drop off the top
|
|
15
|
+
export const CONV_SLOTS = 8 // a lane's conversations each get a piece of the mark: eight, the corner last
|
|
16
|
+
export const CONV_QUIET_MS = 30 * 60 * 1000 // a conversation with nothing for this long shows as quiet
|
|
17
|
+
export const CONV_ACTIVE_MS = 3 * 60 * 1000 // one with something this recent is working now: its piece runs laps
|
|
18
|
+
const MAX_STITCHES = 240
|
|
19
|
+
const PREVIEW = 600
|
|
20
|
+
|
|
21
|
+
// Column widths in px, for deciding how many lanes fit (loom.css draws them).
|
|
22
|
+
export const LANE_MIN = 250
|
|
23
|
+
export const STRIP_W = 46
|
|
24
|
+
export const CROWD_W = 58
|
|
25
|
+
const COL_GAP = 18
|
|
26
|
+
const PAD_X = 36
|
|
27
|
+
export const MAX_STRIPS = 6 // more folded lanes than this: the quietest share the crowd column
|
|
28
|
+
const RECENT_OPENS = 2 // the lanes you opened last stay open ahead of working AIs
|
|
29
|
+
export const MIN_STRIPS = 4 // a lane opens only while this many folded ones still show as strips
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which lanes stay open when they don't all fit in `width`: a Map name ->
|
|
33
|
+
* 'open' | 'strip' | 'crowd'. As many open as fit, at least one. They're picked
|
|
34
|
+
* in this order: pinned, the last two lanes you opened (`opened`, latest first),
|
|
35
|
+
* working AIs, you, lanes opened before that, then whoever did something most recently. Of the folded
|
|
36
|
+
* ones, the first few are strips and the quietest share the crowd column; another
|
|
37
|
+
* lane opens only while at least MIN_STRIPS of the folded ones still get a strip.
|
|
38
|
+
*/
|
|
39
|
+
export function foldPlan (lanes, { width = 0, pinned = [], opened = [] } = {}) {
|
|
40
|
+
const plan = new Map(lanes.map((l) => [l.name, 'open']))
|
|
41
|
+
const n = lanes.length
|
|
42
|
+
const need = (k, s, c) => k * LANE_MIN + s * STRIP_W + (c ? CROWD_W : 0) + Math.max(0, k + s + (c ? 1 : 0) - 1) * COL_GAP + PAD_X
|
|
43
|
+
if (!width || n <= 1 || need(n, 0, 0) <= width) return plan
|
|
44
|
+
|
|
45
|
+
const pin = (l) => { const i = pinned.indexOf(l.name); return i === -1 ? Infinity : i }
|
|
46
|
+
const seen = (l) => { const i = opened.indexOf(l.name); return i === -1 ? Infinity : i }
|
|
47
|
+
const tiers = [
|
|
48
|
+
(l) => pin(l),
|
|
49
|
+
(l) => Math.min(seen(l), RECENT_OPENS),
|
|
50
|
+
(l) => (l.working ? 0 : 1),
|
|
51
|
+
(l) => (l.isMe ? 0 : 1),
|
|
52
|
+
(l) => seen(l),
|
|
53
|
+
(l) => -(l.lastTs || 0)
|
|
54
|
+
]
|
|
55
|
+
const at = new Map(lanes.map((l, i) => [l.name, i]))
|
|
56
|
+
const order = [...lanes].sort((a, b) => {
|
|
57
|
+
for (const t of tiers) {
|
|
58
|
+
const x = t(a)
|
|
59
|
+
const y = t(b)
|
|
60
|
+
if (x !== y) return x < y ? -1 : 1
|
|
61
|
+
}
|
|
62
|
+
return at.get(a.name) - at.get(b.name)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
let k = 1
|
|
66
|
+
let s = 0
|
|
67
|
+
for (let open = n - 1; open >= 1; open--) {
|
|
68
|
+
const rest = n - open
|
|
69
|
+
let strips = Math.min(rest, MAX_STRIPS)
|
|
70
|
+
let crowd = rest - strips
|
|
71
|
+
if (crowd === 1) { strips++; crowd = 0 } // a crowd of one is just a strip
|
|
72
|
+
while (strips >= 0 && need(open, strips, crowd) > width) { strips--; crowd++ }
|
|
73
|
+
if (strips >= Math.min(rest, MIN_STRIPS) || open === 1) { k = open; s = Math.max(0, strips); break }
|
|
74
|
+
}
|
|
75
|
+
order.forEach((l, i) => plan.set(l.name, i < k ? 'open' : i < k + s ? 'strip' : 'crowd'))
|
|
76
|
+
return plan
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* What an action line says about a file: { verb, path }, or null when it names
|
|
81
|
+
* none. Action lines come from every tool's reader in one form ("Edited a/b.js",
|
|
82
|
+
* "Read a/b.js", see src/agents/actions.js).
|
|
83
|
+
*/
|
|
84
|
+
export function fileOf (text) {
|
|
85
|
+
const m = String(text || '').match(/^(Edited|Created|Deleted|Read) (.+)$/)
|
|
86
|
+
if (!m || m[2] === 'a file') return null
|
|
87
|
+
return { verb: m[1].toLowerCase(), path: m[2].trim() }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const changes = (verb) => verb === 'edited' || verb === 'created' || verb === 'deleted'
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Each lane's AI conversations: a person can have several going at once (windows, tools), and
|
|
94
|
+
* all of them land in their one lane. Each gets a slot of the mark (0-7, clockwise from the top
|
|
95
|
+
* left, the corner last) as it starts, so a card can show which conversation it's from. Slots are
|
|
96
|
+
* handed out over what's in view (`items`), so one frees up when its conversation scrolls away;
|
|
97
|
+
* with all eight taken, the conversation quiet longest gives its slot up. Named by the person's
|
|
98
|
+
* own name for it (`convNames`), else the tool's title or first prompt (turnsOf's convLabel).
|
|
99
|
+
* Sets each lane's `convs` ([{ conv, slot, name, tool, live, active, turns, last }] in slot order, those
|
|
100
|
+
* holding a slot), its `tool` (the latest conversation's, when one says), each turn's `convLabel` and,
|
|
101
|
+
* in a lane with more than one, its `convSlot`.
|
|
102
|
+
*/
|
|
103
|
+
function conversationsOf (lanes, items, { convNames = {}, now = Date.now() } = {}) {
|
|
104
|
+
const byLane = new Map()
|
|
105
|
+
for (const it of items) {
|
|
106
|
+
if (it.type !== 'turn') continue
|
|
107
|
+
if (!byLane.has(it.lane)) byLane.set(it.lane, new Map())
|
|
108
|
+
const convs = byLane.get(it.lane)
|
|
109
|
+
let c = convs.get(it.conv)
|
|
110
|
+
if (!c) { c = { conv: it.conv, tool: it.tool, label: it.convLabel, first: it.ts, last: it.end || it.ts, turns: 0 }; convs.set(it.conv, c) }
|
|
111
|
+
c.last = Math.max(c.last, it.end || it.ts)
|
|
112
|
+
if (!c.tool && it.tool) c.tool = it.tool
|
|
113
|
+
c.turns++
|
|
114
|
+
}
|
|
115
|
+
for (const l of lanes) {
|
|
116
|
+
const list = [...(byLane.get(l.name) || new Map()).values()].sort((a, b) => a.first - b.first)
|
|
117
|
+
const slots = new Array(CONV_SLOTS).fill(null)
|
|
118
|
+
for (const c of list) {
|
|
119
|
+
let at = slots.indexOf(null)
|
|
120
|
+
if (at < 0) at = slots.reduce((best, x, i) => (x.last < slots[best].last ? i : best), 0)
|
|
121
|
+
slots[at] = c
|
|
122
|
+
c.slot = at
|
|
123
|
+
}
|
|
124
|
+
const own = convNames && typeof convNames === 'object' ? convNames : {}
|
|
125
|
+
for (const c of list) c.name = (typeof own[c.conv] === 'string' && own[c.conv].trim()) || c.label || c.tool || 'Conversation'
|
|
126
|
+
l.convs = slots.filter(Boolean).map((c) => ({ conv: c.conv, slot: c.slot, name: c.name, tool: c.tool || null, live: now - c.last < CONV_QUIET_MS, active: now - c.last < CONV_ACTIVE_MS, turns: c.turns, last: c.last }))
|
|
127
|
+
// The lane's mark is the tool its latest conversation came from, over the one its session started with.
|
|
128
|
+
const latest = list.filter((c) => c.tool && c.tool !== 'unknown').reduce((a, c) => (!a || c.last >= a.last ? c : a), null)
|
|
129
|
+
if (latest) l.tool = latest.tool
|
|
130
|
+
const of = new Map(list.map((c) => [c.conv, c]))
|
|
131
|
+
for (const it of items) {
|
|
132
|
+
if (it.type !== 'turn' || it.lane !== l.name) continue
|
|
133
|
+
const c = of.get(it.conv)
|
|
134
|
+
it.convLabel = c.name
|
|
135
|
+
if (list.length > 1) it.convSlot = c.slot
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One person's feed as turns, oldest first. A turn starts at a prompt (or at the
|
|
142
|
+
* first thing in a conversation, or after it went quiet) and gathers that
|
|
143
|
+
* conversation's actions and replies until its next prompt. Concurrent
|
|
144
|
+
* conversations of one person each keep their own turn open. Pause and resume
|
|
145
|
+
* marks come through as their own items.
|
|
146
|
+
*/
|
|
147
|
+
export function turnsOf (entries, lane) {
|
|
148
|
+
const list = (Array.isArray(entries) ? entries : []).filter((e) => e && typeof e === 'object')
|
|
149
|
+
const labels = new Map(conversations(list).map((c) => [c.conv, c.label]))
|
|
150
|
+
const out = []
|
|
151
|
+
const open = new Map() // conv -> its turn
|
|
152
|
+
for (const e of [...list].sort((a, b) => (a.ts || 0) - (b.ts || 0))) {
|
|
153
|
+
const ts = Number(e.ts) || 0
|
|
154
|
+
if (e.kind === 'paused' || e.kind === 'resumed') {
|
|
155
|
+
out.push({ type: e.kind, lane, ts, key: `${lane}|${e.id}` })
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!['prompt', 'reply', 'action'].includes(e.kind)) continue
|
|
159
|
+
const conv = String(e.conv || '')
|
|
160
|
+
let t = open.get(conv)
|
|
161
|
+
if (e.kind === 'prompt' || !t || ts - t.end > TURN_GAP_MS) {
|
|
162
|
+
t = {
|
|
163
|
+
type: 'turn',
|
|
164
|
+
key: `${lane}|${e.id}`,
|
|
165
|
+
lane,
|
|
166
|
+
conv,
|
|
167
|
+
convLabel: labels.get(conv) || '',
|
|
168
|
+
tool: e.tool || null,
|
|
169
|
+
ts,
|
|
170
|
+
end: ts,
|
|
171
|
+
prompt: '',
|
|
172
|
+
summary: false,
|
|
173
|
+
replies: [],
|
|
174
|
+
actions: [],
|
|
175
|
+
edits: [],
|
|
176
|
+
reads: 0,
|
|
177
|
+
runs: 0
|
|
178
|
+
}
|
|
179
|
+
out.push(t)
|
|
180
|
+
open.set(conv, t)
|
|
181
|
+
}
|
|
182
|
+
if (!t.tool && e.tool) t.tool = e.tool
|
|
183
|
+
t.end = Math.max(t.end, ts)
|
|
184
|
+
const text = String(e.text || '')
|
|
185
|
+
if (e.kind === 'prompt') {
|
|
186
|
+
t.prompt = text
|
|
187
|
+
if (e.summary) t.summary = true
|
|
188
|
+
} else if (e.kind === 'reply') {
|
|
189
|
+
t.replies.push({ text, ts, summary: !!e.summary })
|
|
190
|
+
} else {
|
|
191
|
+
t.actions.push(text)
|
|
192
|
+
const f = fileOf(text)
|
|
193
|
+
if (f && changes(f.verb)) { if (!t.edits.includes(f.path)) t.edits.push(f.path) } else if (f) t.reads++
|
|
194
|
+
else if (/^Ran /.test(text)) t.runs++
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The members `text` @mentions, by the rule chat uses (src/ui/chat.js): the @
|
|
204
|
+
* starts a word and the name ends one. @Agents names every agent in `agents`.
|
|
205
|
+
*/
|
|
206
|
+
export function mentionsIn (text, names, agents = []) {
|
|
207
|
+
const t = String(text || '')
|
|
208
|
+
if (!t.includes('@')) return []
|
|
209
|
+
const list = [...new Set([...names, 'Agents'].filter(Boolean))].sort((a, b) => b.length - a.length)
|
|
210
|
+
const re = new RegExp(`(^|[^\\w@])@(${list.map(escapeRe).join('|')})(?![\\w-])`, 'giu')
|
|
211
|
+
const out = new Set()
|
|
212
|
+
for (const m of t.matchAll(re)) {
|
|
213
|
+
const said = m[2].toLowerCase()
|
|
214
|
+
if (said === 'agents') { for (const a of agents) out.add(a); continue }
|
|
215
|
+
const name = names.find((n) => n.toLowerCase() === said)
|
|
216
|
+
if (name) out.add(name)
|
|
217
|
+
}
|
|
218
|
+
return [...out]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Rows for the cards, in time order: a card starts no earlier than the one
|
|
223
|
+
* before it (anywhere), and below the last card in its own lane, so cards in
|
|
224
|
+
* different lanes sit side by side while order reads top to bottom. A card spans
|
|
225
|
+
* down to the next card in its lane, so a long turn doesn't push its neighbours
|
|
226
|
+
* apart. Dividers take a row across every lane. Rows count from 1 (CSS grid).
|
|
227
|
+
*/
|
|
228
|
+
export function packRows (items, laneCount) {
|
|
229
|
+
const next = new Array(Math.max(1, laneCount)).fill(1)
|
|
230
|
+
const last = new Array(Math.max(1, laneCount)).fill(null)
|
|
231
|
+
let floor = 1
|
|
232
|
+
for (const it of items) {
|
|
233
|
+
if (it.col == null) {
|
|
234
|
+
const row = Math.max(floor, ...next)
|
|
235
|
+
it.row = row
|
|
236
|
+
it.span = 1
|
|
237
|
+
for (let i = 0; i < next.length; i++) {
|
|
238
|
+
if (last[i]) last[i].span = Math.max(1, row - last[i].row)
|
|
239
|
+
next[i] = row + 1
|
|
240
|
+
last[i] = null
|
|
241
|
+
}
|
|
242
|
+
floor = row + 1
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
const row = Math.max(floor, next[it.col])
|
|
246
|
+
const prev = last[it.col]
|
|
247
|
+
if (prev) prev.span = Math.max(1, row - prev.row)
|
|
248
|
+
it.row = row
|
|
249
|
+
it.span = 1
|
|
250
|
+
next[it.col] = row + 1
|
|
251
|
+
last[it.col] = it
|
|
252
|
+
floor = row
|
|
253
|
+
}
|
|
254
|
+
return Math.max(floor, ...next) - 1
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** How long someone has been stitching since `since`: "<1m", "25m", "1h 10m", "6h", "2d". */
|
|
258
|
+
export function stitchTime (since, now = Date.now()) {
|
|
259
|
+
const m = Math.floor(Math.max(0, now - since) / 60000)
|
|
260
|
+
if (m < 1) return '<1m'
|
|
261
|
+
if (m < 60) return `${m}m`
|
|
262
|
+
const h = Math.floor(m / 60)
|
|
263
|
+
if (h >= 48) return `${Math.floor(h / 24)}d`
|
|
264
|
+
return h < 6 && m % 60 ? `${h}h ${m % 60}m` : `${h}h`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function dayOf (ts) {
|
|
268
|
+
const d = new Date(ts)
|
|
269
|
+
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* One lane per person's AI, as chat shows it: every AI session of a person ("Daniel · Claude
|
|
274
|
+
* Code", "Daniel · Claude Code 2", ones gone since) becomes "Daniel's AI". `owners`: AI session
|
|
275
|
+
* name -> its person (chat.js aiOwners). Returns the buildLoom inputs with those names folded,
|
|
276
|
+
* and `personOf`: lane name -> the person whose AI chat it opens.
|
|
277
|
+
*/
|
|
278
|
+
export function foldAiLanes ({ people = [], owners = new Map(), feeds = new Map(), messages = [], tasks = [], claims = [] } = {}) {
|
|
279
|
+
const lane = (name) => owners.has(name) ? aiName(owners.get(name)) : name
|
|
280
|
+
const personOf = new Map()
|
|
281
|
+
const out = []
|
|
282
|
+
const groups = new Map()
|
|
283
|
+
for (const p of people) {
|
|
284
|
+
if (!p || !p.name) continue
|
|
285
|
+
if (!owners.has(p.name)) { out.push(p); continue }
|
|
286
|
+
const name = lane(p.name)
|
|
287
|
+
const g = groups.get(name)
|
|
288
|
+
if (g) {
|
|
289
|
+
g.online = g.online || p.online
|
|
290
|
+
if (!g.tool && p.tool) g.tool = p.tool
|
|
291
|
+
// Several sessions stitching: the lane's clock is the one that has run longest.
|
|
292
|
+
if (p.focusAt > 0 && !(g.focusAt <= p.focusAt)) { g.focusAt = p.focusAt; g.focus = p.focus || '' }
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
const folded = { name, kind: 'agent', tool: p.tool || '', online: p.online || false, optional: true, agent: null, ...(p.focusAt > 0 ? { focusAt: p.focusAt, focus: p.focus || '' } : {}) }
|
|
296
|
+
groups.set(name, folded)
|
|
297
|
+
personOf.set(name, owners.get(p.name))
|
|
298
|
+
out.push(folded)
|
|
299
|
+
}
|
|
300
|
+
// An AI session that is gone still has its chat and notes: they go to its person's AI lane.
|
|
301
|
+
for (const person of new Set(owners.values())) {
|
|
302
|
+
const name = aiName(person)
|
|
303
|
+
if (groups.has(name)) continue
|
|
304
|
+
groups.set(name, true)
|
|
305
|
+
personOf.set(name, person)
|
|
306
|
+
out.push({ name, kind: 'agent', tool: '', online: false, optional: true, agent: null })
|
|
307
|
+
}
|
|
308
|
+
const folded = new Map()
|
|
309
|
+
for (const [name, list] of feeds) {
|
|
310
|
+
const key = lane(name)
|
|
311
|
+
const items = Array.isArray(list) ? list : []
|
|
312
|
+
folded.set(key, folded.has(key) ? [...folded.get(key), ...items].sort((a, b) => (a.ts || 0) - (b.ts || 0)) : items)
|
|
313
|
+
}
|
|
314
|
+
const by = (x) => owners.has(x.by) ? { ...x, by: lane(x.by) } : x
|
|
315
|
+
return {
|
|
316
|
+
people: out,
|
|
317
|
+
feeds: folded,
|
|
318
|
+
messages: (messages || []).map((m) => m && (owners.has(m.by) || owners.has(m.to)) ? { ...m, by: lane(m.by), ...(m.to ? { to: lane(m.to) } : {}) } : m),
|
|
319
|
+
tasks: (tasks || []).map((t) => t ? { ...by(t), comments: (t.comments || []).map(by) } : t),
|
|
320
|
+
claims: (claims || []).map((c) => c ? by(c) : c),
|
|
321
|
+
personOf
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Everything the Loom shows. `people`: [{ name, color, isMe, kind, tool, online,
|
|
327
|
+
* agent, optional, focus, focusAt }] in lane order; `feeds`: Map name -> feed entries; `messages`: the
|
|
328
|
+
* room's chat (already validated); `tasks`: the board, with comments; `claims`:
|
|
329
|
+
* the session's claims. `show`: { chat, tasks } (both on unless false);
|
|
330
|
+
* `hidden`: names whose lanes are off; `merged`: one column for everyone (a
|
|
331
|
+
* narrow window). `width`: the room for lanes, in px (0: as many as there are);
|
|
332
|
+
* `pinned`, `opened`: see foldPlan; `convNames`: { conv id: name } the person gave conversations
|
|
333
|
+
* (on this computer); `now` for what counts as quiet. Returns { lanes, columns, items, knots, rows,
|
|
334
|
+
* stitches, mentions, files }: a lane's `fold` is 'open', 'strip' or 'crowd' and
|
|
335
|
+
* `col` its column; things in a folded lane aren't cards but `knots`, one per
|
|
336
|
+
* column and row.
|
|
337
|
+
*/
|
|
338
|
+
export function buildLoom ({ people = [], feeds = new Map(), messages = [], tasks = [], claims = [], show = {}, hidden = [], merged = false, width = 0, pinned = [], opened = [], convNames = {}, now = Date.now() } = {}) {
|
|
339
|
+
const off = new Set(hidden)
|
|
340
|
+
// Someone `optional` (an AI session working through a person's app) gets a lane only with something in it.
|
|
341
|
+
const active = new Set([...feeds].filter(([, list]) => Array.isArray(list) && list.length).map(([name]) => name))
|
|
342
|
+
for (const m of messages || []) active.add(m.by)
|
|
343
|
+
for (const t of tasks || []) { active.add(t.by); for (const c of t.comments || []) active.add(c.by) }
|
|
344
|
+
const lanes = people.filter((p) => p && p.name && !off.has(p.name) && (!p.optional || active.has(p.name))).map((p) => {
|
|
345
|
+
const agent = p.agent || null
|
|
346
|
+
const sharing = !agent || agent.sharing !== false
|
|
347
|
+
return {
|
|
348
|
+
name: p.name,
|
|
349
|
+
color: p.color || null,
|
|
350
|
+
isMe: !!p.isMe,
|
|
351
|
+
kind: p.kind === 'agent' ? 'agent' : 'human',
|
|
352
|
+
tool: p.tool && p.tool !== 'unknown' ? p.tool : (agent && agent.tool) || null,
|
|
353
|
+
online: p.online || false,
|
|
354
|
+
sharing,
|
|
355
|
+
working: sharing && !!agent && agent.status === 'working',
|
|
356
|
+
// Since when they've been on their focus (stitching); hidden like their status while paused.
|
|
357
|
+
since: sharing && p.focusAt > 0 ? p.focusAt : 0,
|
|
358
|
+
focus: sharing && p.focusAt > 0 ? String(p.focus || '') : '',
|
|
359
|
+
pinned: pinned.includes(p.name),
|
|
360
|
+
holds: [],
|
|
361
|
+
waiting: 0,
|
|
362
|
+
lastTs: 0,
|
|
363
|
+
fold: 'open',
|
|
364
|
+
col: 0
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
const index = new Map(lanes.map((l, i) => [l.name, i]))
|
|
368
|
+
const names = people.map((p) => p && p.name).filter(Boolean)
|
|
369
|
+
const agentNames = people.filter((p) => p && p.kind === 'agent').map((p) => p.name)
|
|
370
|
+
|
|
371
|
+
for (const c of claims || []) {
|
|
372
|
+
const i = index.get(c && c.by)
|
|
373
|
+
if (i === undefined) continue
|
|
374
|
+
lanes[i].holds.push(c.pattern)
|
|
375
|
+
lanes[i].waiting += Array.isArray(c.queue) ? c.queue.length : 0
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
let items = []
|
|
379
|
+
for (const l of lanes) items.push(...turnsOf(feeds.get(l.name), l.name))
|
|
380
|
+
|
|
381
|
+
const doneByConv = new Map() // conv -> tasks filed under it
|
|
382
|
+
for (const t of tasks || []) {
|
|
383
|
+
if (!t || t.archived) continue
|
|
384
|
+
if (t.conv) {
|
|
385
|
+
if (!doneByConv.has(t.conv)) doneByConv.set(t.conv, [])
|
|
386
|
+
doneByConv.get(t.conv).push({ id: t.id, title: t.title, column: t.column })
|
|
387
|
+
}
|
|
388
|
+
if (show.tasks === false) continue
|
|
389
|
+
if (index.has(t.by)) items.push({ type: 'task', key: `task|${t.id}`, lane: t.by, ts: Number(t.ts) || 0, task: { id: t.id, title: t.title, column: t.column, assignee: t.assignee || '' } })
|
|
390
|
+
for (const c of t.comments || []) {
|
|
391
|
+
if (index.has(c.by)) items.push({ type: 'note', key: `note|${c.id}`, lane: c.by, ts: Number(c.ts) || 0, text: String(c.text || '').slice(0, PREVIEW), task: { id: t.id, title: t.title, column: t.column } })
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const it of items) if (it.type === 'turn' && it.conv && doneByConv.has(it.conv)) it.tasks = doneByConv.get(it.conv)
|
|
395
|
+
|
|
396
|
+
if (show.chat !== false) {
|
|
397
|
+
for (const m of messages || []) {
|
|
398
|
+
if (!index.has(m.by)) continue
|
|
399
|
+
const text = String(m.text || '')
|
|
400
|
+
items.push({
|
|
401
|
+
type: 'chat',
|
|
402
|
+
key: `chat|${m.id}`,
|
|
403
|
+
lane: m.by,
|
|
404
|
+
ts: Number(m.ts) || 0,
|
|
405
|
+
text: text.slice(0, PREVIEW),
|
|
406
|
+
to: m.to || '',
|
|
407
|
+
file: m.file ? String(m.file.name || '') : '',
|
|
408
|
+
mentions: [...new Set([...(m.to ? [m.to] : []), ...mentionsIn(text, names, agentNames)])].filter((n) => n !== m.by)
|
|
409
|
+
})
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
items.sort((a, b) => a.ts - b.ts)
|
|
414
|
+
if (items.length > MAX_ITEMS) items = items.slice(-MAX_ITEMS)
|
|
415
|
+
for (const it of items) { const l = lanes[index.get(it.lane)]; if (l && it.ts > l.lastTs) l.lastTs = it.ts }
|
|
416
|
+
conversationsOf(lanes, items, { convNames, now })
|
|
417
|
+
|
|
418
|
+
// Columns: open lanes and strips in lane order, then the crowd (if any) last.
|
|
419
|
+
const plan = merged ? new Map(lanes.map((l) => [l.name, 'open'])) : foldPlan(lanes, { width, pinned, opened })
|
|
420
|
+
const columns = []
|
|
421
|
+
const crowd = []
|
|
422
|
+
for (const l of lanes) {
|
|
423
|
+
l.fold = plan.get(l.name)
|
|
424
|
+
if (merged) { l.col = 0; continue }
|
|
425
|
+
if (l.fold === 'crowd') { crowd.push(l); continue }
|
|
426
|
+
l.col = columns.length
|
|
427
|
+
columns.push({ kind: l.fold, lane: l.name })
|
|
428
|
+
}
|
|
429
|
+
if (merged) columns.push({ kind: 'merged' })
|
|
430
|
+
if (crowd.length) {
|
|
431
|
+
for (const l of crowd) l.col = columns.length
|
|
432
|
+
columns.push({ kind: 'crowd', lanes: crowd.map((l) => l.name) })
|
|
433
|
+
}
|
|
434
|
+
const laneOf = (name) => lanes[index.get(name)]
|
|
435
|
+
|
|
436
|
+
// "Later" and day dividers between things far apart.
|
|
437
|
+
const withGaps = []
|
|
438
|
+
let prevTs = null
|
|
439
|
+
for (const it of items) {
|
|
440
|
+
if (prevTs !== null && it.ts) {
|
|
441
|
+
if (dayOf(it.ts) !== dayOf(prevTs)) withGaps.push({ type: 'day', key: `day|${it.ts}`, ts: it.ts })
|
|
442
|
+
else if (it.ts - prevTs >= GAP_MS) withGaps.push({ type: 'gap', key: `gap|${it.ts}`, ts: it.ts, ms: it.ts - prevTs })
|
|
443
|
+
}
|
|
444
|
+
withGaps.push(it)
|
|
445
|
+
if (it.ts) prevTs = it.ts
|
|
446
|
+
}
|
|
447
|
+
items = withGaps
|
|
448
|
+
items.forEach((it, i) => {
|
|
449
|
+
it.id = `k${i}`
|
|
450
|
+
const l = it.lane == null ? null : laneOf(it.lane)
|
|
451
|
+
it.col = l ? l.col : null
|
|
452
|
+
it.folded = !!l && l.fold !== 'open'
|
|
453
|
+
})
|
|
454
|
+
let rows = packRows(items.filter((it) => !it.folded), columns.length)
|
|
455
|
+
|
|
456
|
+
// A folded lane's things sit in the row of the card just before them (or just after,
|
|
457
|
+
// at the top or after a divider), one knot per column and row.
|
|
458
|
+
let cur = null
|
|
459
|
+
let lastRow = null
|
|
460
|
+
let pending = []
|
|
461
|
+
for (const it of items) {
|
|
462
|
+
if (it.folded) { if (cur !== null) it.row = cur; else pending.push(it); continue }
|
|
463
|
+
if (it.col == null) { cur = null; continue }
|
|
464
|
+
cur = lastRow = it.row
|
|
465
|
+
for (const p of pending) p.row = cur
|
|
466
|
+
pending = []
|
|
467
|
+
}
|
|
468
|
+
if (pending.length) {
|
|
469
|
+
if (lastRow === null || items.some((it) => !it.folded && it.col == null && it.row > lastRow)) lastRow = ++rows
|
|
470
|
+
for (const p of pending) p.row = lastRow
|
|
471
|
+
}
|
|
472
|
+
const knots = []
|
|
473
|
+
const knotAt = new Map()
|
|
474
|
+
for (const it of items) {
|
|
475
|
+
if (!it.folded) continue
|
|
476
|
+
const at = `${it.col}|${it.row}`
|
|
477
|
+
let k = knotAt.get(at)
|
|
478
|
+
if (!k) { k = { id: `n${knots.length}`, col: it.col, row: it.row, ids: [], keys: [], lanes: [], ts: 0 }; knotAt.set(at, k); knots.push(k) }
|
|
479
|
+
k.ids.push(it.id)
|
|
480
|
+
k.keys.push(it.key)
|
|
481
|
+
if (!k.lanes.includes(it.lane)) k.lanes.push(it.lane)
|
|
482
|
+
k.ts = Math.max(k.ts, it.ts)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Files: who touched each, in order, and the stitches between lanes.
|
|
486
|
+
const files = new Map() // path -> { touches: [{ id, lane, ts }], claim }
|
|
487
|
+
for (const it of items) {
|
|
488
|
+
if (it.type !== 'turn') continue
|
|
489
|
+
for (const p of it.edits) {
|
|
490
|
+
if (!files.has(p)) files.set(p, { touches: [], claim: null })
|
|
491
|
+
files.get(p).touches.push({ id: it.id, lane: it.lane, ts: it.ts, end: it.end })
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const c of claims || []) {
|
|
495
|
+
if (c && files.has(c.pattern)) files.get(c.pattern).claim = { by: c.by, waiting: Array.isArray(c.queue) ? c.queue.map((r) => r.by) : [] }
|
|
496
|
+
}
|
|
497
|
+
const stitches = []
|
|
498
|
+
for (const [path, f] of files) {
|
|
499
|
+
for (let i = 1; i < f.touches.length; i++) {
|
|
500
|
+
const a = f.touches[i - 1]
|
|
501
|
+
const b = f.touches[i]
|
|
502
|
+
if (a.lane === b.lane) continue
|
|
503
|
+
stitches.push({ from: a.id, to: b.id, path, collide: b.ts - a.end <= COLLIDE_MS })
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
stitches.sort((x, y) => Number(y.collide) - Number(x.collide))
|
|
507
|
+
if (stitches.length > MAX_STITCHES) stitches.length = MAX_STITCHES
|
|
508
|
+
|
|
509
|
+
const mentions = []
|
|
510
|
+
for (const it of items) {
|
|
511
|
+
if (it.type !== 'chat') continue
|
|
512
|
+
for (const n of it.mentions) if (index.has(n)) mentions.push({ from: it.id, lane: n, col: laneOf(n).col })
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { lanes, columns, items, knots, rows, stitches, mentions, files }
|
|
516
|
+
}
|