heyquilt 0.0.1 → 0.3.106
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 +633 -2
- package/RELEASES.md +862 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +849 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +591 -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 +187 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +177 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +110 -0
- package/src/ai-vm.js +522 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +136 -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 +133 -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 +1219 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +545 -0
- package/src/api/relay-machines.js +92 -0
- package/src/api/releases.js +87 -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 +252 -0
- package/src/api/routes/ai-vm.js +495 -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 +179 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/secrets.js +448 -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 +232 -0
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +654 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +875 -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 +375 -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 +128 -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 +248 -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 +97 -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 +924 -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 +45 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1258 -0
- package/src/merge3.js +240 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +183 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +134 -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 +214 -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 +166 -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 +1691 -0
- package/src/relay-metrics.js +140 -0
- package/src/relay-outbound.js +1273 -0
- package/src/relay-sync.js +716 -0
- package/src/relay-turn-sync.js +506 -0
- package/src/relay-upstream.js +804 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +138 -0
- package/src/report.js +129 -0
- package/src/runner.js +307 -0
- package/src/same-edit.js +18 -0
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +4214 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7446 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +197 -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 +145 -0
- package/src/turn-sync.js +212 -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 +2494 -0
- package/src/ui/app.js +546 -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 +108 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +433 -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 +150 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1322 -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 +365 -0
- package/src/ui/loom.js +786 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/rail-model.js +75 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +3292 -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 +281 -0
- package/src/ui/workspaces.js +547 -0
- package/src/ui-server.js +1381 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +192 -0
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -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/loom.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
// The Loom (see loom-model.js): everyone's AI conversations at once, one lane
|
|
2
|
+
// per person, time running down. Cards open in place; file chips light up every
|
|
3
|
+
// lane that touched the file; threads stitch lanes that worked on the same file.
|
|
4
|
+
// Lanes that don't fit fold into strips of knots (and a crowd column past that).
|
|
5
|
+
import { esc, clock, ago, avatar, colorFor, basename, I } from './common.js'
|
|
6
|
+
import { toolLogo, toolLabel } from './tool-logo.js'
|
|
7
|
+
import { markdown, visiblePrompt } from './feed.js'
|
|
8
|
+
import { textHtml } from './chat.js'
|
|
9
|
+
import { stitchTime } from './loom-model.js'
|
|
10
|
+
import { GRID, PIECES, CORNER } from './mark.js'
|
|
11
|
+
|
|
12
|
+
const MERGE_BELOW = 620 // narrower than this, lanes fold into one column
|
|
13
|
+
const FILES_SHOWN = 4
|
|
14
|
+
|
|
15
|
+
let expanded = new Set() // card keys opened in place
|
|
16
|
+
let seen = null // card keys already shown (null: nothing shown yet, so nothing is "new")
|
|
17
|
+
let lastHtml = ''
|
|
18
|
+
let model = null
|
|
19
|
+
let focused = null // file path lit from a chip or the file tree
|
|
20
|
+
let anchor = null // { key, y }: a card just opened or closed stays where it was on screen
|
|
21
|
+
let crowdOpen = false // the crowd column's list of people is showing
|
|
22
|
+
let lastWidth = 0
|
|
23
|
+
let handlers = { onAction: () => {} }
|
|
24
|
+
let ticker = 0 // counts stitching times up between renders
|
|
25
|
+
let convFocus = null // { lane, conv }: one conversation followed in its lane, the rest dimmed
|
|
26
|
+
let renaming = null // { lane, conv }: the conversation whose name is being typed
|
|
27
|
+
let followed = false // a conversation was just followed: show its latest card once drawn
|
|
28
|
+
let fresh = false // cards came in while scrolled up: the jump button says "New activity"
|
|
29
|
+
const bound = new WeakSet()
|
|
30
|
+
|
|
31
|
+
/** Forget open cards and what was shown: another session is being shown. */
|
|
32
|
+
export function resetLoom () {
|
|
33
|
+
expanded = new Set()
|
|
34
|
+
convFocus = null
|
|
35
|
+
renaming = null
|
|
36
|
+
seen = null
|
|
37
|
+
lastHtml = ''
|
|
38
|
+
model = null
|
|
39
|
+
focused = null
|
|
40
|
+
crowdOpen = false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `el` holds the Loom now. */
|
|
44
|
+
export const showsLoom = (el) => !!el?.querySelector(':scope > .loom')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Renders the Loom into `el`. `build(merged, width)` returns the model
|
|
48
|
+
* (loom-model.js) for one column or for lanes in `width` px; `prefs`: { chat,
|
|
49
|
+
* tasks, density, layout, hidden, pinned };
|
|
50
|
+
* `onAction(kind, value)`: open 'person' (a name), 'conv' ({ name, conv }), 'file'
|
|
51
|
+
* (a path) or 'task' (an id); change a pref: 'hide' / 'unhide' (a name), 'toggle'
|
|
52
|
+
* ('chat' | 'tasks'), 'density', 'layout', 'pin' / 'open-lane' (a name); or just
|
|
53
|
+
* render again ('expand', 'refresh').
|
|
54
|
+
* Skips the DOM when nothing changed.
|
|
55
|
+
*/
|
|
56
|
+
export function renderLoom (el, { build, prefs, me, names, meAgent, onAction }) {
|
|
57
|
+
handlers = { onAction }
|
|
58
|
+
// Naming a conversation: don't redraw under the typing (it redraws once the name is in).
|
|
59
|
+
const typing = el.querySelector('.lh-conv-edit')
|
|
60
|
+
if (typing && typing === document.activeElement) return
|
|
61
|
+
if (!ticker) ticker = setInterval(tickClocks, 30000)
|
|
62
|
+
const wasLoom = showsLoom(el)
|
|
63
|
+
const merged = prefs.layout === 'merged' || (el.clientWidth > 0 && el.clientWidth < MERGE_BELOW)
|
|
64
|
+
lastWidth = el.clientWidth
|
|
65
|
+
model = build(merged, Math.max(0, el.clientWidth - 14)) // less a scrollbar
|
|
66
|
+
const html = loomHtml(model, { prefs, me, names, meAgent, merged })
|
|
67
|
+
if (wasLoom && html === lastHtml) return
|
|
68
|
+
const scroller = el.querySelector('.loom-scroll')
|
|
69
|
+
const atBottom = !scroller || scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80
|
|
70
|
+
const top = scroller ? scroller.scrollTop : 0
|
|
71
|
+
const left = scroller ? scroller.scrollLeft : 0
|
|
72
|
+
lastHtml = html
|
|
73
|
+
el.innerHTML = html
|
|
74
|
+
phaseNeedles(el)
|
|
75
|
+
packConvs(el)
|
|
76
|
+
const edit = el.querySelector('.lh-conv-edit')
|
|
77
|
+
if (edit) { edit.focus(); edit.select() }
|
|
78
|
+
const s = el.querySelector('.loom-scroll')
|
|
79
|
+
const held = anchor && [...s.querySelectorAll('[data-key]')].find((c) => c.dataset.key === anchor.key)
|
|
80
|
+
if (held) s.scrollTop += held.getBoundingClientRect().top - anchor.y
|
|
81
|
+
else if (!wasLoom || atBottom) s.scrollTop = s.scrollHeight
|
|
82
|
+
else {
|
|
83
|
+
s.scrollTop = top
|
|
84
|
+
if (seen && model.items.some((it) => it.lane && !seen.has(it.key))) fresh = true
|
|
85
|
+
}
|
|
86
|
+
s.scrollLeft = left
|
|
87
|
+
anchor = null
|
|
88
|
+
// A conversation just followed: its latest card, in the middle of the Loom (only the Loom scrolls).
|
|
89
|
+
if (followed && convFocus) {
|
|
90
|
+
const cards = [...s.querySelectorAll('.lc-turn')].filter((x) => x.dataset.lane === convFocus.lane && x.dataset.conv === convFocus.conv)
|
|
91
|
+
const last = cards[cards.length - 1]
|
|
92
|
+
if (last) s.scrollTop += last.getBoundingClientRect().top - s.getBoundingClientRect().top - (s.clientHeight - last.offsetHeight) / 2
|
|
93
|
+
}
|
|
94
|
+
followed = false
|
|
95
|
+
if (!seen) seen = new Set()
|
|
96
|
+
for (const it of model.items) seen.add(it.key)
|
|
97
|
+
// A file lit by a hover that ended while something else was showing (it was clicked open) stays
|
|
98
|
+
// dark: before drawThreads, which lights what's remembered.
|
|
99
|
+
if (!wasLoom) focused = null
|
|
100
|
+
bind(el)
|
|
101
|
+
watchSize(el)
|
|
102
|
+
drawThreads(el)
|
|
103
|
+
if (focused) focusFile(el, focused)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ------------------------------------------------------------- markup --
|
|
107
|
+
|
|
108
|
+
function loomHtml (m, { prefs, me, names, meAgent, merged }) {
|
|
109
|
+
const lanes = m.lanes
|
|
110
|
+
const has = (at) => at && lanes.some((l) => l.name === at.lane && l.convs.some((c) => c.conv === at.conv))
|
|
111
|
+
if (convFocus && !has(convFocus)) convFocus = null
|
|
112
|
+
if (renaming && !has(renaming)) renaming = null
|
|
113
|
+
const byName = new Map(lanes.map((l) => [l.name, l]))
|
|
114
|
+
const WIDTH = { open: 'minmax(250px, 1fr)', strip: '46px', crowd: '58px', merged: 'minmax(0, 1fr)' }
|
|
115
|
+
const cols = m.columns.length ? m.columns.map((c) => WIDTH[c.kind]).join(' ') : 'minmax(0, 1fr)'
|
|
116
|
+
const colorOf = new Map(lanes.map((l) => [l.name, colorFor(l.name, l.color)]))
|
|
117
|
+
const turns = m.items.filter((it) => it.type === 'turn')
|
|
118
|
+
const convs = new Set(turns.map((t) => `${t.lane}|${t.conv}`)).size
|
|
119
|
+
const working = lanes.filter((l) => l.working).length
|
|
120
|
+
const dens = prefs.density === 'compact' ? 'compact' : 'detailed'
|
|
121
|
+
const hidden = (prefs.hidden || []).filter(Boolean)
|
|
122
|
+
|
|
123
|
+
const bar = `<div class="loom-bar">
|
|
124
|
+
<div class="loom-title"><b>Everyone</b><span class="hint">${convs} conversation${convs === 1 ? '' : 's'} · ${m.files.size} file${m.files.size === 1 ? '' : 's'} changed${working ? ` · <span class="loom-live"><span class="pulse"></span>${working} working</span>` : ''}</span></div>
|
|
125
|
+
<span class="spacer"></span>
|
|
126
|
+
${hidden.map((n) => `<button class="loom-chip off" data-loom-unhide="${esc(n)}" title="Show ${esc(n)}'s lane">${I.eye}<span>${esc(n)}</span></button>`).join('')}
|
|
127
|
+
<div class="loom-seg" role="group" aria-label="Show">
|
|
128
|
+
<button class="loom-chip${prefs.chat !== false ? ' on' : ''}" data-loom-pref="chat" aria-pressed="${prefs.chat !== false}">${I.chat}<span>Chat</span></button>
|
|
129
|
+
<button class="loom-chip${prefs.tasks !== false ? ' on' : ''}" data-loom-pref="tasks" aria-pressed="${prefs.tasks !== false}">${I.board}<span>Tasks</span></button>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="loom-seg" role="group" aria-label="Detail">
|
|
132
|
+
<button class="loom-chip${dens === 'detailed' ? ' on' : ''}" data-loom-density="detailed" aria-pressed="${dens === 'detailed'}">Detailed</button>
|
|
133
|
+
<button class="loom-chip${dens === 'compact' ? ' on' : ''}" data-loom-density="compact" aria-pressed="${dens === 'compact'}">Compact</button>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="loom-seg wide-only" role="group" aria-label="Layout">
|
|
136
|
+
<button class="loom-chip${prefs.layout !== 'merged' ? ' on' : ''}" data-loom-layout="lanes" aria-pressed="${prefs.layout !== 'merged'}" title="A lane per person">Lanes</button>
|
|
137
|
+
<button class="loom-chip${prefs.layout === 'merged' ? ' on' : ''}" data-loom-layout="merged" aria-pressed="${prefs.layout === 'merged'}" title="One column, in time order">One column</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>`
|
|
140
|
+
|
|
141
|
+
const heads = merged
|
|
142
|
+
? ''
|
|
143
|
+
: `<div class="loom-heads" style="grid-template-columns:${cols}">${m.columns.map((c) => {
|
|
144
|
+
if (c.kind === 'crowd') return crowdHeadHtml(c.lanes.map((n) => byName.get(n)), colorOf)
|
|
145
|
+
const l = byName.get(c.lane)
|
|
146
|
+
return c.kind === 'strip' ? stripHeadHtml(l, colorOf.get(l.name)) : headHtml(l, colorOf.get(l.name))
|
|
147
|
+
}).join('')}</div>`
|
|
148
|
+
|
|
149
|
+
const parts = []
|
|
150
|
+
if (!merged) {
|
|
151
|
+
m.columns.forEach((c, i) => {
|
|
152
|
+
const l = c.lane ? byName.get(c.lane) : null
|
|
153
|
+
// A lane with a needle: its thread ends at the needle's patch instead of running through it.
|
|
154
|
+
const needled = l && l.working && m.rows > 0
|
|
155
|
+
const cls = l ? `${l.sharing ? '' : ' paused'}${l.working ? ' live' : ''}${c.kind === 'strip' ? ' strip' : ''}${needled ? ' to-needle' : ''}` : ' crowd'
|
|
156
|
+
parts.push(`<div class="loom-thread${cls}" style="grid-column:${i + 1};grid-row:1 / ${m.rows + (needled ? 1 : 2)};--c:${esc(l ? colorOf.get(l.name) : 'var(--border-strong)')}" aria-hidden="true"></div>`)
|
|
157
|
+
})
|
|
158
|
+
} else {
|
|
159
|
+
parts.push(`<div class="loom-thread merged" style="grid-column:1;grid-row:1 / ${m.rows + 2}" aria-hidden="true"></div>`)
|
|
160
|
+
}
|
|
161
|
+
for (const it of m.items) if (!it.folded) parts.push(itemHtml(it, { colorOf, me, names, meAgent, merged, dens }))
|
|
162
|
+
for (const k of m.knots) parts.push(knotHtml(k, m, colorOf, me))
|
|
163
|
+
if (!m.items.some((it) => it.lane)) {
|
|
164
|
+
parts.push(`<div class="loom-empty" style="grid-column:1 / -1;grid-row:1">
|
|
165
|
+
<div class="t">Nothing woven yet</div>
|
|
166
|
+
<div class="hint">When anyone here works with their AI, each turn shows up in their lane: what they asked, what it did and which files it changed. Chat and task notes appear in the lane of whoever wrote them.</div></div>`)
|
|
167
|
+
}
|
|
168
|
+
// A working AI's needle, with the file its latest turn last changed.
|
|
169
|
+
let stacked = 0 // one column: needles one under another
|
|
170
|
+
lanes.forEach((l) => {
|
|
171
|
+
if (!l.working || l.fold === 'crowd') return
|
|
172
|
+
if (l.fold === 'strip') {
|
|
173
|
+
parts.push(`<div class="loom-needle mini" style="grid-column:${l.col + 1};grid-row:${m.rows + 1};--c:${esc(colorOf.get(l.name))}" title="${esc(`${l.name}'s AI is stitching`)}">${NEEDLE}</div>`)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const turn = m.items.filter((it) => it.type === 'turn' && it.lane === l.name).pop()
|
|
177
|
+
const file = turn && turn.edits.length ? turn.edits[turn.edits.length - 1] : ''
|
|
178
|
+
const who = l.isMe ? 'Your AI' : `${l.name}'s AI`
|
|
179
|
+
const what = file ? ` <code title="${esc(file)}">${esc(basename(file))}</code>` : ''
|
|
180
|
+
const col = merged ? 1 : l.col + 1
|
|
181
|
+
const row = m.rows + 1 + (merged ? stacked++ : 0)
|
|
182
|
+
parts.push(`<div class="loom-needle" style="grid-column:${col};grid-row:${row};--c:${esc(colorOf.get(l.name))}">${NEEDLE}<span>${esc(who)} is stitching${what}…</span></div>`)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return `<div class="loom${merged ? ' merged' : ''}${convFocus ? ' following' : ''}" data-density="${dens}">
|
|
186
|
+
${bar}
|
|
187
|
+
<div class="loom-scroll">
|
|
188
|
+
${heads}
|
|
189
|
+
<div class="loom-grid" role="feed" aria-label="Everyone's AI conversations, chat and task notes" style="grid-template-columns:${cols}">
|
|
190
|
+
${parts.join('')}
|
|
191
|
+
<svg class="loom-stitches" aria-hidden="true"></svg>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<button class="btn sm loom-jump" title="Back down to the latest" hidden>${I.down}<span>Latest</span></button>
|
|
195
|
+
<div class="loom-tip" role="tooltip" hidden></div>
|
|
196
|
+
</div>`
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function headHtml (l, color) {
|
|
200
|
+
const status = !l.sharing ? '<span class="lh-st paused">paused</span>'
|
|
201
|
+
: l.working ? '<span class="lh-st live"><span class="pulse"></span><span class="lh-word">working</span></span>'
|
|
202
|
+
: l.online ? '<span class="lh-st"><span class="lh-word">idle</span></span>' : '<span class="lh-st off">offline</span>'
|
|
203
|
+
const holds = l.holds.length
|
|
204
|
+
? `<button type="button" class="lh-holds" data-loom-holds="${esc(l.name)}" title="${esc(`Holds ${l.holds.join(', ')}. Click for how long, why and who waits`)}">holds ${l.holds.length}${l.waiting ? ` · ${l.waiting} waiting` : ''}</button>`
|
|
205
|
+
: ''
|
|
206
|
+
return `<div class="loom-head" style="--c:${esc(color)}">
|
|
207
|
+
<button class="lh-who" data-loom-person="${esc(l.name)}" title="${esc(l.isMe ? 'Your AI chat' : `${l.name}'s AI chat`)}">
|
|
208
|
+
${avatar(l.name, l.color, l.online)}
|
|
209
|
+
<span class="lh-name">${esc(l.isMe ? 'You' : l.name)}</span>
|
|
210
|
+
${l.tool ? `<span class="lh-tool" title="${esc(toolLabel(l.tool))}">${toolLogo(l.tool)}</span>` : ''}
|
|
211
|
+
${l.kind === 'agent' ? '<span class="tag">AI</span>' : ''}
|
|
212
|
+
</button>
|
|
213
|
+
<span class="lh-meta">${status}${sinceHtml(l)}${holds}</span>
|
|
214
|
+
<button class="lh-pin${l.pinned ? ' on' : ''}" data-loom-pin="${esc(l.name)}" title="${l.pinned ? 'Unpin: let this lane fold when there\'s no room' : 'Pin: keep this lane open'}" aria-pressed="${l.pinned}" aria-label="${esc(`${l.pinned ? 'Unpin' : 'Pin'} ${l.name}'s lane`)}">${PIN}</button>
|
|
215
|
+
<button class="lh-hide" data-loom-hide="${esc(l.name)}" title="Hide this lane" aria-label="Hide ${esc(l.name)}'s lane">${I.x}</button>
|
|
216
|
+
${convsHtml(l)}
|
|
217
|
+
</div>`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A lane's conversations, when there's more than one: one row of chips, each its piece of the mark.
|
|
222
|
+
* Click one to follow just that conversation: it moves to the front and opens out to its name (and
|
|
223
|
+
* the pencil, to rename it on this computer), and the others condense to bare marks after it, still a
|
|
224
|
+
* click away. Where even those leave the name no room, they fold into a "+N" that shows them on hover
|
|
225
|
+
* or click (packConvs). A conversation quiet a while keeps its piece, greyed; one working now laps.
|
|
226
|
+
*/
|
|
227
|
+
function convsHtml (l) {
|
|
228
|
+
if (!l.convs || l.convs.length < 2) return ''
|
|
229
|
+
const live = l.convs.filter((c) => c.live).length
|
|
230
|
+
const label = `${l.isMe ? 'Your' : `${l.name}'s`} conversations: ${l.convs.length}, ${live} live`
|
|
231
|
+
const picked = (at) => !!at && at.lane === l.name && l.convs.some((c) => c.conv === at.conv) ? at.conv : null
|
|
232
|
+
const sel = picked(renaming) || picked(convFocus)
|
|
233
|
+
const chip = (c) => {
|
|
234
|
+
const at = `data-lane="${esc(l.name)}" data-conv="${esc(c.conv)}"`
|
|
235
|
+
if (renaming && renaming.lane === l.name && renaming.conv === c.conv) {
|
|
236
|
+
return `<span class="lh-conv on editing">${CONV_MARKS[c.slot]}<input class="lh-conv-edit" ${at} value="${esc(c.name)}" maxlength="60" aria-label="Name this conversation (Enter to keep, Esc to cancel, empty for its own name)"></span>`
|
|
237
|
+
}
|
|
238
|
+
const on = c.conv === sel
|
|
239
|
+
const state = c.active ? 'working now' : c.live ? 'live' : `quiet since ${clock(c.last)}`
|
|
240
|
+
const tip = `${c.name}: ${c.turns} turn${c.turns === 1 ? '' : 's'}, ${state}. ${on ? 'Click to see every conversation' : 'Click to follow just this conversation'}`
|
|
241
|
+
return `<span class="lh-conv${on ? ' on' : ''}${c.live ? '' : ' quiet'}"><button class="lh-conv-btn" data-loom-follow ${at} aria-pressed="${on}" aria-label="${esc(c.name)}" title="${esc(tip)}">${(c.active ? CONV_MARKS_LIVE : CONV_MARKS)[c.slot]}<span class="lh-conv-name">${esc(c.name)}</span></button>${on ? `<button class="lh-conv-ren" data-loom-rename ${at} title="Rename it (on this computer)" aria-label="${esc(`Rename ${c.name}`)}">${I.pencil}</button>` : ''}</span>`
|
|
242
|
+
}
|
|
243
|
+
if (!sel) return `<div class="lh-convs" role="group" aria-label="${esc(label)}">${l.convs.map(chip).join('')}</div>`
|
|
244
|
+
const others = l.convs.filter((c) => c.conv !== sel)
|
|
245
|
+
return `<div class="lh-convs following" role="group" aria-label="${esc(label)}">${chip(l.convs.find((c) => c.conv === sel))}<span class="lh-others">
|
|
246
|
+
<button class="lh-more" data-loom-more aria-expanded="false" title="${esc(`${others.length} other conversation${others.length === 1 ? '' : 's'}: ${others.map((c) => c.name).join(', ')}`)}">+${others.length}</button>
|
|
247
|
+
<span class="lh-pop">${others.map(chip).join('')}</span></span></div>`
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* A followed conversation's name gets the row; where the condensed marks beside it still leave it
|
|
252
|
+
* less than NAME_ROOM, they fold into their "+N" (.packed). Run after each render and resize.
|
|
253
|
+
*/
|
|
254
|
+
const NAME_ROOM = 100
|
|
255
|
+
function packConvs (root) {
|
|
256
|
+
for (const row of root.querySelectorAll('.lh-convs.following')) {
|
|
257
|
+
row.classList.remove('packed')
|
|
258
|
+
const name = row.querySelector('.lh-conv.on .lh-conv-name, .lh-conv.on .lh-conv-edit')
|
|
259
|
+
if (name && name.clientWidth < NAME_ROOM && name.scrollWidth > name.clientWidth) row.classList.add('packed')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** How long a lane has been on its focus (stitching), beside its status; tickClocks counts it up. */
|
|
264
|
+
function sinceHtml (l) {
|
|
265
|
+
if (!l.since) return ''
|
|
266
|
+
const what = `${l.isMe ? 'You have' : `${l.name} has`} been stitching${l.focus ? ` on "${l.focus}"` : ''} since ${clock(l.since)}`
|
|
267
|
+
return `<span class="lh-since" data-since="${l.since}" title="${esc(what)}">${esc(stitchTime(l.since))}</span>`
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function tickClocks () {
|
|
271
|
+
for (const n of document.querySelectorAll('.loom [data-since]')) n.textContent = stitchTime(Number(n.dataset.since))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A folded lane's head: its avatar, a pulse while its AI works. Click to open the lane. */
|
|
275
|
+
function stripHeadHtml (l, color) {
|
|
276
|
+
const status = !l.sharing ? 'paused' : l.working ? 'working' : l.online ? 'idle' : 'offline'
|
|
277
|
+
return `<button class="loom-head strip${l.working ? ' live' : ''}" data-loom-open="${esc(l.name)}" style="--c:${esc(color)}" title="${esc(`${l.isMe ? 'You' : l.name} (${status}${l.since ? `, stitching for ${stitchTime(l.since)}` : l.lastTs ? `, last active ${ago(l.lastTs)}` : ''}). Click to open this lane`)}" aria-label="${esc(`Open ${l.name}'s lane`)}">
|
|
278
|
+
${avatar(l.name, l.color, l.online)}${l.working ? '<span class="pulse"></span>' : ''}
|
|
279
|
+
</button>`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The crowd column's head: a few avatars and a count; it opens a list of everyone in it. */
|
|
283
|
+
function crowdHeadHtml (lanes, colorOf) {
|
|
284
|
+
const live = lanes.filter((l) => l.working).length
|
|
285
|
+
const list = [...lanes].sort((a, b) => b.lastTs - a.lastTs).map((l) => `<button class="lcw-row" data-loom-open="${esc(l.name)}" role="menuitem">
|
|
286
|
+
${avatar(l.name, l.color, l.online)}<span class="lcw-name">${esc(l.isMe ? 'You' : l.name)}</span>
|
|
287
|
+
<span class="lcw-st">${l.working ? `<span class="pulse"></span>working${l.since ? ` · ${esc(stitchTime(l.since))}` : ''}` : l.lastTs ? esc(ago(l.lastTs)) : 'nothing yet'}</span></button>`).join('')
|
|
288
|
+
return `<div class="loom-head crowd${crowdOpen ? ' open' : ''}">
|
|
289
|
+
<button class="lcw-btn" data-loom-crowd aria-haspopup="true" aria-expanded="${crowdOpen}" title="${esc(`${lanes.length} quieter lanes: ${lanes.map((l) => l.name).join(', ')}`)}">
|
|
290
|
+
<span class="lcw-stack">${lanes.slice(0, 3).map((l) => avatar(l.name, colorOf.get(l.name))).join('')}</span>
|
|
291
|
+
<span class="lcw-n">+${lanes.length}</span>${live ? '<span class="pulse"></span>' : ''}
|
|
292
|
+
</button>
|
|
293
|
+
<div class="lcw-list" role="menu" aria-label="Quieter lanes"${crowdOpen ? '' : ' hidden'}>
|
|
294
|
+
<div class="lcw-title">Quieter lanes</div>${list}
|
|
295
|
+
</div>
|
|
296
|
+
</div>`
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Everything a folded lane did around one row, as one knot (a count when it's several). */
|
|
300
|
+
function knotHtml (k, m, colorOf, me) {
|
|
301
|
+
const lane = k.lanes[k.lanes.length - 1]
|
|
302
|
+
const fresh = seen && k.keys.some((key) => !seen.has(key)) ? ' kn-new' : ''
|
|
303
|
+
const who = k.lanes.map((n) => (n === me ? 'You' : n)).join(', ')
|
|
304
|
+
const n = k.ids.length
|
|
305
|
+
return `<button class="loom-knot${fresh}" data-knot="${k.ids.join(' ')}" data-loom-open="${esc(lane)}" tabindex="-1" style="grid-column:${k.col + 1};grid-row:${k.row};--c:${esc(colorOf.get(lane) || colorFor(lane))}" aria-label="${esc(`${who}: ${n} thing${n === 1 ? '' : 's'} at ${clock(k.ts)}. Open the lane`)}">
|
|
306
|
+
<span class="kn-dot"></span>${n > 1 ? `<span class="kn-n">${n}</span>` : ''}</button>`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function place (it, merged) {
|
|
310
|
+
return `grid-column:${merged || it.col == null ? '1 / -1' : it.col + 1};grid-row:${it.row} / span ${it.span || 1}`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function itemHtml (it, { colorOf, me, names, meAgent, merged, dens }) {
|
|
314
|
+
if (it.type === 'gap' || it.type === 'day') {
|
|
315
|
+
const label = it.type === 'day'
|
|
316
|
+
? new Date(it.ts).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })
|
|
317
|
+
: `${later(it.ms)} later`
|
|
318
|
+
return `<div class="loom-gap" style="grid-column:1 / -1;grid-row:${it.row}"><span>${esc(label)}</span></div>`
|
|
319
|
+
}
|
|
320
|
+
const color = colorOf.get(it.lane) || colorFor(it.lane)
|
|
321
|
+
const style = `${place(it, merged)};--c:${esc(color)}`
|
|
322
|
+
// Following one conversation: everything else in the Loom steps back.
|
|
323
|
+
const dim = convFocus && !(it.type === 'turn' && it.lane === convFocus.lane && it.conv === convFocus.conv)
|
|
324
|
+
const fresh = (seen && !seen.has(it.key) ? ' lc-new' : '') + (dim ? ' lc-dim' : '')
|
|
325
|
+
const who = merged ? `<span class="lc-who">${avatar(it.lane, color)}<b>${esc(it.lane === me ? 'You' : it.lane)}</b></span>` : ''
|
|
326
|
+
const time = `<time title="${esc(new Date(it.ts).toLocaleString())}">${esc(clock(it.ts))}</time>`
|
|
327
|
+
|
|
328
|
+
if (it.type === 'paused' || it.type === 'resumed') {
|
|
329
|
+
return `<div class="lc-mark${fresh}" data-id="${it.id}" style="${style}">${who}<span>${it.type === 'paused' ? 'Paused sharing' : 'Resumed sharing'}</span>${time}</div>`
|
|
330
|
+
}
|
|
331
|
+
if (it.type === 'chat') {
|
|
332
|
+
const to = it.to ? `<span class="lc-dm">to ${esc(it.to === me ? 'you' : it.to)}</span>` : ''
|
|
333
|
+
return `<article class="lc lc-chat${fresh}" data-id="${it.id}" tabindex="0" style="${style}" aria-label="${esc(`${it.lane} in chat`)}">
|
|
334
|
+
<header class="lc-head">${who}<span class="lc-kind">${I.chat}</span><span class="lc-label">${it.to ? 'Direct message' : 'Chat'}</span>${to}${time}</header>
|
|
335
|
+
${it.text ? `<div class="lc-text">${textHtml(it.text, names, me, { meAgent })}</div>` : ''}
|
|
336
|
+
${it.file ? `<div class="lc-files"><span class="lc-file plain">${I.clip}${esc(it.file)}</span></div>` : ''}
|
|
337
|
+
</article>`
|
|
338
|
+
}
|
|
339
|
+
if (it.type === 'task' || it.type === 'note') {
|
|
340
|
+
const col = `<span class="lc-col c-${esc(it.task.column)}">${esc(COLUMN[it.task.column] || it.task.column)}</span>`
|
|
341
|
+
return `<article class="lc lc-task${fresh}" data-id="${it.id}" tabindex="0" style="${style}" aria-label="${esc(it.type === 'task' ? `${it.lane} added a task` : `${it.lane} left a note on a task`)}">
|
|
342
|
+
<header class="lc-head">${who}<span class="lc-kind">${I.board}</span><span class="lc-label">${it.type === 'task' ? 'Added a task' : 'Note on a task'}</span>${time}</header>
|
|
343
|
+
<button class="lc-tasklink" data-loom-task="${esc(it.task.id)}" title="Open the task board">${esc(it.task.title)}</button>${col}
|
|
344
|
+
${it.type === 'note' ? `<div class="lc-text">${esc(it.text)}</div>` : ''}
|
|
345
|
+
</article>`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// An AI turn.
|
|
349
|
+
const open = expanded.has(it.key)
|
|
350
|
+
const prompt = visiblePrompt(it.prompt)
|
|
351
|
+
const reply = it.replies.length ? it.replies[it.replies.length - 1] : null
|
|
352
|
+
const done = (it.tasks || []).some((t) => t.column === 'done')
|
|
353
|
+
const files = open ? it.edits : it.edits.slice(0, FILES_SHOWN)
|
|
354
|
+
const more = it.edits.length - files.length
|
|
355
|
+
const counts = [
|
|
356
|
+
it.edits.length ? `${it.edits.length} file${it.edits.length === 1 ? '' : 's'} changed` : '',
|
|
357
|
+
it.reads ? `${it.reads} read` : '',
|
|
358
|
+
it.runs ? `${it.runs} command${it.runs === 1 ? '' : 's'}` : ''
|
|
359
|
+
].filter(Boolean)
|
|
360
|
+
const tasks = (it.tasks || []).map((t) => `<button class="lc-taskchip c-${esc(t.column)}" data-loom-task="${esc(t.id)}" title="${esc(`${COLUMN[t.column] || t.column}: ${t.title}`)}">${I.board}<span>${esc(t.title)}</span></button>`).join('')
|
|
361
|
+
const summary = it.summary || (reply && reply.summary) ? '<span class="tag summary" title="Summarized before sharing">summary</span>' : ''
|
|
362
|
+
return `<article class="lc lc-turn${open ? ' open' : ''}${done ? ' hemmed' : ''}${fresh}" data-id="${it.id}" data-key="${esc(it.key)}" data-lane="${esc(it.lane)}" data-conv="${esc(it.conv)}" tabindex="0" style="${style}" aria-expanded="${open}" aria-label="${esc(`${it.lane}'s AI: ${prompt.slice(0, 80) || 'a turn'}`)}">
|
|
363
|
+
<header class="lc-head">${who}<span class="lc-tool" title="${esc(toolLabel(it.tool))}">${toolLogo(it.tool)}</span>
|
|
364
|
+
${convChip(it)}${summary}${time}</header>
|
|
365
|
+
${prompt ? `<div class="lc-prompt">${esc(prompt)}</div>` : ''}
|
|
366
|
+
${reply && dens === 'detailed' && !open ? `<div class="lc-reply">${esc(plain(reply.text))}</div>` : ''}
|
|
367
|
+
${open ? `<div class="lc-replies">${it.replies.map((r) => `<div class="md">${markdown(r.text)}</div>`).join('')}</div>` : ''}
|
|
368
|
+
${files.length && (dens === 'detailed' || open) ? `<div class="lc-files">${files.map((p) => `<button class="lc-file" data-file="${esc(p)}">${esc(basename(p))}</button>`).join('')}${more > 0 ? `<span class="lc-more">+${more}</span>` : ''}</div>` : ''}
|
|
369
|
+
${counts.length || tasks ? `<div class="lc-meta">${counts.length ? `<span>${esc(counts.join(' · '))}</span>` : ''}${tasks}</div>` : ''}
|
|
370
|
+
${open && it.actions.length ? `<ol class="lc-actions">${it.actions.map((a) => `<li>${esc(a)}</li>`).join('')}</ol>` : ''}
|
|
371
|
+
${open ? `<div class="lc-foot"><button class="btn sm" data-loom-conv="${esc(it.lane)}" data-loom-convid="${esc(it.conv)}">Open the whole conversation${I.arrowRight}</button></div>` : ''}
|
|
372
|
+
</article>`
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const COLUMN = { todo: 'To do', doing: 'In progress', qa: 'QA', done: 'Done' }
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* A turn's conversation on its card. In a lane with several, it carries the conversation's mark
|
|
379
|
+
* and following it is a click away (opening it stays in the open card); with one, it opens it.
|
|
380
|
+
*/
|
|
381
|
+
function convChip (it) {
|
|
382
|
+
if (it.convSlot == null) return `<button class="lc-conv" data-loom-conv="${esc(it.lane)}" data-loom-convid="${esc(it.conv)}" title="Open this conversation">${esc(it.convLabel || toolLabel(it.tool))}</button>`
|
|
383
|
+
const on = !!convFocus && convFocus.lane === it.lane && convFocus.conv === it.conv
|
|
384
|
+
return `<button class="lc-conv marked" data-loom-follow data-lane="${esc(it.lane)}" data-conv="${esc(it.conv)}" aria-pressed="${on}" title="${esc(`${it.convLabel}: click to ${on ? 'see every conversation' : 'follow just this conversation'}`)}">${CONV_MARKS[it.convSlot]}<span>${esc(it.convLabel)}</span></button>`
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Markdown as one plain line of text, for a card's preview of a reply. */
|
|
388
|
+
function plain (text) {
|
|
389
|
+
return String(text || '')
|
|
390
|
+
.replace(/```[\s\S]*?```/g, ' [code] ')
|
|
391
|
+
.replace(/[*_`#>]+/g, '')
|
|
392
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
393
|
+
.replace(/\s+/g, ' ')
|
|
394
|
+
.trim()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function later (ms) {
|
|
398
|
+
const min = Math.round(ms / 60000)
|
|
399
|
+
if (min < 60) return `${min} min`
|
|
400
|
+
const h = Math.round(min / 6) / 10
|
|
401
|
+
return `${h % 1 ? h.toFixed(1) : h} h`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 17v5" /><path d="M9 10.8V4h6v6.8l2.6 3.2H6.4z" /></svg>'
|
|
405
|
+
// A conversation's mark: the Q with its own piece lit (slot 0-7, clockwise from the top left, the
|
|
406
|
+
// corner last), in its lane's colour (--c). One per slot, made once. The working variant adds a runner
|
|
407
|
+
// that laps the Q from that slot while the home piece waits (loom.css: .cm-live).
|
|
408
|
+
const CM_CYCLE = 2860
|
|
409
|
+
const convMark = (slot, working) => {
|
|
410
|
+
const { patch: p, radius: r } = GRID
|
|
411
|
+
const on = (i) => (i === slot ? (working ? ' on cm-home' : ' on') : '')
|
|
412
|
+
const run = working ? `<rect class="cm-run cm-run${slot}" width="${p}" height="${p}" rx="${r}" />` : ''
|
|
413
|
+
return `<svg class="cm${working ? ' cm-live' : ''}" viewBox="0 0 57 57" aria-hidden="true">${PIECES.map(([x, y], i) => `<rect class="cm-p${on(i)}" x="${x}" y="${y}" width="${p}" height="${p}" rx="${r}" />`).join('')}<path class="cm-p${on(7)}" d="${CORNER.ink}" /><path class="cm-a${on(7)}" d="${CORNER.accent}" />${run}</svg>`
|
|
414
|
+
}
|
|
415
|
+
const CONV_MARKS = Array.from({ length: 8 }, (_, slot) => convMark(slot, false))
|
|
416
|
+
const CONV_MARKS_LIVE = Array.from({ length: 8 }, (_, slot) => convMark(slot, true))
|
|
417
|
+
|
|
418
|
+
// A working AI's needle: it pieces patches on its lane's thread and sews them into the Q, slot by slot,
|
|
419
|
+
// the vermilion corner last (loom.css has the timeline). At rest (no motion), the finished Q.
|
|
420
|
+
const LQ_CYCLE = 10800
|
|
421
|
+
export const NEEDLE = (() => {
|
|
422
|
+
const { patch: p, radius: r } = GRID
|
|
423
|
+
const seam = `<rect class="lq-s" x=".6" y=".6" width="${p - 1.2}" height="${p - 1.2}" rx="${r}" pathLength="60" />`
|
|
424
|
+
const slots = [...PIECES, [40, 40]]
|
|
425
|
+
const outline = (x, y, i) => i < 7
|
|
426
|
+
? `<rect class="lq-o lq-o${i}" x="${x + 0.6}" y="${y + 0.6}" width="${p - 1.2}" height="${p - 1.2}" rx="${r}" />`
|
|
427
|
+
: `<path class="lq-o lq-o${i}" d="M40.6 42.6 A2 2 0 0 1 42.6 40.6 H56.4 V54.4 A2 2 0 0 1 54.4 56.4 H40.6 Z" />`
|
|
428
|
+
const piece = (x, y, i) => `<g class="lq-p lq-p${i}" transform="translate(${x} ${y})">${i < 7
|
|
429
|
+
? `<rect class="lq-f" width="${p}" height="${p}" rx="${r}" />`
|
|
430
|
+
: `<g transform="translate(-40 -40)"><path class="lq-f" d="${CORNER.ink}" /><path class="lq-f lq-a" d="${CORNER.accent}" /></g>`}${seam}</g>`
|
|
431
|
+
return `<svg class="lq" viewBox="-1.5 -40 60 98.5" aria-hidden="true">${slots.map(([x, y], i) => outline(x, y, i)).join('')}${slots.map(([x, y], i) => piece(x, y, i)).join('')}</svg>`
|
|
432
|
+
})()
|
|
433
|
+
|
|
434
|
+
/** Starts each needle (and each working conversation's lap) where the clock says it is in its cycle, so a re-render doesn't start it again. */
|
|
435
|
+
function phaseNeedles (el) {
|
|
436
|
+
const ph = `-${Date.now() % LQ_CYCLE}ms`
|
|
437
|
+
for (const n of el.querySelectorAll('.lq')) n.style.setProperty('--lq-ph', ph)
|
|
438
|
+
const lap = `-${Date.now() % CM_CYCLE}ms`
|
|
439
|
+
for (const n of el.querySelectorAll('.cm-live')) n.style.setProperty('--cm-ph', lap)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ------------------------------------------------------------- threads --
|
|
443
|
+
|
|
444
|
+
const SVGNS = 'http://www.w3.org/2000/svg'
|
|
445
|
+
let sizeObs = null
|
|
446
|
+
let raf = 0
|
|
447
|
+
let settle = 0
|
|
448
|
+
// How long the width must hold still before the Loom renders again for it: folding the Files
|
|
449
|
+
// pane or the chat slides the middle's width for ~280ms, and rendering again on every frame
|
|
450
|
+
// of that (the model, the markup, the saved view) is what made the slide stutter.
|
|
451
|
+
const SETTLE_MS = 120
|
|
452
|
+
|
|
453
|
+
function watchSize (el) {
|
|
454
|
+
if (sizeObs) sizeObs.disconnect()
|
|
455
|
+
const grid = el.querySelector('.loom-grid')
|
|
456
|
+
if (!grid || typeof ResizeObserver === 'undefined') return
|
|
457
|
+
sizeObs = new ResizeObserver(() => {
|
|
458
|
+
cancelAnimationFrame(raf)
|
|
459
|
+
raf = requestAnimationFrame(() => {
|
|
460
|
+
const root = el.querySelector(':scope > .loom')
|
|
461
|
+
if (!root) return
|
|
462
|
+
drawThreads(el)
|
|
463
|
+
packConvs(root)
|
|
464
|
+
// A new width can fit more lanes or fewer (or fold them into one column): render again, once it settles.
|
|
465
|
+
clearTimeout(settle)
|
|
466
|
+
if (el.clientWidth !== lastWidth) {
|
|
467
|
+
settle = setTimeout(() => {
|
|
468
|
+
if (el.querySelector(':scope > .loom') && el.clientWidth !== lastWidth) { handlers.onAction('refresh'); drawThreads(el) }
|
|
469
|
+
}, SETTLE_MS)
|
|
470
|
+
}
|
|
471
|
+
})
|
|
472
|
+
})
|
|
473
|
+
sizeObs.observe(grid)
|
|
474
|
+
sizeObs.observe(el)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Draws the stitches between cards that touched the same file, and @mention arrows. */
|
|
478
|
+
function drawThreads (el) {
|
|
479
|
+
const grid = el.querySelector('.loom-grid')
|
|
480
|
+
const svg = el.querySelector('.loom-stitches')
|
|
481
|
+
if (!grid || !svg || !model) return
|
|
482
|
+
const g = grid.getBoundingClientRect()
|
|
483
|
+
// The grid's own box, not its scroll size: that counts this svg too, so once tall it could
|
|
484
|
+
// never shrink, and left empty space under the last card when the cards got shorter.
|
|
485
|
+
const w = grid.clientWidth
|
|
486
|
+
const h = grid.clientHeight
|
|
487
|
+
svg.setAttribute('width', String(w))
|
|
488
|
+
svg.setAttribute('height', String(h))
|
|
489
|
+
svg.setAttribute('viewBox', `0 0 ${w} ${h}`)
|
|
490
|
+
const merged = el.querySelector('.loom').classList.contains('merged')
|
|
491
|
+
const card = (id) => nodeFor(grid, id)
|
|
492
|
+
const box = (node) => { const r = node.getBoundingClientRect(); return { l: r.left - g.left, r: r.right - g.left, t: r.top - g.top, b: r.bottom - g.top, h: r.height } }
|
|
493
|
+
const colorOf = new Map(model.lanes.map((l) => [l.name, colorFor(l.name, l.color)]))
|
|
494
|
+
const byId = new Map(model.items.map((it) => [it.id, it]))
|
|
495
|
+
const out = []
|
|
496
|
+
for (const s of model.stitches) {
|
|
497
|
+
const a = card(s.from)
|
|
498
|
+
const b = card(s.to)
|
|
499
|
+
if (!a || !b || a === b) continue
|
|
500
|
+
const chipY = (node, fallback) => {
|
|
501
|
+
const chip = [...node.querySelectorAll('[data-file]')].find((c) => c.dataset.file === s.path)
|
|
502
|
+
if (!chip) return fallback.t + Math.min(20, fallback.h / 2)
|
|
503
|
+
const c = box(chip)
|
|
504
|
+
return c.t + c.h / 2
|
|
505
|
+
}
|
|
506
|
+
const A = box(a)
|
|
507
|
+
const B = box(b)
|
|
508
|
+
const y1 = chipY(a, A)
|
|
509
|
+
const y2 = chipY(b, B)
|
|
510
|
+
let d
|
|
511
|
+
if (merged) {
|
|
512
|
+
const x = Math.max(A.r, B.r)
|
|
513
|
+
const bulge = Math.min(60, 18 + Math.abs(y2 - y1) / 8)
|
|
514
|
+
d = `M ${A.r} ${y1} C ${x + bulge} ${y1}, ${x + bulge} ${y2}, ${B.r} ${y2}`
|
|
515
|
+
} else {
|
|
516
|
+
const right = A.l < B.l
|
|
517
|
+
const x1 = right ? A.r : A.l
|
|
518
|
+
const x2 = right ? B.l : B.r
|
|
519
|
+
const dx = (x2 - x1) / 2
|
|
520
|
+
d = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`
|
|
521
|
+
}
|
|
522
|
+
const color = colorOf.get(byId.get(s.to)?.lane) || 'currentColor'
|
|
523
|
+
out.push(`<path class="st${s.collide ? ' collide' : ''}" data-path="${esc(s.path)}" data-from="${s.from}" data-to="${s.to}" d="${d}" style="--c:${esc(color)}" />`)
|
|
524
|
+
if (s.collide && !merged) {
|
|
525
|
+
const right = A.l < B.l
|
|
526
|
+
const mx = ((right ? A.r : A.l) + (right ? B.l : B.r)) / 2
|
|
527
|
+
const my = (y1 + y2) / 2
|
|
528
|
+
out.push(`<g class="knot" data-path="${esc(s.path)}" data-from="${s.from}" data-to="${s.to}" transform="translate(${mx} ${my})"><circle r="7" /><path d="M1 -4 L-2 1 L1 1 L-1 4" /></g>`)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (!merged) {
|
|
532
|
+
const heads = [...el.querySelectorAll('.loom-head')]
|
|
533
|
+
for (const mn of model.mentions) {
|
|
534
|
+
const a = card(mn.from)
|
|
535
|
+
const head = heads[mn.col]
|
|
536
|
+
if (!a || !head || a.matches('.loom-knot')) continue // a folded lane's mentions stay folded too
|
|
537
|
+
const A = box(a)
|
|
538
|
+
const h = head.getBoundingClientRect()
|
|
539
|
+
const x2 = h.left - g.left + h.width / 2
|
|
540
|
+
const right = A.l < x2
|
|
541
|
+
const x1 = right ? A.r : A.l
|
|
542
|
+
const y = A.t + Math.min(26, A.h / 2)
|
|
543
|
+
if (Math.abs(x2 - x1) < 4) continue
|
|
544
|
+
const color = colorOf.get(mn.lane) || 'currentColor'
|
|
545
|
+
out.push(`<path class="mention-line" data-from="${mn.from}" d="M ${x1} ${y} C ${(x1 + x2) / 2} ${y - 18}, ${x2} ${y - 10}, ${x2} ${y}" style="--c:${esc(color)}" /><circle class="mention-dot" data-from="${mn.from}" cx="${x2}" cy="${y}" r="3.5" style="--c:${esc(color)}" />`)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
svg.innerHTML = out.join('')
|
|
549
|
+
if (focused) focusFile(el, focused)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// ------------------------------------------------------------- focus --
|
|
553
|
+
|
|
554
|
+
/** The card showing item `id`, or the knot it's folded into. */
|
|
555
|
+
function nodeFor (root, id) {
|
|
556
|
+
return root.querySelector(`.loom-grid > [data-id="${id}"]`) || root.querySelector(`.loom-grid > [data-knot~="${id}"]`)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Lights every card and stitch on `path` and dims the rest (null: back to normal). */
|
|
560
|
+
export function focusFile (el, path) {
|
|
561
|
+
focused = path || null
|
|
562
|
+
const root = el?.querySelector('.loom')
|
|
563
|
+
if (!root) return
|
|
564
|
+
for (const n of root.querySelectorAll('.lit')) n.classList.remove('lit')
|
|
565
|
+
root.classList.toggle('focusing', !!focused)
|
|
566
|
+
if (!focused || !model) return
|
|
567
|
+
const f = model.files.get(focused)
|
|
568
|
+
for (const t of f ? f.touches : []) nodeFor(root, t.id)?.classList.add('lit')
|
|
569
|
+
for (const n of root.querySelectorAll('[data-path], [data-file]')) {
|
|
570
|
+
if ((n.dataset.path ?? n.dataset.file) === focused) n.classList.add('lit')
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A card's own stitches and the cards at their other end, lit while the pointer is on it. */
|
|
575
|
+
function traceCard (root, id, on) {
|
|
576
|
+
for (const n of root.querySelectorAll('.traced')) n.classList.remove('traced')
|
|
577
|
+
root.classList.toggle('tracing', !!(on && id))
|
|
578
|
+
if (!on || !id) return
|
|
579
|
+
root.querySelector(`[data-id="${id}"]`)?.classList.add('traced')
|
|
580
|
+
for (const n of root.querySelectorAll(`.loom-stitches [data-from="${id}"], .loom-stitches [data-to="${id}"]`)) {
|
|
581
|
+
n.classList.add('traced')
|
|
582
|
+
const other = n.dataset.from === id ? n.dataset.to : n.dataset.from
|
|
583
|
+
if (other) nodeFor(root, other)?.classList.add('traced')
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function tipFor (path) {
|
|
588
|
+
const f = model && model.files.get(path)
|
|
589
|
+
if (!f) return `<b>${esc(path)}</b>`
|
|
590
|
+
const touches = f.touches.map((t) => `${t.lane} ${clock(t.ts)}`)
|
|
591
|
+
const lanes = new Set(f.touches.map((t) => t.lane))
|
|
592
|
+
const claim = f.claim ? `<div>Held by ${esc(f.claim.by)}${f.claim.waiting.length ? ` · waiting: ${esc(f.claim.waiting.join(', '))}` : ''}</div>` : ''
|
|
593
|
+
return `<b>${esc(path)}</b>
|
|
594
|
+
<div>Changed by ${esc(touches.slice(-5).join(', '))}${touches.length > 5 ? ` and ${touches.length - 5} more` : ''}</div>
|
|
595
|
+
${lanes.size > 1 ? `<div class="warn">${lanes.size} people's AIs worked on this file</div>` : ''}${claim}
|
|
596
|
+
<div class="hint">Click to open it</div>`
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function knotTip (ids) {
|
|
600
|
+
const byId = new Map(model.items.map((it) => [it.id, it]))
|
|
601
|
+
const list = ids.map((id) => byId.get(id)).filter(Boolean)
|
|
602
|
+
const line = (it) => {
|
|
603
|
+
if (it.type === 'turn') {
|
|
604
|
+
const ask = visiblePrompt(it.prompt).split('\n')[0].slice(0, 90) || it.convLabel || 'AI turn'
|
|
605
|
+
const conv = it.convSlot != null && visiblePrompt(it.prompt) ? `<span class="hint">${esc(it.convLabel)}:</span> ` : ''
|
|
606
|
+
return `${conv}${esc(ask)}${it.edits.length ? ` <span class="hint">· ${it.edits.length} file${it.edits.length === 1 ? '' : 's'}</span>` : ''}`
|
|
607
|
+
}
|
|
608
|
+
if (it.type === 'chat') return `<span class="hint">Chat:</span> ${esc(it.text.slice(0, 90))}`
|
|
609
|
+
if (it.type === 'task') return `<span class="hint">Added a task:</span> ${esc(it.task.title)}`
|
|
610
|
+
if (it.type === 'note') return `<span class="hint">Note on</span> ${esc(it.task.title)}`
|
|
611
|
+
return esc(it.type === 'paused' ? 'Paused sharing' : 'Resumed sharing')
|
|
612
|
+
}
|
|
613
|
+
const lanes = [...new Set(list.map((it) => it.lane))]
|
|
614
|
+
return `<b class="kt-who">${esc(lanes.join(', '))}</b>
|
|
615
|
+
${list.slice(-4).map((it) => `<div class="kt-line"><span class="hint">${esc(clock(it.ts))}</span> ${line(it)}</div>`).join('')}
|
|
616
|
+
${list.length > 4 ? `<div class="hint">and ${list.length - 4} more</div>` : ''}
|
|
617
|
+
<div class="hint">Click to open ${lanes.length === 1 ? 'this lane' : `${esc(lanes[lanes.length - 1])}'s lane`}</div>`
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function showTip (root, anchor, html) {
|
|
621
|
+
const tip = root.querySelector('.loom-tip')
|
|
622
|
+
if (!tip) return
|
|
623
|
+
tip.innerHTML = html
|
|
624
|
+
tip.hidden = false
|
|
625
|
+
const r = anchor.getBoundingClientRect()
|
|
626
|
+
const w = tip.offsetWidth
|
|
627
|
+
const h = tip.offsetHeight
|
|
628
|
+
let x = Math.min(window.innerWidth - w - 8, Math.max(8, r.left + r.width / 2 - w / 2))
|
|
629
|
+
let y = r.top - h - 8
|
|
630
|
+
if (y < 8) y = r.bottom + 8
|
|
631
|
+
tip.style.left = `${Math.round(x)}px`
|
|
632
|
+
tip.style.top = `${Math.round(y)}px`
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ------------------------------------------------------------- events --
|
|
636
|
+
|
|
637
|
+
function bind (el) {
|
|
638
|
+
const root = el.querySelector('.loom')
|
|
639
|
+
const s = root.querySelector('.loom-scroll')
|
|
640
|
+
const jump = root.querySelector('.loom-jump')
|
|
641
|
+
// Scrolled up a way (or anything new came in below): a button back down to the latest.
|
|
642
|
+
let jumping = false // on its way down: stays hidden while the smooth scroll runs
|
|
643
|
+
let lastTop = s.scrollTop
|
|
644
|
+
const syncJump = () => {
|
|
645
|
+
const below = s.scrollHeight - s.scrollTop - s.clientHeight
|
|
646
|
+
if (below < 80 || s.scrollTop < lastTop) jumping = false // there, or scrolled back up on the way
|
|
647
|
+
if (below < 80) fresh = false
|
|
648
|
+
lastTop = s.scrollTop
|
|
649
|
+
jump.hidden = jumping || !(below > Math.max(160, s.clientHeight / 2) || (fresh && below >= 80))
|
|
650
|
+
jump.lastChild.textContent = fresh ? 'New activity' : 'Latest'
|
|
651
|
+
}
|
|
652
|
+
jump.onclick = () => { jumping = true; jump.hidden = true; s.scrollTo({ top: s.scrollHeight, behavior: 'smooth' }) }
|
|
653
|
+
s.addEventListener('scroll', () => {
|
|
654
|
+
syncJump()
|
|
655
|
+
root.querySelector('.loom-tip').hidden = true
|
|
656
|
+
}, { passive: true })
|
|
657
|
+
syncJump()
|
|
658
|
+
if (bound.has(el)) return
|
|
659
|
+
bound.add(el)
|
|
660
|
+
|
|
661
|
+
el.addEventListener('click', (e) => {
|
|
662
|
+
const r = el.querySelector('.loom')
|
|
663
|
+
if (!r || !r.contains(e.target)) return
|
|
664
|
+
const act = handlers.onAction
|
|
665
|
+
const t = e.target
|
|
666
|
+
const pick = (sel) => t.closest(sel)
|
|
667
|
+
let b
|
|
668
|
+
const crowd = r.querySelector('.loom-head.crowd')
|
|
669
|
+
if ((b = pick('[data-loom-crowd]'))) {
|
|
670
|
+
crowdOpen = !crowdOpen
|
|
671
|
+
crowd.classList.toggle('open', crowdOpen)
|
|
672
|
+
b.setAttribute('aria-expanded', String(crowdOpen))
|
|
673
|
+
crowd.querySelector('.lcw-list').hidden = !crowdOpen
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
if (crowdOpen && crowd && !crowd.contains(t)) { crowdOpen = false; crowd.classList.remove('open'); crowd.querySelector('.lcw-list').hidden = true }
|
|
677
|
+
if ((b = pick('[data-loom-open]'))) { crowdOpen = false; return act('open-lane', b.dataset.loomOpen) }
|
|
678
|
+
if ((b = pick('[data-loom-pin]'))) return act('pin', b.dataset.loomPin)
|
|
679
|
+
if ((b = pick('[data-loom-holds]'))) return act('holds', { name: b.dataset.loomHolds, anchor: b })
|
|
680
|
+
if ((b = pick('[data-loom-person]'))) return act('person', b.dataset.loomPerson)
|
|
681
|
+
if ((b = pick('[data-loom-follow]'))) {
|
|
682
|
+
const same = convFocus && convFocus.lane === b.dataset.lane && convFocus.conv === b.dataset.conv
|
|
683
|
+
convFocus = same ? null : { lane: b.dataset.lane, conv: b.dataset.conv }
|
|
684
|
+
followed = !same
|
|
685
|
+
return act('refresh')
|
|
686
|
+
}
|
|
687
|
+
for (const o of r.querySelectorAll('.lh-others.open')) {
|
|
688
|
+
if (!o.contains(t)) { o.classList.remove('open'); o.querySelector('.lh-more').setAttribute('aria-expanded', 'false') }
|
|
689
|
+
}
|
|
690
|
+
if ((b = pick('[data-loom-more]'))) {
|
|
691
|
+
const open = b.getAttribute('aria-expanded') !== 'true'
|
|
692
|
+
b.setAttribute('aria-expanded', String(open))
|
|
693
|
+
b.closest('.lh-others').classList.toggle('open', open)
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
if ((b = pick('[data-loom-rename]'))) { renaming = { lane: b.dataset.lane, conv: b.dataset.conv }; return act('refresh') }
|
|
697
|
+
if (pick('.lh-conv-edit')) return
|
|
698
|
+
if ((b = pick('[data-loom-conv]'))) return act('conv', { name: b.dataset.loomConv, conv: b.dataset.loomConvid })
|
|
699
|
+
if ((b = pick('[data-loom-task]'))) return act('task', b.dataset.loomTask)
|
|
700
|
+
if ((b = pick('[data-file]'))) return act('file', b.dataset.file)
|
|
701
|
+
if ((b = pick('[data-loom-hide]'))) return act('hide', b.dataset.loomHide)
|
|
702
|
+
if ((b = pick('[data-loom-unhide]'))) return act('unhide', b.dataset.loomUnhide)
|
|
703
|
+
if ((b = pick('[data-loom-pref]'))) return act('toggle', b.dataset.loomPref)
|
|
704
|
+
if ((b = pick('[data-loom-density]'))) return act('density', b.dataset.loomDensity)
|
|
705
|
+
if ((b = pick('[data-loom-layout]'))) return act('layout', b.dataset.loomLayout)
|
|
706
|
+
if (t.closest('a, button')) return
|
|
707
|
+
if (String(window.getSelection?.() || '')) return // selecting text in a card, not opening it
|
|
708
|
+
const c = pick('.lc-turn')
|
|
709
|
+
if (c) toggle(el, c)
|
|
710
|
+
})
|
|
711
|
+
// Naming a conversation: Enter (or leaving the box) keeps the name, Esc drops it.
|
|
712
|
+
const named = (input, keep) => {
|
|
713
|
+
if (!renaming) return
|
|
714
|
+
renaming = null
|
|
715
|
+
input.blur() // focus out comes back here and stops at !renaming; the redraw no longer waits for typing
|
|
716
|
+
if (keep) return handlers.onAction('conv-name', { conv: input.dataset.conv, name: input.value.trim() })
|
|
717
|
+
handlers.onAction('refresh')
|
|
718
|
+
}
|
|
719
|
+
el.addEventListener('focusout', (e) => { if (e.target.matches?.('.lh-conv-edit')) named(e.target, true) })
|
|
720
|
+
el.addEventListener('keydown', (e) => {
|
|
721
|
+
if (e.target.matches?.('.lh-conv-edit')) {
|
|
722
|
+
if (e.key === 'Enter') { e.preventDefault(); named(e.target, true) } else if (e.key === 'Escape') { e.preventDefault(); named(e.target, false) }
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
if (e.key === 'Escape' && convFocus && !crowdOpen) { convFocus = null; handlers.onAction('refresh'); return }
|
|
726
|
+
if (e.key === 'Escape' && crowdOpen) {
|
|
727
|
+
crowdOpen = false
|
|
728
|
+
const crowd = el.querySelector('.loom-head.crowd')
|
|
729
|
+
if (crowd) { crowd.classList.remove('open'); crowd.querySelector('.lcw-list').hidden = true; crowd.querySelector('[data-loom-crowd]').focus() }
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
733
|
+
const c = e.target.closest?.('.lc-turn')
|
|
734
|
+
if (!c || e.target !== c) return
|
|
735
|
+
e.preventDefault()
|
|
736
|
+
toggle(el, c)
|
|
737
|
+
})
|
|
738
|
+
el.addEventListener('mouseover', (e) => {
|
|
739
|
+
const r = el.querySelector('.loom')
|
|
740
|
+
if (!r || !r.contains(e.target)) return
|
|
741
|
+
const chip = e.target.closest('[data-file]')
|
|
742
|
+
if (chip) {
|
|
743
|
+
focusFile(el, chip.dataset.file)
|
|
744
|
+
showTip(r, chip, tipFor(chip.dataset.file))
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
const knot = e.target.closest('[data-knot]')
|
|
748
|
+
if (knot) {
|
|
749
|
+
showTip(r, knot, knotTip(knot.dataset.knot.split(' ')))
|
|
750
|
+
traceCard(r, knot.dataset.knot.split(' ')[0], true)
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
const c = e.target.closest('.loom-grid > [data-id]')
|
|
754
|
+
traceCard(r, c?.dataset.id, true)
|
|
755
|
+
})
|
|
756
|
+
el.addEventListener('mouseout', (e) => {
|
|
757
|
+
const r = el.querySelector('.loom')
|
|
758
|
+
if (!r) return
|
|
759
|
+
const chip = e.target.closest?.('[data-file]')
|
|
760
|
+
if (chip && !chip.contains(e.relatedTarget)) {
|
|
761
|
+
focusFile(el, null)
|
|
762
|
+
r.querySelector('.loom-tip').hidden = true
|
|
763
|
+
}
|
|
764
|
+
const knot = e.target.closest?.('[data-knot]')
|
|
765
|
+
if (knot && !knot.contains(e.relatedTarget)) { r.querySelector('.loom-tip').hidden = true; traceCard(r, null, false) }
|
|
766
|
+
const c = e.target.closest?.('.loom-grid > [data-id]')
|
|
767
|
+
if (c && !c.contains(e.relatedTarget)) traceCard(r, null, false)
|
|
768
|
+
})
|
|
769
|
+
// A chip or knot redrawn under the pointer never says it was left: leaving the Loom does.
|
|
770
|
+
el.addEventListener('mouseleave', () => {
|
|
771
|
+
const r = el.querySelector('.loom')
|
|
772
|
+
if (!r) return
|
|
773
|
+
r.querySelector('.loom-tip').hidden = true
|
|
774
|
+
traceCard(r, null, false)
|
|
775
|
+
focusFile(el, null)
|
|
776
|
+
})
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function toggle (el, card) {
|
|
780
|
+
const key = card.dataset.key
|
|
781
|
+
if (!key) return
|
|
782
|
+
if (expanded.has(key)) expanded.delete(key)
|
|
783
|
+
else expanded.add(key)
|
|
784
|
+
anchor = { key, y: card.getBoundingClientRect().top }
|
|
785
|
+
handlers.onAction('expand', key) // the owner re-renders; the card keeps its place
|
|
786
|
+
}
|