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.
Files changed (57) hide show
  1. package/CHANGELOG.md +24 -1
  2. package/README.md +13 -8
  3. package/bin/wisp.js +10 -3
  4. package/package.json +4 -3
  5. package/server/adapters/claude.js +8 -4
  6. package/server/adapters/codex.js +9 -6
  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 +19 -0
  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/openapi.js +27 -2
  18. package/server/remote.js +4 -2
  19. package/server/routes/machines.js +1 -1
  20. package/server/routes/proxy.js +6 -0
  21. package/server/routes/sessions.js +32 -2
  22. package/server/routes/settings.js +24 -38
  23. package/server/routes/uploads.js +2 -0
  24. package/server/runner.js +154 -8
  25. package/server/services/launch.js +32 -4
  26. package/server/services/move.js +130 -0
  27. package/server/services/phone.js +20 -1
  28. package/server/services/places.js +150 -0
  29. package/server/services/uploads.js +23 -4
  30. package/server/store.js +39 -6
  31. package/skills/wisp/SKILL.md +1 -0
  32. package/web/css/blocks.css +2 -0
  33. package/web/css/composer.css +23 -1
  34. package/web/css/feed.css +49 -1
  35. package/web/css/machines.css +4 -0
  36. package/web/css/modals.css +19 -0
  37. package/web/css/ship.css +2 -0
  38. package/web/css/transcript.css +115 -0
  39. package/web/index.html +2 -0
  40. package/web/js/core/state.js +3 -2
  41. package/web/js/features/actions.js +42 -10
  42. package/web/js/features/btw.js +145 -0
  43. package/web/js/features/clones.js +7 -6
  44. package/web/js/features/commands.js +3 -1
  45. package/web/js/features/composer.js +162 -18
  46. package/web/js/features/feed.js +1 -1
  47. package/web/js/features/keys.js +2 -1
  48. package/web/js/features/launcher.js +104 -94
  49. package/web/js/features/place-menu.js +44 -0
  50. package/web/js/features/places.js +15 -5
  51. package/web/js/features/projects.js +14 -12
  52. package/web/js/features/settings.js +24 -3
  53. package/web/js/features/slash.js +63 -0
  54. package/web/js/features/transcript.js +290 -0
  55. package/web/js/features/turns.js +99 -28
  56. package/web/js/slash-text.js +22 -0
  57. package/web/js/ui/kbd.js +1 -0
@@ -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
+ }
@@ -10,16 +10,16 @@ import { S } from '../core/state.js';
10
10
  import { overlay } from '../ui/overlay.js';
11
11
  import { toastError } from '../ui/toast.js';
12
12
  import { execAction, moreMenu, openInEditor, revertFile, rollbackTurn, takeOver, toggleDone } from './actions.js';
13
+ import { btwTip, paintBtw } from './btw.js';
13
14
  import { draftInto, renderDock, sendReply, startOptimistic, updateDock } from './composer.js';
14
- import { scheduleRefetch } from './feed.js';
15
+ import { expand, fetchFull, renderFeed, scheduleRefetch, setFocus } from './feed.js';
15
16
  import { feedbackOn, rateTurn, whyForm } from './feedback.js';
16
17
  import { projectById } from './places.js';
17
18
  import { agentName, DEFAULT_MODE, effortLabel, modelBadge, MODES } from './runners.js';
18
- import { scrollByPx, watching } from './scroll.js';
19
+ import { scrollByPx, topInset, watching } from './scroll.js';
19
20
  import { renderPrStrip, shipDialog } from './ship.js';
20
21
  import { copiesOf, memberLabel, renderSubs, subKey, teamLiveHtml, teamSize } from './team.js';
21
-
22
- 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' };
22
+ import { appendStep, KIND_ICON, pickView, renderLiveTranscript, renderTranscript, sameStep, transcriptOn, updateStep } from './transcript.js';
23
23
 
