wisp-agents 0.15.0 → 0.17.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 +55 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +7 -3
- package/server/adapters/claude.js +79 -55
- package/server/adapters/codex.js +5 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +4 -2
- package/server/adapters/grok.js +17 -2
- package/server/adapters/index.js +25 -2
- package/server/adapters/opencode.js +78 -12
- package/server/checkpoint.js +12 -68
- package/server/client.js +6 -4
- package/server/git.js +9 -4
- package/server/handoff.js +71 -0
- package/server/http/respond.js +59 -0
- package/server/http/router.js +64 -0
- package/server/http/static.js +36 -0
- package/server/index.js +138 -1216
- package/server/machines/bootstrap.js +56 -0
- package/server/machines/hosts.js +127 -0
- package/server/machines.js +7 -179
- package/server/mcp-ask.js +113 -8
- package/server/mcp-wisp.js +17 -5
- package/server/mentions.js +39 -0
- package/server/openapi.js +36 -4
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/protocol.js +8 -1
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- package/server/render.js +3 -1
- package/server/router.js +7 -3
- package/server/routes/auto.js +29 -0
- package/server/routes/automations.js +92 -0
- package/server/routes/internal.js +176 -0
- package/server/routes/machines.js +43 -0
- package/server/routes/meta.js +76 -0
- package/server/routes/proxy.js +47 -0
- package/server/routes/sessions.js +351 -0
- package/server/routes/settings.js +144 -0
- package/server/routes/ship.js +61 -0
- package/server/routes/uploads.js +19 -0
- package/server/runner.js +216 -49
- package/server/services/auto.js +67 -0
- package/server/services/feedback.js +53 -0
- package/server/services/install.js +69 -0
- package/server/services/launch.js +127 -0
- package/server/services/phone.js +65 -0
- package/server/services/ship.js +82 -0
- package/server/services/telemetry.js +34 -0
- package/server/services/uploads.js +59 -0
- package/server/services/webhooks.js +28 -0
- package/server/store.js +13 -5
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +5 -7
- package/server/update.js +4 -3
- package/server/usage.js +94 -0
- package/server/util.js +42 -0
- package/server/welcome.js +3 -3
- package/web/css/auto.css +29 -0
- package/web/css/automations.css +71 -0
- package/web/css/base.css +83 -0
- package/web/css/blocks.css +461 -0
- package/web/css/composer.css +84 -0
- package/web/css/feed.css +235 -0
- package/web/css/launcher.css +204 -0
- package/web/css/layout.css +61 -0
- package/web/css/machines.css +125 -0
- package/web/css/modals.css +176 -0
- package/web/css/palette.css +79 -0
- package/web/css/responsive.css +35 -0
- package/web/css/ship.css +95 -0
- package/web/css/subagents.css +130 -0
- package/web/css/tokens.css +115 -0
- package/web/index.html +17 -1
- package/web/js/app.js +28 -5237
- package/web/js/blocks.js +46 -0
- package/web/js/core/api.js +25 -0
- package/web/js/core/bus.js +28 -0
- package/web/js/core/dom.js +7 -0
- package/web/js/core/format.js +27 -0
- package/web/js/core/state.js +43 -0
- package/web/js/features/actions.js +344 -0
- package/web/js/features/attachments.js +115 -0
- package/web/js/features/auto.js +54 -0
- package/web/js/features/automations.js +305 -0
- package/web/js/features/commands.js +123 -0
- package/web/js/features/compare.js +105 -0
- package/web/js/features/composer.js +315 -0
- package/web/js/features/feed.js +400 -0
- package/web/js/features/feedback.js +127 -0
- package/web/js/features/keys.js +83 -0
- package/web/js/features/launcher.js +561 -0
- package/web/js/features/live.js +91 -0
- package/web/js/features/machines.js +313 -0
- package/web/js/features/memory.js +154 -0
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +36 -0
- package/web/js/features/phone.js +124 -0
- package/web/js/features/places.js +38 -0
- package/web/js/features/projects.js +284 -0
- package/web/js/features/runner-picker.js +233 -0
- package/web/js/features/runners.js +163 -0
- package/web/js/features/scroll.js +75 -0
- package/web/js/features/settings.js +390 -0
- package/web/js/features/setup.js +94 -0
- package/web/js/features/ship.js +122 -0
- package/web/js/features/team.js +386 -0
- package/web/js/features/turns.js +432 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -0
- package/web/js/mention-text.js +59 -0
- package/web/js/ui/kbd.js +46 -0
- package/web/js/ui/overlay.js +38 -0
- package/web/js/ui/popover.js +52 -0
- package/web/js/ui/toast.js +23 -0
- package/web/css/app.css +0 -1910
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Machines: other computers, over SSH. A machine runs its own wisp; this one signs in over SSH,
|
|
2
|
+
// tunnels to it and shows its sessions as <id>@<machine>. Projects can live on a machine; launching
|
|
3
|
+
// in one runs the agent there.
|
|
4
|
+
|
|
5
|
+
import { copy } from '../blocks.js';
|
|
6
|
+
import { esc } from '../md.js';
|
|
7
|
+
import { api } from '../core/api.js';
|
|
8
|
+
import * as bus from '../core/bus.js';
|
|
9
|
+
import { $, el, icon } from '../core/dom.js';
|
|
10
|
+
import { S } from '../core/state.js';
|
|
11
|
+
import { present } from '../ui/overlay.js';
|
|
12
|
+
import { popover } from '../ui/popover.js';
|
|
13
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
14
|
+
import { renderLaunchChips } from './launcher.js';
|
|
15
|
+
import { loadConfig, loadSessions } from './live.js';
|
|
16
|
+
import { MACHINE_STATE, machineById, machinesSummary } from './places.js';
|
|
17
|
+
import { openProjects, renderProjects } from './projects.js';
|
|
18
|
+
import { harnessLogo } from './runners.js';
|
|
19
|
+
|
|
20
|
+
export const loadMachines = () => api('/machines').then(setMachines).catch(() => {});
|
|
21
|
+
export function setMachines(list) {
|
|
22
|
+
const was = new Map(S.machines.map((m) => [m.id, m.state === 'online']));
|
|
23
|
+
S.machines = list;
|
|
24
|
+
paintMachinesBtn();
|
|
25
|
+
renderLaunchChips();
|
|
26
|
+
$('#launcher')?.placesChanged?.();
|
|
27
|
+
renderProjects();
|
|
28
|
+
bus.emit('machines');
|
|
29
|
+
// Coming online lists its sessions; going offline greys their cards.
|
|
30
|
+
if (list.some((m) => (was.get(m.id) || false) !== (m.state === 'online'))) loadSessions();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function paintMachinesBtn() {
|
|
34
|
+
const b = $('#machinesBtn');
|
|
35
|
+
if (!b) return;
|
|
36
|
+
const on = S.machines.filter((m) => m.state === 'online').length;
|
|
37
|
+
const trouble = S.machines.some((m) => m.state === 'error' || m.state === 'reconnecting');
|
|
38
|
+
b.classList.toggle('live', on > 0);
|
|
39
|
+
b.classList.toggle('trouble', trouble);
|
|
40
|
+
const n = $('.ab-n', b);
|
|
41
|
+
n.hidden = !on;
|
|
42
|
+
n.textContent = on;
|
|
43
|
+
b.title = S.machines.length ? `Machines: ${machinesSummary()}` : 'Machines: run agents on another computer over SSH';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function connectMachine(id) {
|
|
47
|
+
return tryApi(() => api(`/machines/${id}/connect`, { method: 'POST', body: {} }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function offlineToast(id) {
|
|
51
|
+
const m = machineById(id);
|
|
52
|
+
if (m?.state === 'connecting' || m?.state === 'reconnecting') return toast(`Still connecting to <b>${esc(m.name)}</b>…`);
|
|
53
|
+
toast(`<b>${esc(m?.name || 'That machine')}</b> isn’t connected`, { tone: 'bad', action: { label: 'Connect', run: () => connectMachine(id) } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const STEP_ICON = { done: 'check', fail: 'x', warn: 'warn', run: '', pending: '' };
|
|
57
|
+
|
|
58
|
+
export function openMachines({ host, focusAdd = false } = {}) {
|
|
59
|
+
const node = el(`<div class="modal machines-modal">
|
|
60
|
+
<header><h2>Machines</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
61
|
+
<div class="mc-body">
|
|
62
|
+
<p class="mem-lede">Run agents on your other computers: a dev box, a GPU server, a cloud VM. wisp signs in over SSH, starts itself there and tunnels back, so worktrees, diffs and Ship it work as usual, and agents keep going when this computer sleeps.</p>
|
|
63
|
+
<div class="mc-list"></div>
|
|
64
|
+
<section class="mc-add"></section>
|
|
65
|
+
</div>
|
|
66
|
+
</div>`);
|
|
67
|
+
const close = present(node, host);
|
|
68
|
+
const body = $('.mc-body', node);
|
|
69
|
+
let editing = null; // machine id whose form is open, '' = a new one, null = closed
|
|
70
|
+
let hosts = []; // found on this computer: ~/.ssh/config, Tailscale peers, ~/.ssh/known_hosts
|
|
71
|
+
api('/machines/discover')
|
|
72
|
+
.then((h) => {
|
|
73
|
+
hosts = h;
|
|
74
|
+
const slot = $('.mc-found-slot', node);
|
|
75
|
+
if (slot) slot.innerHTML = found();
|
|
76
|
+
const dl = $('#sshHosts', node);
|
|
77
|
+
if (dl) dl.innerHTML = datalist();
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
const datalist = () => hosts.map((x) => `<option value="${esc(x.target)}">${esc(x.detail || '')}</option>`).join('');
|
|
81
|
+
const SOURCE = { 'ssh-config': '~/.ssh/config', tailscale: 'Tailscale', 'known-hosts': 'You’ve SSHed here before' };
|
|
82
|
+
// Hosts not added yet, one click from a filled-in form.
|
|
83
|
+
const found = () => {
|
|
84
|
+
const added = new Set(S.machines.map((m) => m.target));
|
|
85
|
+
const list = hosts.filter((x) => !added.has(x.target));
|
|
86
|
+
if (!list.length) return '';
|
|
87
|
+
return `<div class="mc-found"><h4>Found on this computer</h4><div class="mc-found-list">${list
|
|
88
|
+
.slice(0, 12)
|
|
89
|
+
.map(
|
|
90
|
+
(x) =>
|
|
91
|
+
`<button type="button" class="lp-chip${x.online === false ? ' off' : ''}" data-a="pick" data-target="${esc(x.target)}" data-name="${esc(x.name)}" title="${esc([SOURCE[x.source], x.target, x.detail].filter(Boolean).join(' · '))}">${icon('server')}${esc(x.name)}${x.online !== undefined ? `<i class="mc-fdot"></i>` : ''}</button>`,
|
|
92
|
+
)
|
|
93
|
+
.join('')}</div></div>`;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Projects on a machine open here, with a way back.
|
|
97
|
+
const sub = (view) => {
|
|
98
|
+
const head = $(':scope > header', node);
|
|
99
|
+
body.hidden = true;
|
|
100
|
+
head.hidden = true;
|
|
101
|
+
view.classList.add('embedded');
|
|
102
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
103
|
+
back.onclick = () => {
|
|
104
|
+
view.remove();
|
|
105
|
+
body.hidden = false;
|
|
106
|
+
head.hidden = false;
|
|
107
|
+
paint();
|
|
108
|
+
};
|
|
109
|
+
$('header', view).prepend(back);
|
|
110
|
+
$('.close', view).onclick = close;
|
|
111
|
+
node.append(view);
|
|
112
|
+
return close;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const steps = (m) => `<ol class="mc-steps">${m.steps
|
|
116
|
+
.map((st) => `<li class="s-${st.status}"><span class="mc-si">${STEP_ICON[st.status] ? icon(STEP_ICON[st.status]) : ''}</span><b>${esc(st.label)}</b>${st.detail ? `<small>${esc(st.detail)}</small>` : ''}</li>`)
|
|
117
|
+
.join('')}</ol>`;
|
|
118
|
+
const trouble = (m) => `<div class="mc-hint ${m.state === 'reconnecting' ? 'soft' : ''}">
|
|
119
|
+
<p><b>${esc(m.state === 'reconnecting' ? `${m.error || 'Connection lost.'} Trying again${m.attempt > 1 ? ` (attempt ${m.attempt})` : ''}…` : m.error)}</b></p>
|
|
120
|
+
${m.hint?.text ? `<p>${esc(m.hint.text)}</p>` : ''}
|
|
121
|
+
${m.hint?.command ? `<div class="mc-cmd"><code>${esc(m.hint.command)}</code><button class="ghost xs" data-a="copy" data-cmd="${esc(m.hint.command)}" title="Copy">${icon('copy')}</button></div>` : ''}
|
|
122
|
+
${m.stale ? `<button class="ghost sm" data-a="restart">${icon('rerun')}Restart wisp there</button>` : ''}
|
|
123
|
+
${m.log.length ? `<details class="mc-log"><summary>Log</summary><pre>${esc(m.log.join('\n'))}</pre></details>` : ''}
|
|
124
|
+
</div>`;
|
|
125
|
+
const projects = (m) => {
|
|
126
|
+
const list = S.config.projects.filter((p) => p.machine === m.id);
|
|
127
|
+
return `<div class="mc-projects">${list.map((p) => `<span class="lp-chip c-${p.color}" title="${esc(p.path)}"><i class="p-dot"></i>${esc(p.name)}</span>`).join('')}
|
|
128
|
+
<button class="lp-chip add" data-a="projects">${icon(list.length ? 'plus' : 'folder')}${list.length ? 'Add' : 'Pick projects on it…'}</button></div>`;
|
|
129
|
+
};
|
|
130
|
+
const card = (m) => {
|
|
131
|
+
const st = m.state;
|
|
132
|
+
const busy = st === 'connecting' || st === 'reconnecting';
|
|
133
|
+
const ready = (m.remote?.agents || []).filter((a) => a.available);
|
|
134
|
+
return `<div class="mc st-${st}" data-id="${m.id}">
|
|
135
|
+
<div class="mc-head">
|
|
136
|
+
<span class="mc-icon">${icon('server')}<i class="mc-dot"></i></span>
|
|
137
|
+
<div class="mc-title"><b>${esc(m.name)}</b><span>${esc(m.target)}${m.port !== 4477 ? ` · port ${m.port}` : ''}</span></div>
|
|
138
|
+
<span class="mc-state">${MACHINE_STATE[st] || st}</span>
|
|
139
|
+
${st === 'online' || busy ? `<button class="ghost sm" data-a="disconnect">${busy ? 'Cancel' : 'Disconnect'}</button>` : `<button class="primary sm" data-a="connect">${st === 'error' ? 'Try again' : 'Connect'}</button>`}
|
|
140
|
+
<button class="icon-btn" data-a="menu" title="More">${icon('dots')}</button>
|
|
141
|
+
</div>
|
|
142
|
+
${
|
|
143
|
+
st === 'online'
|
|
144
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
145
|
+
: ''
|
|
146
|
+
}
|
|
147
|
+
${busy || st === 'error' ? steps(m) : ''}
|
|
148
|
+
${(st === 'error' || st === 'reconnecting') && m.error ? trouble(m) : ''}
|
|
149
|
+
${st === 'online' || m.projects ? projects(m) : ''}
|
|
150
|
+
${editing === m.id ? `<div class="mc-edit">${form(m)}</div>` : ''}
|
|
151
|
+
</div>`;
|
|
152
|
+
};
|
|
153
|
+
const form = (m) => `<form class="mc-form" data-id="${m?.id || ''}">
|
|
154
|
+
<div class="mc-row">
|
|
155
|
+
<label class="mc-field grow"><span>SSH host</span><input class="input mono" name="target" list="sshHosts" placeholder="me@devbox, 10.0.0.5, or a Host from ~/.ssh/config" spellcheck="false" autocomplete="off" autocapitalize="off" value="${esc(m?.target || '')}" required></label>
|
|
156
|
+
<label class="mc-field"><span>Name</span><input class="input" name="name" placeholder="${esc(m?.name || 'devbox')}" value="${esc(m?.name || '')}" spellcheck="false"></label>
|
|
157
|
+
</div>
|
|
158
|
+
<details class="mc-adv" ${m && (m.port !== 4477 || m.command) ? 'open' : ''}><summary>Advanced</summary>
|
|
159
|
+
<div class="mc-row">
|
|
160
|
+
<label class="mc-field"><span>wisp’s port there</span><input class="input mono" name="port" type="number" min="1" max="65535" value="${m?.port || 4477}"></label>
|
|
161
|
+
<label class="mc-field grow"><span>Start command</span><input class="input mono" name="command" placeholder="${esc(S.machines[0]?.defaultCommand || 'npx -y wisp-agents@<this version>')}" value="${esc(m?.command || '')}" spellcheck="false"></label>
|
|
162
|
+
</div>
|
|
163
|
+
<label class="check mc-check"><input type="checkbox" name="autoConnect" ${m?.autoConnect === false ? '' : 'checked'}> Connect when wisp starts</label>
|
|
164
|
+
</details>
|
|
165
|
+
<div class="mc-foot">
|
|
166
|
+
<p class="hint">${m ? 'Changing the host, port or command reconnects.' : 'Uses your SSH keys, agent and ~/.ssh/config; wisp never asks for a password. The machine needs Node.js 20+ and the harness CLIs, signed in.'}</p>
|
|
167
|
+
${editing !== null && (m || S.machines.length) ? '<button type="button" class="ghost sm" data-a="cancel">Cancel</button>' : ''}
|
|
168
|
+
<button class="primary sm" type="submit">${m ? 'Save' : `${icon('server')}Connect`}</button>
|
|
169
|
+
</div>
|
|
170
|
+
</form>`;
|
|
171
|
+
|
|
172
|
+
const paint = () => {
|
|
173
|
+
if (!node.isConnected && !host) return;
|
|
174
|
+
$('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
175
|
+
const add = $('.mc-add', node);
|
|
176
|
+
// The form for a new machine: open when there are none yet, else behind a button.
|
|
177
|
+
if (!S.machines.length || editing === '') {
|
|
178
|
+
// Keep a half-filled form; only the suggestions above it change.
|
|
179
|
+
if ($('form[data-id=""]', add)) $('.mc-found-slot', add).innerHTML = found();
|
|
180
|
+
else add.innerHTML = `<div class="mc-found-slot">${found()}</div><h4>${S.machines.length ? 'Add another machine' : 'Add your first machine'}</h4>${form(null)}<datalist id="sshHosts">${datalist()}</datalist>`;
|
|
181
|
+
} else add.innerHTML = `<div class="mc-found-slot">${found()}</div><button class="lp-chip add mc-new" data-a="new">${icon('plus')}Add a machine</button><datalist id="sshHosts">${datalist()}</datalist>`;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const menu = (m, anchor) => {
|
|
185
|
+
$('.menu.machine')?.close();
|
|
186
|
+
const r = anchor.getBoundingClientRect();
|
|
187
|
+
const box = el(`<div class="menu machine" style="top:${r.bottom + 4}px;left:${Math.max(8, r.right - 230)}px">
|
|
188
|
+
<button data-m="edit">${icon('pen')}Edit…</button>
|
|
189
|
+
<button data-m="projects">${icon('folder')}Projects on it…</button>
|
|
190
|
+
${m.state === 'online' ? `<button data-m="restart">${icon('rerun')}Restart wisp there</button><button data-m="stop" class="danger">${icon('stop')}Stop wisp there</button>` : ''}
|
|
191
|
+
<button data-m="remove" class="danger">${icon('trash')}Remove machine</button>
|
|
192
|
+
</div>`);
|
|
193
|
+
box.onclick = (ev) => {
|
|
194
|
+
const a = ev.target.closest('button')?.dataset.m;
|
|
195
|
+
shut();
|
|
196
|
+
if (a) act(a, m);
|
|
197
|
+
};
|
|
198
|
+
const shut = popover(box);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const act = async (a, m) => {
|
|
202
|
+
try {
|
|
203
|
+
if (a === 'connect') await api(`/machines/${m.id}/connect`, { method: 'POST', body: {} });
|
|
204
|
+
if (a === 'disconnect') await api(`/machines/${m.id}/disconnect`, { method: 'POST', body: {} });
|
|
205
|
+
if (a === 'projects') openProjects({ machine: m.id, host: sub, focusAdd: true });
|
|
206
|
+
if (a === 'edit') {
|
|
207
|
+
editing = m.id;
|
|
208
|
+
paint();
|
|
209
|
+
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
210
|
+
}
|
|
211
|
+
if (a === 'restart' || a === 'update') {
|
|
212
|
+
const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
|
|
213
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
|
|
214
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
215
|
+
}
|
|
216
|
+
if (a === 'stop') {
|
|
217
|
+
if (!confirm(`Stop wisp on ${m.name}? ${m.working ? `${m.working} working agent${m.working === 1 ? '' : 's'} stop too. ` : ''}Its sessions stay there for next time.`)) return;
|
|
218
|
+
await api(`/machines/${m.id}/stop`, { method: 'POST', body: {} });
|
|
219
|
+
toast(`Stopped wisp on <b>${esc(m.name)}</b>`);
|
|
220
|
+
}
|
|
221
|
+
if (a === 'remove') {
|
|
222
|
+
if (!confirm(`Remove ${m.name} from wisp? Its projects leave the launcher. Nothing on the machine is touched, and wisp keeps running there until you stop it.`)) return;
|
|
223
|
+
await api(`/machines/${m.id}`, { method: 'DELETE' });
|
|
224
|
+
await loadConfig();
|
|
225
|
+
await loadMachines();
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
toastError(err);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
node.addEventListener('click', async (e) => {
|
|
233
|
+
const b = e.target.closest('[data-a]');
|
|
234
|
+
if (!b || !body.contains(b)) return;
|
|
235
|
+
const a = b.dataset.a;
|
|
236
|
+
const m = machineById(b.closest('.mc')?.dataset.id);
|
|
237
|
+
if (a === 'copy') {
|
|
238
|
+
copy(b.dataset.cmd);
|
|
239
|
+
toast('Copied. Run it in a terminal, then connect again.');
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (a === 'new') {
|
|
243
|
+
editing = '';
|
|
244
|
+
paint();
|
|
245
|
+
$('.mc-add input[name="target"]', node)?.focus();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (a === 'pick') {
|
|
249
|
+
// Fill in the form; connecting still waits for the button.
|
|
250
|
+
editing = '';
|
|
251
|
+
paint();
|
|
252
|
+
const f = $('.mc-add form[data-id=""]', node);
|
|
253
|
+
f.target.value = b.dataset.target;
|
|
254
|
+
f.name.value = b.dataset.name;
|
|
255
|
+
$('button[type="submit"]', f).focus();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (a === 'cancel') {
|
|
259
|
+
editing = null;
|
|
260
|
+
paint();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (a === 'menu' && m) {
|
|
264
|
+
menu(m, b);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (m) act(a, m);
|
|
268
|
+
});
|
|
269
|
+
// The name follows the host until you type one.
|
|
270
|
+
node.addEventListener('input', (e) => {
|
|
271
|
+
if (e.target.name !== 'target') return;
|
|
272
|
+
const name = $('input[name="name"]', e.target.form);
|
|
273
|
+
const h = hosts.find((x) => x.target === e.target.value.trim());
|
|
274
|
+
name.placeholder = (h?.name || e.target.value.trim().replace(/^.*@/, '')).slice(0, 40) || 'devbox';
|
|
275
|
+
});
|
|
276
|
+
node.addEventListener('submit', async (e) => {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const f = e.target;
|
|
279
|
+
const id = f.dataset.id;
|
|
280
|
+
const v = Object.fromEntries(new FormData(f));
|
|
281
|
+
const payload = { target: v.target.trim(), name: v.name.trim(), port: Number(v.port) || 4477, command: v.command.trim(), autoConnect: !!v.autoConnect };
|
|
282
|
+
const btn = $('button[type="submit"]', f);
|
|
283
|
+
btn.disabled = true;
|
|
284
|
+
try {
|
|
285
|
+
if (id) await api(`/machines/${id}`, { method: 'PATCH', body: payload });
|
|
286
|
+
else await api('/machines', { method: 'POST', body: payload });
|
|
287
|
+
editing = null;
|
|
288
|
+
await loadMachines();
|
|
289
|
+
paint();
|
|
290
|
+
} catch (err) {
|
|
291
|
+
toastError(err);
|
|
292
|
+
} finally {
|
|
293
|
+
btn.disabled = false;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
bus.on(
|
|
297
|
+
'machines',
|
|
298
|
+
() => {
|
|
299
|
+
// Don't clobber a half-typed form: repaint the cards only.
|
|
300
|
+
const typing = node.contains(document.activeElement) && document.activeElement.matches('input, select');
|
|
301
|
+
if (typing && editing !== null && editing !== '') return;
|
|
302
|
+
if (typing) $('.mc-list', node).innerHTML = S.machines.map(card).join('');
|
|
303
|
+
else paint();
|
|
304
|
+
},
|
|
305
|
+
{ node },
|
|
306
|
+
);
|
|
307
|
+
paint();
|
|
308
|
+
if (focusAdd && S.machines.length) {
|
|
309
|
+
editing = '';
|
|
310
|
+
paint();
|
|
311
|
+
}
|
|
312
|
+
if (focusAdd || !S.machines.length) setTimeout(() => $('.mc-add input[name="target"]', node)?.focus(), 60);
|
|
313
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Memory: what the background retros learned, as preferences and lessons per project.
|
|
2
|
+
|
|
3
|
+
import { esc } from '../md.js';
|
|
4
|
+
import { api } from '../core/api.js';
|
|
5
|
+
import * as bus from '../core/bus.js';
|
|
6
|
+
import { $, el, icon } from '../core/dom.js';
|
|
7
|
+
import { ago } from '../core/format.js';
|
|
8
|
+
import { launch, S } from '../core/state.js';
|
|
9
|
+
import { present } from '../ui/overlay.js';
|
|
10
|
+
import { toastError, tryApi } from '../ui/toast.js';
|
|
11
|
+
import { reveal } from './feed.js';
|
|
12
|
+
import { loadConfig } from './live.js';
|
|
13
|
+
import { projectById, projectName } from './places.js';
|
|
14
|
+
import { agentName } from './runners.js';
|
|
15
|
+
|
|
16
|
+
const MEM_KINDS = [
|
|
17
|
+
{ kind: 'preferences', label: 'Preferences', one: 'preference', icon: 'spark', hint: 'How you like agents to work', empty: 'Corrections and “always / never” asks from your sessions land here.' },
|
|
18
|
+
{ kind: 'lessons', label: 'Lessons', one: 'lesson', icon: 'check', hint: 'A mistake an agent made, and the way that worked', empty: 'When an agent hits a wall and finds the fix, the fix lands here.' },
|
|
19
|
+
];
|
|
20
|
+
const MEM_CAP = 10;
|
|
21
|
+
const memCount = (x) => x.preferences.length + x.lessons.length;
|
|
22
|
+
|
|
23
|
+
export async function setLearning(on) {
|
|
24
|
+
await api('/config', { method: 'PATCH', body: { defaults: { learn: on ? 'on' : 'off' } } });
|
|
25
|
+
await loadConfig();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function memorySummary(data) {
|
|
29
|
+
if (!data) return '';
|
|
30
|
+
if (!data.enabled) return 'Off · nothing learned or applied';
|
|
31
|
+
const n = memCount(data.global) + data.projects.reduce((a, p) => a + memCount(p), 0);
|
|
32
|
+
const last = data.history[0];
|
|
33
|
+
if (data.running) return `Retro running · ${n} remembered`;
|
|
34
|
+
return `${n ? `${n} remembered` : 'Nothing yet'}${last ? ` · last retro ${ago(last.at)}` : ''}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openMemory({ tab = 'global', host } = {}) {
|
|
38
|
+
const node = el(`<div class="modal memory-modal">
|
|
39
|
+
<header><h2>Memory</h2><span class="mem-live"></span><button class="switch" id="memOn" role="switch" title="Learn from sessions"></button><button class="icon-btn close">${icon('x')}</button></header>
|
|
40
|
+
<p class="mem-lede">Learned from your sessions and added to every agent's prompt. What you ask always wins.</p>
|
|
41
|
+
<div class="mem-tabs" role="tablist"></div>
|
|
42
|
+
<div class="mem-body"></div>
|
|
43
|
+
</div>`);
|
|
44
|
+
const close = present(node, host);
|
|
45
|
+
let data;
|
|
46
|
+
let editing = null; // { scope, kind, index } (index -1 = adding)
|
|
47
|
+
|
|
48
|
+
const render = async () => {
|
|
49
|
+
data = (await api('/preferences').catch(() => null)) || data;
|
|
50
|
+
if (!data || !node.isConnected) return;
|
|
51
|
+
// Projects with something learned, plus the one picked in the launcher so you can add to it.
|
|
52
|
+
const current = projectById(launch.project);
|
|
53
|
+
const projects = data.projects.filter((p) => memCount(p) || p.path === current?.path);
|
|
54
|
+
if (current && !projects.some((p) => p.path === current.path)) projects.push({ path: current.path, name: current.name, preferences: [], lessons: [] });
|
|
55
|
+
const scopes = [{ scope: 'global', name: 'Everywhere', ...data.global }, ...projects.map((p) => ({ scope: p.path, ...p }))];
|
|
56
|
+
if (tab !== 'retros' && !scopes.some((x) => x.scope === tab)) tab = 'global';
|
|
57
|
+
$('#memOn', node).setAttribute('aria-checked', data.enabled);
|
|
58
|
+
$('.mem-live', node).innerHTML = data.running ? `<span class="orb live"></span>Retro running` : data.enabled ? '' : 'Off';
|
|
59
|
+
$('.mem-tabs', node).innerHTML =
|
|
60
|
+
scopes.map((x) => `<button role="tab" data-tab="${esc(x.scope)}" class="${tab === x.scope ? 'on' : ''}" title="${esc(x.scope === 'global' ? 'Applies in every project' : x.scope.replace(S.config.home, '~'))}">${esc(x.name)}<span>${memCount(x)}</span></button>`).join('') +
|
|
61
|
+
`<i></i><button role="tab" data-tab="retros" class="${tab === 'retros' ? 'on' : ''}">Retros<span>${data.history.length}</span></button>`;
|
|
62
|
+
const body = $('.mem-body', node);
|
|
63
|
+
body.classList.toggle('off', !data.enabled);
|
|
64
|
+
const banner = data.enabled ? '' : `<div class="mem-off">${icon('info')}<span>Memory is off: no retros run, and nothing below reaches your agents.</span><button class="ghost xs" data-act="on">Turn on</button></div>`;
|
|
65
|
+
body.innerHTML = banner + (tab === 'retros' ? retrosView() : scopeView(scopes.find((x) => x.scope === tab)));
|
|
66
|
+
const input = $('.mem-edit input', body);
|
|
67
|
+
if (input) input.focus(), input.setSelectionRange(input.value.length, input.value.length);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Which retro added an item, if it's still in the log.
|
|
71
|
+
const origin = (scope, kind, text) => data.history.find((h) => h.added?.some((a) => a.text === text && a.kind === kind && (a.scope === 'global') === (scope === 'global')));
|
|
72
|
+
|
|
73
|
+
const scopeView = (x) => {
|
|
74
|
+
const chars = [...(x.scope === 'global' ? [] : [...data.global.preferences, ...data.global.lessons]), ...x.preferences, ...x.lessons].reduce((a, t) => a + t.length + 3, 0);
|
|
75
|
+
const budget = x.scope === 'global' ? `Added to every prompt · about ${Math.ceil(chars / 4)} tokens` : `Added to prompts in ${esc(x.name)}, with Everywhere · about ${Math.ceil(chars / 4)} tokens`;
|
|
76
|
+
return `<p class="mem-budget muted xs">${budget}</p>` + MEM_KINDS.map((k) => {
|
|
77
|
+
const items = x[k.kind];
|
|
78
|
+
const row = (t, i) => {
|
|
79
|
+
if (editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === i) return editRow(t, k);
|
|
80
|
+
const o = origin(x.scope, k.kind, t);
|
|
81
|
+
return `<div class="mem-item" data-i="${i}"><span class="mem-ico">${icon(k.icon)}</span><div class="mem-main"><p>${esc(t)}</p>${o ? `<span class="mem-src">${o.sessionId && S.sessions.has(o.sessionId) ? `<a href="#" data-session="${esc(o.sessionId)}">${esc(o.title || 'session')}</a>` : esc(o.title || 'a session')} · ${ago(o.at)}</span>` : ''}</div>
|
|
82
|
+
<span class="mem-acts"><button class="icon-btn" data-act="edit" title="Edit">${icon('pen')}</button><button class="icon-btn" data-act="forget" title="Forget">${icon('trash')}</button></span></div>`;
|
|
83
|
+
};
|
|
84
|
+
const adding = editing && editing.scope === x.scope && editing.kind === k.kind && editing.index === -1;
|
|
85
|
+
return `<section class="mem-sec" data-scope="${esc(x.scope)}" data-kind="${k.kind}">
|
|
86
|
+
<h4>${k.label}<span class="muted xs">${k.hint}</span><em class="${items.length >= MEM_CAP ? 'full' : ''}" title="At most ${MEM_CAP}; a retro drops the least useful when full">${items.length}/${MEM_CAP}</em></h4>
|
|
87
|
+
${items.map(row).join('') || (adding ? '' : `<p class="mem-empty">${k.empty}</p>`)}
|
|
88
|
+
${adding ? editRow('', k) : `<button class="mem-add" data-act="add">${icon('plus')}Add a ${k.one}</button>`}
|
|
89
|
+
</section>`;
|
|
90
|
+
}).join('');
|
|
91
|
+
};
|
|
92
|
+
const editRow = (t, k) => `<div class="mem-item mem-edit"><span class="mem-ico">${icon(k.icon)}</span><input value="${esc(t)}" maxlength="180" spellcheck="false" placeholder="One short sentence, e.g. “Run tests with node --test”"><span class="muted xs">↵ save · esc cancel</span></div>`;
|
|
93
|
+
|
|
94
|
+
const retrosView = () => {
|
|
95
|
+
const live = data.running ? `<div class="tl running"><span class="tl-dot"><span class="orb live"></span></span><div class="tl-main"><div class="tl-head"><b>${esc(data.running.title || 'session')}</b><span class="muted xs">now</span></div><p class="muted xs">Reading ${data.running.turns} turn${data.running.turns === 1 ? '' : 's'} with ${esc(agentName(data.running.agent))}…</p></div></div>` : '';
|
|
96
|
+
const where = (a) => `<span class="tl-tag">${a.scope === 'global' ? 'everywhere' : 'project'} · ${a.kind === 'lessons' ? 'lesson' : 'preference'}</span>`;
|
|
97
|
+
const entries = data.history.map((h) => {
|
|
98
|
+
const status = h.status || 'learned';
|
|
99
|
+
const lines =
|
|
100
|
+
status === 'failed' ? `<p class="tl-err">${esc(h.error || 'The retro run failed.')}</p>`
|
|
101
|
+
: status === 'nothing' ? '<p class="muted xs">Nothing new worth remembering.</p>'
|
|
102
|
+
: [...h.added.map((a) => `<p class="tl-add">${esc(a.text ?? a)} ${a.kind ? where(a) : ''}</p>`), ...h.removed.map((a) => `<p class="tl-del"><s>${esc(a.text ?? a)}</s> ${a.kind ? where(a) : ''}</p>`)].join('');
|
|
103
|
+
const title = S.sessions.has(h.sessionId) ? `<a href="#" data-session="${esc(h.sessionId)}">${esc(h.title || 'session')}</a>` : esc(h.title || 'session');
|
|
104
|
+
return `<div class="tl ${status}"><span class="tl-dot"></span><div class="tl-main"><div class="tl-head"><b>${title}</b>${h.project ? `<span class="muted xs">${esc(projectName(h.project))}</span>` : ''}<span class="muted xs">${ago(h.at)}</span></div>${lines}</div></div>`;
|
|
105
|
+
});
|
|
106
|
+
return live + entries.join('') || `<p class="mem-empty">No retros yet. One runs 90 s after a session finishes, or as soon as you mark it done or archive it.</p>`;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const save = async (scope, kind, items) => {
|
|
110
|
+
await api('/preferences', { method: 'PUT', body: { scope, kind, items } }).catch(toastError);
|
|
111
|
+
editing = null;
|
|
112
|
+
await render();
|
|
113
|
+
};
|
|
114
|
+
const commit = (input) => {
|
|
115
|
+
if (!editing) return;
|
|
116
|
+
const { scope, kind, index } = editing;
|
|
117
|
+
editing = null; // Enter then blur must not save twice
|
|
118
|
+
const list = ((scope === 'global' ? data.global : data.projects.find((p) => p.path === scope)) || { [kind]: [] })[kind].slice();
|
|
119
|
+
const v = input.value.trim();
|
|
120
|
+
if (index === -1) v ? list.push(v) : null;
|
|
121
|
+
else v ? (list[index] = v) : list.splice(index, 1);
|
|
122
|
+
save(scope, kind, list);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
node.addEventListener('click', async (e) => {
|
|
126
|
+
const t = e.target.closest('[data-tab],[data-act],[data-session]');
|
|
127
|
+
if (!t) return;
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
if (t.dataset.tab) return (tab = t.dataset.tab), (editing = null), render();
|
|
130
|
+
if (t.dataset.session) return close(), reveal(t.dataset.session);
|
|
131
|
+
const act = t.dataset.act;
|
|
132
|
+
if (act === 'on') return tryApi(() => setLearning(true)).then(render);
|
|
133
|
+
const sec = t.closest('.mem-sec');
|
|
134
|
+
const { scope, kind } = sec.dataset;
|
|
135
|
+
const index = Number(t.closest('.mem-item')?.dataset.i ?? -1);
|
|
136
|
+
if (act === 'add' || act === 'edit') return (editing = { scope, kind, index }), render();
|
|
137
|
+
if (act === 'forget') {
|
|
138
|
+
const list = (scope === 'global' ? data.global : data.projects.find((p) => p.path === scope))[kind].filter((_, i) => i !== index);
|
|
139
|
+
return save(scope, kind, list);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
node.addEventListener('keydown', (e) => {
|
|
143
|
+
if (!e.target.matches('.mem-edit input')) return;
|
|
144
|
+
if (e.key === 'Enter') e.preventDefault(), commit(e.target);
|
|
145
|
+
if (e.key === 'Escape') e.preventDefault(), e.stopPropagation(), (editing = null), render();
|
|
146
|
+
});
|
|
147
|
+
node.addEventListener('focusout', (e) => e.target.matches('.mem-edit input') && editing && commit(e.target));
|
|
148
|
+
$('#memOn', node).onclick = async () => {
|
|
149
|
+
await tryApi(() => setLearning(!data?.enabled));
|
|
150
|
+
render();
|
|
151
|
+
};
|
|
152
|
+
bus.on('preferences', () => !editing && render(), { node });
|
|
153
|
+
render();
|
|
154
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Task mentions in a prompt box, the way Linear does issue links: copy a task's id (right-click a
|
|
2
|
+
// card → Copy task ID), paste it into a prompt, and it shows as the task's title. The agent gets
|
|
3
|
+
// the id and reads that task with wisp_session.
|
|
4
|
+
//
|
|
5
|
+
// A textarea can't hold chips, so the box shows `@Title` as plain text and a mirror behind it,
|
|
6
|
+
// laid out exactly like the textarea, paints a chip under each mention. The ids stay on the box
|
|
7
|
+
// (ta.mentions). Its `value` reads and writes the stored form, `@[Title](wisp:<id>)`, so drafts,
|
|
8
|
+
// sends and restores elsewhere keep the mentions without knowing about them.
|
|
9
|
+
|
|
10
|
+
import { esc } from '../md.js';
|
|
11
|
+
import { el } from '../core/dom.js';
|
|
12
|
+
import { S } from '../core/state.js';
|
|
13
|
+
import { toast } from '../ui/toast.js';
|
|
14
|
+
import { cleanTitle, decode, encode, pastedTasks, spans } from '../mention-text.js';
|
|
15
|
+
|
|
16
|
+
const NATIVE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
|
17
|
+
// What the mirror copies from the textarea, so its text wraps on the same lines.
|
|
18
|
+
const METRICS = ['font', 'letterSpacing', 'wordSpacing', 'lineHeight', 'textTransform', 'textIndent', 'tabSize', 'whiteSpace', 'overflowWrap', 'wordBreak', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing'];
|
|
19
|
+
|
|
20
|
+
export function mentionBox(ta) {
|
|
21
|
+
if (ta.mentions) return ta;
|
|
22
|
+
ta.mentions = [];
|
|
23
|
+
const wrap = el('<div class="mn-wrap"></div>');
|
|
24
|
+
const mirror = el('<div class="mn-mirror" aria-hidden="true"></div>');
|
|
25
|
+
ta.replaceWith(wrap);
|
|
26
|
+
wrap.append(mirror, ta);
|
|
27
|
+
const shown = () => NATIVE.get.call(ta);
|
|
28
|
+
|
|
29
|
+
Object.defineProperty(ta, 'value', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
get: () => encode(shown(), ta.mentions),
|
|
32
|
+
set: (v) => {
|
|
33
|
+
const d = decode(v);
|
|
34
|
+
ta.mentions = d.mentions;
|
|
35
|
+
NATIVE.set.call(ta, d.text);
|
|
36
|
+
paint();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function paint() {
|
|
41
|
+
const text = shown();
|
|
42
|
+
const found = ta.mentions.length ? spans(text, ta.mentions) : [];
|
|
43
|
+
wrap.classList.toggle('has-mn', found.length > 0);
|
|
44
|
+
if (!found.length) return (mirror.textContent = '');
|
|
45
|
+
const cs = getComputedStyle(ta);
|
|
46
|
+
for (const k of METRICS) mirror.style[k] = cs[k];
|
|
47
|
+
// A scrollbar narrows the textarea's text; narrow the mirror's the same.
|
|
48
|
+
const bar = ta.offsetWidth - ta.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
|
|
49
|
+
mirror.style.paddingRight = `${parseFloat(cs.paddingRight) + Math.max(0, bar)}px`;
|
|
50
|
+
let html = '';
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const s of found) {
|
|
53
|
+
html += `${esc(text.slice(at, s.start))}<mark class="mn">${esc(text.slice(s.start, s.end))}</mark>`;
|
|
54
|
+
at = s.end;
|
|
55
|
+
}
|
|
56
|
+
mirror.innerHTML = `${html}${esc(text.slice(at))}`;
|
|
57
|
+
sync();
|
|
58
|
+
}
|
|
59
|
+
const sync = () => {
|
|
60
|
+
mirror.scrollTop = ta.scrollTop;
|
|
61
|
+
mirror.scrollLeft = ta.scrollLeft;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ta.addEventListener('input', paint);
|
|
65
|
+
ta.addEventListener('scroll', sync);
|
|
66
|
+
new ResizeObserver(paint).observe(ta);
|
|
67
|
+
|
|
68
|
+
ta.addEventListener('paste', (e) => {
|
|
69
|
+
if (e.clipboardData?.files?.length) return; // attachments take those
|
|
70
|
+
const ids = pastedTasks(e.clipboardData?.getData('text/plain'), (id) => S.sessions.has(id) && !S.sessions.get(id).pending);
|
|
71
|
+
if (!ids) return;
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const title = cleanTitle(S.sessions.get(id).title);
|
|
75
|
+
if (!ta.mentions.some((m) => m.id === id)) ta.mentions.push({ id, title });
|
|
76
|
+
}
|
|
77
|
+
const before = shown().slice(0, ta.selectionStart);
|
|
78
|
+
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
79
|
+
insert(lead + ids.map((id) => `@${ta.mentions.find((m) => m.id === id).title}`).join(' ') + ' ');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Backspace right after a mention (or Delete right before one) takes the whole mention.
|
|
83
|
+
ta.addEventListener('keydown', (e) => {
|
|
84
|
+
if ((e.key !== 'Backspace' && e.key !== 'Delete') || e.isComposing || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
85
|
+
const { selectionStart: a, selectionEnd: b } = ta;
|
|
86
|
+
if (a !== b || !ta.mentions.length) return;
|
|
87
|
+
const hit = spans(shown(), ta.mentions).find((s) => (e.key === 'Backspace' ? s.end === a : s.start === a));
|
|
88
|
+
if (!hit) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
ta.setSelectionRange(hit.start, hit.end);
|
|
91
|
+
insert('');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Through the editing commands, so Cmd+Z undoes it like typing.
|
|
95
|
+
function insert(text) {
|
|
96
|
+
if (!document.execCommand('insertText', false, text)) {
|
|
97
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
|
|
98
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
paint();
|
|
103
|
+
return ta;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function copyTaskId(id) {
|
|
107
|
+
const s = S.sessions.get(id);
|
|
108
|
+
if (!s || s.pending) return;
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.writeText(id);
|
|
111
|
+
toast(`Copied the ID of <b>${esc(cleanTitle(s.title))}</b>. Paste it into a prompt to point an agent at this task.`);
|
|
112
|
+
} catch {
|
|
113
|
+
toast(`Couldn’t copy. The task ID is <code>${id}</code>`, { tone: 'bad', timeout: 8000 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clicking a mention chip in a turn's prompt opens that task, or says it's gone.
|
|
118
|
+
document.addEventListener('click', (e) => {
|
|
119
|
+
const a = e.target.closest?.('a.mn[href^="#/s/"]');
|
|
120
|
+
if (!a) return;
|
|
121
|
+
const id = a.getAttribute('href').slice(4);
|
|
122
|
+
if (S.sessions.has(id)) return; // the #/s/ route reveals it
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
toast('That task is no longer in wisp.', { tone: 'bad' });
|
|
125
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Notifications when a task finishes or asks: a toast, and a desktop notification when the window
|
|
2
|
+
// isn't focused.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { icon } from '../core/dom.js';
|
|
6
|
+
import { S } from '../core/state.js';
|
|
7
|
+
import { toast } from '../ui/toast.js';
|
|
8
|
+
import { reveal } from './feed.js';
|
|
9
|
+
import { watching } from './scroll.js';
|
|
10
|
+
|
|
11
|
+
// On unless turned off in settings, and only if the browser lets us.
|
|
12
|
+
export function notifyOn() {
|
|
13
|
+
return S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'granted';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function askNotify() {
|
|
17
|
+
if (S.config.defaults.notify !== 'off' && 'Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function notifyFinished(s) {
|
|
21
|
+
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
22
|
+
if (viewing) return;
|
|
23
|
+
let verb = s.state === 'waiting' ? (s.approval ? 'needs your approval' : 'has a question') : 'is ready';
|
|
24
|
+
// A task run in several places (or compared) says so once, when its last run finishes.
|
|
25
|
+
const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
|
|
26
|
+
if (runs.length > 1 && s.state !== 'waiting') {
|
|
27
|
+
if (runs.some((x) => x.id !== s.id && x.state === 'running')) return;
|
|
28
|
+
const places = new Set(runs.map((x) => x.machine?.id || '')).size;
|
|
29
|
+
verb = places > 1 ? `is ready on ${places} machines` : `is ready (${runs.length} runs)`;
|
|
30
|
+
}
|
|
31
|
+
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
32
|
+
if (notifyOn() && !document.hasFocus()) {
|
|
33
|
+
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
|
|
34
|
+
note.onclick = () => (window.focus(), reveal(s.id));
|
|
35
|
+
}
|
|
36
|
+
}
|