wisp-agents 0.21.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 +24 -1
- package/README.md +13 -8
- package/bin/wisp.js +10 -3
- package/package.json +4 -3
- package/server/adapters/claude.js +8 -4
- package/server/adapters/codex.js +9 -6
- 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 +19 -0
- 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/openapi.js +27 -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 +32 -2
- package/server/routes/settings.js +24 -38
- package/server/routes/uploads.js +2 -0
- package/server/runner.js +154 -8
- package/server/services/launch.js +32 -4
- package/server/services/move.js +130 -0
- package/server/services/phone.js +20 -1
- package/server/services/places.js +150 -0
- package/server/services/uploads.js +23 -4
- package/server/store.js +39 -6
- package/skills/wisp/SKILL.md +1 -0
- package/web/css/blocks.css +2 -0
- package/web/css/composer.css +23 -1
- package/web/css/feed.css +49 -1
- package/web/css/machines.css +4 -0
- package/web/css/modals.css +19 -0
- package/web/css/ship.css +2 -0
- package/web/css/transcript.css +115 -0
- package/web/index.html +2 -0
- package/web/js/core/state.js +3 -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 +1 -1
- package/web/js/features/keys.js +2 -1
- package/web/js/features/launcher.js +104 -94
- package/web/js/features/place-menu.js +44 -0
- package/web/js/features/places.js +15 -5
- package/web/js/features/projects.js +14 -12
- package/web/js/features/settings.js +24 -3
- package/web/js/features/slash.js +63 -0
- package/web/js/features/transcript.js +290 -0
- package/web/js/features/turns.js +99 -28
- package/web/js/slash-text.js +22 -0
- package/web/js/ui/kbd.js +1 -0
|
@@ -17,7 +17,10 @@ import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
|
|
|
17
17
|
import { launchBehind } from './launcher.js';
|
|
18
18
|
import { copyTaskId, mentionBox } from './mentions.js';
|
|
19
19
|
import { runnerPicker } from './runner-picker.js';
|
|
20
|
-
import {
|
|
20
|
+
import { connectMachine } from './machines.js';
|
|
21
|
+
import { placeMenu } from './place-menu.js';
|
|
22
|
+
import { machineById, pathAt, placeName, projectById } from './places.js';
|
|
23
|
+
import { agentById, agentName, DEFAULT_MODE, effortsFor, harnessesAt, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
|
|
21
24
|
import { feedAnchor, keepInPlace } from './scroll.js';
|
|
22
25
|
import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
|
|
23
26
|
|
|
@@ -221,15 +224,18 @@ export async function deleteSession(id) {
|
|
|
221
224
|
});
|
|
222
225
|
}
|
|
223
226
|
|
|
224
|
-
// Launch the task again as a new session in the same
|
|
225
|
-
// optionally edited, on whichever harness, model and thinking level you pick. The
|
|
227
|
+
// Launch the task again as a new session in the same project: same prompt and attachments,
|
|
228
|
+
// optionally edited, on whichever computer, harness, model and thinking level you pick. The
|
|
229
|
+
// original stays. Another computer runs it in its own checkout (cloned there if need be).
|
|
226
230
|
export async function rerun(id) {
|
|
227
231
|
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(toastError));
|
|
228
232
|
const first = s?.turns?.[0];
|
|
229
233
|
if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const
|
|
234
|
+
let place = s.machine?.id || '';
|
|
235
|
+
const here = () => harnessesAt([place]).filter((x) => x.available);
|
|
236
|
+
const has = (a) => here().some((x) => x.id === a);
|
|
237
|
+
let agent = has(s.agent) ? s.agent : here()[0]?.id || s.agent;
|
|
238
|
+
const models = agentById(agent)?.models || [];
|
|
233
239
|
let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
|
|
234
240
|
let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
|
|
235
241
|
let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
|
|
@@ -242,13 +248,14 @@ export async function rerun(id) {
|
|
|
242
248
|
<textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
|
|
243
249
|
${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
|
|
244
250
|
<div class="l-bar">
|
|
251
|
+
<button class="tog-btn where-btn" data-a="place" aria-haspopup="menu" ${S.machines.length ? '' : 'hidden'}></button>
|
|
245
252
|
<button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
|
|
246
253
|
<button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
|
|
247
254
|
<span class="grow"></span>
|
|
248
255
|
<button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
|
|
249
256
|
</div>
|
|
250
257
|
</div>
|
|
251
|
-
<p class="hint"
|
|
258
|
+
<p class="hint rr-hint"></p>
|
|
252
259
|
</div>`);
|
|
253
260
|
const close = overlay(node);
|
|
254
261
|
$('.close', node).onclick = close;
|
|
@@ -264,12 +271,37 @@ export async function rerun(id) {
|
|
|
264
271
|
};
|
|
265
272
|
const runnerBtn = $('[data-a="runner"]', node);
|
|
266
273
|
const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
|
|
274
|
+
// Where it runs: where it ran, unless you pick another computer.
|
|
275
|
+
const placeBtn = $('[data-a="place"]', node);
|
|
276
|
+
const project = projectById(S.sessions.get(id)?.projectId);
|
|
277
|
+
const paintPlace = () => {
|
|
278
|
+
placeBtn.classList.toggle('on', !!place);
|
|
279
|
+
placeBtn.innerHTML = `${icon(place ? 'server' : 'laptop')}<span>${esc(placeName(place))}</span>${icon('chevron', 'chev')}`;
|
|
280
|
+
placeBtn.title = 'Run it on another computer';
|
|
281
|
+
const fresh = project && !pathAt(project, place);
|
|
282
|
+
$('.rr-hint', node).innerHTML = `Starts a new session in <code>${esc(project && !fresh ? pathAt(project, place) : s.cwd)}</code>${S.machines.length ? ` on <b>${esc(placeName(place))}</b>` : ''}${fresh ? `, in a checkout wisp clones there first` : ''}. “${esc(s.title)}” is kept as it is.`;
|
|
283
|
+
};
|
|
267
284
|
paintMode();
|
|
268
285
|
paintRunner();
|
|
286
|
+
paintPlace();
|
|
287
|
+
placeBtn.onclick = () =>
|
|
288
|
+
placeMenu(placeBtn, { title: 'Rerun on', current: place, from: s.machine?.id || '' }, (k) => {
|
|
289
|
+
place = k;
|
|
290
|
+
if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
291
|
+
// The harness has to be there too: keep it if it is, else the first one that is.
|
|
292
|
+
if (!has(agent) && here().length) {
|
|
293
|
+
agent = here()[0].id;
|
|
294
|
+
model = savedModel(agent);
|
|
295
|
+
effort = savedEffort(agent, model);
|
|
296
|
+
paintRunner();
|
|
297
|
+
}
|
|
298
|
+
paintPlace();
|
|
299
|
+
ta.focus();
|
|
300
|
+
});
|
|
269
301
|
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
270
302
|
// Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
|
|
271
303
|
runnerBtn.onclick = () =>
|
|
272
|
-
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
|
|
304
|
+
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, places: [place], auto: true }, (a, m, e) => {
|
|
273
305
|
[agent, model, effort] = [a, m, e];
|
|
274
306
|
paintRunner();
|
|
275
307
|
});
|
|
@@ -279,8 +311,8 @@ export async function rerun(id) {
|
|
|
279
311
|
if (!prompt) return ta.focus();
|
|
280
312
|
close();
|
|
281
313
|
try {
|
|
282
|
-
const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
283
|
-
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
314
|
+
const body = { prompt, cwd: s.cwd, also: s.also?.map((a) => a.cwd), machine: place, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
315
|
+
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort }, machine: place }, () => api('/sessions', { method: 'POST', body }));
|
|
284
316
|
setFocus(next.id);
|
|
285
317
|
} catch (err) {
|
|
286
318
|
toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// /btw side questions on a card: "/btw did you also update the docs?" in the composer asks without
|
|
2
|
+
// interrupting the run, and the answer shows on the turn it was asked during. The server answers
|
|
3
|
+
// each one with a read-only run of its own (server/btw.js).
|
|
4
|
+
|
|
5
|
+
import { fmtDuration } from '../blocks.js';
|
|
6
|
+
import { esc, md } from '../md.js';
|
|
7
|
+
import { mentionHtml } from '../mention-text.js';
|
|
8
|
+
import { api } from '../core/api.js';
|
|
9
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
10
|
+
import { load, S, save } from '../core/state.js';
|
|
11
|
+
import { isBtw } from '../slash-text.js';
|
|
12
|
+
import { toastError } from '../ui/toast.js';
|
|
13
|
+
import { scheduleRefetch } from './feed.js';
|
|
14
|
+
import { agentName } from './runners.js';
|
|
15
|
+
|
|
16
|
+
const waiting = new Map(); // session id -> side questions sent, not yet confirmed by the server
|
|
17
|
+
const gone = new Set(); // ids dismissed here, until the server has dropped them too
|
|
18
|
+
let seq = 0;
|
|
19
|
+
|
|
20
|
+
// Optimistic: the question is on the card at once, waiting for its answer. If the server refuses
|
|
21
|
+
// it, it comes off again and restore() gives the text back.
|
|
22
|
+
export async function askBtw(id, question, { attachments = [], files = [], restore } = {}) {
|
|
23
|
+
const s = S.full.get(id);
|
|
24
|
+
if (!s) return;
|
|
25
|
+
retireTip(); // they know about it now
|
|
26
|
+
const pending = { id: `pending-${++seq}`, question, status: 'running', askedAt: Date.now(), agent: s.agent, attachments: files.filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
|
|
27
|
+
const mine = () => waiting.get(id) || [];
|
|
28
|
+
const unwait = () => (mine().length > 1 ? waiting.set(id, mine().filter((b) => b !== pending)) : waiting.delete(id));
|
|
29
|
+
waiting.set(id, [...mine(), pending]);
|
|
30
|
+
paintBtw(id);
|
|
31
|
+
try {
|
|
32
|
+
const entry = await api(`/sessions/${id}/btw`, { method: 'POST', body: { text: question, attachments } });
|
|
33
|
+
unwait();
|
|
34
|
+
// Its node takes the real id, so it stays put instead of animating in a second time.
|
|
35
|
+
const node = $(`.btw[data-id="${pending.id}"]`, S.nodes.get(id)?.card);
|
|
36
|
+
if (node) node.dataset.id = entry.id;
|
|
37
|
+
S.nodes.get(id)?.btwSeen?.add(entry.id);
|
|
38
|
+
const cur = S.full.get(id);
|
|
39
|
+
if (cur && !cur.turns.some((t) => t.btw?.some((b) => b.id === entry.id))) (cur.turns.at(-1).btw ||= []).push(entry);
|
|
40
|
+
paintBtw(id);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
unwait();
|
|
43
|
+
paintBtw(id);
|
|
44
|
+
restore?.();
|
|
45
|
+
toastError(err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The tip in a working panel, so /btw gets found: until it's been used once, or hidden with its ×.
|
|
50
|
+
// Clicking it starts a /btw in the box below.
|
|
51
|
+
const TIP = 'wisp.btwTipDone';
|
|
52
|
+
export function btwTip(id) {
|
|
53
|
+
const s = S.full.get(id);
|
|
54
|
+
if (load(TIP, false) || !s || s.agent === 'wisp' || s.ask) return null;
|
|
55
|
+
const node = el(`
|
|
56
|
+
<span class="w-tip">
|
|
57
|
+
<button class="w-tip-go" title="Ask ${esc(agentName(s.agent))} something on the side. It keeps working, and the answer shows on this card"><span class="w-tip-k">Tip</span><code>/btw</code><span class="w-tip-t">asks without interrupting</span></button>
|
|
58
|
+
<button class="w-tip-x" title="Hide this tip" aria-label="Hide this tip">${icon('x')}</button>
|
|
59
|
+
</span>`);
|
|
60
|
+
$('.w-tip-go', node).onclick = () => {
|
|
61
|
+
const ta = $('.reply', S.nodes.get(id)?.card);
|
|
62
|
+
if (!ta) return;
|
|
63
|
+
if (!isBtw(ta.value)) ta.value = `/btw ${ta.value.trim()}`; // a draft already there becomes the question
|
|
64
|
+
ta.dispatchEvent(new Event('input'));
|
|
65
|
+
ta.focus();
|
|
66
|
+
ta.setSelectionRange(ta.value.length, ta.value.length);
|
|
67
|
+
};
|
|
68
|
+
$('.w-tip-x', node).onclick = retireTip;
|
|
69
|
+
return node;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function retireTip() {
|
|
73
|
+
save(TIP, true);
|
|
74
|
+
$$('.w-tip').forEach((n) => n.remove());
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Dismissing one that's still being answered stops its run. Gone at once; back if that fails.
|
|
78
|
+
async function dismissBtw(id, btwId) {
|
|
79
|
+
gone.add(btwId);
|
|
80
|
+
paintBtw(id);
|
|
81
|
+
try {
|
|
82
|
+
await api(`/sessions/${id}/btw?item=${encodeURIComponent(btwId)}`, { method: 'DELETE' });
|
|
83
|
+
for (const t of S.full.get(id)?.turns || []) if (t.btw) t.btw = t.btw.filter((b) => b.id !== btwId);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
toastError(err);
|
|
86
|
+
scheduleRefetch(id);
|
|
87
|
+
}
|
|
88
|
+
gone.delete(btwId);
|
|
89
|
+
paintBtw(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fill each open turn's side-question list, keeping the nodes that haven't changed: only a newly
|
|
93
|
+
// asked one animates in, and an answer fades in where its "answering" line was.
|
|
94
|
+
export function paintBtw(id) {
|
|
95
|
+
const s = S.full.get(id);
|
|
96
|
+
const n = S.nodes.get(id);
|
|
97
|
+
if (!s || !n?.turnNodes) return;
|
|
98
|
+
const first = !n.btwSeen; // what's already there when the card opens doesn't animate
|
|
99
|
+
n.btwSeen ||= new Set();
|
|
100
|
+
const latest = s.turns.at(-1)?.id;
|
|
101
|
+
for (const t of s.turns) {
|
|
102
|
+
const list = n.turnNodes.get(t.id)?.node.querySelector(':scope > .btw-list');
|
|
103
|
+
if (!list) continue;
|
|
104
|
+
const items = [...(t.btw || []), ...(t.id === latest ? waiting.get(id) || [] : [])].filter((b) => !gone.has(b.id));
|
|
105
|
+
const ids = new Set(items.map((b) => b.id));
|
|
106
|
+
for (const c of [...list.children]) if (!ids.has(c.dataset.id)) c.remove();
|
|
107
|
+
items.forEach((b, i) => {
|
|
108
|
+
const key = `${b.status}|${b.answer || b.error || ''}`;
|
|
109
|
+
let node = [...list.children].find((c) => c.dataset.id === b.id);
|
|
110
|
+
if (!node || node.dataset.key !== key) {
|
|
111
|
+
const next = item(s, b);
|
|
112
|
+
next.dataset.key = key;
|
|
113
|
+
if (!node && !first && !n.btwSeen.has(b.id)) next.classList.add('new');
|
|
114
|
+
if (node?.dataset.key.startsWith('running|') && b.status !== 'running') next.classList.add('landed');
|
|
115
|
+
$('.btw-x', next).onclick = () => {
|
|
116
|
+
dismissBtw(id, b.id);
|
|
117
|
+
};
|
|
118
|
+
if (node) node.replaceWith(next);
|
|
119
|
+
node = next;
|
|
120
|
+
}
|
|
121
|
+
n.btwSeen.add(b.id);
|
|
122
|
+
if (list.children[i] !== node) list.insertBefore(node, list.children[i] || null);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function item(s, b) {
|
|
128
|
+
const who = agentName(b.agent || s.agent);
|
|
129
|
+
const at = new Date(b.askedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
130
|
+
const how = b.status !== 'done' ? '' : b.forked ? 'on a copy of the conversation' : 'from wisp’s record of the task';
|
|
131
|
+
const tip = [`Side question, asked ${at}`, b.answeredAt && `answered in ${fmtDuration(b.answeredAt - b.askedAt)}`, how].filter(Boolean).join(' · ');
|
|
132
|
+
const body =
|
|
133
|
+
b.status === 'running'
|
|
134
|
+
? `<div class="btw-wait"><span class="btw-dots"><i></i><i></i><i></i></span>${esc(who)} is answering on the side · the run carries on<span class="elapsed" data-start="${b.askedAt}">${fmtDuration(Date.now() - b.askedAt)}</span></div>`
|
|
135
|
+
: b.status === 'error'
|
|
136
|
+
? `<div class="btw-err">${icon('warn')}<span>${esc(b.error || 'No answer.')}</span></div>`
|
|
137
|
+
: `<div class="btw-a prose">${md(b.answer || '')}</div>`;
|
|
138
|
+
return el(`
|
|
139
|
+
<div class="btw st-${b.status}" data-id="${esc(b.id)}">
|
|
140
|
+
<div class="btw-q"><span class="btw-tag" title="${esc(tip)}">${icon('ask')}btw</span><span class="btw-text">${mentionHtml(esc(b.question), { link: false })}</span></div>
|
|
141
|
+
${b.attachments?.length ? `<div class="btw-atts">${b.attachments.map((a) => `<span class="att-pill">${icon('file')}${esc(a.name)}</span>`).join('')}</div>` : ''}
|
|
142
|
+
${body}
|
|
143
|
+
<button class="icon-btn btw-x" title="${b.status === 'running' ? 'Cancel this side question' : 'Dismiss'}">${icon('x')}</button>
|
|
144
|
+
</div>`);
|
|
145
|
+
}
|
|
@@ -33,7 +33,7 @@ const tilde = (p) => (S.config.home && p.startsWith(S.config.home) ? `~${p.slice
|
|
|
33
33
|
// goes back to what it had picked.
|
|
34
34
|
export function startClone(url, where = null) {
|
|
35
35
|
const k = where || '';
|
|
36
|
-
const prev = launch.picks[
|
|
36
|
+
const prev = launch.picks[''];
|
|
37
37
|
const job = { id: `clone-${++seq}`, url, where: where || null, name: repoName(url), label: repoLabel(url) };
|
|
38
38
|
clones.push(job);
|
|
39
39
|
// A feed filtered to another project would pull the launcher back to that project.
|
|
@@ -43,13 +43,14 @@ export function startClone(url, where = null) {
|
|
|
43
43
|
renderFeed();
|
|
44
44
|
renderProjects();
|
|
45
45
|
}
|
|
46
|
-
launch.picks[
|
|
46
|
+
launch.picks[''] = job.id;
|
|
47
|
+
delete launch.also[''];
|
|
48
|
+
launch.project = job.id;
|
|
47
49
|
if (!launch.where.includes(k)) launch.where = [k];
|
|
48
|
-
if (k === launch.where[0]) launch.project = job.id;
|
|
49
50
|
const repick = (id) => {
|
|
50
|
-
if (launch.picks[
|
|
51
|
-
launch.picks[
|
|
52
|
-
|
|
51
|
+
if (launch.picks[''] !== job.id) return; // picked something else meanwhile
|
|
52
|
+
launch.picks[''] = id;
|
|
53
|
+
launch.project = id;
|
|
53
54
|
saveLaunch();
|
|
54
55
|
};
|
|
55
56
|
const on = where ? ` on ${esc(machineById(where)?.name || 'the machine')}` : '';
|
|
@@ -20,7 +20,8 @@ import { openProjects, renderProjects } from './projects.js';
|
|
|
20
20
|
import { agentName } from './runners.js';
|
|
21
21
|
import { openSettings } from './settings.js';
|
|
22
22
|
import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
|
|
23
|
-
import {
|
|
23
|
+
import { transcriptOn } from './transcript.js';
|
|
24
|
+
import { replay, toggleTranscript } from './turns.js';
|
|
24
25
|
import { openUsage } from './usage.js';
|
|
25
26
|
|
|
26
27
|
const STATE_LABEL = { running: 'Working', waiting: 'Asking', review: 'Review', done: 'Done' };
|
|
@@ -55,6 +56,7 @@ function paletteItems() {
|
|
|
55
56
|
if (cur.groupId) items.push({ group: g, label: 'Compare runs', sub, icon: 'agent', keywords: 'vs side by side harness', run: () => compareView(id) });
|
|
56
57
|
items.push({ group: g, label: 'Save as recipe…', sub, icon: 'spark', keywords: 'template reuse automation', run: () => saveAsRecipe(id) });
|
|
57
58
|
items.push({ group: g, label: 'Replay presentation', sub, icon: 'replay', kbd: 'R', run: () => withCard(id, () => replay(id), { open: true }) });
|
|
59
|
+
items.push({ group: g, label: transcriptOn(id) ? 'Show the presentation' : 'Show the full transcript', sub, icon: 'transcript', kbd: 'V', keywords: 'transcript log messages steps tool calls verbose activity history', run: () => withCard(id, () => toggleTranscript(id)) });
|
|
58
60
|
items.push({ group: g, label: 'Rename', sub, icon: 'pen', kbd: 'M', run: () => withCard(id, () => rename(id)) });
|
|
59
61
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
60
62
|
}
|
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
// The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups
|
|
1
|
+
// The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups, and
|
|
2
|
+
// side questions (/btw), which don't interrupt the run.
|
|
2
3
|
|
|
3
4
|
import { esc } from '../md.js';
|
|
4
5
|
import { mentionHtml } from '../mention-text.js';
|
|
6
|
+
import { btwQuestion, isBtw } from '../slash-text.js';
|
|
5
7
|
import { api } from '../core/api.js';
|
|
6
8
|
import { $, el, icon } from '../core/dom.js';
|
|
7
|
-
import { S } from '../core/state.js';
|
|
9
|
+
import { S, saveOpen } from '../core/state.js';
|
|
8
10
|
import { clampToViewport, popover } from '../ui/popover.js';
|
|
9
11
|
import { toast, toastError } from '../ui/toast.js';
|
|
10
12
|
import { toggleDone } from './actions.js';
|
|
11
13
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
12
|
-
import {
|
|
14
|
+
import { askBtw } from './btw.js';
|
|
15
|
+
import { expand, renderFeed, scheduleRefetch, setFocus } from './feed.js';
|
|
13
16
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
14
17
|
import { mentionBox } from './mentions.js';
|
|
15
|
-
import {
|
|
18
|
+
import { connectMachine } from './machines.js';
|
|
19
|
+
import { placeMenu } from './place-menu.js';
|
|
20
|
+
import { machineById, placeName } from './places.js';
|
|
16
21
|
import { runnerPicker } from './runner-picker.js';
|
|
17
22
|
import { agentById, agentName, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
18
23
|
import { feedAnchor, keepInPlace, watching } from './scroll.js';
|
|
19
24
|
import { shipDialog } from './ship.js';
|
|
25
|
+
import { slashMenu } from './slash.js';
|
|
20
26
|
import { renderBody } from './turns.js';
|
|
21
27
|
|
|
22
28
|
export function draftInto(id, text) {
|
|
@@ -39,6 +45,7 @@ export function renderDock(id) {
|
|
|
39
45
|
<button class="icon-btn c-attach" title="Attach files">${icon('clip')}</button>
|
|
40
46
|
<button class="mode-btn c-mode" aria-haspopup="menu"></button>
|
|
41
47
|
<button class="c-runner" aria-haspopup="dialog"></button>
|
|
48
|
+
<button class="c-place" aria-haspopup="menu" hidden></button>
|
|
42
49
|
<span class="c-hint"></span>
|
|
43
50
|
<button class="ghost sm c-ship" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
|
|
44
51
|
<button class="ghost sm c-done" data-a="done" title="Mark done (E)">${icon('done')}<span>Mark done</span></button>
|
|
@@ -50,6 +57,7 @@ export function renderDock(id) {
|
|
|
50
57
|
n.tray = attachTray();
|
|
51
58
|
comp.prepend(n.tray.node);
|
|
52
59
|
const ta = mentionBox($('.reply', comp));
|
|
60
|
+
slashMenu(ta, comp);
|
|
53
61
|
autosize(ta, 260);
|
|
54
62
|
acceptFiles(comp, ta, n.tray);
|
|
55
63
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
@@ -62,8 +70,15 @@ export function renderDock(id) {
|
|
|
62
70
|
runnerBtn.onclick = () => {
|
|
63
71
|
pickReplyRunner(id, runnerBtn);
|
|
64
72
|
};
|
|
73
|
+
const placeBtn = $('.c-place', comp);
|
|
74
|
+
placeBtn.onclick = () => {
|
|
75
|
+
pickReplyPlace(id, placeBtn);
|
|
76
|
+
};
|
|
65
77
|
ta.value = S.drafts.get(id) || '';
|
|
66
|
-
ta.addEventListener('input', () =>
|
|
78
|
+
ta.addEventListener('input', () => {
|
|
79
|
+
S.drafts.set(id, ta.value);
|
|
80
|
+
paintSide(id);
|
|
81
|
+
});
|
|
67
82
|
ta.addEventListener('focus', () => setFocus(id, { scroll: false }));
|
|
68
83
|
ta.addEventListener('keydown', (e) => {
|
|
69
84
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
@@ -122,7 +137,7 @@ function pickReplyRunner(id, btn) {
|
|
|
122
137
|
// The session's own harness offers its own model first; other harnesses their remembered one.
|
|
123
138
|
const model = (a) => (a === s.agent && (agentById(a)?.models || []).some((m) => m.id === s.model) ? s.model : savedModel(a));
|
|
124
139
|
const effort = (a, m) => (a === s.agent && m === s.model && effortsFor(a, m).includes(s.effort) ? s.effort : savedEffort(a, m));
|
|
125
|
-
runnerPicker(btn, { agent: cur.agent, model: cur.model, effort: cur.effort, savedModel: model, savedEffort: effort, places: [
|
|
140
|
+
runnerPicker(btn, { agent: cur.agent, model: cur.model, effort: cur.effort, savedModel: model, savedEffort: effort, places: [replyPlace(id)] }, (agent, m, e) => {
|
|
126
141
|
setReplyRunner(id, { agent, model: m, effort: e });
|
|
127
142
|
});
|
|
128
143
|
}
|
|
@@ -138,13 +153,68 @@ function paintReplyRunner(id, btn) {
|
|
|
138
153
|
const label = run.agent === s?.agent ? model : `${harness} · ${model}`;
|
|
139
154
|
btn.innerHTML = `<span class="rb-logo">${harnessLogo(run.agent)}</span>${changed ? `<span class="cr-label">${esc(label)}</span>${run.effort ? `<span class="rb-e">${esc(effortLabel(run.effort))}</span>` : ''}` : ''}`;
|
|
140
155
|
const now = runnerTip(s?.agent, s?.model, s?.effort);
|
|
141
|
-
const where = s?.machine ? ` (on ${placeName(
|
|
156
|
+
const where = s?.machine || S.replyPlaces.has(id) ? ` (on ${placeName(replyPlace(id))})` : '';
|
|
142
157
|
btn.title = !changed
|
|
143
158
|
? `${now}\nPick another harness or model for your next follow-up${where}`
|
|
144
159
|
: run.agent !== s.agent
|
|
145
160
|
? `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up moves this task from ${agentName(s.agent)} to ${harness}, with the conversation so far. Click to change`
|
|
146
161
|
: `${runnerTip(run.agent, run.model, run.effort)}\nYour next follow-up switches from ${now}. Same conversation. Click to change`;
|
|
147
|
-
btn.hidden = !s || s.agent === 'wisp' || !!s.parentId || !harnessesAt([
|
|
162
|
+
btn.hidden = !s || s.agent === 'wisp' || !!s.parentId || !harnessesAt([replyPlace(id)]).some((a) => a.available);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Where the next follow-up runs: where the task is, until the user picks another computer here.
|
|
166
|
+
// Then the follow-up moves the task there, its work and history with it (server/services/move.js).
|
|
167
|
+
const placeNow = (s) => s?.machine?.id || '';
|
|
168
|
+
const replyPlace = (id) => (S.replyPlaces.has(id) ? S.replyPlaces.get(id) : placeNow(S.full.get(id)));
|
|
169
|
+
|
|
170
|
+
function setReplyPlace(id, k) {
|
|
171
|
+
const s = S.full.get(id);
|
|
172
|
+
if (!s) return;
|
|
173
|
+
if (k === placeNow(s)) S.replyPlaces.delete(id);
|
|
174
|
+
else S.replyPlaces.set(id, k);
|
|
175
|
+
if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
176
|
+
// The harness it runs on has to be there: keep it if it is, else the first one that is.
|
|
177
|
+
const run = replyRunner(id);
|
|
178
|
+
const there = harnessesAt([k]).filter((a) => a.available);
|
|
179
|
+
if (there.length && !there.some((a) => a.id === run.agent)) {
|
|
180
|
+
const a = there[0].id;
|
|
181
|
+
const m = savedModel(a);
|
|
182
|
+
setReplyRunner(id, { agent: a, model: m, effort: savedEffort(a, m) });
|
|
183
|
+
}
|
|
184
|
+
updateDock(id);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pickReplyPlace(id, btn) {
|
|
188
|
+
const s = S.full.get(id);
|
|
189
|
+
if (!s) return;
|
|
190
|
+
const busy = s.state === 'running' || s.state === 'waiting';
|
|
191
|
+
placeMenu(
|
|
192
|
+
btn,
|
|
193
|
+
{
|
|
194
|
+
title: 'Next follow-up runs on',
|
|
195
|
+
current: replyPlace(id),
|
|
196
|
+
from: placeNow(s),
|
|
197
|
+
note: 'Another computer takes the task with it: its branch, uncommitted changes and history. wisp clones the project there if it has to.',
|
|
198
|
+
locked: busy ? 'Stop the task or let it finish to move it to another computer.' : '',
|
|
199
|
+
},
|
|
200
|
+
(k) => {
|
|
201
|
+
setReplyPlace(id, k);
|
|
202
|
+
$('.reply', S.nodes.get(id)?.card)?.focus();
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function paintReplyPlace(id, btn) {
|
|
208
|
+
const s = S.full.get(id);
|
|
209
|
+
const k = replyPlace(id);
|
|
210
|
+
const changed = !!s && S.replyPlaces.has(id);
|
|
211
|
+
btn.className = `c-place${changed ? ' changed' : ''}`;
|
|
212
|
+
btn.innerHTML = `${icon(k ? 'server' : 'laptop')}${changed ? `<span class="cr-label">${esc(placeName(k))}</span>` : ''}`;
|
|
213
|
+
btn.title = changed
|
|
214
|
+
? `Your next follow-up moves this task from ${placeName(placeNow(s))} to ${placeName(k)}, with its work and history. Click to change`
|
|
215
|
+
: `Runs on ${placeName(k)}. Pick another computer for your next follow-up`;
|
|
216
|
+
// Only with machines to move to; subagents go where their lead is, the tour nowhere.
|
|
217
|
+
btn.hidden = !s || !S.machines.length || s.agent === 'wisp' || !!s.parentId;
|
|
148
218
|
}
|
|
149
219
|
|
|
150
220
|
export function updateDock(id) {
|
|
@@ -164,16 +234,19 @@ export function updateDock(id) {
|
|
|
164
234
|
: `Answer ${name}…`
|
|
165
235
|
: s.state === 'running'
|
|
166
236
|
? queueing()
|
|
167
|
-
? `Queue a follow-up — sent when ${name} finishes`
|
|
168
|
-
: `Steer ${name} — interrupts and sends now`
|
|
169
|
-
:
|
|
170
|
-
?
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
237
|
+
? `Queue a follow-up — sent when ${name} finishes. /btw asks now`
|
|
238
|
+
: `Steer ${name} — interrupts and sends now. /btw asks without interrupting`
|
|
239
|
+
: S.replyPlaces.has(id)
|
|
240
|
+
? `Follow up on ${replyPlace(id) ? placeName(replyPlace(id)) : 'this computer'} with ${next}…`
|
|
241
|
+
: s.state === 'done'
|
|
242
|
+
? next !== name
|
|
243
|
+
? `Follow up with ${next} to reopen…`
|
|
244
|
+
: 'Follow up to reopen…'
|
|
245
|
+
: `Follow up with ${next}…`;
|
|
246
|
+
paintSide(id);
|
|
175
247
|
paintReplyMode(id, $('.c-mode', n.card));
|
|
176
248
|
paintReplyRunner(id, $('.c-runner', n.card));
|
|
249
|
+
paintReplyPlace(id, $('.c-place', n.card));
|
|
177
250
|
renderQueued(s, $('.c-queued', n.card), name);
|
|
178
251
|
const q = $('.c-queue', n.card);
|
|
179
252
|
q.hidden = tour || s.state !== 'running';
|
|
@@ -183,6 +256,23 @@ export function updateDock(id) {
|
|
|
183
256
|
: `Sending now: follow-ups interrupt ${name}. Click to queue them instead.`;
|
|
184
257
|
}
|
|
185
258
|
|
|
259
|
+
// "/btw …" in the box: a side question, so the composer says it won't interrupt anything, and the
|
|
260
|
+
// mode and harness pickers (which are for the next follow-up) step back.
|
|
261
|
+
function paintSide(id) {
|
|
262
|
+
const s = S.full.get(id);
|
|
263
|
+
const comp = $('.composer', S.nodes.get(id)?.card);
|
|
264
|
+
if (!s || !comp) return;
|
|
265
|
+
const side = isBtw($('.reply', comp).value);
|
|
266
|
+
const live = s.state === 'running' || s.state === 'waiting';
|
|
267
|
+
comp.classList.toggle('is-btw', side);
|
|
268
|
+
$('.send', comp).title = side ? 'Ask on the side (Enter): nothing is interrupted' : 'Send (Enter · Shift+Enter for newline)';
|
|
269
|
+
$('.c-hint', comp).innerHTML = side
|
|
270
|
+
? `<span class="c-side">Side question</span> · ${live ? `${esc(agentName(s.agent))} keeps working` : 'no new turn'}`
|
|
271
|
+
: s.queue?.length
|
|
272
|
+
? `${s.queue.length} queued`
|
|
273
|
+
: '';
|
|
274
|
+
}
|
|
275
|
+
|
|
186
276
|
// Follow-ups waiting their turn, shown above the composer so it's clear what goes out next.
|
|
187
277
|
function renderQueued(s, list, name) {
|
|
188
278
|
const key = (s.queue || []).map((q) => `${q.id}${q.run ? `:${q.run.agent}/${q.run.model}` : ''}`).join();
|
|
@@ -245,6 +335,21 @@ export async function sendReply(id, text) {
|
|
|
245
335
|
text = (text || '').trim();
|
|
246
336
|
if (!s || !text) return;
|
|
247
337
|
const ta = n && $('.reply', n.card);
|
|
338
|
+
// A side question: answered next to the run, so nothing is interrupted, queued or switched.
|
|
339
|
+
if (isBtw(text)) {
|
|
340
|
+
const question = btwQuestion(text);
|
|
341
|
+
if (!question) return toast('Type your question after /btw, like “/btw did you update the docs too?”');
|
|
342
|
+
if (n?.tray?.busy) toast('Waiting for uploads…');
|
|
343
|
+
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
344
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
345
|
+
S.drafts.delete(id);
|
|
346
|
+
const files = n?.tray?.take() || [];
|
|
347
|
+
const restore = () => {
|
|
348
|
+
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
349
|
+
n?.tray?.restore(files);
|
|
350
|
+
};
|
|
351
|
+
return askBtw(id, question, { attachments, files, restore });
|
|
352
|
+
}
|
|
248
353
|
if (isFeedback(text)) {
|
|
249
354
|
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
250
355
|
S.drafts.delete(id);
|
|
@@ -254,6 +359,8 @@ export async function sendReply(id, text) {
|
|
|
254
359
|
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
255
360
|
const mode = S.replyModes.get(id);
|
|
256
361
|
const run = S.replyRunners.get(id);
|
|
362
|
+
const place = S.replyPlaces.has(id) ? S.replyPlaces.get(id) : undefined;
|
|
363
|
+
if (place !== undefined) return moveWithReply(id, text, { attachments, mode, run, place });
|
|
257
364
|
// An answer goes to the run that asked; a new mode or harness needs a new run, so that's a follow-up.
|
|
258
365
|
const answering = !!s.ask && !attachments.length && !mode && !run;
|
|
259
366
|
// Optimistic: the composer empties and the card goes to Working before the server answers
|
|
@@ -282,10 +389,47 @@ export async function sendReply(id, text) {
|
|
|
282
389
|
}
|
|
283
390
|
}
|
|
284
391
|
|
|
392
|
+
// A follow-up on another computer: the task moves there and carries on as a new session (the old
|
|
393
|
+
// one is archived). The card says it's moving at once; the new card takes its place, open.
|
|
394
|
+
async function moveWithReply(id, text, { attachments, mode, run, place }) {
|
|
395
|
+
const n = S.nodes.get(id);
|
|
396
|
+
const ta = n && $('.reply', n.card);
|
|
397
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
398
|
+
S.drafts.delete(id);
|
|
399
|
+
for (const m of [S.replyModes, S.replyRunners, S.replyPlaces]) m.delete(id);
|
|
400
|
+
const files = n?.tray?.take();
|
|
401
|
+
const undo = startOptimistic(id, text, files, { mode, run, live: `Moving to ${placeName(place)}` });
|
|
402
|
+
try {
|
|
403
|
+
const r = await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, mode, ...run, machine: place } });
|
|
404
|
+
const next = r.session;
|
|
405
|
+
S.sessions.set(next.id, next);
|
|
406
|
+
const old = S.sessions.get(id);
|
|
407
|
+
if (old) S.sessions.set(id, { ...old, state: 'review', live: '', archived: true, movedTo: { id: next.id, place: placeName(place) } });
|
|
408
|
+
S.full.delete(id);
|
|
409
|
+
const wasOpen = S.open.delete(id);
|
|
410
|
+
saveOpen();
|
|
411
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
412
|
+
if (wasOpen) expand(next.id);
|
|
413
|
+
setFocus(next.id);
|
|
414
|
+
toast(`Moved to <b>${esc(place ? placeName(place) : 'this computer')}</b> with its work and history`);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
undo?.();
|
|
417
|
+
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
418
|
+
if (mode && !S.replyModes.has(id)) setReplyMode(id, mode);
|
|
419
|
+
if (run && !S.replyRunners.has(id)) setReplyRunner(id, run);
|
|
420
|
+
if (!S.replyPlaces.has(id)) {
|
|
421
|
+
S.replyPlaces.set(id, place);
|
|
422
|
+
updateDock(id);
|
|
423
|
+
}
|
|
424
|
+
n?.tray?.restore(files);
|
|
425
|
+
toastError(err);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
285
429
|
// Show a follow-up as started right away: the card moves to Working with the prompt as a live
|
|
286
430
|
// turn. Only when it will start now, not when it queues or interrupts a run already working.
|
|
287
431
|
// Returns an undo for a failed send, or null when nothing changed.
|
|
288
|
-
export function startOptimistic(id, text, files, { answering, mode, run }) {
|
|
432
|
+
export function startOptimistic(id, text, files, { answering, mode, run, live: label }) {
|
|
289
433
|
const full = S.full.get(id);
|
|
290
434
|
const sum = S.sessions.get(id);
|
|
291
435
|
if (!full || !sum) return null;
|
|
@@ -296,7 +440,7 @@ export function startOptimistic(id, text, files, { answering, mode, run }) {
|
|
|
296
440
|
if (mode) turn.mode = mode;
|
|
297
441
|
if (run) Object.assign(turn, run);
|
|
298
442
|
const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn], ...(mode && { mode }), ...run };
|
|
299
|
-
const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : run && run.agent !== full.agent ? `Handing over to ${agentName(run.agent)}` : 'Starting up', updatedAt: now, ...(mode && { mode }), ...run };
|
|
443
|
+
const nextSum = { ...sum, state: 'running', live: label || (answering ? 'Picking up your answer' : run && run.agent !== full.agent ? `Handing over to ${agentName(run.agent)}` : 'Starting up'), updatedAt: now, ...(mode && { mode }), ...run };
|
|
300
444
|
const apply = (f, sm) => {
|
|
301
445
|
S.full.set(id, f);
|
|
302
446
|
S.sessions.set(id, sm);
|
package/web/js/features/feed.js
CHANGED
|
@@ -359,7 +359,7 @@ export function collapse(id) {
|
|
|
359
359
|
renderFeedHead(groups().needs, groups().working);
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
async function fetchFull(id) {
|
|
362
|
+
export async function fetchFull(id) {
|
|
363
363
|
const s = await api(`/sessions/${id}`).catch(() => null);
|
|
364
364
|
if (!s) {
|
|
365
365
|
S.open.delete(id);
|
package/web/js/features/keys.js
CHANGED
|
@@ -10,7 +10,7 @@ import { collapse, collapseAll, expand, feedIds, setFocus, toggle } from './feed
|
|
|
10
10
|
import { focusLauncher } from './launcher.js';
|
|
11
11
|
import { backToTop } from './scroll.js';
|
|
12
12
|
import { shipDialog, shippable } from './ship.js';
|
|
13
|
-
import { replay } from './turns.js';
|
|
13
|
+
import { replay, toggleTranscript } from './turns.js';
|
|
14
14
|
|
|
15
15
|
export function onKey(e) {
|
|
16
16
|
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
@@ -69,6 +69,7 @@ export function onKey(e) {
|
|
|
69
69
|
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
70
70
|
else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
|
|
71
71
|
else if (k === 'r' && id) replay(id);
|
|
72
|
+
else if (k === 'v' && id) toggleTranscript(id);
|
|
72
73
|
else if (k === '/' && id) {
|
|
73
74
|
e.preventDefault();
|
|
74
75
|
const focusReply = () => $('.reply', S.nodes.get(id)?.card)?.focus();
|