24
24
  export function ctxFor(s, turn, isLatest) {
25
25
  return {
@@ -34,6 +34,7 @@ export function ctxFor(s, turn, isLatest) {
34
34
  revertFile: (file, restore) => revertFile(s.id, turn.id, file, restore),
35
35
  exec: (command, key) => execAction(s.id, turn.id, command, key),
36
36
  approve: (allow) => approveBlocked(s, turn, allow),
37
+ refresh: () => fetchFull(s.id).then(() => S.full.get(s.id)),
37
38
  };
38
39
  }
39
40
 
@@ -67,6 +68,7 @@ export function renderBody(id, { fresh }) {
67
68
  <div class="it-bar">
68
69
  <div class="it-meta"></div>
69
70
  <div class="it-actions">
71
+ <button class="ghost xs tx-toggle" data-a="transcript" aria-pressed="false">${icon('transcript')}<span>Transcript</span></button>
70
72
  <button class="ghost xs" data-a="replay" title="Replay presentation (R)">${icon('replay')}</button>
71
73
  <button class="ghost xs" data-a="takeover" title="Take over in your terminal (T)">${icon('terminal')}</button>
72
74
  <button class="ghost xs" data-a="ship" title="Commit, push and open a pull request (S)" hidden>${icon('send')}<span>Ship it</span></button>
@@ -83,11 +85,13 @@ export function renderBody(id, { fresh }) {
83
85
  n.body = body;
84
86
  $('.it-bar', body).addEventListener('click', (e) => {
85
87
  const a = e.target.closest('[data-a]')?.dataset.a;
88
+ if (a === 'transcript') toggleTranscript(id);
86
89
  if (a === 'replay') replay(id);
87
90
  if (a === 'takeover') takeOver(id);
88
91
  if (a === 'ship') shipDialog(id);
89
92
  if (a === 'done') toggleDone(id);
90
93
  if (a === 'more') moreMenu(id, e.target.closest('button'));
94
+ if (a === 'moved') openMoved(S.full.get(id)?.movedTo?.id);
91
95
  });
92
96
  $('.earlier', body).onclick = () => ((n.showAll = true), renderBody(id, { fresh: false }));
93
97
  renderDock(id);
@@ -106,6 +110,8 @@ export function renderBody(id, { fresh }) {
106
110
  ${s.worktree ? `<span class="m branch" title="Worktree: ${esc(s.worktree.path)}\nFrom ${esc(s.worktree.baseRef)}">${icon('branch')}${esc(s.worktree.branch)}</span>` : ''}
107
111
  ${(s.also || []).map((a, i) => `<span class="m also c-${summary.also?.[i]?.color || 'slate'}" title="${esc(`Also works in ${a.cwd}${a.worktree ? `\nWorktree: ${a.worktree.path}\nBranch ${a.worktree.branch}, from ${a.worktree.baseRef}` : ''}`)}"><i class="p-dot"></i>${esc(summary.also?.[i]?.project || a.cwd.split(/[\\/]/).pop())}</span>`).join('')}
108
112
  ${s.subagents?.length ? `<span class="m team-m" title="${esc(s.subagents.map((m) => `@${m.name}${copiesOf(m) > 1 ? ` ×${copiesOf(m)}` : ''}: ${memberLabel(m)}${m.use ? ` — ${m.use}` : ''}`).join('\n'))}">${icon('agent')}${teamSize(s.subagents)} subagent${teamSize(s.subagents) > 1 ? 's' : ''}</span>` : ''}
113
+ ${s.moved?.from ? `<span class="m" title="${esc(`This task ran on ${s.moved.from} before it moved here, with its work and history`)}">${icon('server')}From ${esc(s.moved.from)}</span>` : ''}
114
+ ${s.movedTo?.id ? `<button class="m moved-to" data-a="moved" title="This task carries on as a new session on ${esc(s.movedTo.place)}">${icon('server')}Continues on ${esc(s.movedTo.place)}</button>` : ''}
109
115
  ${s.origin ? `<span class="m" title="${s.origin.kind === 'telegram' ? 'Started from Telegram' : `Launched by a ${esc(s.origin.kind)}`}">${icon({ trigger: 'replay', telegram: 'send' }[s.origin.kind] || 'spark')}${esc(s.origin.name)}</span>` : ''}`;
110
116
  const live = s.state === 'running' || s.state === 'waiting';
111
117
  // Ship and Done sit in the top bar and again next to the composer, so they're there on opening the
@@ -117,6 +123,11 @@ export function renderBody(id, { fresh }) {
117
123
  $('span', b).textContent = s.state === 'done' ? 'Reopen' : 'Mark done';
118
124
  });
119
125
  $('[data-a="takeover"]', body).hidden = !s.agentSessionId;
126
+ const tx = transcriptOn(id);
127
+ const txBtn = $('[data-a="transcript"]', body);
128
+ txBtn.classList.toggle('on', tx);
129
+ txBtn.setAttribute('aria-pressed', tx);
130
+ txBtn.title = tx ? 'Back to the presentation (V)' : 'Full transcript: every message and step (V)';
120
131
 
121
132
  // Turns: keep nodes whose content hasn't changed so animations don't replay.
122
133
  const turnsEl = $('.turns', body);
@@ -141,12 +152,12 @@ export function renderBody(id, { fresh }) {
141
152
  const isLatest = t.id === latestId;
142
153
  const folded = !isLatest && !n.unfolded.has(t.id);
143
154
  const revKey = `${t.rolledBackAt || 0}:${(t.changes || []).map((c) => (c.reverted ? 1 : 0)).join('')}`;
144
- const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}`;
155
+ const key = `${t.status}|${isLatest}|${folded}|${revKey}|${t.approval || ''}|${t.status === 'running' ? (s.ask?.id || '') + s.queue?.length : ''}|${subKey(s, t)}|${tx}`;
145
156
  const prev = n.turnNodes.get(t.id);
146
157
  let node = prev?.node;
147
158
  if (!prev || prev.key !== key) {
148
159
  const toggle = () => (n.unfolded.has(t.id) ? n.unfolded.delete(t.id) : n.unfolded.add(t.id), renderBody(id, { fresh: false }));
149
- node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle);
160
+ node = folded ? renderFoldedTurn(t, toggle) : renderTurn(s, t, isLatest, isLatest ? null : toggle, tx);
150
161
  if (prev) prev.node.replaceWith(node);
151
162
  if (!prev && !fresh && !folded) firstNew ??= node;
152
163
  if (prev && prev.status === 'running' && t.status !== 'running') firstNew ??= node;
@@ -158,10 +169,45 @@ export function renderBody(id, { fresh }) {
158
169
  if (turnsEl.children[i] !== node) turnsEl.insertBefore(node, turnsEl.children[i] || null);
159
170
  });
160
171
  for (const [tid, v] of n.turnNodes) if (!keep.has(tid)) (v.node.remove(), n.turnNodes.delete(tid));
172
+ paintBtw(id); // side questions update in place, so they don't re-render their turn
161
173
 
162
174
  updateDock(id);
163
175
  if (dock?.isConnected) scrollByPx(dock.getBoundingClientRect().top - dockTop);
164
- if (firstNew && watched) requestAnimationFrame(() => firstNew.scrollIntoView({ behavior: 'smooth', block: 'start' }));
176
+ // A transcript was being read at its tail: its result lands right there, so show that, not the prompt.
177
+ const landing = firstNew && tx ? $('.tx-end', firstNew) || firstNew : firstNew;
178
+ if (landing && watched) requestAnimationFrame(() => landing.scrollIntoView({ behavior: 'smooth', block: 'start' }));
179
+ }
180
+
181
+ // The full transcript or the presentation, for one card. The turn being read keeps its place: its
182
+ // prompt stays where it was on screen, or comes back into view if it had scrolled off the top.
183
+ export function toggleTranscript(id) {
184
+ const n = S.nodes.get(id);
185
+ pickView(id, !transcriptOn(id));
186
+ if (!S.open.has(id)) return expand(id);
187
+ if (!n?.body?.querySelector('.turns')) return;
188
+ const turns = [...n.body.querySelectorAll('.turn:not(.folded)')];
189
+ const mid = innerHeight / 2;
190
+ const reading = turns.find((x) => { const r = x.getBoundingClientRect(); return r.top <= mid && r.bottom >= mid; }) || turns.at(-1);
191
+ const tid = reading?.dataset.turn;
192
+ const before = reading && $('.ask', reading)?.getBoundingClientRect().top;
193
+ renderBody(id, { fresh: false });
194
+ const ask = tid && $(`.turn[data-turn="${tid}"] .ask`, n.body);
195
+ if (!ask || before === undefined) return;
196
+ if (before < topInset()) ask.scrollIntoView({ block: 'start', behavior: 'smooth' });
197
+ else scrollByPx(ask.getBoundingClientRect().top - before);
198
+ }
199
+
200
+ // After the setting changes: every open card follows it.
201
+ export function repaintViews() {
202
+ for (const id of S.open) if (S.full.has(id)) renderBody(id, { fresh: false });
203
+ }
204
+
205
+ // The session a moved task carries on in: shown, opened and focused (archived ones stay hidden).
206
+ function openMoved(next) {
207
+ if (!next || !S.sessions.has(next)) return;
208
+ renderFeed();
209
+ expand(next);
210
+ setFocus(next);
165
211
  }
166
212
 
167
213
  function renderFoldedTurn(t, unfold) {
@@ -172,6 +218,7 @@ function renderFoldedTurn(t, unfold) {
172
218
  ${icon('chevron', 'chev')}
173
219
  <span class="t-prompt">${t.by === 'wisp' ? '<b class="t-by">wisp</b> ' : ''}${mentionHtml(esc(t.prompt.replace(/\s+/g, ' ')), { link: false })}</span>
174
220
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
221
+ ${t.moved ? `<span class="t-moved" title="${esc(`Ran on ${t.moved}`)}">${icon('server')}${esc(t.moved)}</span>` : ''}
175
222
  ${hero?.title ? `<span class="t-result">${esc(hero.title)}</span>` : ''}
176
223
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
177
224
  </button>
@@ -188,8 +235,9 @@ function modeSwitch(s, t) {
188
235
  return m ? `<span class="t-mode m-${m.id}" title="Switched from ${esc(MODES.find((x) => x.id === prev)?.label || prev)} to ${m.label} for this follow-up">${icon(m.icon)}${m.label}</span>` : '';
189
236
  }
190
237
 
191
- // `fold` is set for older turns the user unfolded, so they can tuck them away again.
192
- function renderTurn(s, t, isLatest, fold) {
238
+ // `fold` is set for older turns the user unfolded, so they can tuck them away again. `tx`: the card
239
+ // shows the full transcript.
240
+ function renderTurn(s, t, isLatest, fold, tx) {
193
241
  const node = el(`<article class="turn ${t.rolledBackAt ? 'is-rolled-back' : ''}" data-turn="${t.id}"></article>`);
194
242
  const long = t.prompt.length > 360 || t.prompt.split('\n').length > 5;
195
243
  const cp = t.checkpoint;
@@ -203,6 +251,7 @@ function renderTurn(s, t, isLatest, fold) {
203
251
  <div class="ask-foot">
204
252
  ${modelBadge(t.agent || s.agent, t.model ?? s.model, t.effort ?? s.effort)}
205
253
  ${modeSwitch(s, t)}
254
+ ${t.moved ? `<span class="t-moved" title="${esc(`Ran on ${t.moved}, before the task moved`)}">${icon('server')}${esc(t.moved)}</span>` : ''}
206
255
  <time>${new Date(t.startedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</time>
207
256
  ${cpLabel ? `<span class="cp-sha" title="Git checkpoint before this turn (${esc(cp.head)}${cp.dirty ? ' + uncommitted changes' : ''})">${icon('branch')}${esc(cpLabel)}</span>` : ''}
208
257
  ${t.rolledBackAt ? '<span class="rev-tag">Rolled back</span>' : ''}
@@ -218,20 +267,25 @@ function renderTurn(s, t, isLatest, fold) {
218
267
  node.append(ask);
219
268
 
220
269
  if (t.status === 'running') {
221
- node.append(renderWorking(s, t));
270
+ if (tx) node.append(renderLiveTranscript(s, t, ctxFor(s, t, isLatest)));
271
+ node.append(renderWorking(s, t, tx));
222
272
  } else {
223
- node.append(renderPresentation(t.presentation, ctxFor(s, t, isLatest)));
273
+ const ctx = ctxFor(s, t, isLatest);
274
+ node.append(tx ? renderTranscript(s, t, ctx) : renderPresentation(t.presentation, ctx));
224
275
  const subs = renderSubs(s, t);
225
276
  if (subs) node.append(subs);
226
- node.append(renderTrail(s, t));
277
+ node.append(renderTrail(s, t, tx));
227
278
  }
279
+ node.append(el('<div class="btw-list"></div>')); // side questions asked during this turn (btw.js)
228
280
  return node;
229
281
  }
230
282
 
231
- function renderWorking(s, t) {
283
+ // The live panel. With the transcript on, the steps are already on the timeline above it, so it
284
+ // keeps only the status, the question, the team and Stop.
285
+ function renderWorking(s, t, tx) {
232
286
  const waiting = !!s.ask;
233
287
  const node = el(`
234
- <div class="w-panel ${waiting ? 'waiting' : ''}">
288
+ <div class="w-panel ${waiting ? 'waiting' : ''} ${tx ? 'tx-panel' : ''}">
235
289
  <div class="w-orb"><div class="orb live"><i></i><i></i><i></i></div></div>
236
290
  <div class="w-body">
237
291
  <div class="w-status">
@@ -241,9 +295,9 @@ function renderWorking(s, t) {
241
295
  </div>
242
296
  <div class="ask-slot"></div>
243
297
  ${teamLiveHtml(s, t)}
244
- <ul class="ticker"></ul>
298
+ ${tx ? '' : '<ul class="ticker"></ul>'}
245
299
  <div class="w-actions">
246
- <button class="link xs peek">Show all activity</button>
300
+ ${tx ? '' : '<button class="link xs peek">Show all activity</button>'}
247
301
  ${s.queue?.length ? `<span class="w-queued">${s.queue.length} follow-up${s.queue.length > 1 ? 's' : ''} queued</span>` : ''}
248
302
  <span class="grow"></span>
249
303
  <button class="ghost sm danger stop">${icon('stop')}Stop</button>
@@ -252,11 +306,16 @@ function renderWorking(s, t) {
252
306
  </div>
253
307
  </div>`);
254
308
  if (waiting) $('.ask-slot', node).append(renderAskCard(s));
309
+ // The /btw tip goes after Show all activity. The transcript's panel has none: it leads the row with Stop.
310
+ const tip = btwTip(s.id);
311
+ if (tip && tx) $('.w-actions', node).prepend(tip);
312
+ else if (tip) $('.peek', node).after(tip);
255
313
  const ticker = $('.ticker', node);
256
- t.activity
257
- .filter((a) => a.kind !== 'text')
258
- .slice(-5)
259
- .forEach((a) => ticker.prepend(actItem(a)));
314
+ if (ticker)
315
+ t.activity
316
+ .filter((a) => a.kind !== 'text')
317
+ .slice(-5)
318
+ .forEach((a) => ticker.prepend(actItem(a)));
260
319
  $('.stop', node).onclick = async (e) => {
261
320
  // Shows at once; the run winds down and the live update replaces this panel.
262
321
  const btn = e.currentTarget;
@@ -270,7 +329,7 @@ function renderWorking(s, t) {
270
329
  toastError(err);
271
330
  });
272
331
  };
273
- $('.peek', node).onclick = (e) => {
332
+ if (!tx) $('.peek', node).onclick = (e) => {
274
333
  const log = $('.log', node);
275
334
  log.hidden = !log.hidden;
276
335
  e.target.textContent = log.hidden ? 'Show all activity' : 'Hide activity';
@@ -279,8 +338,10 @@ function renderWorking(s, t) {
279
338
  return node;
280
339
  }
281
340
 
341
+ // A line in the ticker and the activity log. A message (kept whole for the transcript) shows its start.
282
342
  function actItem(a) {
283
- return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(a.label || '')}</span></li>`);
343
+ const label = a.kind === 'text' && a.label?.length > 400 ? `${a.label.slice(0, 400)}…` : a.label || '';
344
+ return el(`<li class="act k-${a.kind}">${icon(KIND_ICON[a.kind] || 'tool')}<span>${esc(label)}</span></li>`);
284
345
  }
285
346
 
286
347
  function renderAskCard(s) {
@@ -326,7 +387,9 @@ function renderAskCard(s) {
326
387
  return node;
327
388
  }
328
389
 
329
- function renderTrail(s, t) {
390
+ // Under a finished turn. With the transcript showing, its steps and the agent's notes are already on
391
+ // the page, so the bar drops those two.
392
+ function renderTrail(s, t, tx) {
330
393
  const acts = t.activity || [];
331
394
  const count = (k) => acts.filter((a) => a.kind === k).length;
332
395
  const parts = [
@@ -347,13 +410,13 @@ function renderTrail(s, t) {
347
410
  const node = el(`
348
411
  <div class="trail">
349
412
  <div class="trail-bar">
350
- ${acts.length ? `<button class="tb-acts" data-tab="activity" title="Show activity">${parts.length ? parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('') : 'Activity'}</button>` : ''}
413
+ ${acts.length && !tx ? `<button class="tb-acts" data-tab="activity" title="Show activity">${parts.length ? parts.map(([ic, l, n]) => `<span class="tb" title="${n} ${l}">${icon(ic)}${n}</span>`).join('') : 'Activity'}</button>` : ''}
351
414
  ${t.endedAt ? `<span class="tb">${fmtDuration(t.endedAt - t.startedAt)}</span>` : ''}
352
415
  ${st.costUsd ? `<span class="tb">$${st.costUsd.toFixed(2)}</span>` : ''}
353
416
  <span class="grow"></span>
354
417
  ${t.changes?.length ? `<button class="link xs" data-tab="changes">${t.changes.length} file${t.changes.length > 1 ? 's' : ''} <span class="plus">+${totals.add}</span> <span class="minus">−${totals.del}</span></button>` : ''}
355
418
  <span class="tb-tools">
356
- ${t.presentation?.notes ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
419
+ ${t.presentation?.notes && !tx ? `<button class="tb-btn" data-tab="notes" title="Agent notes" aria-label="Agent notes">${icon('text')}</button>` : ''}
357
420
  ${canRollback ? (rolledBack
358
421
  ? `<button class="link xs cp-rollback rolled-back" data-a="rollback" title="Re-apply changes from this turn">${icon('rerun')}Re-apply</button>`
359
422
  : `<button class="tb-btn cp-rollback" data-a="rollback" title="Restore working tree to before this turn${t.checkpoint?.head ? ` (${t.checkpoint.head.slice(0, 7)})` : ''}" aria-label="Restore to before this turn">${icon('replay')}</button>`) : ''}
@@ -403,13 +466,21 @@ function renderTrail(s, t) {
403
466
  return node;
404
467
  }
405
468
 
406
- // Live activity without re-rendering the whole turn.
407
- export function onActivity({ sessionId, turnId, item }) {
469
+ // Live activity without re-rendering the whole turn. `update`: a step already shown changed (its
470
+ // output came in).
471
+ export function onActivity({ sessionId, turnId, item, update }) {
408
472
  const s = S.full.get(sessionId);
409
473
  if (!s) return;
410
474
  const turn = s.turns.find((t) => t.id === turnId);
411
- if (turn && !turn.activity.includes(item)) turn.activity.push(item);
412
475
  const node = S.nodes.get(sessionId)?.turnNodes.get(turnId)?.node;
476
+ if (update) {
477
+ const known = turn?.activity.findLast((a) => sameStep(a, item));
478
+ if (known) Object.assign(known, item);
479
+ if (node) updateStep(node, item, turn);
480
+ return;
481
+ }
482
+ if (turn && !turn.activity.includes(item)) turn.activity.push(item);
483
+ if (node && turn) appendStep(node, item, turn, scrollByPx);
413
484
  if (!node || item.kind === 'text') return;
414
485
  const ticker = $('.ticker', node);
415
486
  if (ticker) {