wisp-agents 0.14.0 → 0.16.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 +50 -1
- package/README.md +29 -13
- package/bin/wisp.js +3 -2
- package/package.json +3 -3
- package/server/adapters/antigravity.js +2 -2
- package/server/adapters/claude.js +31 -50
- package/server/adapters/codex.js +2 -2
- package/server/adapters/common.js +14 -34
- package/server/adapters/cursor.js +2 -2
- package/server/adapters/grok.js +2 -2
- package/server/adapters/index.js +16 -1
- package/server/adapters/opencode.js +47 -6
- package/server/checkpoint.js +12 -68
- package/server/client.js +5 -3
- package/server/git.js +9 -4
- package/server/handoff.js +70 -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-wisp.js +17 -5
- package/server/openapi.js +33 -2
- package/server/paths.js +32 -0
- package/server/preferences.js +2 -6
- package/server/proc.js +54 -0
- package/server/recipes.js +2 -4
- package/server/remote.js +14 -14
- 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 +56 -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 +339 -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 +87 -36
- 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 +5 -3
- package/server/subagents.js +5 -4
- package/server/telemetry.js +1 -1
- package/server/terminal.js +2 -1
- package/server/triggers.js +4 -6
- package/server/update.js +4 -3
- package/server/util.js +42 -0
- package/server/welcome.js +1 -1
- 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 +62 -0
- package/web/css/feed.css +208 -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 +16 -1
- package/web/js/app.js +28 -5232
- 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 +339 -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 +104 -0
- package/web/js/features/composer.js +311 -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 +559 -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/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 +161 -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 +401 -0
- package/web/js/features/update.js +48 -0
- package/web/js/features/usage.js +75 -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 -1901
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The wisp HTTP API (server/routes/). A failed request throws with the server's message.
|
|
2
|
+
|
|
3
|
+
export async function api(path, { method = 'GET', body } = {}) {
|
|
4
|
+
const r = await fetch(`/api${path}`, {
|
|
5
|
+
method,
|
|
6
|
+
// The server only accepts JSON on non-GET requests (CSRF guard), even body-less DELETEs.
|
|
7
|
+
headers: method !== 'GET' ? { 'content-type': 'application/json' } : {},
|
|
8
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
9
|
+
});
|
|
10
|
+
const j = await r.json().catch(() => ({}));
|
|
11
|
+
if (!r.ok) throw new Error(j.error || r.statusText);
|
|
12
|
+
return j;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// An attachment: the raw bytes, with its name and type in the query. Resolves to { id }.
|
|
16
|
+
export async function upload(file, { name, type }) {
|
|
17
|
+
const r = await fetch(`/api/uploads?name=${encodeURIComponent(name)}&type=${encodeURIComponent(type)}`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
20
|
+
body: file,
|
|
21
|
+
});
|
|
22
|
+
const j = await r.json();
|
|
23
|
+
if (!r.ok) throw new Error(j.error);
|
|
24
|
+
return j;
|
|
25
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// A small event bus. Live server events (features/live.js) are published here, and open views
|
|
2
|
+
// subscribe instead of being looked up in the DOM and poked. A subscription tied to a node ends
|
|
3
|
+
// once that node has left the page, so a closed dialog stops listening.
|
|
4
|
+
//
|
|
5
|
+
// Events: agents, config, install, machines, preferences, remote, router.
|
|
6
|
+
|
|
7
|
+
const subs = new Map(); // type -> Set<{ fn, node }>
|
|
8
|
+
|
|
9
|
+
export function on(type, fn, { node } = {}) {
|
|
10
|
+
const sub = { fn, node };
|
|
11
|
+
if (!subs.has(type)) subs.set(type, new Set());
|
|
12
|
+
subs.get(type).add(sub);
|
|
13
|
+
return () => subs.get(type).delete(sub);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function emit(type, data) {
|
|
17
|
+
const set = subs.get(type);
|
|
18
|
+
if (!set) return;
|
|
19
|
+
for (const sub of [...set]) {
|
|
20
|
+
if (sub.node && !sub.node.isConnected) set.delete(sub);
|
|
21
|
+
else sub.fn(data);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Forget subscriptions whose node is gone (called when a dialog closes).
|
|
26
|
+
export function sweep() {
|
|
27
|
+
for (const set of subs.values()) for (const sub of set) if (sub.node && !sub.node.isConnected) set.delete(sub);
|
|
28
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// DOM helpers. el (an element from an HTML string) and icon (a sprite <svg>) live with the block
|
|
2
|
+
// renderers, which use them too.
|
|
3
|
+
|
|
4
|
+
export { el, icon } from '../blocks.js';
|
|
5
|
+
|
|
6
|
+
export const $ = (s, root = document) => root.querySelector(s);
|
|
7
|
+
export const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Small display formatters shared across the page.
|
|
2
|
+
|
|
3
|
+
export const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
4
|
+
|
|
5
|
+
export function ago(t) {
|
|
6
|
+
const s = (Date.now() - t) / 1000;
|
|
7
|
+
if (s < 45) return 'now';
|
|
8
|
+
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
9
|
+
if (s < 86400) return `${Math.round(s / 3600)}h`;
|
|
10
|
+
return `${Math.round(s / 86400)}d`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function fmtSize(n) {
|
|
14
|
+
if (n < 1024) return `${n} B`;
|
|
15
|
+
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
16
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const whenLabel = (t) => {
|
|
20
|
+
if (!t) return '';
|
|
21
|
+
const d = new Date(t);
|
|
22
|
+
const today = new Date().toDateString() === d.toDateString();
|
|
23
|
+
return d.toLocaleString([], today ? { hour: '2-digit', minute: '2-digit' } : { weekday: 'short', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const money = (v) => (v >= 100 ? `$${Math.round(v)}` : `$${(v || 0).toFixed(2)}`);
|
|
27
|
+
export const pct = (v) => (v === null || v === undefined ? '—' : `${Math.round(v * 100)}%`);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// What the page knows: sessions and their cards, config, harnesses and machines, plus the
|
|
2
|
+
// launcher's picks. UI state that should survive a reload goes to localStorage as wisp.*.
|
|
3
|
+
|
|
4
|
+
export function load(k, d) {
|
|
5
|
+
try {
|
|
6
|
+
return JSON.parse(localStorage.getItem(k)) ?? d;
|
|
7
|
+
} catch {
|
|
8
|
+
return d;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function save(k, v) {
|
|
12
|
+
try {
|
|
13
|
+
localStorage.setItem(k, JSON.stringify(v));
|
|
14
|
+
} catch {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const S = {
|
|
18
|
+
sessions: new Map(), // id -> summary
|
|
19
|
+
full: new Map(), // id -> full session, for expanded items
|
|
20
|
+
open: new Set(load('wisp.open', [])), // expanded item ids
|
|
21
|
+
nodes: new Map(), // id -> { card, head, body, turnNodes, tray, showAll, unfolded, latestTurn }
|
|
22
|
+
tasks: new Map(), // compare task key -> { card, head, runs, lead }
|
|
23
|
+
focus: null, // id of the keyboard-focused item
|
|
24
|
+
config: { defaults: {}, projects: [] },
|
|
25
|
+
agents: [],
|
|
26
|
+
machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
|
|
27
|
+
filter: load('wisp.filter', 'all'),
|
|
28
|
+
showDone: load('wisp.showDone', false),
|
|
29
|
+
showArchived: false,
|
|
30
|
+
drafts: new Map(),
|
|
31
|
+
replyModes: new Map(), // session id → the mode its next follow-up switches to
|
|
32
|
+
replyRunners: new Map(), // session id → { agent, model, effort } its next follow-up switches to
|
|
33
|
+
};
|
|
34
|
+
export const saveOpen = () => save('wisp.open', [...S.open]);
|
|
35
|
+
|
|
36
|
+
// Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
|
|
37
|
+
// each in the project picked for it (launch.picks[place]), and show up as one task.
|
|
38
|
+
export const launch = {
|
|
39
|
+
project: load('wisp.launchProject', null), // the project picked in the first place
|
|
40
|
+
where: load('wisp.launchWhere', ['']),
|
|
41
|
+
picks: load('wisp.launchPicks', {}),
|
|
42
|
+
};
|
|
43
|
+
export const saveLaunch = () => (save('wisp.launchWhere', launch.where), save('wisp.launchPicks', launch.picks), save('wisp.launchProject', launch.project));
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// What you can do to a session: done, archive, delete, rename, rerun, revert, take over, open files,
|
|
2
|
+
// and the menus that offer it. Local changes apply at once and roll back if the server refuses.
|
|
3
|
+
|
|
4
|
+
import { copy } from '../blocks.js';
|
|
5
|
+
import { esc } from '../md.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import { $, el, icon } from '../core/dom.js';
|
|
8
|
+
import { S } from '../core/state.js';
|
|
9
|
+
import { keyHint } from '../ui/kbd.js';
|
|
10
|
+
import { overlay } from '../ui/overlay.js';
|
|
11
|
+
import { popover } from '../ui/popover.js';
|
|
12
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
13
|
+
import { autosize } from './attachments.js';
|
|
14
|
+
import { saveAsRecipe } from './automations.js';
|
|
15
|
+
import { compareView } from './compare.js';
|
|
16
|
+
import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
|
|
17
|
+
import { launchBehind } from './launcher.js';
|
|
18
|
+
import { runnerPicker } from './runner-picker.js';
|
|
19
|
+
import { agentName, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
|
|
20
|
+
import { feedAnchor, keepInPlace } from './scroll.js';
|
|
21
|
+
import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
|
|
22
|
+
|
|
23
|
+
export async function toggleDone(id) {
|
|
24
|
+
const s = S.sessions.get(id);
|
|
25
|
+
if (!s) return;
|
|
26
|
+
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
27
|
+
const next = s.state === 'done' ? 'review' : 'done';
|
|
28
|
+
// Optimistic: the card moves now; a failed save puts it back.
|
|
29
|
+
move(id, { ...s, state: next }, next === 'done');
|
|
30
|
+
if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
|
|
31
|
+
try {
|
|
32
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
revertSummary(id, s);
|
|
35
|
+
toastError(err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
|
|
40
|
+
// hands focus to the next card; coming back just re-renders around the reader.
|
|
41
|
+
function move(id, next, leaving) {
|
|
42
|
+
if (leaving) return handOff(id)(() => S.sessions.set(id, next));
|
|
43
|
+
S.sessions.set(id, next);
|
|
44
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Put back a summary changed optimistically, unless a live update has replaced it since.
|
|
48
|
+
function revertSummary(id, prev) {
|
|
49
|
+
const cur = S.sessions.get(id);
|
|
50
|
+
if (cur && cur.updatedAt > prev.updatedAt) return;
|
|
51
|
+
S.sessions.set(id, prev);
|
|
52
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
56
|
+
export async function taskDone(ids) {
|
|
57
|
+
if (!ids.length) return;
|
|
58
|
+
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
59
|
+
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
60
|
+
const set = (state) => {
|
|
61
|
+
const prev = ids.map((id) => S.sessions.get(id));
|
|
62
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
|
|
63
|
+
renderFeed();
|
|
64
|
+
Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
|
|
65
|
+
prev.forEach((p, i) => p && revertSummary(ids[i], p));
|
|
66
|
+
toastError(err);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
set('done');
|
|
70
|
+
if (next && S.nodes.has(next)) setFocus(next);
|
|
71
|
+
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function revertFile(id, turnId, file, restore = false) {
|
|
75
|
+
return tryApi(async () => {
|
|
76
|
+
await api(`/sessions/${id}/revert`, { method: 'POST', body: { turnId, file, restore } });
|
|
77
|
+
toast(restore ? `Re-applied ${esc(file)}` : `Reverted ${esc(file)}`, {
|
|
78
|
+
action: { label: 'Undo', run: () => revertFile(id, turnId, file, !restore) },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function rollbackTurn(id, turnId, restore = false) {
|
|
84
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
85
|
+
const turn = s?.turns?.find((t) => t.id === turnId) || s?.turns?.at(-1);
|
|
86
|
+
if (!turn) return;
|
|
87
|
+
await tryApi(async () => {
|
|
88
|
+
const r = await api(`/sessions/${id}/rollback`, { method: 'POST', body: { turnId: turn.id, restore } });
|
|
89
|
+
toast(
|
|
90
|
+
restore
|
|
91
|
+
? `Re-applied turn changes`
|
|
92
|
+
: `Restored working tree to before this turn${r.head ? ` (${esc(r.head.slice(0, 7))})` : ''}`,
|
|
93
|
+
{ action: { label: 'Undo', run: () => rollbackTurn(id, turn.id, !restore) } },
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The server keeps the result on the turn under `key` (turn.actionRuns), where the block that
|
|
99
|
+
// ran it looks for it when the card renders again.
|
|
100
|
+
export async function execAction(id, turnId, command, key) {
|
|
101
|
+
return (await tryApi(() => api(`/sessions/${id}/exec`, { method: 'POST', body: { command, turnId, key } }))) ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function takeOver(id) {
|
|
105
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(() => null));
|
|
106
|
+
if (!s?.agentSessionId) return;
|
|
107
|
+
if (s.state === 'running' && !confirm(`${agentName(s.agent)} is still running. Stop it and take over in your terminal?`)) return;
|
|
108
|
+
await tryApi(async () => {
|
|
109
|
+
const { command } = await api(`/sessions/${id}/takeover`, { method: 'POST', body: {} });
|
|
110
|
+
toast(`${icon('terminal')} Opened in your terminal`, { action: { label: 'Copy command', run: () => copy(command) } });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function rename(id) {
|
|
115
|
+
const n = S.nodes.get(id);
|
|
116
|
+
const title = n && $('.it-title', n.head);
|
|
117
|
+
if (!title) return;
|
|
118
|
+
title.contentEditable = 'true';
|
|
119
|
+
title.focus();
|
|
120
|
+
document.getSelection().selectAllChildren(title);
|
|
121
|
+
const done = (commit) => {
|
|
122
|
+
title.contentEditable = 'false';
|
|
123
|
+
const s = S.sessions.get(id);
|
|
124
|
+
const t = title.textContent.trim();
|
|
125
|
+
if (commit && t && t !== s.title) api(`/sessions/${id}`, { method: 'PATCH', body: { title: t } }).catch((e) => toastError(e));
|
|
126
|
+
else title.textContent = s.title;
|
|
127
|
+
};
|
|
128
|
+
title.onkeydown = (e) => {
|
|
129
|
+
if (e.key === 'Enter') (e.preventDefault(), title.blur());
|
|
130
|
+
if (e.key === 'Escape') ((title.textContent = S.sessions.get(id).title), title.blur());
|
|
131
|
+
};
|
|
132
|
+
title.onclick = (e) => e.stopPropagation();
|
|
133
|
+
title.onblur = () => done(true);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function moreMenu(id, anchor) {
|
|
137
|
+
const s = S.full.get(id);
|
|
138
|
+
if (!s) return;
|
|
139
|
+
const lastTurn = s.turns?.at(-1);
|
|
140
|
+
const canRollback = s.agent !== 'wisp' && s.state !== 'running' && (lastTurn?.changes?.length || lastTurn?.checkpoint?.commit);
|
|
141
|
+
const rolledBack = !!lastTurn?.rolledBackAt;
|
|
142
|
+
const r = anchor.getBoundingClientRect();
|
|
143
|
+
const menu = el(`
|
|
144
|
+
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
145
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
146
|
+
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
147
|
+
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
148
|
+
${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
|
|
149
|
+
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : s.state !== 'running' && s.state !== 'waiting' ? `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>` : ''}
|
|
150
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
151
|
+
</div>`);
|
|
152
|
+
menu.onclick = async (e) => {
|
|
153
|
+
const a = e.target.closest('button')?.dataset.a;
|
|
154
|
+
close();
|
|
155
|
+
if (a === 'rename') rename(id);
|
|
156
|
+
if (a === 'copy') {
|
|
157
|
+
await tryApi(async () => {
|
|
158
|
+
const { command } = await api(`/sessions/${id}/takeover`);
|
|
159
|
+
copy(command);
|
|
160
|
+
toast('Copied resume command');
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (a === 'folder') openInEditor(s, '.');
|
|
164
|
+
if (a === 'rollback') rollbackTurn(id, lastTurn?.id, rolledBack);
|
|
165
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
166
|
+
if (a === 'delete') deleteSession(id);
|
|
167
|
+
};
|
|
168
|
+
const close = popover(menu);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Right-click on a feed item.
|
|
172
|
+
export function taskMenu(id, e) {
|
|
173
|
+
const s = S.sessions.get(id);
|
|
174
|
+
if (!s) return;
|
|
175
|
+
$('.menu.task')?.close();
|
|
176
|
+
const busy = s.state === 'running' || s.state === 'waiting';
|
|
177
|
+
const menu = el(`
|
|
178
|
+
<div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
179
|
+
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
180
|
+
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
181
|
+
<button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
|
|
182
|
+
${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
|
|
183
|
+
${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
|
|
184
|
+
${s.groupId ? `<button data-a="compare"><b class="vs-mark">vs</b>Compare runs</button>` : ''}
|
|
185
|
+
<button data-a="recipe">${icon('spark')}Save as recipe…</button>
|
|
186
|
+
${busy || s.archived ? '' : `<button data-a="done">${icon('done')}${s.state === 'done' ? 'Reopen' : 'Mark done'}${keyHint('E')}</button>`}
|
|
187
|
+
${s.archived ? `<button data-a="unarchive">${icon('archive')}Unarchive${keyHint('A')}</button>` : busy ? '' : `<button data-a="archive">${icon('archive')}Archive${keyHint('A')}</button>`}
|
|
188
|
+
<button data-a="delete" class="danger">${icon('trash')}Delete session${keyHint('⌫')}</button>
|
|
189
|
+
</div>`);
|
|
190
|
+
menu.onclick = async (ev) => {
|
|
191
|
+
const a = ev.target.closest('button')?.dataset.a;
|
|
192
|
+
close();
|
|
193
|
+
if (a === 'open') expand(id);
|
|
194
|
+
if (a === 'collapse') collapse(id);
|
|
195
|
+
if (a === 'rename') rename(id);
|
|
196
|
+
if (a === 'rerun') rerun(id);
|
|
197
|
+
if (a === 'ship') shipDialog(id);
|
|
198
|
+
if (a === 'merge') mergePr(id);
|
|
199
|
+
if (a === 'compare') compareView(id);
|
|
200
|
+
if (a === 'recipe') saveAsRecipe(id);
|
|
201
|
+
if (a === 'done') toggleDone(id);
|
|
202
|
+
if (a === 'archive' || a === 'unarchive') setArchived(id, a === 'archive');
|
|
203
|
+
if (a === 'delete') deleteSession(id);
|
|
204
|
+
};
|
|
205
|
+
const close = popover(menu);
|
|
206
|
+
const r = menu.getBoundingClientRect();
|
|
207
|
+
if (r.right > innerWidth - 8) menu.style.left = `${innerWidth - r.width - 8}px`;
|
|
208
|
+
if (r.bottom > innerHeight - 8) menu.style.top = `${innerHeight - r.height - 8}px`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function deleteSession(id) {
|
|
212
|
+
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
213
|
+
const prev = S.sessions.get(id);
|
|
214
|
+
handOff(id)(() => S.sessions.delete(id));
|
|
215
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
|
|
216
|
+
if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
|
|
217
|
+
toastError(err);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
222
|
+
// optionally edited, on whichever harness, model and thinking level you pick. The original stays.
|
|
223
|
+
export async function rerun(id) {
|
|
224
|
+
const s = S.full.get(id) || (await api(`/sessions/${id}`).catch(toastError));
|
|
225
|
+
const first = s?.turns?.[0];
|
|
226
|
+
if (!first?.prompt) return toast('Nothing to rerun — this session has no prompt.', { tone: 'bad' });
|
|
227
|
+
const has = (a) => S.agents.some((x) => x.id === a && x.available);
|
|
228
|
+
let agent = has(s.agent) ? s.agent : S.agents.find((x) => x.available)?.id || s.agent;
|
|
229
|
+
const models = S.agents.find((x) => x.id === agent)?.models || [];
|
|
230
|
+
let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
|
|
231
|
+
let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
|
|
232
|
+
let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || 'edits';
|
|
233
|
+
const files = first.attachments || [];
|
|
234
|
+
|
|
235
|
+
const node = el(`
|
|
236
|
+
<div class="modal rerun-modal">
|
|
237
|
+
<header><h2>Rerun task</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
238
|
+
<div class="rr-box">
|
|
239
|
+
<textarea class="rr-prompt" rows="3" spellcheck="false"></textarea>
|
|
240
|
+
${files.length ? `<div class="rr-files">${files.map((f) => `<span class="rr-file">${icon('clip')}${esc(f.name)}</span>`).join('')}</div>` : ''}
|
|
241
|
+
<div class="l-bar">
|
|
242
|
+
<button class="mode-btn" data-a="mode" aria-haspopup="menu"></button>
|
|
243
|
+
<button class="runner-btn" data-a="runner" title="Harness, model and thinking level" aria-haspopup="dialog"></button>
|
|
244
|
+
<span class="grow"></span>
|
|
245
|
+
<button class="primary sm" data-a="go">${icon('rerun')}Rerun</button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
<p class="hint">Starts a new session in <code>${esc(s.cwd)}</code>. “${esc(s.title)}” is kept as it is.</p>
|
|
249
|
+
</div>`);
|
|
250
|
+
const close = overlay(node);
|
|
251
|
+
$('.close', node).onclick = close;
|
|
252
|
+
const ta = $('.rr-prompt', node);
|
|
253
|
+
ta.value = first.prompt;
|
|
254
|
+
autosize(ta);
|
|
255
|
+
const modeBtn = $('[data-a="mode"]', node);
|
|
256
|
+
const paintMode = () => {
|
|
257
|
+
const m = MODES.find((x) => x.id === mode) || MODES[1];
|
|
258
|
+
modeBtn.className = `mode-btn m-${m.id}`;
|
|
259
|
+
modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
|
|
260
|
+
modeBtn.title = m.hint;
|
|
261
|
+
};
|
|
262
|
+
const runnerBtn = $('[data-a="runner"]', node);
|
|
263
|
+
const paintRunner = () => paintRunnerBtn(runnerBtn, agent, model, effort);
|
|
264
|
+
paintMode();
|
|
265
|
+
paintRunner();
|
|
266
|
+
modeBtn.onclick = () => modeMenu(modeBtn, mode, (m) => ((mode = m), paintMode(), ta.focus()));
|
|
267
|
+
// Picks here only apply to this rerun; the launcher's remembered defaults are left alone.
|
|
268
|
+
runnerBtn.onclick = () =>
|
|
269
|
+
runnerPicker(runnerBtn, { agent, model, effort, savedModel, savedEffort, auto: true }, (a, m, e) => {
|
|
270
|
+
[agent, model, effort] = [a, m, e];
|
|
271
|
+
paintRunner();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const go = async () => {
|
|
275
|
+
const prompt = ta.value.trim();
|
|
276
|
+
if (!prompt) return ta.focus();
|
|
277
|
+
close();
|
|
278
|
+
try {
|
|
279
|
+
const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
280
|
+
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
281
|
+
setFocus(next.id);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
$('[data-a="go"]', node).onclick = go;
|
|
287
|
+
ta.addEventListener('keydown', (e) => {
|
|
288
|
+
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) (e.preventDefault(), go());
|
|
289
|
+
if (e.key === 'Tab' && e.shiftKey) {
|
|
290
|
+
e.preventDefault();
|
|
291
|
+
mode = MODES[(MODES.findIndex((m) => m.id === mode) + 1) % MODES.length].id;
|
|
292
|
+
paintMode();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
requestAnimationFrame(() => (ta.focus(), ta.setSelectionRange(ta.value.length, ta.value.length)));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function setArchived(id, archived) {
|
|
299
|
+
const prev = S.sessions.get(id);
|
|
300
|
+
if (!prev) return;
|
|
301
|
+
// Optimistic: the card leaves (or comes back) now; a failed save puts it back.
|
|
302
|
+
move(id, { ...prev, archived }, archived);
|
|
303
|
+
if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
304
|
+
try {
|
|
305
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
revertSummary(id, prev);
|
|
308
|
+
toastError(err);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const EDITORS = {
|
|
313
|
+
vscode: (f, l) => `vscode://file${f}${l ? `:${l}` : ''}`,
|
|
314
|
+
cursor: (f, l) => `cursor://file${f}${l ? `:${l}` : ''}`,
|
|
315
|
+
windsurf: (f, l) => `windsurf://file${f}${l ? `:${l}` : ''}`,
|
|
316
|
+
zed: (f, l) => `zed://file${f}${l ? `:${l}` : ''}`,
|
|
317
|
+
idea: (f, l) => `idea://open?file=${encodeURIComponent(f)}${l ? `&line=${l}` : ''}`,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// VS Code and its forks open a file on a machine through their Remote - SSH extension.
|
|
321
|
+
const REMOTE_EDITORS = new Set(['vscode', 'cursor', 'windsurf']);
|
|
322
|
+
|
|
323
|
+
export function openInEditor(s, p, line) {
|
|
324
|
+
const editor = S.config.defaults.editor || 'vscode';
|
|
325
|
+
const abs = p.startsWith('/') ? p : `${s.cwd}/${p}`.replace(/\/\.$/, '');
|
|
326
|
+
if (s.machine) {
|
|
327
|
+
if (!REMOTE_EDITORS.has(editor)) {
|
|
328
|
+
copy(abs);
|
|
329
|
+
return toast(`Copied path ${esc(abs)} (on ${esc(s.machine.name)})`);
|
|
330
|
+
}
|
|
331
|
+
location.href = `${editor}://vscode-remote/ssh-remote+${encodeURIComponent(s.machine.target)}${abs}${line ? `:${line}` : ''}`;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (editor === 'none' || !EDITORS[editor]) {
|
|
335
|
+
copy(abs);
|
|
336
|
+
return toast(`Copied path ${esc(abs)}`);
|
|
337
|
+
}
|
|
338
|
+
location.href = EDITORS[editor](abs, line);
|
|
339
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Files attached to a prompt: pasted, dropped or picked, uploaded at once, sent as ids.
|
|
2
|
+
|
|
3
|
+
import { esc } from '../md.js';
|
|
4
|
+
import { upload } from '../core/api.js';
|
|
5
|
+
import { el, icon } from '../core/dom.js';
|
|
6
|
+
import { fmtSize } from '../core/format.js';
|
|
7
|
+
import { toast } from '../ui/toast.js';
|
|
8
|
+
|
|
9
|
+
// One file picker for every tray: the tray that opened it gets the files.
|
|
10
|
+
const fileInput = el(`<input type="file" multiple hidden>`);
|
|
11
|
+
document.body.append(fileInput);
|
|
12
|
+
|
|
13
|
+
export function attachTray() {
|
|
14
|
+
const items = [];
|
|
15
|
+
const node = el(`<div class="tray" hidden></div>`);
|
|
16
|
+
const render = () => {
|
|
17
|
+
node.hidden = !items.length;
|
|
18
|
+
node.innerHTML = items
|
|
19
|
+
.map(
|
|
20
|
+
(it, i) => `<div class="att ${it.status}" data-i="${i}" title="${esc(it.name)}">
|
|
21
|
+
${it.thumb ? `<img src="${it.thumb}" alt="">` : `<span class="att-ic">${icon(it.mime.startsWith('video') ? 'web' : 'file')}</span>`}
|
|
22
|
+
<span class="att-name">${esc(it.name)}</span><span class="att-size">${fmtSize(it.size)}</span>
|
|
23
|
+
<button class="att-x" title="Remove">${icon('x')}</button>
|
|
24
|
+
</div>`,
|
|
25
|
+
)
|
|
26
|
+
.join('');
|
|
27
|
+
};
|
|
28
|
+
node.addEventListener('click', (e) => {
|
|
29
|
+
const x = e.target.closest('.att-x');
|
|
30
|
+
if (x) {
|
|
31
|
+
items.splice(Number(x.closest('.att').dataset.i), 1);
|
|
32
|
+
render();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const add = (files) => {
|
|
36
|
+
for (const f of files) {
|
|
37
|
+
const it = { name: f.name || 'pasted', size: f.size, mime: f.type || '', status: 'uploading', thumb: f.type.startsWith('image/') ? URL.createObjectURL(f) : null };
|
|
38
|
+
items.push(it);
|
|
39
|
+
it.promise = upload(f, { name: it.name, type: it.mime })
|
|
40
|
+
.then((j) => ((it.id = j.id), (it.status = 'ready'), render()))
|
|
41
|
+
.catch((err) => ((it.status = 'failed'), render(), toast(`Upload failed: ${esc(err.message)}`, { tone: 'bad' })));
|
|
42
|
+
}
|
|
43
|
+
render();
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
node,
|
|
47
|
+
add,
|
|
48
|
+
pick: () => {
|
|
49
|
+
fileInput.onchange = () => {
|
|
50
|
+
add(fileInput.files);
|
|
51
|
+
fileInput.value = '';
|
|
52
|
+
};
|
|
53
|
+
fileInput.click();
|
|
54
|
+
},
|
|
55
|
+
async ids() {
|
|
56
|
+
await Promise.allSettled(items.map((i) => i.promise));
|
|
57
|
+
return items.filter((i) => i.id).map((i) => i.id);
|
|
58
|
+
},
|
|
59
|
+
clear() {
|
|
60
|
+
items.length = 0;
|
|
61
|
+
render();
|
|
62
|
+
},
|
|
63
|
+
// Empty the tray for an optimistic send, and put the files back if the send fails.
|
|
64
|
+
take() {
|
|
65
|
+
const list = items.splice(0);
|
|
66
|
+
render();
|
|
67
|
+
return list;
|
|
68
|
+
},
|
|
69
|
+
restore(list) {
|
|
70
|
+
if (!list?.length) return;
|
|
71
|
+
items.unshift(...list);
|
|
72
|
+
render();
|
|
73
|
+
},
|
|
74
|
+
get busy() {
|
|
75
|
+
return items.some((i) => i.status === 'uploading');
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Wire paste + drag/drop of files onto a container.
|
|
81
|
+
export function acceptFiles(container, textarea, tray) {
|
|
82
|
+
textarea.addEventListener('paste', (e) => {
|
|
83
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
84
|
+
if (files.length) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
tray.add(files);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
container.addEventListener('dragover', (e) => {
|
|
90
|
+
if ([...e.dataTransfer.types].includes('Files')) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
e.stopPropagation();
|
|
93
|
+
container.classList.add('drop');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
container.addEventListener('dragleave', (e) => !container.contains(e.relatedTarget) && container.classList.remove('drop'));
|
|
97
|
+
container.addEventListener('drop', (e) => {
|
|
98
|
+
container.classList.remove('drop');
|
|
99
|
+
if (e.dataTransfer.files.length) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
e.stopPropagation();
|
|
102
|
+
tray.add(e.dataTransfer.files);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function autosize(ta, max = 340) {
|
|
108
|
+
const fit = () => {
|
|
109
|
+
ta.style.height = 'auto';
|
|
110
|
+
ta.style.height = `${Math.min(max, ta.scrollHeight)}px`;
|
|
111
|
+
};
|
|
112
|
+
ta.addEventListener('input', fit);
|
|
113
|
+
requestAnimationFrame(fit);
|
|
114
|
+
return fit;
|
|
115
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Auto: what the router (server/router.js) learned about which harness and model work for you.
|
|
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 { S } from '../core/state.js';
|
|
8
|
+
import { present } from '../ui/overlay.js';
|
|
9
|
+
import { toastError } from '../ui/toast.js';
|
|
10
|
+
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
11
|
+
|
|
12
|
+
const ARM_STATE = { ready: ['Ready', 'ok'], new: ['Trying out', 'warn'], untried: ['No data yet', ''], gone: ['Not installed', 'bad'] };
|
|
13
|
+
export async function openRouter({ host } = {}) {
|
|
14
|
+
const node = el(`<div class="modal router-modal">
|
|
15
|
+
<header><h2>Auto</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
16
|
+
<div class="ro-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>
|
|
17
|
+
</div>`);
|
|
18
|
+
const close = present(node, host);
|
|
19
|
+
const pctOf = (x) => `${Math.round(x * 100)}%`;
|
|
20
|
+
const outcome = (r) => (r === null ? '<span class="dim">waiting</span>' : r >= 0.9 ? '<span class="ok">worked</span>' : r >= 0.5 ? '<span>finished</span>' : r >= 0.25 ? '<span class="warn">partly</span>' : '<span class="bad">didn’t work</span>');
|
|
21
|
+
const render = async () => {
|
|
22
|
+
const r = await api('/router').catch((err) => (toastError(err), null));
|
|
23
|
+
if (!r || !node.isConnected) return;
|
|
24
|
+
const arms = r.arms.filter((a) => a.state !== 'gone' || a.n);
|
|
25
|
+
$('.ro-body', node).innerHTML = `
|
|
26
|
+
<p class="hint">For each task Auto estimates every harness · model’s chance of doing it well, from wisp-bench (${esc(r.priors.version)}) and then more and more from your own tasks: merged PRs, 👍 / 👎, rollbacks, follow-ups and how the run ended. It picks the best value for your goal (<b>${esc(AUTO_GOALS.find((g) => g.id === r.goal)?.label || '')}</b>), now and then trying an alternative so the estimates stay honest.</p>
|
|
27
|
+
<h4>Harness · model</h4>
|
|
28
|
+
<table class="us-table ro-table"><thead><tr><th></th><th>Works</th><th>Your tasks</th><th>~Cost</th><th>~Time</th><th></th></tr></thead><tbody>${arms
|
|
29
|
+
.map((a) => {
|
|
30
|
+
const [state, tone] = ARM_STATE[a.state] || ARM_STATE.ready;
|
|
31
|
+
const types = Object.entries(a.byType).map(([t, x]) => `${t} ${pctOf(x.chance)} (${x.n})`).join(' · ');
|
|
32
|
+
return `<tr><td><span class="us-runner"><span class="model-logo">${harnessLogo(a.agent)}</span>${esc(a.label)}</span>${types ? `<small class="ro-types">${esc(types)}</small>` : ''}</td>${a.state === 'untried' ? '<td>—</td><td>—</td><td>—</td><td>—</td>' : `<td><b>${pctOf(a.chance)}</b></td><td>${a.n || '—'}</td><td>${a.usd ? `$${a.usd.toFixed(2)}` : '$0'}</td><td>${a.minutes}m</td>`}<td><span class="sx-state ${tone}"><i class="dot ${tone}"></i>${state}</span></td></tr>`;
|
|
33
|
+
})
|
|
34
|
+
.join('')}</tbody></table>
|
|
35
|
+
<h4>Recent picks</h4>
|
|
36
|
+
${r.recent.length
|
|
37
|
+
? `<ul class="ro-recent">${r.recent
|
|
38
|
+
.map((x) => {
|
|
39
|
+
const s = S.sessions.get(x.id);
|
|
40
|
+
return `<li><span class="model-logo">${harnessLogo(x.key.split(':')[0])}</span><span class="ro-title">${esc(s?.title || 'Task')}</span><small>${esc(x.label)} · ${esc(x.type)}${x.trying ? ' · trying' : x.explored ? ' · exploring' : ''}</small>${outcome(x.r)}</li>`;
|
|
41
|
+
})
|
|
42
|
+
.join('')}</ul>`
|
|
43
|
+
: '<p class="mem-empty">Nothing yet. Launch with Auto and its picks show up here.</p>'}
|
|
44
|
+
<div class="ro-foot"><button class="ghost sm danger" id="roReset">Forget what Auto learned</button><span class="hint">The wisp-bench starting point stays.</span></div>`;
|
|
45
|
+
$('#roReset', node).onclick = async () => {
|
|
46
|
+
if (!confirm('Forget what Auto learned from your tasks?')) return;
|
|
47
|
+
await api('/router/reset', { method: 'POST', body: {} }).catch(toastError);
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
bus.on('router', render, { node });
|
|
52
|
+
render();
|
|
53
|
+
return close;
|
|
54
|
+
}
|