heyquilt 0.3.90 → 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/README.md +14 -11
- package/RELEASES.md +131 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +141 -5
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +143 -17
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +120 -10
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- 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 +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -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 +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +136 -27
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- 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/ui/merges.js +0 -158
package/src/ui/fileview.js
CHANGED
|
@@ -19,9 +19,11 @@ const MAX_LINES = 20000
|
|
|
19
19
|
* @param {object} [o.people] { who, colorOf } for the Changes tab
|
|
20
20
|
* @param {object|null} [o.summary] the file's totals in this session (changes.js fileChanges)
|
|
21
21
|
*/
|
|
22
|
-
export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly = false, tab = 'file', history = null, people, summary = null }) {
|
|
22
|
+
export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly = false, tab = 'file', history = null, people, summary = null, chatHref = null }) {
|
|
23
23
|
const scroller = el.querySelector('.fv-scroll')
|
|
24
24
|
const keep = scroller && scroller.dataset.path === path ? scroller.scrollTop : 0
|
|
25
|
+
// The tree tells which files came from chat: one opened before it loaded is drawn again once it has.
|
|
26
|
+
if (meta && meta.chat) return bannerOnly && scroller && scroller.dataset.tab === 'chat' && scroller.dataset.path === path ? undefined : renderChatFile(el, { path, file, chat: meta.chat, me, href: chatHref, keep: scroller && scroller.dataset.path === path ? keep : 0 })
|
|
25
27
|
|
|
26
28
|
const edited = meta && meta.edited
|
|
27
29
|
const claim = meta && meta.claim
|
|
@@ -69,6 +71,31 @@ export function renderFileView (el, { path, file, meta, me, prevText, bannerOnly
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
const IMAGE = /\.(png|jpe?g|gif|webp)$/i
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A file sent in chat, from the chat files folder: who sent it, a download, and the file itself
|
|
78
|
+
* (text, or a picture). No claim and no changes: it is this person's copy, never synced.
|
|
79
|
+
*/
|
|
80
|
+
function renderChatFile (el, { path, file, chat, me, href, keep }) {
|
|
81
|
+
const who = (n) => n === me ? 'you' : esc(n)
|
|
82
|
+
const banner = `<div class="fv-banner"><span class="fv-path mono">${esc(path)}</span><span class="fv-edited">Sent in chat by ${who(chat.by)}${chat.to ? ` to ${who(chat.to)}` : ''} ${agoLong(chat.ts)}</span>
|
|
83
|
+
<span class="hint">Only people in the session get it. Never synced or committed.</span><span class="spacer"></span>${href ? `<a class="btn sm" href="${href}" download>${I.down}<span>Download</span></a>` : ''}</div>`
|
|
84
|
+
let body
|
|
85
|
+
if (!file) body = `<div class="fv-note">${loadingNote()}</div>`
|
|
86
|
+
else if (file.missing) body = '<div class="fv-note">This file is no longer in your Chat files folder.</div>'
|
|
87
|
+
else if (file.binary && IMAGE.test(path) && href) body = `<div class="fv-image"><img src="${href}&inline=1" alt="${esc(path.split('/').pop())}"></div>`
|
|
88
|
+
else if (file.binary) body = `<div class="fv-note">${I.file} ${bytes(file.size)}. It is in the Chat files folder in this session's folder.</div>`
|
|
89
|
+
else {
|
|
90
|
+
const lines = file.text.split('\n')
|
|
91
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
|
|
92
|
+
body = `<table class="fv-code"><tbody>${lines.slice(0, MAX_LINES).map((l, i) => `<tr><td class="ln">${i + 1}</td><td class="lt">${esc(l) || ' '}</td></tr>`).join('')}</tbody></table>
|
|
93
|
+
${lines.length > MAX_LINES ? `<div class="fv-note">Showing the first ${MAX_LINES.toLocaleString()} lines.</div>` : ''}`
|
|
94
|
+
}
|
|
95
|
+
el.innerHTML = `<div class="fv">${banner}<div class="fv-scroll" data-path="${esc(path)}" data-tab="chat">${body}</div></div>`
|
|
96
|
+
el.querySelector('.fv-scroll').scrollTop = keep
|
|
97
|
+
}
|
|
98
|
+
|
|
72
99
|
const tabsMarkup = (tab, history, summary) => `<div class="fv-tabs segmented" role="tablist" aria-label="File or its changes">
|
|
73
100
|
<button type="button" role="tab" data-fvtab="file" class="${tab === 'file' ? 'on' : ''}" aria-selected="${tab === 'file'}">File</button>
|
|
74
101
|
<button type="button" role="tab" data-fvtab="changes" class="${tab === 'changes' ? 'on' : ''}" aria-selected="${tab === 'changes'}" title="Every change to this file, with who made it and what changed">Changes${history ? ` <span class="n">${changeCount(history, summary)}</span>` : ''}</button>
|
package/src/ui/home.js
CHANGED
|
@@ -10,6 +10,7 @@ import { workspacesHtml, bindWorkspaces, workspacePageHtml, bindWorkspacePage, l
|
|
|
10
10
|
import { allFilesHtml, bindAllFiles } from './files.js'
|
|
11
11
|
import { invitesHtml, bindInvites } from './invites.js'
|
|
12
12
|
import { agentCardHtml, bindPlacement, placeableWorkspaces, agentMiniHtml, openAgentMenu } from './agent-place.js'
|
|
13
|
+
import { bindSandboxKeys } from './sandbox-keys.js'
|
|
13
14
|
import { SOUND_EVENTS, playSound } from './sounds.js'
|
|
14
15
|
import { gitControl, gitSegHtml } from './git-rule.js'
|
|
15
16
|
import { cloudAiCardHtml, bindCloudAi } from './cloud-ai.js'
|
|
@@ -729,44 +730,34 @@ function agentsPageHtml () {
|
|
|
729
730
|
<label class="search-box"><span class="sr-only">Search agents</span><input class="input" id="agents-search" type="search" placeholder="Search agents…" autocomplete="off"></label>
|
|
730
731
|
<button class="btn primary" type="button" id="agents-make" aria-haspopup="menu" aria-expanded="false">${I.bot}<span>Invite an agent</span></button>
|
|
731
732
|
</header>
|
|
732
|
-
<p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card
|
|
733
|
+
<p class="hint ag-lead">AIs that work with you as their own members. Global agents are in all your workspaces; workspace and session agents in one. Open a card for where it works and its keys.</p>
|
|
733
734
|
<div id="agents-invite"><p class="error" id="agents-error"></p></div>
|
|
734
735
|
<div id="agents-list"><p class="hint">${loadingNote()}</p></div>
|
|
735
736
|
<div class="ag-pages" id="agents-pages" hidden></div>
|
|
737
|
+
<div class="ag-details" id="agent-details" hidden></div>
|
|
736
738
|
<p class="hint ag-foot"><a href="https://heyquilt.com/docs/agents#kinds" target="_blank" rel="noopener">How agent kinds work</a> · Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a></p>
|
|
737
739
|
</div>`
|
|
738
740
|
}
|
|
739
741
|
|
|
740
742
|
/**
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
+
* An agent's details, in place under the cards (no dialog): with workspaces on, where it works
|
|
744
|
+
* (Works in and Invited to new sessions; each change saves), then its sandbox keys (sandbox-keys.js).
|
|
743
745
|
*/
|
|
744
|
-
async function
|
|
746
|
+
async function showAgentDetails (root, a) {
|
|
747
|
+
const box = $('#agent-details', root)
|
|
748
|
+
box.hidden = false
|
|
749
|
+
box.dataset.agent = a.id
|
|
750
|
+
box.innerHTML = `${state.workspacesOn ? '<div data-ag-place-slot></div>' : ''}<div data-sk-slot></div>`
|
|
751
|
+
bindSandboxKeys($('[data-sk-slot]', box), a)
|
|
752
|
+
box.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' })
|
|
753
|
+
if (!state.workspacesOn) return
|
|
754
|
+
const slot = $('[data-ag-place-slot]', box)
|
|
745
755
|
let place
|
|
746
|
-
try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) {
|
|
747
|
-
if (
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
back.className = 'modal-back settings-back'
|
|
752
|
-
back.innerHTML = `
|
|
753
|
-
<div class="settings-modal agent-modal" role="dialog" aria-modal="true" aria-labelledby="agent-place-title">
|
|
754
|
-
<header class="settings-head">
|
|
755
|
-
<h2 id="agent-place-title">${I.bot}<span>${esc(a.name)}</span></h2>
|
|
756
|
-
<p>Where it works, and whether it is invited to new sessions. Changes save at once.</p>
|
|
757
|
-
<button class="btn icon ghost settings-close" type="button" data-settings-close aria-label="Close">${I.x}</button>
|
|
758
|
-
</header>
|
|
759
|
-
<div class="settings-body">${agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })}</div>
|
|
760
|
-
</div>`
|
|
761
|
-
document.body.appendChild(back)
|
|
762
|
-
bindPlacement(back.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
|
|
763
|
-
back.addEventListener('change', onChange)
|
|
764
|
-
back.addEventListener('click', (e) => { if (e.target.closest('[data-placement-reach], [data-placement-sessions], [data-placement-ws]')) onChange() })
|
|
765
|
-
const close = () => back.remove()
|
|
766
|
-
back.addEventListener('mousedown', (e) => { if (e.target === back) close() })
|
|
767
|
-
back.addEventListener('keydown', (e) => { if (e.key === 'Escape') close() })
|
|
768
|
-
back.querySelector('[data-settings-close]').onclick = close
|
|
769
|
-
back.querySelector('[data-settings-close]').focus()
|
|
756
|
+
try { place = (await api('GET', `/api/agents/${encodeURIComponent(a.id)}/placement`)).placement } catch (err) { slot.innerHTML = `<p class="hint warn">${esc(err.message)}</p>`; return }
|
|
757
|
+
if (box.dataset.agent !== a.id) return
|
|
758
|
+
if (!place) { slot.innerHTML = '<p class="hint">This agent has no placement yet.</p>'; return }
|
|
759
|
+
slot.innerHTML = agentCardHtml(a, place, placeableWorkspaces(state.workspaces), { when: a.lastUsedAt ? `last used ${ago(a.lastUsedAt)}` : `added ${ago(a.createdAt)}` })
|
|
760
|
+
bindPlacement(slot.querySelector('[data-agent-place]'), a, place, placeableWorkspaces(state.workspaces))
|
|
770
761
|
}
|
|
771
762
|
|
|
772
763
|
// Settings come in tabs: You (profile, appearance, sounds), Sessions (defaults for starting and
|
|
@@ -905,7 +896,8 @@ function settingsHtml ({ head = true } = {}) {
|
|
|
905
896
|
<div id="agents-invite"><p class="error" id="agents-error"></p></div>
|
|
906
897
|
<div id="agents-list"><p class="hint">${loadingNote()}</p></div>
|
|
907
898
|
<div class="ag-pages" id="agents-pages" hidden></div>
|
|
908
|
-
<div class="
|
|
899
|
+
<div class="ag-details" id="agent-details" hidden></div>
|
|
900
|
+
<div class="sec-actions"><span class="hint">${state.workspacesOn ? 'Open a card for where the agent works, whether it joins new sessions, and its keys.' : 'An invite is pasted into the AI once; from then on it joins any session you send it. Open a card for its keys.'} Revoke agents on <a href="https://heyquilt.com/dashboard/agents" target="_blank" rel="noopener">heyquilt.com</a>.</span>${state.workspacesOn ? `<button class="btn sm" type="button" data-view="agents">Open Agents</button>` : ''}</div>
|
|
909
901
|
</div>
|
|
910
902
|
</section>` : ''}
|
|
911
903
|
|
|
@@ -1017,6 +1009,12 @@ function bindAgentsGrid (root, list) {
|
|
|
1017
1009
|
const search = $('#agents-search', root)
|
|
1018
1010
|
let all = []
|
|
1019
1011
|
let page = 0
|
|
1012
|
+
let open = '' // the agent whose details show under the cards
|
|
1013
|
+
const details = (a) => {
|
|
1014
|
+
open = a.id
|
|
1015
|
+
for (const c of list.querySelectorAll('[data-agent]')) c.setAttribute('aria-pressed', String(c.dataset.agent === a.id))
|
|
1016
|
+
showAgentDetails(root, a)
|
|
1017
|
+
}
|
|
1020
1018
|
const matching = () => {
|
|
1021
1019
|
const q = (search.value || '').trim().toLowerCase()
|
|
1022
1020
|
return q ? all.filter((a) => [a.name, a.type, a.provider].some((x) => String(x || '').toLowerCase().includes(q))) : all
|
|
@@ -1028,7 +1026,7 @@ function bindAgentsGrid (root, list) {
|
|
|
1028
1026
|
const from = page * AGENTS_PAGE
|
|
1029
1027
|
const shown = hits.slice(from, from + AGENTS_PAGE)
|
|
1030
1028
|
list.innerHTML = shown.length
|
|
1031
|
-
? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens:
|
|
1029
|
+
? `<div class="ag-grid">${shown.map((a) => agentMiniHtml(a, { opens: true })).join('').replace(`data-agent="${esc(open)}"`, `data-agent="${esc(open)}" aria-pressed="true"`)}</div>`
|
|
1032
1030
|
: `<p class="hint">${all.length ? 'No agent matches that.' : 'No agents yet. Invite one above.'}</p>`
|
|
1033
1031
|
pages.hidden = hits.length <= AGENTS_PAGE
|
|
1034
1032
|
pages.innerHTML = `<span>${from + 1}–${from + shown.length} of ${hits.length}</span>
|
|
@@ -1048,23 +1046,24 @@ function bindAgentsGrid (root, list) {
|
|
|
1048
1046
|
if (!card || e.target !== card || (e.key !== 'Enter' && e.key !== ' ')) return
|
|
1049
1047
|
e.preventDefault()
|
|
1050
1048
|
const a = all.find((x) => x.id === card.dataset.agent)
|
|
1051
|
-
if (a)
|
|
1049
|
+
if (a) details(a)
|
|
1052
1050
|
})
|
|
1053
1051
|
list.addEventListener('click', (e) => {
|
|
1054
1052
|
const b = e.target.closest('[data-agent-menu]')
|
|
1055
1053
|
const card = e.target.closest('[data-agent]')
|
|
1056
1054
|
const a = all.find((x) => x.id === (b ? b.dataset.agentMenu : card && card.dataset.agent))
|
|
1057
1055
|
if (!a) return
|
|
1058
|
-
if (!b) {
|
|
1056
|
+
if (!b) { details(a); return }
|
|
1059
1057
|
const items = [
|
|
1060
1058
|
...(state.workspacesOn ? [{ act: 'place', label: 'Where it works', icon: I.grid }] : []),
|
|
1059
|
+
{ act: 'keys', label: 'Keys', icon: I.lock },
|
|
1061
1060
|
{ act: 'revoke', label: 'Revoke on heyquilt.com', icon: I.external, danger: true }
|
|
1062
1061
|
]
|
|
1063
1062
|
openAgentMenu(b, {
|
|
1064
1063
|
label: `Actions for ${a.name}`,
|
|
1065
1064
|
items,
|
|
1066
1065
|
on: (act) => {
|
|
1067
|
-
if (act === 'place')
|
|
1066
|
+
if (act === 'place' || act === 'keys') details(a)
|
|
1068
1067
|
if (act === 'revoke') window.open('https://heyquilt.com/dashboard/agents', '_blank', 'noopener')
|
|
1069
1068
|
}
|
|
1070
1069
|
})
|
package/src/ui/loom.css
CHANGED
|
@@ -150,7 +150,22 @@
|
|
|
150
150
|
.lh-conv-ren:focus-visible { opacity: 1; }
|
|
151
151
|
.lh-conv-ren:hover { color: var(--text); background: var(--panel-2); }
|
|
152
152
|
.lh-conv.editing { flex: 0 1 auto; min-width: 0; gap: 5px; padding-left: 6px; border-color: var(--c); }
|
|
153
|
-
.lh-conv-edit { width: 170px; min-width: 60px; height: 22px; padding: 0 6px 0 0; border: 0; background: transparent; font: inherit; font-size: 11.5px; color: var(--text); outline: none; }
|
|
153
|
+
.lh-conv-edit { flex: 1 1 auto; width: 170px; max-width: 100%; min-width: 60px; height: 22px; padding: 0 6px 0 0; border: 0; background: transparent; font: inherit; font-size: 11.5px; color: var(--text); outline: none; }
|
|
154
|
+
/* Following one: it leads and its name has the room; the rest condense to bare marks after it, and fold
|
|
155
|
+
into "+N" when even that leaves the name too little (.packed, set by packConvs in loom.js). */
|
|
156
|
+
.lh-convs.following { overflow: visible; gap: 6px; }
|
|
157
|
+
.lh-convs.following .lh-conv-name { max-width: none; }
|
|
158
|
+
.lh-others { position: relative; display: inline-flex; align-items: center; flex: none; }
|
|
159
|
+
.lh-pop { display: inline-flex; align-items: center; gap: 1px; }
|
|
160
|
+
.lh-pop .lh-conv { border-color: transparent; background: none; }
|
|
161
|
+
.lh-pop .lh-conv-btn { padding: 3px; border-radius: 4px; }
|
|
162
|
+
.lh-pop .lh-conv-btn:hover, .lh-pop .lh-conv-btn:focus-visible { background: var(--panel-2); }
|
|
163
|
+
.lh-more { display: none; align-items: center; height: 22px; padding: 0 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel); font: inherit; font-size: 11.5px; color: var(--muted); cursor: pointer; }
|
|
164
|
+
.lh-more:hover, .lh-others.open .lh-more { color: var(--text); border-color: var(--border-strong); }
|
|
165
|
+
.lh-convs.packed .lh-more { display: inline-flex; }
|
|
166
|
+
.lh-convs.packed .lh-pop { display: none; position: absolute; top: 100%; right: 0; z-index: 6; gap: 2px; margin-top: 2px; padding: 4px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 8px; box-shadow: var(--shadow-pop); }
|
|
167
|
+
.lh-convs.packed .lh-others::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: 4px; } /* keeps the hover across the gap */
|
|
168
|
+
.lh-convs.packed .lh-others:is(:hover, :focus-within, .open) .lh-pop { display: inline-flex; }
|
|
154
169
|
|
|
155
170
|
.lc-prompt { font-weight: 500; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
156
171
|
.lc-reply { margin-top: 4px; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; padding-left: 8px; border-left: 2px solid var(--border); }
|
|
@@ -270,7 +285,8 @@
|
|
|
270
285
|
.loom-empty .t { font-weight: 600; font-size: 15px; margin-bottom: 6px; }
|
|
271
286
|
.loom-empty .hint { color: var(--muted); }
|
|
272
287
|
|
|
273
|
-
|
|
288
|
+
/* Centred with margins, not a transform: .btn:active's press would replace it and slide the button off the pointer. */
|
|
289
|
+
.loom-jump { position: absolute; bottom: 16px; left: 0; right: 0; width: max-content; margin: 0 auto; z-index: 6; box-shadow: var(--shadow-pop); }
|
|
274
290
|
.loom-jump svg { width: 14px; height: 14px; }
|
|
275
291
|
|
|
276
292
|
.loom-tip { position: fixed; z-index: 50; max-width: 340px; background: var(--panel); border: 1px solid var(--border-strong); border-radius: 6px; box-shadow: var(--shadow-pop); padding: 8px 11px; font-size: 12px; pointer-events: none; display: flex; flex-direction: column; gap: 3px; }
|
package/src/ui/loom.js
CHANGED
|
@@ -72,6 +72,7 @@ export function renderLoom (el, { build, prefs, me, names, meAgent, onAction })
|
|
|
72
72
|
lastHtml = html
|
|
73
73
|
el.innerHTML = html
|
|
74
74
|
phaseNeedles(el)
|
|
75
|
+
packConvs(el)
|
|
75
76
|
const edit = el.querySelector('.lh-conv-edit')
|
|
76
77
|
if (edit) { edit.focus(); edit.select() }
|
|
77
78
|
const s = el.querySelector('.loom-scroll')
|
|
@@ -217,24 +218,46 @@ function headHtml (l, color) {
|
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
/**
|
|
220
|
-
* A lane's conversations, when there's more than one: each
|
|
221
|
-
*
|
|
222
|
-
* computer)
|
|
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.
|
|
223
226
|
*/
|
|
224
227
|
function convsHtml (l) {
|
|
225
228
|
if (!l.convs || l.convs.length < 2) return ''
|
|
226
229
|
const live = l.convs.filter((c) => c.live).length
|
|
227
230
|
const label = `${l.isMe ? 'Your' : `${l.name}'s`} conversations: ${l.convs.length}, ${live} live`
|
|
228
|
-
|
|
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) => {
|
|
229
234
|
const at = `data-lane="${esc(l.name)}" data-conv="${esc(c.conv)}"`
|
|
230
235
|
if (renaming && renaming.lane === l.name && renaming.conv === c.conv) {
|
|
231
|
-
return `<span class="lh-conv 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>`
|
|
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>`
|
|
232
237
|
}
|
|
233
|
-
const on =
|
|
238
|
+
const on = c.conv === sel
|
|
234
239
|
const state = c.active ? 'working now' : c.live ? 'live' : `quiet since ${clock(c.last)}`
|
|
235
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'}`
|
|
236
|
-
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
|
|
237
|
-
}
|
|
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
|
+
}
|
|
238
261
|
}
|
|
239
262
|
|
|
240
263
|
/** How long a lane has been on its focus (stitching), beside its status; tickClocks counts it up. */
|
|
@@ -437,6 +460,7 @@ function watchSize (el) {
|
|
|
437
460
|
const root = el.querySelector(':scope > .loom')
|
|
438
461
|
if (!root) return
|
|
439
462
|
drawThreads(el)
|
|
463
|
+
packConvs(root)
|
|
440
464
|
// A new width can fit more lanes or fewer (or fold them into one column): render again, once it settles.
|
|
441
465
|
clearTimeout(settle)
|
|
442
466
|
if (el.clientWidth !== lastWidth) {
|
|
@@ -660,6 +684,15 @@ function bind (el) {
|
|
|
660
684
|
followed = !same
|
|
661
685
|
return act('refresh')
|
|
662
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
|
+
}
|
|
663
696
|
if ((b = pick('[data-loom-rename]'))) { renaming = { lane: b.dataset.lane, conv: b.dataset.conv }; return act('refresh') }
|
|
664
697
|
if (pick('.lh-conv-edit')) return
|
|
665
698
|
if ((b = pick('[data-loom-conv]'))) return act('conv', { name: b.dataset.loomConv, conv: b.dataset.loomConvid })
|
package/src/ui/rail-model.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The Files rail (rail.js): the folded Files pane as signals instead of names. Who holds
|
|
2
|
-
// what, since when, why, who waits for it and when it frees up on its own
|
|
2
|
+
// what, since when, why, who waits for it and when it frees up on its own.
|
|
3
3
|
// Pure, so tests can run it without a page.
|
|
4
4
|
import { stitchTime } from './loom-model.js'
|
|
5
5
|
|
|
@@ -11,14 +11,14 @@ const claimFolder = (pattern) => String(pattern).replace(/\/\*\*$/, '').replace(
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Everything the rail and its lock list show. `claims`: the session's ({ pattern, by, ts, note,
|
|
14
|
-
* active, activeAt, queue }); `
|
|
14
|
+
* active, activeAt, queue }); `me`: your name;
|
|
15
15
|
* `owners`: AI session name -> its person (chat.js aiOwners), so a person's AI sessions are one
|
|
16
16
|
* holder, "<person>'s AI"; `aiName`: person -> that name; `files`: the session's paths (to count a
|
|
17
|
-
* folder's); `
|
|
17
|
+
* folder's); `focusOf`: name -> what they
|
|
18
18
|
* said they're doing (a lock's why when it has no note); `kindOf`: name -> 'agent' for an AI agent.
|
|
19
|
-
* Returns { locks, holders, waiting, mine
|
|
19
|
+
* Returns { locks, holders, waiting, mine }, locks oldest first.
|
|
20
20
|
*/
|
|
21
|
-
export function railModel ({ claims = [],
|
|
21
|
+
export function railModel ({ claims = [], me = '', owners = new Map(), aiName = (p) => `${p}'s AI`, files = [], focusOf = () => '', kindOf = () => '', now = Date.now(), idleMs = CLAIM_IDLE_MS } = {}) {
|
|
22
22
|
const fileSet = new Set(files)
|
|
23
23
|
const holderOf = (by) => owners.has(by) ? aiName(owners.get(by)) : by
|
|
24
24
|
const locks = (claims || []).filter((c) => c && c.pattern && c.by).map((c) => {
|
|
@@ -52,14 +52,11 @@ export function railModel ({ claims = [], merges = [], me = '', owners = new Map
|
|
|
52
52
|
h.locks++
|
|
53
53
|
h.waiting += l.queue.length
|
|
54
54
|
}
|
|
55
|
-
const openSet = new Set(open)
|
|
56
|
-
const toMerge = (merges || []).filter((m) => m && m.state !== 'done')
|
|
57
55
|
return {
|
|
58
56
|
locks,
|
|
59
57
|
holders,
|
|
60
58
|
waiting: locks.filter((l) => l.mine && l.queue.length), // someone waits for a file you hold: yours to act on
|
|
61
|
-
mine: locks.filter((l) => l.ours).length
|
|
62
|
-
merges: { count: toMerge.length, urgent: toMerge.some((m) => openSet.has(m.path)) }
|
|
59
|
+
mine: locks.filter((l) => l.ours).length
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
62
|
|
package/src/ui/releases.js
CHANGED
|
@@ -144,7 +144,7 @@ export function openReleaseNotes () {
|
|
|
144
144
|
<div class="rn-body">
|
|
145
145
|
${newer.map((rel) => releaseHtml(rel, 0, { latest: true })).join('')}
|
|
146
146
|
${rels.map((rel, i) => releaseHtml(rel, i + newer.length, { current: rel.version === r.version })).join('')}
|
|
147
|
-
<footer class="rn-foot">
|
|
147
|
+
<footer class="rn-foot">Every download is on <a href="https://heyquilt.com/#download" target="_blank" rel="noopener">heyquilt.com</a>.</footer>
|
|
148
148
|
</div>
|
|
149
149
|
</div>
|
|
150
150
|
</div>`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The Keys table on each agent (sandbox keys, QUILT_AGENT_KEY): its words and numbers, shared by
|
|
2
|
+
// the app (sandbox-keys.js) and heyquilt.com (web/components/SandboxKeys.js). Pure, no page.
|
|
3
|
+
const MIN = 60 * 1000
|
|
4
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
5
|
+
|
|
6
|
+
/** When a key was last used: "Just now", "2 min ago", "5 h ago", "3 days ago", then "Oct 12". */
|
|
7
|
+
export function usedText (ts, now = Date.now()) {
|
|
8
|
+
if (!ts) return 'Never'
|
|
9
|
+
const m = Math.max(0, Math.floor((now - ts) / MIN))
|
|
10
|
+
if (m < 1) return 'Just now'
|
|
11
|
+
if (m < 60) return `${m} min ago`
|
|
12
|
+
if (m < 24 * 60) return `${Math.floor(m / 60)} h ago`
|
|
13
|
+
const d = Math.floor(m / (24 * 60))
|
|
14
|
+
if (d < 7) return `${d} ${d === 1 ? 'day' : 'days'} ago`
|
|
15
|
+
const at = new Date(ts)
|
|
16
|
+
return `${MONTHS[at.getMonth()]} ${at.getDate()}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Each day's bar as a percentage of the busiest day's. */
|
|
20
|
+
export function sparkHeights (uses) {
|
|
21
|
+
const top = Math.max(0, ...uses)
|
|
22
|
+
return uses.map((n) => (top ? Math.round((n / top) * 100) : 0))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LABELS = { 'in-use': 'In use', idle: 'Idle', revoked: 'Revoked' }
|
|
26
|
+
const CLASSES = { 'in-use': 'live', idle: 'idle', revoked: 'revoked' }
|
|
27
|
+
export const stateLabel = (s) => LABELS[s] || LABELS.idle
|
|
28
|
+
export const stateClass = (s) => CLASSES[s] || CLASSES.idle
|
|
29
|
+
|
|
30
|
+
/** How a key is shown: never the key, its last four characters. */
|
|
31
|
+
export const keyLabel = (hint) => `qak_…${hint || '????'}`
|
|
32
|
+
|
|
33
|
+
/** One of four colours for a session's dot, the same for its room everywhere. */
|
|
34
|
+
export const sessionTone = (room) => [...String(room)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % 4
|
|
35
|
+
|
|
36
|
+
/** Keys whose name or session has the search in it. */
|
|
37
|
+
export function matchKeys (keys, q) {
|
|
38
|
+
const s = String(q || '').trim().toLowerCase()
|
|
39
|
+
return s ? keys.filter((k) => [k.name, k.session].some((x) => String(x || '').toLowerCase().includes(s))) : keys
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The New key session picker: `local` sessions ({ value, room, name }, on this computer, so their
|
|
44
|
+
* links are at hand) first, then `known` ones a key already works in ({ room, name }), each room once.
|
|
45
|
+
*/
|
|
46
|
+
export function sessionOptions (local = [], known = []) {
|
|
47
|
+
const rooms = new Set(local.map((s) => s.room))
|
|
48
|
+
const named = new Map(known.map((s) => [s.room, s.name]))
|
|
49
|
+
return [
|
|
50
|
+
...local.map((s) => ({ value: s.value, name: named.get(s.room) || s.name })),
|
|
51
|
+
...known.filter((s) => !rooms.has(s.room)).map((s) => ({ value: `room:${s.room}`, name: s.name }))
|
|
52
|
+
]
|
|
53
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// An agent's Keys table in the app (Settings › Agents, opened from its card; mockup
|
|
2
|
+
// docs/mockups/2026-10-18-sandbox-keys.html, option A): its sandbox keys (QUILT_AGENT_KEY), each
|
|
3
|
+
// with its session, last use, use over seven days and state, Rotate and Revoke (Remove once
|
|
4
|
+
// revoked). New key opens a row at the top; a new key shows once, with Copy. The words and numbers
|
|
5
|
+
// are sandbox-keys-model.js, which heyquilt.com shares.
|
|
6
|
+
import { I, esc, api, toast } from './common.js'
|
|
7
|
+
import { usedText, sparkHeights, stateLabel, stateClass, keyLabel, sessionTone, matchKeys, sessionOptions } from './sandbox-keys-model.js'
|
|
8
|
+
|
|
9
|
+
const KEY = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="5" cy="8" r="3" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 8h6M12 8v2.5M14 8v2" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
|
|
10
|
+
const ROTATE = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.5M13 2v3h-3" fill="none" stroke="currentColor" stroke-width="1.4"/></svg>'
|
|
11
|
+
|
|
12
|
+
function sparkHtml (k) {
|
|
13
|
+
if (k.state === 'revoked') return ''
|
|
14
|
+
const total = k.uses.reduce((a, b) => a + b, 0)
|
|
15
|
+
return `<span class="sk-spark" title="${total} ${total === 1 ? 'request' : 'requests'} in 7 days">${sparkHeights(k.uses).map((h) => `<i${h ? ` style="height:${h}%"` : ' class="z"'}></i>`).join('')}</span>`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function rowHtml (k, now, q) {
|
|
19
|
+
const hidden = matchKeys([k], q).length ? '' : ' hidden'
|
|
20
|
+
const revoked = k.state === 'revoked'
|
|
21
|
+
const acts = revoked
|
|
22
|
+
? `<button type="button" class="btn sm ghost" data-sk-remove="${esc(k.id)}">Remove</button>`
|
|
23
|
+
: `<button type="button" class="btn sm" data-sk-rotate="${esc(k.id)}">${ROTATE}<span>Rotate</span></button><button type="button" class="btn sm danger" data-sk-revoke="${esc(k.id)}"><span>Revoke</span></button>`
|
|
24
|
+
return `<tr data-sk-row="${esc(k.id)}"${revoked ? ' class="sk-revoked"' : ''}${hidden}>
|
|
25
|
+
<td><b>${esc(k.name)}</b><div class="sk-hint">${esc(keyLabel(k.hint))}</div></td>
|
|
26
|
+
<td><span class="sk-chip" title="${esc(k.session)}"><span class="sk-dot t${sessionTone(k.room)}"></span><span class="sk-chip-t">${esc(k.session)}</span></span></td>
|
|
27
|
+
<td class="sk-n">${esc(usedText(k.lastUsedAt, now))}</td>
|
|
28
|
+
<td class="sk-n sk-m">${sparkHtml(k)}</td>
|
|
29
|
+
<td><span class="pill ${stateClass(k.state)}">${stateLabel(k.state)}</span></td>
|
|
30
|
+
<td class="sk-act"><span>${acts}</span></td>
|
|
31
|
+
</tr>`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function newRowHtml (sessions) {
|
|
35
|
+
const where = sessions.length
|
|
36
|
+
? `<select class="input sm" name="sk-session" aria-label="Session">${sessions.map((s) => `<option value="${esc(s.value)}">${esc(s.name)}</option>`).join('')}</select>`
|
|
37
|
+
: '<input class="input sm" name="sk-link" type="url" placeholder="Session invite link" aria-label="Session invite link" autocomplete="off">'
|
|
38
|
+
return `<tr class="sk-newrow"><td colspan="6"><form class="sk-inline" data-sk-form>
|
|
39
|
+
<input class="input sm" name="sk-name" maxlength="40" placeholder="Name, e.g. bolt.new · checkout" aria-label="Key name" autocomplete="off" required>
|
|
40
|
+
${where}
|
|
41
|
+
<button type="submit" class="btn primary sm" data-sk-create>Create</button><button type="button" class="btn ghost sm" data-sk-cancel>Cancel</button>
|
|
42
|
+
</form></td></tr>`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Keys card for `agent`. `v`: { keys, sessions (picker options), adding, fresh ({ envKey }),
|
|
47
|
+
* error, query, loading, now }.
|
|
48
|
+
*/
|
|
49
|
+
export function keysCardHtml (agent, v) {
|
|
50
|
+
const now = v.now || Date.now()
|
|
51
|
+
const keys = v.keys || []
|
|
52
|
+
const body = v.loading
|
|
53
|
+
? '<tr><td colspan="6" class="hint">Loading…</td></tr>'
|
|
54
|
+
: keys.length ? keys.map((k) => rowHtml(k, now, v.query)).join('') : '<tr><td colspan="6" class="hint">No keys yet.</td></tr>'
|
|
55
|
+
return `<div class="sk-card card" data-sk-agent="${esc(agent.id)}">
|
|
56
|
+
<div class="sk-head"><span class="sk-ico">${KEY}</span><span class="sk-t">Keys</span><span class="sk-who">${esc(agent.name)} (agent)</span><span class="sk-grow"></span>
|
|
57
|
+
<label class="sk-search"><span class="sr-only">Search keys</span><input class="input sm" data-sk-search type="search" placeholder="Search keys" value="${esc(v.query || '')}" autocomplete="off"></label>
|
|
58
|
+
<button type="button" class="btn primary sm" data-sk-new${v.adding ? ' disabled' : ''}>New key</button></div>
|
|
59
|
+
${v.fresh ? `<div class="sk-once"><span class="pill live">New</span><code class="sk-key" data-sk-value>${esc(v.fresh.envKey)}</code><span class="sk-acts"><button type="button" class="btn sm" data-sk-copy>${I.copy}<span>Copy</span></button><span class="hint">shown once</span></span></div>` : ''}
|
|
60
|
+
${v.error ? `<p class="error sk-error">${esc(v.error)}</p>` : ''}
|
|
61
|
+
<div class="sk-scroll"><table class="sk-tbl">
|
|
62
|
+
<thead><tr><th>Key</th><th>Session</th><th class="sk-n">Last used</th><th class="sk-n sk-m">Use, 7 days</th><th>Status</th><th><span class="sr-only">Actions</span></th></tr></thead>
|
|
63
|
+
<tbody>${v.adding ? newRowHtml(v.sessions || []) : ''}${body}</tbody>
|
|
64
|
+
</table></div>
|
|
65
|
+
</div>`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Draws and runs one agent's Keys card in `el`. */
|
|
69
|
+
export function bindSandboxKeys (el, agent) {
|
|
70
|
+
const base = `/api/agents/${encodeURIComponent(agent.id)}/sandbox-keys`
|
|
71
|
+
const v = { keys: [], sessions: [], adding: false, fresh: null, error: '', query: '', loading: true }
|
|
72
|
+
const draw = () => { el.innerHTML = keysCardHtml(agent, v) }
|
|
73
|
+
const load = async () => {
|
|
74
|
+
try {
|
|
75
|
+
const r = await api('GET', base)
|
|
76
|
+
Object.assign(v, { keys: r.keys, sessions: sessionOptions(r.local, r.sessions), loading: false })
|
|
77
|
+
} catch (err) { Object.assign(v, { loading: false, error: err.message }) }
|
|
78
|
+
draw()
|
|
79
|
+
}
|
|
80
|
+
// Rotate and Revoke ask once more on the button itself (no dialog): a second click within 4 s does it.
|
|
81
|
+
const armed = (b, label) => {
|
|
82
|
+
if (b.dataset.armed) return true
|
|
83
|
+
b.dataset.armed = '1'
|
|
84
|
+
b.classList.add('sk-armed')
|
|
85
|
+
b.querySelector('span').textContent = label
|
|
86
|
+
setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.classList.remove('sk-armed'); b.querySelector('span').textContent = label.replace(' now', '') } }, 4000)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
const act = async (fn) => {
|
|
90
|
+
v.error = ''
|
|
91
|
+
try { await fn() } catch (err) { v.error = err.message }
|
|
92
|
+
await load()
|
|
93
|
+
}
|
|
94
|
+
el.addEventListener('click', async (e) => {
|
|
95
|
+
const b = e.target.closest('button')
|
|
96
|
+
if (!b || b.disabled) return
|
|
97
|
+
if (b.matches('[data-sk-new]')) { v.adding = true; draw(); el.querySelector('[name="sk-name"]')?.focus(); return }
|
|
98
|
+
if (b.matches('[data-sk-cancel]')) { v.adding = false; v.error = ''; draw(); return }
|
|
99
|
+
if (b.matches('[data-sk-copy]')) {
|
|
100
|
+
try { await navigator.clipboard.writeText(v.fresh.envKey); toast('Copied') } catch { toast('Select the key and press ⌘/Ctrl+C to copy') }
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (b.dataset.skRotate && armed(b, 'Rotate now')) {
|
|
104
|
+
b.disabled = true
|
|
105
|
+
return act(async () => { const r = await api('POST', `${base}/rotate`, { keyId: b.dataset.skRotate }); v.fresh = { envKey: r.envKey } })
|
|
106
|
+
}
|
|
107
|
+
if (b.dataset.skRevoke && armed(b, 'Revoke now')) {
|
|
108
|
+
b.disabled = true
|
|
109
|
+
return act(() => api('POST', `${base}/revoke`, { keyId: b.dataset.skRevoke }))
|
|
110
|
+
}
|
|
111
|
+
if (b.dataset.skRemove) { b.disabled = true; return act(() => api('POST', `${base}/remove`, { keyId: b.dataset.skRemove })) }
|
|
112
|
+
})
|
|
113
|
+
el.addEventListener('submit', async (e) => {
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
const f = e.target
|
|
116
|
+
const name = f.elements['sk-name'].value.trim()
|
|
117
|
+
const pick = f.elements['sk-session']?.value || ''
|
|
118
|
+
const link = f.elements['sk-link']?.value.trim() || ''
|
|
119
|
+
f.querySelector('[data-sk-create]').disabled = true
|
|
120
|
+
await act(async () => {
|
|
121
|
+
const r = await api('POST', base, { name, ...(pick ? { session: pick } : { link }) })
|
|
122
|
+
Object.assign(v, { fresh: { envKey: r.envKey }, adding: false })
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
el.addEventListener('input', (e) => {
|
|
126
|
+
if (!e.target.matches('[data-sk-search]')) return
|
|
127
|
+
v.query = e.target.value
|
|
128
|
+
for (const tr of el.querySelectorAll('[data-sk-row]')) tr.hidden = !matchKeys(v.keys.filter((k) => k.id === tr.dataset.skRow), v.query).length
|
|
129
|
+
})
|
|
130
|
+
draw()
|
|
131
|
+
load()
|
|
132
|
+
}
|