wisp-agents 0.20.0 → 0.22.0
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/CHANGELOG.md +35 -1
- package/README.md +17 -8
- package/bin/wisp.js +14 -6
- package/package.json +4 -3
- package/server/adapters/claude.js +12 -7
- package/server/adapters/codex.js +13 -7
- package/server/adapters/cursor.js +4 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/index.js +7 -2
- package/server/adapters/opencode.js +5 -2
- package/server/btw.js +76 -0
- package/server/client.js +22 -2
- package/server/git.js +80 -4
- package/server/handoff.js +13 -7
- package/server/index.js +4 -0
- package/server/machines.js +15 -4
- package/server/mcp-wisp.js +2 -1
- package/server/openapi.js +28 -2
- package/server/remote.js +4 -2
- package/server/routes/machines.js +1 -1
- package/server/routes/proxy.js +6 -0
- package/server/routes/sessions.js +47 -10
- package/server/routes/settings.js +24 -38
- package/server/routes/ship.js +19 -24
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +178 -12
- package/server/services/auto.js +10 -2
- package/server/services/launch.js +67 -7
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/ship.js +97 -10
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +3 -1
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +52 -1
- package/web/css/launcher.css +12 -0
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/palette.css +6 -0
- package/web/css/ship.css +14 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +5 -2
- package/web/js/features/actions.js +42 -10
- package/web/js/features/btw.js +145 -0
- package/web/js/features/clones.js +7 -6
- package/web/js/features/commands.js +3 -1
- package/web/js/features/composer.js +162 -18
- package/web/js/features/feed.js +10 -10
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +121 -67
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +25 -5
- package/web/js/features/project-list.js +20 -5
- package/web/js/features/projects.js +16 -13
- package/web/js/features/settings.js +24 -3
- package/web/js/features/ship.js +66 -29
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +101 -29
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
|
@@ -20,6 +20,8 @@ import { openPhone, phoneSummary } from './phone.js';
|
|
|
20
20
|
import { machinesSummary } from './places.js';
|
|
21
21
|
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
22
22
|
import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
|
|
23
|
+
import { clearPicks } from './transcript.js';
|
|
24
|
+
import { repaintViews } from './turns.js';
|
|
23
25
|
import { openUsage } from './usage.js';
|
|
24
26
|
|
|
25
27
|
// A sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
@@ -80,6 +82,9 @@ export function openSettings(opts) {
|
|
|
80
82
|
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
81
83
|
${row('Auto-fix PR conflicts', 'Without waiting for a click on Fix conflicts: when a shipped PR conflicts with its base, the task merges the base in, resolves it and pushes', '<button class="switch" id="fixConflicts" role="switch"></button>')}
|
|
82
84
|
</div>
|
|
85
|
+
<div class="sx-group">
|
|
86
|
+
${row('Full transcript', 'Every message and step the agent took, with its presentation at the end. Any one card switches with its Transcript button or <kbd>V</kbd>', '<button class="switch" id="txDefault" role="switch"></button>')}
|
|
87
|
+
</div>
|
|
83
88
|
</section>
|
|
84
89
|
<section class="sx-pane" data-pane="notifications">
|
|
85
90
|
<div class="sx-group">
|
|
@@ -116,7 +121,7 @@ export function openSettings(opts) {
|
|
|
116
121
|
<div class="sx-group">
|
|
117
122
|
${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
|
|
118
123
|
</div>
|
|
119
|
-
<p class="hint">Also in the top bar
|
|
124
|
+
<p class="hint">Also in the top bar. Every project runs on every machine: pick one under Run on and wisp clones the project there the first time. A follow-up or a rerun can move a task to another one.</p>
|
|
120
125
|
</section>
|
|
121
126
|
<section class="sx-pane" data-pane="memory">
|
|
122
127
|
<div class="sx-group">
|
|
@@ -308,6 +313,9 @@ export function openSettings(opts) {
|
|
|
308
313
|
});
|
|
309
314
|
if (!saved) return;
|
|
310
315
|
paintWt();
|
|
316
|
+
renderLauncher();
|
|
317
|
+
};
|
|
318
|
+
paintWt();
|
|
311
319
|
const fc = $('#fixConflicts', node);
|
|
312
320
|
const paintFc = () => fc.setAttribute('aria-checked', S.config.defaults.fixConflicts === 'on');
|
|
313
321
|
fc.onclick = async () => {
|
|
@@ -321,9 +329,22 @@ export function openSettings(opts) {
|
|
|
321
329
|
}
|
|
322
330
|
};
|
|
323
331
|
paintFc();
|
|
324
|
-
|
|
332
|
+
// The transcript switch flips every open card at once; a refused save flips them back.
|
|
333
|
+
const tx = $('#txDefault', node);
|
|
334
|
+
const paintTx = () => tx.setAttribute('aria-checked', S.config.defaults.transcript === 'on');
|
|
335
|
+
const setTx = (v) => {
|
|
336
|
+
S.config.defaults.transcript = v;
|
|
337
|
+
clearPicks();
|
|
338
|
+
paintTx();
|
|
339
|
+
repaintViews();
|
|
325
340
|
};
|
|
326
|
-
|
|
341
|
+
tx.onclick = async () => {
|
|
342
|
+
const was = S.config.defaults.transcript || 'off';
|
|
343
|
+
setTx(was === 'on' ? 'off' : 'on');
|
|
344
|
+
const saved = await tryApi(() => api('/config', { method: 'PATCH', body: { defaults: { transcript: S.config.defaults.transcript } } }));
|
|
345
|
+
if (!saved) setTx(was);
|
|
346
|
+
};
|
|
347
|
+
paintTx();
|
|
327
348
|
// Auto: goal, exploring and allowed harnesses here; the full record in its own view.
|
|
328
349
|
const paintAuto = async () => {
|
|
329
350
|
const r = await api('/router').catch(() => null);
|
package/web/js/features/ship.js
CHANGED
|
@@ -31,16 +31,25 @@ const STEP_LABEL = { commit: 'Commit', push: 'Push', pr: 'Open PR' };
|
|
|
31
31
|
const STEP_DOING = { commit: 'Committing', push: 'Pushing', pr: 'Opening the PR' };
|
|
32
32
|
const shipping = (s) => !!(s?.shipping || (ships.has(s?.id) && !ships.get(s.id).error));
|
|
33
33
|
|
|
34
|
+
// A task in several repos (also) has a PR per repo: its own first, then the others', each { repo, pr }.
|
|
35
|
+
export const prsOf = (s) => [...(s?.pr ? [{ repo: s.project, pr: s.pr }] : []), ...(s?.also || []).filter((a) => a.pr).map((a) => ({ repo: a.project, pr: a.pr }))];
|
|
36
|
+
export const prOf = (s) => prsOf(s)[0]?.pr || null; // the one the card's pill shows
|
|
37
|
+
const branches = (s) => [s?.branch, ...(s?.also || []).map((a) => a.branch)].filter(Boolean);
|
|
38
|
+
const severalRepos = (s) => branches(s).length > 1;
|
|
39
|
+
|
|
34
40
|
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
35
41
|
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
42
|
+
// Several repos: the server counts the ones with something new (toShip.repos).
|
|
36
43
|
export function shippable(s) {
|
|
37
|
-
if (!s
|
|
44
|
+
if (!branches(s).length || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || shipping(s)) return false;
|
|
38
45
|
const t = s.toShip;
|
|
46
|
+
if (t?.repos !== undefined) return t.repos > 0;
|
|
47
|
+
if (s.pr?.state === 'MERGED') return false;
|
|
39
48
|
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
40
49
|
}
|
|
41
|
-
export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
50
|
+
export const shipLabel = (s) => (severalRepos(s) ? (prsOf(s).some((x) => x.pr.state === 'OPEN') ? 'Push to the PRs…' : 'Ship it…') : s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
42
51
|
|
|
43
|
-
export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}…"><i></i>Shipping</span>` : '');
|
|
52
|
+
export const shipPill = (s) => (s.shipping ? `<span class="pill ship-pill" title="Shipping: ${esc(STEP_DOING[s.shipping.step] || 'Starting')}${s.shipping.repo ? ` ${esc(s.shipping.repo)}` : ''}…"><i></i>Shipping</span>` : '');
|
|
44
53
|
|
|
45
54
|
export const prPill = (pr, fix) => `<span class="pill pr pr-${prTone(pr, fix)}" title="PR #${pr.number}: ${esc(prLine(pr, fix))}"><i></i>#${pr.number}</span>`;
|
|
46
55
|
|
|
@@ -50,29 +59,41 @@ export function renderPrStrip(id) {
|
|
|
50
59
|
const node = n?.body && $('.pr-strip', n.body);
|
|
51
60
|
const s = S.sessions.get(id);
|
|
52
61
|
if (!node || !s) return;
|
|
53
|
-
const
|
|
62
|
+
const prs = prsOf(s);
|
|
63
|
+
const pr = prs[0]?.pr;
|
|
64
|
+
const several = prs.length > 1;
|
|
54
65
|
$$('[data-a="ship"]', n.body).forEach((b) => {
|
|
55
66
|
b.hidden = !shippable(s);
|
|
56
|
-
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
67
|
+
$('span', b).textContent = severalRepos(s) ? (prs.some((x) => x.pr.state === 'OPEN') ? 'Push to PRs' : prs.length ? 'Ship again' : 'Ship it') : pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
57
68
|
});
|
|
58
69
|
// Started here, or elsewhere (another tab, the API): the server's stage moves the track along.
|
|
59
70
|
const run = ships.get(id) || (s.shipping && { steps: s.shipping.update ? ['push', 'pr'] : ['commit', 'push', 'pr'], step: 'push' });
|
|
60
|
-
if (run && STEP_LABEL[s.shipping?.step] && !run.error) run.step
|
|
71
|
+
if (run && STEP_LABEL[s.shipping?.step] && !run.error) Object.assign(run, { step: s.shipping.step, repo: s.shipping.repo, at: s.shipping.at, of: s.shipping.of });
|
|
61
72
|
if (run) return renderShipRun(node, id, run);
|
|
62
73
|
const landed = node.classList.contains('ship-run') && !node.classList.contains('failed');
|
|
63
74
|
node.hidden = !pr;
|
|
64
75
|
if (!pr) return;
|
|
65
|
-
|
|
76
|
+
// Several repos: the first PR leads the row and the others follow as small links, each in its
|
|
77
|
+
// own tone; the row takes the worst of them, and Merge merges them all.
|
|
78
|
+
const fix = s.pr ? s.conflictFix : null; // wisp fixes conflicts on the task's own PR only
|
|
79
|
+
const TONES = ['fail', 'pending', 'pass', 'merged', 'closed'];
|
|
80
|
+
const tone = prs.map((x, i) => prTone(x.pr, i ? null : fix)).sort((a, b) => TONES.indexOf(a) - TONES.indexOf(b))[0];
|
|
81
|
+
const open = prs.filter((x) => x.pr.state === 'OPEN');
|
|
82
|
+
const conflict = open.some((x) => x.pr.conflict);
|
|
66
83
|
node.className = `pr-strip pr-${tone}${landed ? ' landed' : ''}`;
|
|
67
84
|
node.innerHTML = `
|
|
68
85
|
<span class="pr-dot"></span>
|
|
69
|
-
<a href="${esc(pr.url)}" target="_blank" class="pr-num"
|
|
70
|
-
|
|
86
|
+
<a href="${esc(pr.url)}" target="_blank" class="pr-num">${several ? `${esc(prs[0].repo)} #${pr.number}` : `PR #${pr.number}`}</a>
|
|
87
|
+
${prs
|
|
88
|
+
.slice(1)
|
|
89
|
+
.map((x) => `<a href="${esc(x.pr.url)}" target="_blank" class="pr-also pr-${prTone(x.pr)}" title="${esc(`${x.repo}: PR #${x.pr.number} · ${prLine(x.pr)}`)}"><i></i>${esc(x.repo)} #${x.pr.number}</a>`)
|
|
90
|
+
.join('')}
|
|
91
|
+
<span class="pr-line">${esc(prLine(pr, fix))}</span>
|
|
71
92
|
<span class="grow"></span>
|
|
72
|
-
${pr.state === 'OPEN' && pr.conflict && !fixing(
|
|
73
|
-
${
|
|
74
|
-
|
|
75
|
-
${
|
|
93
|
+
${pr.state === 'OPEN' && pr.conflict && s.pr && !fixing(fix) && s.state !== 'running' ? `<button class="ghost xs pr-fix" data-pr="fix" title="Send the task a follow-up to merge the base in and resolve the conflicts; wisp pushes the fix to the PR">${icon('merge')}<span>Fix conflicts</span></button>` : ''}
|
|
94
|
+
${open.length ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
95
|
+
${several ? '' : `<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>`}
|
|
96
|
+
${open.length && !conflict ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge${open.length > 1 ? ` all ${open.length} PRs` : ''}, then fast-forward your local base branch${open.length > 1 ? 'es' : ''}">${icon('check')}<span>Merge${open.length > 1 ? ' all' : ''}</span></button>` : ''}`;
|
|
76
97
|
node.onclick = (e) => {
|
|
77
98
|
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
78
99
|
if (a === 'refresh') refreshPr(id, e.target.closest('[data-pr]'));
|
|
@@ -135,7 +156,8 @@ function renderShipRun(node, id, run) {
|
|
|
135
156
|
node.className = `pr-strip ship-run${failed ? ' failed pr-fail' : ''}`;
|
|
136
157
|
node.title = failed ? run.error : '';
|
|
137
158
|
$$('.ship-track li', node).forEach((li, i) => (li.className = i < at ? 'done' : i === at ? (failed ? 'bad' : 'now') : ''));
|
|
138
|
-
const
|
|
159
|
+
const repo = !failed && run.repo ? ` ${esc(run.repo)}${run.of > 1 ? ` <span class="muted">(${run.at} of ${run.of})</span>` : ''}` : '';
|
|
160
|
+
const what = failed ? `Ship failed at ${STEP_LABEL[run.steps[at]] || 'start'}: <span class="ship-err">${esc(run.error)}</span>` : `${STEP_DOING[run.steps[at]]}${repo}<span class="dots"></span>`;
|
|
139
161
|
if ($('.ship-what', node).innerHTML !== what) $('.ship-what', node).innerHTML = what;
|
|
140
162
|
const acts = failed ? `<button class="ghost xs" data-ship="retry">${icon('replay')}<span>Retry</span></button><button class="ghost xs" data-ship="dismiss" title="Dismiss">${icon('x')}</button>` : '';
|
|
141
163
|
if ($('.ship-acts', node).innerHTML !== acts) $('.ship-acts', node).innerHTML = acts;
|
|
@@ -151,19 +173,23 @@ export async function shipDialog(id) {
|
|
|
151
173
|
} catch (err) {
|
|
152
174
|
return toastError(err);
|
|
153
175
|
}
|
|
154
|
-
|
|
176
|
+
// Several repos: a route line each, and the PRs open or update together.
|
|
177
|
+
const repos = info.repos || [info];
|
|
178
|
+
const open = repos.every((r) => r.pr?.state === 'OPEN');
|
|
155
179
|
const typed = ships.get(id)?.values; // a failed ship's message, back for Retry
|
|
156
180
|
const nothing = !info.dirty && !info.ahead;
|
|
181
|
+
const noRemote = repos.every((r) => !r.remote);
|
|
182
|
+
const route = (r) => `<p class="ship-route">${icon('branch')}${info.repos ? `<b class="ship-repo">${esc(r.repo)}</b>` : ''}<code>${esc(r.branch)}</code>${icon('arrow')}<code>${esc(r.remote || 'no remote')}/${esc(r.base)}</code>
|
|
183
|
+
<span class="muted">${r.dirty ? `${r.dirty} uncommitted change${r.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${r.ahead} commit${r.ahead === 1 ? '' : 's'} on the branch${info.repos && r.pr ? ` · PR #${r.pr.number}` : ''}</span></p>`;
|
|
157
184
|
const node = el(`
|
|
158
185
|
<div class="modal ship-modal">
|
|
159
|
-
<header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
160
|
-
|
|
161
|
-
<span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
|
|
186
|
+
<header><h2>${open ? (info.repos ? `Push to ${repos.length} PRs` : `Push to PR #${info.pr.number}`) : info.repos ? `Ship it to ${repos.length} repos` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
187
|
+
${repos.map(route).join('')}
|
|
162
188
|
${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
|
|
163
189
|
${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
|
|
164
190
|
<label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
|
|
165
|
-
${
|
|
166
|
-
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing ||
|
|
191
|
+
${noRemote ? `<p class="hint bad">${info.repos ? 'These repos have' : 'This repo has'} no <code>origin</code> remote, so there is nowhere to push.</p>` : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
|
|
192
|
+
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in ${info.repos ? 'each worktree with changes, one PR per repo' : 'the worktree'}.</span><button class="primary sm" data-a="go" ${nothing || noRemote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : info.repos ? 'Commit, push & open PRs' : 'Commit, push & open PR'}</button></div>
|
|
167
193
|
</div>`);
|
|
168
194
|
const close = overlay(node);
|
|
169
195
|
$('.close', node).onclick = close;
|
|
@@ -188,10 +214,12 @@ export async function shipDialog(id) {
|
|
|
188
214
|
(pr) => {
|
|
189
215
|
const s = S.sessions.get(id);
|
|
190
216
|
// Everything just went up: no Push to #n until the server's recheck finds something new.
|
|
191
|
-
|
|
217
|
+
// Several repos: their PRs come with the server's next summary.
|
|
218
|
+
if (s) S.sessions.set(id, { ...s, ...(!pr.prs && { pr: { ...s.pr, ...pr } }), shipping: null, toShip: s.toShip && { ...s.toShip, dirty: 0, unpushed: 0, ...(s.toShip.repos !== undefined && { repos: 0 }) } });
|
|
192
219
|
ships.delete(id);
|
|
193
220
|
renderPrStrip(id);
|
|
194
|
-
|
|
221
|
+
const what = pr.prs ? pr.prs.map((x) => `<b>${esc(x.repo)} #${x.number}</b>`).join(', ') : `<b>PR #${pr.number}</b>`;
|
|
222
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : pr.prs ? 'PRs:' : 'Opened'} ${what}`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
195
223
|
},
|
|
196
224
|
(err) => {
|
|
197
225
|
if (ships.has(id)) ships.get(id).error = err.message;
|
|
@@ -210,15 +238,24 @@ export async function shipDialog(id) {
|
|
|
210
238
|
|
|
211
239
|
export async function mergePr(id) {
|
|
212
240
|
const s = S.sessions.get(id);
|
|
213
|
-
const
|
|
214
|
-
if (!
|
|
215
|
-
const
|
|
216
|
-
const warn =
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
const open = prsOf(s).filter((x) => x.pr.state === 'OPEN');
|
|
242
|
+
if (!open.length) return;
|
|
243
|
+
const several = open.length > 1;
|
|
244
|
+
const warn = open
|
|
245
|
+
.map(({ repo, pr }) => {
|
|
246
|
+
const c = pr.checks || {};
|
|
247
|
+
const where = several ? ` in ${repo}` : '';
|
|
248
|
+
return c.fail ? `⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing${where}.` : c.pending ? `⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running${where}.` : '';
|
|
249
|
+
})
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
.join('\n');
|
|
252
|
+
const what = several ? `these ${open.length} PRs (${open.map((x) => `${x.repo} #${x.pr.number}`).join(', ')})` : `PR #${open[0].pr.number}${open[0].pr.draft ? ' (marks it ready first)' : ''}`;
|
|
253
|
+
if (!confirm(`Squash-merge ${what}, then fast-forward your local base branch${several ? 'es' : ''} and remove the worktree${several ? 's' : ''}?${warn ? `\n\n${warn}` : ''}`)) return;
|
|
254
|
+
toast(several ? `Merging ${open.length} PRs…` : `Merging PR #${open[0].pr.number}…`);
|
|
219
255
|
try {
|
|
220
256
|
const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
|
|
221
|
-
|
|
257
|
+
const merged = r.prs ? r.prs.filter((x) => x.state === 'MERGED').map((x) => `${esc(x.repo)} #${x.number}`).join(', ') : `PR #${r.pr.number}`;
|
|
258
|
+
toast(`${icon('check')} Merged <b>${merged}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
|
|
222
259
|
} catch (err) {
|
|
223
260
|
toastError(err, { timeout: 12_000 });
|
|
224
261
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The / menu in a follow-up box: type "/" to see the commands, pick one with ↑↓ and Tab (or Enter,
|
|
2
|
+
// or a click) and the box reads "/btw " ready for the rest. Esc closes it.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { el } from '../core/dom.js';
|
|
6
|
+
import { SLASH, slashMatches } from '../slash-text.js';
|
|
7
|
+
import { feedbackOn } from './feedback.js';
|
|
8
|
+
|
|
9
|
+
// Register before the box's own Enter handler: picking a command must not send it.
|
|
10
|
+
export function slashMenu(ta, host) {
|
|
11
|
+
const menu = el('<div class="slash-menu" role="listbox" aria-label="Commands" hidden></div>');
|
|
12
|
+
host.append(menu);
|
|
13
|
+
let items = [];
|
|
14
|
+
let sel = 0;
|
|
15
|
+
let closed = false; // Esc: hidden until the text changes
|
|
16
|
+
const commands = () => SLASH.filter((c) => c.name !== 'feedback' || feedbackOn());
|
|
17
|
+
const paint = () => {
|
|
18
|
+
const next = !closed && document.activeElement === ta ? slashMatches(ta.value, commands()) : [];
|
|
19
|
+
if (next.map((c) => c.name).join() !== items.map((c) => c.name).join()) sel = 0;
|
|
20
|
+
items = next;
|
|
21
|
+
menu.hidden = !items.length;
|
|
22
|
+
menu.innerHTML = items
|
|
23
|
+
.map((c, i) => `<button type="button" class="slash-item${i === sel ? ' on' : ''}" role="option" aria-selected="${i === sel}" data-i="${i}"><b>/${c.name}</b><span class="slash-args">${esc(c.args)}</span><span class="slash-hint">${esc(c.hint)}</span></button>`)
|
|
24
|
+
.join('');
|
|
25
|
+
};
|
|
26
|
+
const pick = (c) => {
|
|
27
|
+
ta.value = `/${c.name} `;
|
|
28
|
+
ta.dispatchEvent(new Event('input'));
|
|
29
|
+
ta.focus();
|
|
30
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
31
|
+
};
|
|
32
|
+
ta.addEventListener('input', () => {
|
|
33
|
+
closed = false;
|
|
34
|
+
paint();
|
|
35
|
+
});
|
|
36
|
+
ta.addEventListener('focus', paint);
|
|
37
|
+
ta.addEventListener('blur', paint);
|
|
38
|
+
menu.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the box
|
|
39
|
+
menu.addEventListener('click', (e) => {
|
|
40
|
+
const b = e.target.closest('[data-i]');
|
|
41
|
+
if (b) pick(items[b.dataset.i]);
|
|
42
|
+
});
|
|
43
|
+
ta.addEventListener('keydown', (e) => {
|
|
44
|
+
if (menu.hidden || e.isComposing) return;
|
|
45
|
+
const c = items[sel];
|
|
46
|
+
// Enter on a command typed out in full that works on its own (/feedback opens the dialog) sends it.
|
|
47
|
+
const complete = (e.key === 'Tab' && !e.shiftKey) || (e.key === 'Enter' && !e.shiftKey && !(c.bare && ta.value.slice(1).toLowerCase() === c.name));
|
|
48
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
sel = (sel + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
|
|
51
|
+
paint();
|
|
52
|
+
} else if (complete) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
e.stopImmediatePropagation();
|
|
55
|
+
pick(c);
|
|
56
|
+
} else if (e.key === 'Escape') {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
e.stopPropagation();
|
|
59
|
+
closed = true;
|
|
60
|
+
paint();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The full transcript: everything the agent said and did in a turn, oldest first, on a timeline that
|
|
2
|
+
// ends with its presentation, rendered as blocks. Off by default (Settings → General → Full
|
|
3
|
+
// transcript); any card switches with its Transcript button or V.
|
|
4
|
+
|
|
5
|
+
import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
|
|
6
|
+
import { esc, md } from '../md.js';
|
|
7
|
+
import { $, el, icon } from '../core/dom.js';
|
|
8
|
+
import { load, S, save } from '../core/state.js';
|
|
9
|
+
|
|
10
|
+
export const KIND_ICON = { check: 'check', read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask', background: 'clock' };
|
|
11
|
+
|
|
12
|
+
// --- which view a card shows ---------------------------------------------------
|
|
13
|
+
|
|
14
|
+
// Cards switched away from the setting: session id → true (transcript) or false (presentation).
|
|
15
|
+
const picked = new Map(Object.entries(load('wisp.transcript', {})));
|
|
16
|
+
const savePicks = () => save('wisp.transcript', Object.fromEntries(picked));
|
|
17
|
+
|
|
18
|
+
export const transcriptByDefault = () => S.config.defaults?.transcript === 'on';
|
|
19
|
+
export const transcriptOn = (id) => picked.get(id) ?? transcriptByDefault();
|
|
20
|
+
|
|
21
|
+
export function pickView(id, on) {
|
|
22
|
+
if (on === transcriptByDefault()) picked.delete(id);
|
|
23
|
+
else picked.set(id, on);
|
|
24
|
+
if (S.sessions.size) for (const k of picked.keys()) if (!S.sessions.has(k)) picked.delete(k);
|
|
25
|
+
savePicks();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A new setting applies to every card, including the ones switched by hand before.
|
|
29
|
+
export function clearPicks() {
|
|
30
|
+
picked.clear();
|
|
31
|
+
savePicks();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- what the transcript shows -----------------------------------------------
|
|
35
|
+
|
|
36
|
+
// The agent's final answer is its presentation, shown as blocks at the end rather than as a message.
|
|
37
|
+
// It's the turn's last message (or last few: some harnesses stream it in parts); wisp's own notes
|
|
38
|
+
// after it (Auto's test check) don't break the run. Returns their indexes in turn.activity.
|
|
39
|
+
export function finalAnswer(t) {
|
|
40
|
+
const out = new Set();
|
|
41
|
+
const raw = typeof t.raw === 'string' ? t.raw : '';
|
|
42
|
+
const acts = t.activity || [];
|
|
43
|
+
if (!raw.trim()) return out;
|
|
44
|
+
for (let i = acts.length - 1; i >= 0; i--) {
|
|
45
|
+
const a = acts[i];
|
|
46
|
+
if (a.by === 'wisp') continue;
|
|
47
|
+
const head = a.kind === 'text' ? String(a.label || '').trim().slice(0, 200) : '';
|
|
48
|
+
if (!head || !raw.includes(head)) break;
|
|
49
|
+
out.add(i);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A message as it reads mid-run: the presentation it ends with is on its way, not prose to show.
|
|
55
|
+
export const messageText = (text) => {
|
|
56
|
+
const s = String(text || '');
|
|
57
|
+
const open = s.search(/```wisp/);
|
|
58
|
+
return (open < 0 ? s : s.slice(0, open)).trim();
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Steps between two messages, in runs. A run longer than FOLD_AT shows KEEP at each end and folds the
|
|
62
|
+
// middle behind one row.
|
|
63
|
+
const FOLD_AT = 12;
|
|
64
|
+
const KEEP = 4;
|
|
65
|
+
export function layout(items) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let run = [];
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (run.length > FOLD_AT) out.push(...run.slice(0, KEEP), { fold: run.slice(KEEP, -KEEP) }, ...run.slice(-KEEP));
|
|
70
|
+
else out.push(...run);
|
|
71
|
+
run = [];
|
|
72
|
+
};
|
|
73
|
+
for (const it of items) {
|
|
74
|
+
if (it.kind === 'text') {
|
|
75
|
+
flush();
|
|
76
|
+
out.push(it);
|
|
77
|
+
} else run.push(it);
|
|
78
|
+
}
|
|
79
|
+
flush();
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const OPENS = new Set(['run', 'search', 'web', 'tool', 'agent']);
|
|
84
|
+
const findChange = (t, file) => t?.changes?.find((c) => c.file === file);
|
|
85
|
+
// Edits a step made: its own slice of the file's edits when the server linked them, else the file's.
|
|
86
|
+
const editsOf = (a, t) => {
|
|
87
|
+
const c = findChange(t, a.file);
|
|
88
|
+
if (!c) return null;
|
|
89
|
+
return Array.isArray(a.edits) ? c.edits.slice(a.edits[0], a.edits[1]) : c.edits;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// Whether a step opens up to more: a command and what it printed, an edit's diff, an error in full.
|
|
93
|
+
// Mid-run an edit's diff isn't loaded yet, so it opens anyway and fetches it.
|
|
94
|
+
function opens(a, t, live) {
|
|
95
|
+
if (a.kind === 'error') return true;
|
|
96
|
+
if (a.kind === 'edit') return !!a.file && (live || !!findChange(t, a.file));
|
|
97
|
+
return OPENS.has(a.kind) && !!(a.output || a.detail);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// How far into the turn a step came, shown on hover.
|
|
101
|
+
const since = (a, t) => (a.t && t.startedAt ? `<time class="tx-t" title="Since the turn started">${fmtDuration(Math.max(0, a.t - t.startedAt))}</time>` : '');
|
|
102
|
+
|
|
103
|
+
function stepHtml(a, k, t, live) {
|
|
104
|
+
const more = opens(a, t, live);
|
|
105
|
+
// The command itself, unless the label already is it ("Running ls; …" when there was no description).
|
|
106
|
+
const flat = a.kind === 'run' && a.detail ? a.detail.replace(/\s+/g, ' ').trim() : '';
|
|
107
|
+
const cmd = flat && !String(a.label || '').replace(/\s+/g, ' ').includes(flat.slice(0, 24)) ? flat : '';
|
|
108
|
+
const edits = a.kind === 'edit' && editsOf(a, t);
|
|
109
|
+
const n = edits ? countChanges(edits) : null;
|
|
110
|
+
return `<li class="tx-step k-${esc(a.kind)}${a.by === 'wisp' ? ' by-wisp' : ''}" data-k="${k}">
|
|
111
|
+
<div class="tx-row" ${more ? 'role="button" tabindex="0" aria-expanded="false"' : ''}>
|
|
112
|
+
<span class="tx-ic">${icon(a.by === 'wisp' ? 'spark' : KIND_ICON[a.kind] || 'tool')}</span>
|
|
113
|
+
<span class="tx-label">${esc(a.label || '')}</span>
|
|
114
|
+
${cmd ? `<code class="tx-cmd">${esc(cmd)}</code>` : ''}
|
|
115
|
+
${n ? `<span class="nums"><span class="plus">+${n.add}</span><span class="minus">−${n.del}</span></span>` : ''}
|
|
116
|
+
${since(a, t)}
|
|
117
|
+
${a.file && (a.kind === 'read' || a.kind === 'edit') ? `<button class="tx-open" title="Open in editor" aria-label="Open in editor">${icon('external')}</button>` : ''}
|
|
118
|
+
${more ? icon('chevron', 'chev') : ''}
|
|
119
|
+
</div>
|
|
120
|
+
</li>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function msgHtml(a, k, t) {
|
|
124
|
+
const text = messageText(a.label);
|
|
125
|
+
return text ? `<li class="tx-msg" data-k="${k}"><span class="tx-dot"></span><div class="prose">${md(text)}</div>${since(a, t)}</li>` : '';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const foldHtml = (n, k) => `<li class="tx-fold" data-k="${k}"><button class="tx-row"><span class="tx-ic">${icon('dots')}</span><span class="tx-label">${n} more step${n === 1 ? '' : 's'}</span></button></li>`;
|
|
129
|
+
|
|
130
|
+
// The steps rendered into a list, by data-k: the row's item, or a fold's hidden items.
|
|
131
|
+
const registry = new WeakMap();
|
|
132
|
+
|
|
133
|
+
function listHtml(list, items, t, live, { fold = true } = {}) {
|
|
134
|
+
const reg = registry.get(list);
|
|
135
|
+
return (fold ? layout(items) : items)
|
|
136
|
+
.map((it) => {
|
|
137
|
+
const k = reg.push(it) - 1;
|
|
138
|
+
if (it.fold) return foldHtml(it.fold.length, k);
|
|
139
|
+
return it.kind === 'text' ? msgHtml(it, k, t) : stepHtml(it, k, t, live);
|
|
140
|
+
})
|
|
141
|
+
.join('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- rendering ---------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
// Where the timeline ends. How long it took is on the hero right under it.
|
|
147
|
+
const endLabel = (t) => {
|
|
148
|
+
if (t.status === 'error') return ['Ended with an error', 'warn'];
|
|
149
|
+
if (t.status === 'stopped') return [t.presentation?.blocks?.[0]?.title === 'Interrupted' ? 'Interrupted' : 'Stopped', 'stop'];
|
|
150
|
+
return ['Finished', 'done'];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// A finished turn: the timeline, then the presentation. ctx is the block context (turns.js ctxFor).
|
|
154
|
+
export function renderTranscript(s, t, ctx) {
|
|
155
|
+
const skip = finalAnswer(t);
|
|
156
|
+
const items = (t.activity || []).filter((_, i) => !skip.has(i));
|
|
157
|
+
const notes = t.presentation?.notes;
|
|
158
|
+
if (notes) items.push({ kind: 'text', label: notes, t: t.endedAt });
|
|
159
|
+
const msgs = items.filter((a) => a.kind === 'text').length;
|
|
160
|
+
const steps = items.length - msgs;
|
|
161
|
+
const [end, endIcon] = endLabel(t);
|
|
162
|
+
const node = el(`
|
|
163
|
+
<div class="tx">
|
|
164
|
+
<div class="tx-head">
|
|
165
|
+
<span class="tx-title">${icon('transcript')}Transcript</span>
|
|
166
|
+
<span class="tx-count">${[msgs && `${msgs} message${msgs === 1 ? '' : 's'}`, steps && `${steps} step${steps === 1 ? '' : 's'}`].filter(Boolean).join(' · ') || 'Nothing recorded'}</span>
|
|
167
|
+
${t.trimmed ? `<span class="tx-count" title="A turn keeps its latest steps">· ${t.trimmed} earlier not kept</span>` : ''}
|
|
168
|
+
<span class="grow"></span>
|
|
169
|
+
${items.length > 10 ? `<button class="link xs tx-jump">Jump to the result${icon('arrow')}</button>` : ''}
|
|
170
|
+
</div>
|
|
171
|
+
<ol class="tx-list"></ol>
|
|
172
|
+
</div>`);
|
|
173
|
+
const list = $('.tx-list', node);
|
|
174
|
+
registry.set(list, []);
|
|
175
|
+
list.innerHTML = listHtml(list, items, t, false) + `<li class="tx-end st-${esc(t.status)}"><span class="tx-ic">${icon(endIcon)}</span><span class="tx-label">${esc(end)}</span></li>`;
|
|
176
|
+
node.append(renderPresentation(t.presentation, ctx));
|
|
177
|
+
wire(node, s, t, ctx);
|
|
178
|
+
return node;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// A running turn: the timeline so far, which onActivity grows (appendStep). The working panel with
|
|
182
|
+
// its Stop button and any question sits under it (turns.js).
|
|
183
|
+
export function renderLiveTranscript(s, t, ctx) {
|
|
184
|
+
const node = el(`<div class="tx live"><ol class="tx-list"></ol></div>`);
|
|
185
|
+
const list = $('.tx-list', node);
|
|
186
|
+
registry.set(list, []);
|
|
187
|
+
list.innerHTML = listHtml(list, t.activity || [], t, true);
|
|
188
|
+
wire(node, s, t, ctx);
|
|
189
|
+
return node;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A new step. In a running turn it goes at the end, and the working panel under the timeline holds
|
|
193
|
+
// still on screen while the timeline grows above it, so watching a run reads like a chat; scrolled
|
|
194
|
+
// away, nothing moves. A finished turn still gets wisp's own steps after the agent's answer (Auto
|
|
195
|
+
// checking the tests): they go above the end marker, and what you're reading below doesn't move.
|
|
196
|
+
export function appendStep(turnNode, item, t, scrollBy) {
|
|
197
|
+
const list = $('.tx-list', turnNode);
|
|
198
|
+
const reg = list && registry.get(list);
|
|
199
|
+
if (!reg) return;
|
|
200
|
+
const live = !!list.closest('.live');
|
|
201
|
+
const html = item.kind === 'text' ? msgHtml(item, reg.length, t) : stepHtml(item, reg.length, t, live);
|
|
202
|
+
if (!html) return;
|
|
203
|
+
reg.push(item);
|
|
204
|
+
const end = $(':scope > .tx-end', list);
|
|
205
|
+
const anchor = live ? $('.w-panel', turnNode) : end;
|
|
206
|
+
const r = anchor?.getBoundingClientRect();
|
|
207
|
+
const held = !r ? null : live ? (r.bottom > 0 && r.top < innerHeight ? r.top : null) : r.bottom <= 0 ? r.top : null;
|
|
208
|
+
const li = el(html);
|
|
209
|
+
li.classList.add('new');
|
|
210
|
+
list.insertBefore(li, end);
|
|
211
|
+
if (held !== null) scrollBy(anchor.getBoundingClientRect().top - held);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A step that changed after it was shown (its output came in).
|
|
215
|
+
export const sameStep = (a, b) => (a.call || b.call ? a.call === b.call : a.t === b.t && a.kind === b.kind && a.label === b.label);
|
|
216
|
+
export function updateStep(turnNode, item, t) {
|
|
217
|
+
for (const list of turnNode.querySelectorAll('.tx-list')) {
|
|
218
|
+
const reg = registry.get(list) || [];
|
|
219
|
+
const k = reg.findLastIndex((x) => !x.fold && sameStep(x, item));
|
|
220
|
+
if (k < 0) continue;
|
|
221
|
+
Object.assign(reg[k], item);
|
|
222
|
+
const li = $(`.tx-step[data-k="${k}"]`, list);
|
|
223
|
+
if (!li) continue;
|
|
224
|
+
const open = li.classList.contains('open');
|
|
225
|
+
const next = el(stepHtml(reg[k], k, t, list.closest('.live') !== null));
|
|
226
|
+
li.replaceWith(next);
|
|
227
|
+
if (open) toggleStep(next, reg[k], t, null);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// What a step opens to.
|
|
232
|
+
function moreEl(a, t, ctx) {
|
|
233
|
+
if (a.kind === 'edit') {
|
|
234
|
+
const edits = editsOf(a, t);
|
|
235
|
+
if (!edits) return el(`<div class="tx-more"><p class="tx-none">No captured edits for this file.</p></div>`);
|
|
236
|
+
const blocks = edits.map((e) => ({ type: 'diff', file: a.file, before: e.before, after: e.after, bare: true }));
|
|
237
|
+
const box = el(`<div class="tx-more tx-diff"></div>`);
|
|
238
|
+
box.append(renderPresentation({ blocks }, { ...ctx, turn: t }));
|
|
239
|
+
return box;
|
|
240
|
+
}
|
|
241
|
+
if (a.kind === 'error') return el(`<div class="tx-more"><pre class="tx-out err">${esc(a.label)}</pre></div>`);
|
|
242
|
+
const cmd = a.detail ? `<span class="tx-cmdline">${a.kind === 'run' ? '<b>$</b> ' : ''}${esc(a.detail)}</span>` : '';
|
|
243
|
+
const out = a.output ? `<span class="tx-res">${esc(a.output)}</span>` : '';
|
|
244
|
+
return el(`<div class="tx-more"><pre class="tx-out">${cmd}${out}</pre></div>`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function toggleStep(li, a, t, ctx) {
|
|
248
|
+
const row = $('.tx-row', li);
|
|
249
|
+
const open = !li.classList.contains('open');
|
|
250
|
+
li.classList.toggle('open', open);
|
|
251
|
+
row.setAttribute('aria-expanded', open);
|
|
252
|
+
$('.tx-more', li)?.remove();
|
|
253
|
+
if (!open) return;
|
|
254
|
+
// Mid-run, the card's copy of the turn predates this edit: load it first.
|
|
255
|
+
if (a.kind === 'edit' && !findChange(t, a.file) && ctx?.refresh) {
|
|
256
|
+
li.classList.add('loading');
|
|
257
|
+
t = (await ctx.refresh())?.turns?.find((x) => x.id === t.id) || t;
|
|
258
|
+
a.edits ??= t.activity?.findLast((x) => sameStep(x, a))?.edits; // linked to its edit after it was shown
|
|
259
|
+
li.classList.remove('loading');
|
|
260
|
+
if (!li.classList.contains('open')) return;
|
|
261
|
+
}
|
|
262
|
+
li.append(moreEl(a, t, ctx || {}));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function wire(node, s, t, ctx) {
|
|
266
|
+
// The latest copy of the turn: a refetch replaces it while this node stays.
|
|
267
|
+
const turn = () => S.full.get(s.id)?.turns?.find((x) => x.id === t.id) || t;
|
|
268
|
+
const itemOf = (li) => registry.get(li.closest('.tx-list'))?.[li.dataset.k];
|
|
269
|
+
const act = (e) => {
|
|
270
|
+
if (e.target.closest('.tx-jump')) return $('.tx-end', node)?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
271
|
+
const li = e.target.closest('.tx-step, .tx-fold');
|
|
272
|
+
const a = li && itemOf(li);
|
|
273
|
+
if (!a) return;
|
|
274
|
+
if (e.target.closest('.tx-open')) return ctx.openFile(a.file);
|
|
275
|
+
if (a.fold) {
|
|
276
|
+
const list = li.closest('.tx-list');
|
|
277
|
+
li.outerHTML = listHtml(list, a.fold, turn(), node.classList.contains('live'), { fold: false });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (e.target.closest('.tx-row[role="button"]') && !e.target.closest('.tx-more')) toggleStep(li, a, turn(), ctx);
|
|
281
|
+
};
|
|
282
|
+
node.addEventListener('click', act);
|
|
283
|
+
node.addEventListener('keydown', (e) => {
|
|
284
|
+
if ((e.key === 'Enter' || e.key === ' ') && e.target.matches('.tx-row[role="button"]')) {
|
|
285
|
+
e.preventDefault();
|
|
286
|
+
e.stopPropagation(); // Enter on the page would fold the whole card (keys.js)
|
|
287
|
+
act(e);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
}
|