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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +17 -8
  3. package/bin/wisp.js +14 -6
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +12 -7
  6. package/server/adapters/codex.js +13 -7
  7. package/server/adapters/cursor.js +4 -1
  8. package/server/adapters/grok.js +2 -1
  9. package/server/adapters/index.js +7 -2
  10. package/server/adapters/opencode.js +5 -2
  11. package/server/btw.js +76 -0
  12. package/server/client.js +22 -2
  13. package/server/git.js +80 -4
  14. package/server/handoff.js +13 -7
  15. package/server/index.js +4 -0
  16. package/server/machines.js +15 -4
  17. package/server/mcp-wisp.js +2 -1
  18. package/server/openapi.js +28 -2
  19. package/server/remote.js +4 -2
  20. package/server/routes/machines.js +1 -1
  21. package/server/routes/proxy.js +6 -0
  22. package/server/routes/sessions.js +47 -10
  23. package/server/routes/settings.js +24 -38
  24. package/server/routes/ship.js +19 -24
  25. package/server/routes/uploads.js +2 -0
  26. package/server/runner.js +178 -12
  27. package/server/services/auto.js +10 -2
  28. package/server/services/launch.js +67 -7
  29. package/server/services/move.js +130 -0
  30. package/server/services/phone.js +20 -1
  31. package/server/services/places.js +150 -0
  32. package/server/services/ship.js +97 -10
  33. package/server/services/uploads.js +23 -4
  34. package/server/store.js +39 -6
  35. package/skills/wisp/SKILL.md +3 -1
  36. package/web/css/blocks.css +2 -0
  37. package/web/css/composer.css +23 -1
  38. package/web/css/feed.css +52 -1
  39. package/web/css/launcher.css +12 -0
  40. package/web/css/machines.css +4 -0
  41. package/web/css/modals.css +19 -0
  42. package/web/css/palette.css +6 -0
  43. package/web/css/ship.css +14 -0
  44. package/web/css/transcript.css +115 -0
  45. package/web/index.html +2 -0
  46. package/web/js/core/state.js +5 -2
  47. package/web/js/features/actions.js +42 -10
  48. package/web/js/features/btw.js +145 -0
  49. package/web/js/features/clones.js +7 -6
  50. package/web/js/features/commands.js +3 -1
  51. package/web/js/features/composer.js +162 -18
  52. package/web/js/features/feed.js +10 -10
  53. package/web/js/features/keys.js +2 -1
  54. package/web/js/features/launcher.js +121 -67
  55. package/web/js/features/place-menu.js +44 -0
  56. package/web/js/features/places.js +25 -5
  57. package/web/js/features/project-list.js +20 -5
  58. package/web/js/features/projects.js +16 -13
  59. package/web/js/features/settings.js +24 -3
  60. package/web/js/features/ship.js +66 -29
  61. package/web/js/features/slash.js +63 -0
  62. package/web/js/features/transcript.js +290 -0
  63. package/web/js/features/turns.js +101 -29
  64. package/web/js/slash-text.js +22 -0
  65. package/web/js/ui/kbd.js +1 -0
@@ -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[k];
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[k] = job.id;
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[k] !== job.id) return; // picked something else meanwhile
51
- launch.picks[k] = id;
52
- if (k === launch.where[0]) launch.project = id;
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 { replay } from './turns.js';
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 { renderFeed, scheduleRefetch, setFocus } from './feed.js';
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 { placeName } from './places.js';
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', () => S.drafts.set(id, ta.value));
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: [s.machine?.id || ''] }, (agent, m, e) => {
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(s.machine.id)})` : '';
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([s.machine?.id || '']).some((a) => a.available);
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
- : s.state === 'done'
170
- ? next !== name
171
- ? `Follow up with ${next} to reopen…`
172
- : 'Follow up to reopen…'
173
- : `Follow up with ${next}…`;
174
- $('.c-hint', n.card).innerHTML = s.queue?.length ? `${s.queue.length} queued` : '';
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);
@@ -9,16 +9,16 @@ import { S, save, saveOpen } from '../core/state.js';
9
9
  import { taskDone, taskMenu, toggleDone } from './actions.js';
10
10
  import { compareView } from './compare.js';
11
11
  import { renderLaunchChips } from './launcher.js';
12
- import { machinePill } from './places.js';
12
+ import { inProject, machinePill, projectLabel } from './places.js';
13
13
  import { renderProjects } from './projects.js';
14
14
  import { agentName, effortLabel, harnessLogo, modelBadge } from './runners.js';
15
15
  import { scrollByPx, stage, topInset } from './scroll.js';
16
- import { prPill, shipPill } from './ship.js';
16
+ import { prOf, prPill, shipPill } from './ship.js';
17
17
  import { teamStack } from './team.js';
18
18
  import { renderBody } from './turns.js';
19
19
 
20
20
  function visible(list) {
21
- return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
21
+ return S.filter === 'all' ? list : list.filter((s) => inProject(s, S.filter));
22
22
  }
23
23
 
24
24
  // Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
@@ -209,10 +209,10 @@ function renderHead(n, s) {
209
209
  ${pill}
210
210
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
211
211
  ${autoPill(s)}
212
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
212
+ ${s.shipping ? shipPill(s) : prOf(s) ? prPill(prOf(s), s.conflictFix) : ''}
213
213
  ${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
214
214
  ${s.machine ? machinePill(s.machine) : ''}
215
- <span class="q-proj">${esc(s.project)}</span>
215
+ ${projectLabel(s)}
216
216
  <span class="q-time">${ago(s.updatedAt)}</span>
217
217
  ${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
218
218
  ${icon('chevron', 'chev')}
@@ -253,11 +253,11 @@ function renderRunHead(n, s, { open, live, pill }) {
253
253
  ${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
254
254
  </span>
255
255
  <span class="it-side">
256
- ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
256
+ ${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}${projectLabel(s)}` : ''}
257
257
  ${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
258
258
  ${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
259
259
  ${autoPill(s)}
260
- ${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
260
+ ${s.shipping ? shipPill(s) : prOf(s) ? prPill(prOf(s), s.conflictFix) : ''}
261
261
  <span class="q-time">${ago(s.updatedAt)}</span>
262
262
  ${icon('chevron', 'chev')}
263
263
  </span>`;
@@ -297,7 +297,7 @@ function taskItem(t) {
297
297
  </span>
298
298
  <span class="it-side">
299
299
  <button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
300
- ${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
300
+ ${places > 1 ? '' : projectLabel(t)}
301
301
  <span class="q-time">${ago(t.updatedAt)}</span>
302
302
  ${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
303
303
  </span>`;
@@ -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);
@@ -394,7 +394,7 @@ export function reveal(id) {
394
394
  S.showDone = true;
395
395
  save('wisp.showDone', true);
396
396
  }
397
- if (S.filter !== 'all' && S.filter !== s.projectId) {
397
+ if (S.filter !== 'all' && !inProject(s, S.filter)) {
398
398
  S.filter = 'all';
399
399
  save('wisp.filter', S.filter);
400
400
  renderLaunchChips();
@@ -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();