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,390 @@
|
|
|
1
|
+
// Settings: a sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
2
|
+
// (memory, automations, usage history) keep their own views; a pane only summarises and links.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import * as bus from '../core/bus.js';
|
|
7
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
8
|
+
import { money, pct } from '../core/format.js';
|
|
9
|
+
import { S } from '../core/state.js';
|
|
10
|
+
import { overlay } from '../ui/overlay.js';
|
|
11
|
+
import { toast, toastError, tryApi } from '../ui/toast.js';
|
|
12
|
+
import { openRouter } from './auto.js';
|
|
13
|
+
import { openAutomations } from './automations.js';
|
|
14
|
+
import { renderLauncher } from './launcher.js';
|
|
15
|
+
import { loadConfig } from './live.js';
|
|
16
|
+
import { openMachines } from './machines.js';
|
|
17
|
+
import { memorySummary, openMemory, setLearning } from './memory.js';
|
|
18
|
+
import { notifyOn } from './notify.js';
|
|
19
|
+
import { openPhone, phoneSummary } from './phone.js';
|
|
20
|
+
import { machinesSummary } from './places.js';
|
|
21
|
+
import { AUTO_GOALS, harnessLogo } from './runners.js';
|
|
22
|
+
import { INSTALLABLE, installHarness, isInstalling } from './setup.js';
|
|
23
|
+
import { openUsage } from './usage.js';
|
|
24
|
+
|
|
25
|
+
// A sidebar of sections and one pane at a time, like every other app's settings. Big lists
|
|
26
|
+
// (memory, automations, usage history) keep their own views; a pane only summarises and links.
|
|
27
|
+
const SETTINGS_PANES = [
|
|
28
|
+
{ id: 'general', label: 'General', icon: 'settings', lede: 'Where files open, where you take over, and how tasks start.' },
|
|
29
|
+
{ id: 'notifications', label: 'Notifications', icon: 'bolt', lede: 'Hear about questions and finished tasks, here or on your phone.' },
|
|
30
|
+
{ id: 'agents', label: 'Agents', icon: 'agent', lede: 'The harnesses wisp found on this machine. Pick one next to the model in the launcher.' },
|
|
31
|
+
{ id: 'auto', label: 'Auto', icon: 'spark', lede: 'wisp picks the harness and model for each task, and learns from how it went.' },
|
|
32
|
+
{ id: 'machines', label: 'Machines', icon: 'server', lede: 'Other computers that run agents for you, over SSH.' },
|
|
33
|
+
{ id: 'memory', label: 'Memory', icon: 'spark', lede: "Learned from your sessions and added to every agent's prompt. What you ask always wins." },
|
|
34
|
+
{ id: 'automations', label: 'Automations', icon: 'play', lede: 'Saved prompts, and the triggers that launch them for you.' },
|
|
35
|
+
{ id: 'usage', label: 'Usage', icon: 'stats', lede: 'Spend and outcomes over the last 7 days.' },
|
|
36
|
+
{ id: 'privacy', label: 'Privacy', icon: 'info', lede: 'What wisp sends about itself. Never prompts, paths or code.' },
|
|
37
|
+
];
|
|
38
|
+
let settingsPane = 'general';
|
|
39
|
+
|
|
40
|
+
// What a re-check turned up: harnesses found or gone, and model lists that grew or shrank.
|
|
41
|
+
function recheckSummary(before, after) {
|
|
42
|
+
const count = (a) => (a?.models || []).filter((m) => m.id).length;
|
|
43
|
+
const notes = [];
|
|
44
|
+
for (const a of after) {
|
|
45
|
+
const was = before.get(a.id);
|
|
46
|
+
if (a.available && !was?.available) notes.push(`<b>${esc(a.name)}</b> found`);
|
|
47
|
+
else if (!a.available && was?.available) notes.push(`<b>${esc(a.name)}</b> not found`);
|
|
48
|
+
else if (a.available && count(a) !== count(was)) {
|
|
49
|
+
const d = count(a) - count(was);
|
|
50
|
+
notes.push(`<b>${esc(a.name)}</b> ${d > 0 ? '+' : '−'}${Math.abs(d)} model${Math.abs(d) === 1 ? '' : 's'}`);
|
|
51
|
+
} else if (a.available && a.version !== was?.version) notes.push(`<b>${esc(a.name)}</b> updated`);
|
|
52
|
+
}
|
|
53
|
+
return notes.length ? notes.join(' · ') : 'Up to date: nothing new';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function openSettings(opts) {
|
|
57
|
+
if (SETTINGS_PANES.some((p) => p.id === opts?.pane)) settingsPane = opts.pane;
|
|
58
|
+
const d = S.config.defaults;
|
|
59
|
+
// A row: name and a line of explanation on the left, the control on the right.
|
|
60
|
+
const row = (name, desc, control, cls = '') => `<div class="sx-row ${cls}"><div class="sx-text"><b>${name}</b>${desc ? `<small>${desc}</small>` : ''}</div><div class="sx-ctl">${control}</div></div>`;
|
|
61
|
+
const missing = S.agents.filter((a) => !a.available).length;
|
|
62
|
+
const node = el(`
|
|
63
|
+
<div class="modal settings-modal sx">
|
|
64
|
+
<nav class="sx-nav" role="tablist" aria-orientation="vertical">
|
|
65
|
+
<h2>Settings</h2>
|
|
66
|
+
${SETTINGS_PANES.map((p) => `<button role="tab" data-pane="${p.id}">${icon(p.icon)}<span>${p.label}</span>${p.id === 'agents' && missing ? `<i class="dot bad" title="${missing} not found"></i>` : ''}</button>`).join('')}
|
|
67
|
+
</nav>
|
|
68
|
+
<div class="sx-main">
|
|
69
|
+
<header><div><h3 class="sx-title"></h3><p class="sx-lede"></p></div><button class="icon-btn close">${icon('x')}</button></header>
|
|
70
|
+
<section class="sx-pane" data-pane="general">
|
|
71
|
+
<div class="sx-group">
|
|
72
|
+
${row('Open files in', 'File links in cards and diffs', `<select name="editor">${[['vscode', 'VS Code'], ['cursor', 'Cursor'], ['windsurf', 'Windsurf'], ['zed', 'Zed'], ['idea', 'JetBrains'], ['none', 'Copy path']]
|
|
73
|
+
.map(([v, l]) => `<option value="${v}" ${d.editor === v || (!d.editor && v === 'vscode') ? 'selected' : ''}>${l}</option>`)
|
|
74
|
+
.join('')}</select>`)}
|
|
75
|
+
${row('Take over in', 'The terminal a session opens in when you take it over', `<select name="terminal">${(S.config.terminals || []).map((t) => `<option value="${t.id}" ${(d.terminal || 'auto') === t.id ? 'selected' : ''}>${t.label}</option>`).join('')}</select>`)}
|
|
76
|
+
<div class="sx-row custom-term" ${d.terminal === 'custom' ? '' : 'hidden'}><div class="sx-text"><b>Terminal command</b><small><code>{cwd}</code> and <code>{cmd}</code> are filled in</small></div>
|
|
77
|
+
<div class="sx-ctl"><input class="input mono" name="terminalCommand" value="${esc(d.terminalCommand || '')}" placeholder="alacritty --working-directory {cwd} -e {cmd}" spellcheck="false"></div></div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="sx-group">
|
|
80
|
+
${row('Worktree per task', 'Each task gets its own branch from origin/main, in git repos', '<button class="switch" id="wtDefault" role="switch"></button>')}
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
<section class="sx-pane" data-pane="notifications">
|
|
84
|
+
<div class="sx-group">
|
|
85
|
+
${row('Desktop notifications', '<span class="notif-hint"></span>', '<button class="switch" id="notif" role="switch"></button>')}
|
|
86
|
+
</div>
|
|
87
|
+
<div class="sx-group">
|
|
88
|
+
${row('Phone', '<span class="phone-hint">ntfy or Telegram: answer questions from your phone</span>', '<button class="ghost sm" id="openPhone">Set up</button>')}
|
|
89
|
+
</div>
|
|
90
|
+
</section>
|
|
91
|
+
<section class="sx-pane" data-pane="agents">
|
|
92
|
+
<div class="sx-group sx-agents"></div>
|
|
93
|
+
<div class="sx-group">
|
|
94
|
+
${row('Installed something new?', '<span class="recheck-hint"></span>', `<button class="ghost sm" id="recheck">${icon('replay')}Re-check</button>`)}
|
|
95
|
+
</div>
|
|
96
|
+
</section>
|
|
97
|
+
<section class="sx-pane" data-pane="auto">
|
|
98
|
+
<div class="sx-group">
|
|
99
|
+
${row('Goal', 'What Auto optimises for. Also in the launcher, next to Auto', `<span class="pk-seg ro-goal">${AUTO_GOALS.map((g) => `<button data-goal="${g.id}" title="${esc(g.hint)}">${g.label}</button>`).join('')}</span>`)}
|
|
100
|
+
${row('Try new models', 'A new model or harness gets a few tasks that aren’t marked important before Auto relies on it', '<button class="switch" id="roExplore" role="switch"></button>')}
|
|
101
|
+
${row('Harnesses it may use', 'Leave all off to let Auto use every one you have', '<span class="ro-allowed"></span>')}
|
|
102
|
+
</div>
|
|
103
|
+
<div class="sx-group">
|
|
104
|
+
${row('What it learned', '<span class="ro-hint"></span>', '<button class="ghost sm" id="openRouter">Open</button>')}
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
<section class="sx-pane" data-pane="machines">
|
|
108
|
+
<div class="sx-group">
|
|
109
|
+
${row('Machines', '<span class="machines-hint"></span>', '<button class="ghost sm" id="openMachines">Manage</button>')}
|
|
110
|
+
</div>
|
|
111
|
+
<p class="hint">Also in the top bar, and in Projects: add folders on a machine, then launch there like anywhere else.</p>
|
|
112
|
+
</section>
|
|
113
|
+
<section class="sx-pane" data-pane="memory">
|
|
114
|
+
<div class="sx-group">
|
|
115
|
+
${row('Learn from sessions', 'A short retro after each session keeps preferences and lessons', '<button class="switch" id="learn" role="switch"></button>')}
|
|
116
|
+
${row('Remembered', '<span class="learn-hint"></span>', '<button class="ghost sm" id="openMemory">Open memory</button>')}
|
|
117
|
+
</div>
|
|
118
|
+
</section>
|
|
119
|
+
<section class="sx-pane" data-pane="automations">
|
|
120
|
+
<div class="sx-group">
|
|
121
|
+
${row('Recipes', '<span class="recipe-hint"></span>', '<button class="ghost sm" data-auto="recipes">Manage</button>')}
|
|
122
|
+
${row('Triggers', '<span class="trigger-hint"></span>', '<button class="ghost sm" data-auto="triggers">Manage</button>')}
|
|
123
|
+
</div>
|
|
124
|
+
</section>
|
|
125
|
+
<section class="sx-pane" data-pane="usage">
|
|
126
|
+
<div class="us-tiles sx-usage"><div class="it-loading"><span class="orb tiny"></span>Adding it up…</div></div>
|
|
127
|
+
<div class="sx-group">${row('Charts and breakdowns', 'Per day, per project and per harness · model', '<button class="ghost sm" id="openUsage">Open usage</button>')}</div>
|
|
128
|
+
</section>
|
|
129
|
+
<section class="sx-pane" data-pane="privacy">
|
|
130
|
+
<div class="sx-group">
|
|
131
|
+
${row('Anonymous usage stats', '<span class="tele-hint"></span>', '<button class="switch" id="tele" role="switch"></button>')}
|
|
132
|
+
</div>
|
|
133
|
+
<p class="hint">Agent, model, status, timings and scrubbed errors only.</p>
|
|
134
|
+
</section>
|
|
135
|
+
</div>
|
|
136
|
+
</div>`);
|
|
137
|
+
const close = overlay(node);
|
|
138
|
+
$('.close', node).onclick = close;
|
|
139
|
+
const paintAgents = () => {
|
|
140
|
+
$('.sx-agents', node).innerHTML = S.agents.length
|
|
141
|
+
? S.agents.map((a) => {
|
|
142
|
+
const n = (a.models || []).filter((m) => m.id).length;
|
|
143
|
+
// opencode: wisp can install it (free models, no account), so its row offers that.
|
|
144
|
+
const canInstall = !a.available && INSTALLABLE.has(a.id);
|
|
145
|
+
const busy = isInstalling(a.id);
|
|
146
|
+
const desc = a.available
|
|
147
|
+
? [a.version, n && `${n} model${n === 1 ? '' : 's'}`].filter(Boolean).join(' · ')
|
|
148
|
+
: busy ? 'Installing into ~/.wisp/tools…' : canInstall ? 'Install it here: free models, no account needed' : `Install the ${a.name} CLI and sign in, then re-check`;
|
|
149
|
+
const install = canInstall ? `<button class="ghost sm" data-install="${esc(a.id)}" ${busy ? 'disabled' : ''}>${busy ? 'Installing…' : 'Install'}</button>` : '';
|
|
150
|
+
return row(`<span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}`, esc(desc), `${install}<span class="sx-state ${a.available ? 'ok' : 'bad'}" ${a.error ? `title="${esc(a.error)}"` : ''}><i class="dot ${a.available ? 'ok' : 'bad'}"></i>${a.available ? 'Ready' : 'Not found'}</span>`);
|
|
151
|
+
}).join('')
|
|
152
|
+
: '<div class="sx-row"><div class="it-loading"><span class="orb tiny"></span>Looking for harnesses…</div></div>';
|
|
153
|
+
const missing = S.agents.filter((a) => !a.available).length;
|
|
154
|
+
const dot = $('.sx-nav [data-pane="agents"] .dot', node);
|
|
155
|
+
if (dot && !missing) dot.remove();
|
|
156
|
+
if (!dot && missing) $('.sx-nav [data-pane="agents"]', node).insertAdjacentHTML('beforeend', `<i class="dot bad" title="${missing} not found"></i>`);
|
|
157
|
+
const online = S.machines.filter((m) => m.state === 'online').length;
|
|
158
|
+
$('.recheck-hint', node).textContent = `After installing a CLI, signing in or adding a provider, look for harnesses and models again${online ? `, here and on ${online} machine${online === 1 ? '' : 's'}` : ''}`;
|
|
159
|
+
};
|
|
160
|
+
paintAgents();
|
|
161
|
+
bus.on('agents', paintAgents, { node });
|
|
162
|
+
bus.on('install', paintAgents, { node });
|
|
163
|
+
$('.sx-agents', node).onclick = (e) => {
|
|
164
|
+
const b = e.target.closest('[data-install]');
|
|
165
|
+
if (b) installHarness(b.dataset.install);
|
|
166
|
+
};
|
|
167
|
+
$('#recheck', node).onclick = async (e) => {
|
|
168
|
+
const btn = e.currentTarget;
|
|
169
|
+
btn.disabled = true;
|
|
170
|
+
btn.classList.add('busy');
|
|
171
|
+
const before = new Map(S.agents.map((a) => [a.id, a]));
|
|
172
|
+
try {
|
|
173
|
+
S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
|
|
174
|
+
if (!node.isConnected) return;
|
|
175
|
+
paintAgents();
|
|
176
|
+
$('#launcher')?.placesChanged?.();
|
|
177
|
+
toast(recheckSummary(before, S.agents));
|
|
178
|
+
} catch (err) {
|
|
179
|
+
toastError(err);
|
|
180
|
+
} finally {
|
|
181
|
+
btn.disabled = false;
|
|
182
|
+
btn.classList.remove('busy');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const pick = (id, focus = false) => {
|
|
186
|
+
settingsPane = id;
|
|
187
|
+
const p = SETTINGS_PANES.find((x) => x.id === id);
|
|
188
|
+
$$('.sx-nav [data-pane]', node).forEach((b) => {
|
|
189
|
+
const on = b.dataset.pane === id;
|
|
190
|
+
b.setAttribute('aria-selected', on);
|
|
191
|
+
b.tabIndex = on ? 0 : -1;
|
|
192
|
+
if (on && focus) b.focus();
|
|
193
|
+
});
|
|
194
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = s.dataset.pane !== id));
|
|
195
|
+
$('.sx-title', node).textContent = p.label;
|
|
196
|
+
$('.sx-lede', node).textContent = p.lede;
|
|
197
|
+
$('.sx-main', node).scrollTop = 0;
|
|
198
|
+
};
|
|
199
|
+
// Memory, Automations, Usage and Phone open in the main area, next to the sidebar, with a
|
|
200
|
+
// way back to the section they came from. Leaving one goes back; only the X closes Settings.
|
|
201
|
+
const main = $('.sx-main', node);
|
|
202
|
+
const leave = () => {
|
|
203
|
+
const v = $('.sx-main > .embedded', node);
|
|
204
|
+
if (!v) return;
|
|
205
|
+
v.remove();
|
|
206
|
+
$('.sx-main > header', node).hidden = false;
|
|
207
|
+
paintHints();
|
|
208
|
+
};
|
|
209
|
+
const show = (id, focus) => (leave(), pick(id, focus));
|
|
210
|
+
const host = (pane) => (view) => {
|
|
211
|
+
show(pane);
|
|
212
|
+
view.classList.add('embedded');
|
|
213
|
+
const back = el(`<button class="ghost sm sx-back">${icon('back')}Back</button>`);
|
|
214
|
+
back.onclick = () => show(pane);
|
|
215
|
+
$('header', view).prepend(back);
|
|
216
|
+
$('.close', view).onclick = close;
|
|
217
|
+
$('.sx-main > header', node).hidden = true;
|
|
218
|
+
$$('.sx-pane', node).forEach((s) => (s.hidden = true));
|
|
219
|
+
main.append(view);
|
|
220
|
+
main.scrollTop = 0;
|
|
221
|
+
return close;
|
|
222
|
+
};
|
|
223
|
+
$('.sx-nav', node).onclick = (e) => {
|
|
224
|
+
const b = e.target.closest('[data-pane]');
|
|
225
|
+
if (b) show(b.dataset.pane);
|
|
226
|
+
};
|
|
227
|
+
$('.sx-nav', node).onkeydown = (e) => {
|
|
228
|
+
const step = { ArrowDown: 1, ArrowUp: -1 }[e.key];
|
|
229
|
+
if (!step) return;
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
const i = SETTINGS_PANES.findIndex((p) => p.id === settingsPane);
|
|
232
|
+
show(SETTINGS_PANES[(i + step + SETTINGS_PANES.length) % SETTINGS_PANES.length].id, true);
|
|
233
|
+
};
|
|
234
|
+
show(settingsPane);
|
|
235
|
+
node.addEventListener('change', async (e) => {
|
|
236
|
+
const { name, value } = e.target;
|
|
237
|
+
if (!name || e.target.closest('.embedded')) return; // a nested view's own form
|
|
238
|
+
if (name === 'terminal') $('.custom-term', node).hidden = value !== 'custom';
|
|
239
|
+
await tryApi(async () => {
|
|
240
|
+
await api('/config', { method: 'PATCH', body: { defaults: { [name]: value } } });
|
|
241
|
+
await loadConfig();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// The switch shows wisp's own on/off preference; the browser permission can only be
|
|
245
|
+
// granted from here, never revoked, so it surfaces as a hint when it blocks the switch.
|
|
246
|
+
const sw = $('#notif', node);
|
|
247
|
+
const paint = () => {
|
|
248
|
+
const perm = 'Notification' in window ? Notification.permission : 'unsupported';
|
|
249
|
+
const on = notifyOn();
|
|
250
|
+
sw.setAttribute('aria-checked', on);
|
|
251
|
+
sw.disabled = perm === 'denied' || perm === 'unsupported';
|
|
252
|
+
$('.notif-hint', node).textContent =
|
|
253
|
+
perm === 'denied' ? 'Blocked — allow notifications for this site in your browser' : perm === 'unsupported' ? 'Not supported here' : on ? 'On' : 'Off';
|
|
254
|
+
};
|
|
255
|
+
sw.onclick = async () => {
|
|
256
|
+
let want = !notifyOn();
|
|
257
|
+
if (want && Notification.permission !== 'granted') want = (await Notification.requestPermission()) === 'granted';
|
|
258
|
+
await tryApi(async () => {
|
|
259
|
+
await api('/config', { method: 'PATCH', body: { defaults: { notify: want ? 'on' : 'off' } } });
|
|
260
|
+
await loadConfig();
|
|
261
|
+
});
|
|
262
|
+
paint();
|
|
263
|
+
};
|
|
264
|
+
paint();
|
|
265
|
+
// Agent, model, status, timings and scrubbed errors only; never prompts, paths or code.
|
|
266
|
+
const tele = $('#tele', node);
|
|
267
|
+
const paintTele = () => {
|
|
268
|
+
const t = S.config.telemetry || {};
|
|
269
|
+
tele.setAttribute('aria-checked', !!t.enabled);
|
|
270
|
+
tele.disabled = !t.available;
|
|
271
|
+
$('.tele-hint', node).textContent = !t.available ? 'Off (DO_NOT_TRACK, WISP_TELEMETRY=0 or CI)' : 'No prompts, paths or code';
|
|
272
|
+
};
|
|
273
|
+
tele.onclick = async () => {
|
|
274
|
+
await tryApi(async () => {
|
|
275
|
+
await api('/config', { method: 'PATCH', body: { defaults: { telemetry: S.config.telemetry?.enabled ? 'off' : 'on' } } });
|
|
276
|
+
await loadConfig();
|
|
277
|
+
});
|
|
278
|
+
paintTele();
|
|
279
|
+
};
|
|
280
|
+
paintTele();
|
|
281
|
+
// Memory: one line here (on/off and a summary); the lists and retros live in their own view.
|
|
282
|
+
const learn = $('#learn', node);
|
|
283
|
+
const paintLearn = async () => {
|
|
284
|
+
learn.setAttribute('aria-checked', S.config.defaults.learn !== 'off');
|
|
285
|
+
$('.learn-hint', node).textContent = memorySummary(await api('/preferences').catch(() => null));
|
|
286
|
+
};
|
|
287
|
+
learn.onclick = async () => {
|
|
288
|
+
await tryApi(() => setLearning(S.config.defaults.learn === 'off'));
|
|
289
|
+
paintLearn();
|
|
290
|
+
};
|
|
291
|
+
bus.on('preferences', paintLearn, { node });
|
|
292
|
+
// One line each; their lists and charts live in their own views.
|
|
293
|
+
const wt = $('#wtDefault', node);
|
|
294
|
+
const paintWt = () => wt.setAttribute('aria-checked', S.config.defaults.worktree !== 'off');
|
|
295
|
+
wt.onclick = async () => {
|
|
296
|
+
const saved = await tryApi(async () => {
|
|
297
|
+
await api('/config', { method: 'PATCH', body: { defaults: { worktree: S.config.defaults.worktree === 'off' ? 'on' : 'off' } } });
|
|
298
|
+
await loadConfig();
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
if (!saved) return;
|
|
302
|
+
paintWt();
|
|
303
|
+
renderLauncher();
|
|
304
|
+
};
|
|
305
|
+
paintWt();
|
|
306
|
+
// Auto: goal, exploring and allowed harnesses here; the full record in its own view.
|
|
307
|
+
const paintAuto = async () => {
|
|
308
|
+
const r = await api('/router').catch(() => null);
|
|
309
|
+
if (!r || !node.isConnected) return;
|
|
310
|
+
$$('.ro-goal button', node).forEach((b) => b.classList.toggle('on', b.dataset.goal === r.goal));
|
|
311
|
+
$('#roExplore', node).setAttribute('aria-checked', r.settings.explore);
|
|
312
|
+
const allowed = r.settings.allowed || [];
|
|
313
|
+
$('.ro-allowed', node).innerHTML = S.agents
|
|
314
|
+
.filter((a) => a.available)
|
|
315
|
+
.map((a) => `<button class="chip-tog${allowed.includes(a.id) ? ' on' : ''}" data-id="${esc(a.id)}" aria-pressed="${allowed.includes(a.id)}" title="${esc(a.name)}"><span class="model-logo">${harnessLogo(a.id)}</span>${esc(a.name)}</button>`)
|
|
316
|
+
.join('');
|
|
317
|
+
$('.ro-hint', node).textContent = r.learned
|
|
318
|
+
? `${r.learned} task${r.learned === 1 ? '' : 's'} learned from · ${r.arms.filter((a) => a.state !== 'gone').length} harness · model pairs`
|
|
319
|
+
: `Nothing of yours yet · starting from wisp-bench (${r.priors.version})`;
|
|
320
|
+
};
|
|
321
|
+
const patchAuto = async (body) => {
|
|
322
|
+
try {
|
|
323
|
+
await api('/router', { method: 'PATCH', body });
|
|
324
|
+
await loadConfig();
|
|
325
|
+
renderLauncher();
|
|
326
|
+
} catch (err) {
|
|
327
|
+
toastError(err);
|
|
328
|
+
}
|
|
329
|
+
paintAuto();
|
|
330
|
+
};
|
|
331
|
+
$('.ro-goal', node).onclick = (e) => {
|
|
332
|
+
const b = e.target.closest('[data-goal]');
|
|
333
|
+
if (b) {
|
|
334
|
+
$$('.ro-goal button', node).forEach((x) => x.classList.toggle('on', x === b));
|
|
335
|
+
patchAuto({ goal: b.dataset.goal });
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
$('#roExplore', node).onclick = (e) => {
|
|
339
|
+
const on = e.currentTarget.getAttribute('aria-checked') !== 'true';
|
|
340
|
+
e.currentTarget.setAttribute('aria-checked', on);
|
|
341
|
+
patchAuto({ explore: on });
|
|
342
|
+
};
|
|
343
|
+
$('.ro-allowed', node).onclick = (e) => {
|
|
344
|
+
const b = e.target.closest('[data-id]');
|
|
345
|
+
if (!b) return;
|
|
346
|
+
b.classList.toggle('on');
|
|
347
|
+
b.setAttribute('aria-pressed', b.classList.contains('on'));
|
|
348
|
+
patchAuto({ allowed: $$('.ro-allowed .on', node).map((x) => x.dataset.id) });
|
|
349
|
+
};
|
|
350
|
+
bus.on('router', paintAuto, { node });
|
|
351
|
+
|
|
352
|
+
// The one-line summaries; painted again on the way back from a view that may have changed them.
|
|
353
|
+
const paintHints = () => {
|
|
354
|
+
const r = S.config.recipes?.length || 0;
|
|
355
|
+
const all = S.config.triggers || [];
|
|
356
|
+
const t = all.filter((x) => x.enabled).length;
|
|
357
|
+
$('.recipe-hint', node).textContent = r ? `${r} saved prompt${r === 1 ? '' : 's'}` : 'None yet · save a prompt you run often';
|
|
358
|
+
$('.trigger-hint', node).textContent = all.length ? `${t} of ${all.length} on` : 'None yet · run a recipe on a schedule, CI failure or new issue';
|
|
359
|
+
api('/usage?days=7')
|
|
360
|
+
.then((u) => {
|
|
361
|
+
const x = u.total;
|
|
362
|
+
$('.sx-usage', node).innerHTML = `
|
|
363
|
+
<div><span>Spend</span><b>${x.priced ? money(x.cost) : '—'}</b></div>
|
|
364
|
+
<div><span>Turns</span><b>${x.turns}</b></div>
|
|
365
|
+
<div><span>Worked</span><b>${pct(x.successRate)}</b></div>
|
|
366
|
+
<div><span>Questions</span><b>${x.questions}</b></div>`;
|
|
367
|
+
})
|
|
368
|
+
.catch(() => ($('.sx-usage', node).innerHTML = ''));
|
|
369
|
+
$('.machines-hint', node).textContent = machinesSummary();
|
|
370
|
+
api('/remote').then((x) => ($('.phone-hint', node).textContent = phoneSummary(x) === 'Off' ? 'ntfy or Telegram: answer questions from your phone' : phoneSummary(x))).catch(() => {});
|
|
371
|
+
paintLearn();
|
|
372
|
+
paintAuto();
|
|
373
|
+
};
|
|
374
|
+
paintHints();
|
|
375
|
+
bus.on('machines', () => ($('.machines-hint', node).textContent = machinesSummary()), { node });
|
|
376
|
+
$('#openRouter', node).onclick = () => openRouter({ host: host('auto') });
|
|
377
|
+
$('#openMemory', node).onclick = () => openMemory({ host: host('memory') });
|
|
378
|
+
$$('[data-auto]', node).forEach((b) => (b.onclick = () => openAutomations({ tab: b.dataset.auto, host: host('automations') })));
|
|
379
|
+
$('#openUsage', node).onclick = () => openUsage({ days: 7, host: host('usage') });
|
|
380
|
+
$('#openPhone', node).onclick = () => openPhone({ host: host('notifications') });
|
|
381
|
+
$('#openMachines', node).onclick = () => openMachines({ host: host('machines') });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Say once that anonymous stats are on, with a way out.
|
|
385
|
+
export function telemetryNotice() {
|
|
386
|
+
if (!S.config.telemetry?.enabled || S.config.defaults.telemetryNotice === 'seen') return;
|
|
387
|
+
S.config.defaults.telemetryNotice = 'seen';
|
|
388
|
+
api('/config', { method: 'PATCH', body: { defaults: { telemetryNotice: 'seen' } } }).catch(() => {});
|
|
389
|
+
toast('wisp sends anonymous usage stats and error reports. No prompts, paths or code.', { timeout: 10_000, action: { label: 'Settings', run: () => openSettings({ pane: 'privacy' }) } });
|
|
390
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// No harness on this computer yet: the launcher offers to install opencode (its free models need
|
|
2
|
+
// no account), so a first run goes from `npx wisp-agents` to a working task in one click.
|
|
3
|
+
// Settings → Agents uses the same install.
|
|
4
|
+
|
|
5
|
+
import { esc } from '../md.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import * as bus from '../core/bus.js';
|
|
8
|
+
import { $, icon } from '../core/dom.js';
|
|
9
|
+
import { launch, S } from '../core/state.js';
|
|
10
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
11
|
+
import { harnessLogo } from './runners.js';
|
|
12
|
+
|
|
13
|
+
// What wisp can install itself (server/services/install.js).
|
|
14
|
+
export const INSTALLABLE = new Set(['opencode']);
|
|
15
|
+
|
|
16
|
+
let installing = null; // the running install's id
|
|
17
|
+
let failed = ''; // the last install's error, shown in the strip until the next try
|
|
18
|
+
|
|
19
|
+
export const isInstalling = (id) => installing === id;
|
|
20
|
+
|
|
21
|
+
// Installs, then swaps in the new harness list. One at a time; the server waits for npm.
|
|
22
|
+
export async function installHarness(id) {
|
|
23
|
+
if (installing) return;
|
|
24
|
+
installing = id;
|
|
25
|
+
failed = '';
|
|
26
|
+
bus.emit('install');
|
|
27
|
+
try {
|
|
28
|
+
S.agents = await api('/agents/install', { method: 'POST', body: { id } });
|
|
29
|
+
$('#launcher')?.placesChanged?.();
|
|
30
|
+
toast(`<b>${esc(S.agents.find((a) => a.id === id)?.name || id)}</b> is ready: free models, no account needed. Type a task and press Enter.`, { timeout: 7000 });
|
|
31
|
+
$('#prompt')?.focus();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
failed = err.message;
|
|
34
|
+
toastError(err);
|
|
35
|
+
} finally {
|
|
36
|
+
installing = null;
|
|
37
|
+
bus.emit('agents');
|
|
38
|
+
bus.emit('install');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The launch would have nowhere to run: this computer is picked and has no harness. Unknown
|
|
43
|
+
// until the first detection is back (an empty list), so a slow start doesn't flash the strip.
|
|
44
|
+
export const needsSetup = () => launch.where.includes('') && S.agents.length > 0 && !S.agents.some((a) => a.available);
|
|
45
|
+
|
|
46
|
+
export function renderSetup() {
|
|
47
|
+
const box = $('#launcher .l-setup');
|
|
48
|
+
if (!box) return;
|
|
49
|
+
box.hidden = !needsSetup();
|
|
50
|
+
if (box.hidden) return;
|
|
51
|
+
const busy = isInstalling('opencode');
|
|
52
|
+
box.classList.toggle('busy', busy);
|
|
53
|
+
const line = busy
|
|
54
|
+
? 'Installing opencode into ~/.wisp/tools… usually under a minute'
|
|
55
|
+
: failed
|
|
56
|
+
? `Couldn’t install it: ${failed}`
|
|
57
|
+
: 'No coding agent here yet. opencode runs free models, no account needed.';
|
|
58
|
+
box.innerHTML = `
|
|
59
|
+
<span class="model-logo">${harnessLogo('opencode')}</span>
|
|
60
|
+
<span class="ls-text ${failed && !busy ? 'bad' : ''}" title="${esc(line)}">${esc(line)}</span>
|
|
61
|
+
<button class="icon-btn ls-recheck" title="Installed one yourself? Look again" ${busy ? 'disabled' : ''}>${icon('replay')}</button>
|
|
62
|
+
<button class="primary sm ls-install" ${busy ? 'disabled' : ''}>${busy ? '<span class="ls-spin"></span>Installing…' : failed ? 'Try again' : 'Install opencode'}</button>`;
|
|
63
|
+
$('.ls-install', box).onclick = () => {
|
|
64
|
+
installHarness('opencode');
|
|
65
|
+
};
|
|
66
|
+
$('.ls-recheck', box).onclick = async (e) => {
|
|
67
|
+
const btn = e.currentTarget;
|
|
68
|
+
btn.disabled = true;
|
|
69
|
+
btn.classList.add('busy');
|
|
70
|
+
try {
|
|
71
|
+
S.agents = await api('/agents/recheck', { method: 'POST', body: {} });
|
|
72
|
+
$('#launcher')?.placesChanged?.();
|
|
73
|
+
bus.emit('agents');
|
|
74
|
+
if (needsSetup()) toast('Still no harness found. Install opencode here, or Claude Code, Codex, Cursor… yourself.');
|
|
75
|
+
} catch (err) {
|
|
76
|
+
toastError(err);
|
|
77
|
+
} finally {
|
|
78
|
+
btn.disabled = false;
|
|
79
|
+
btn.classList.remove('busy');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Enter with nowhere to run: point at the strip instead of sending a launch that can only fail.
|
|
85
|
+
export function nudgeSetup() {
|
|
86
|
+
const box = $('#launcher .l-setup');
|
|
87
|
+
if (!box) return;
|
|
88
|
+
box.classList.remove('nudge');
|
|
89
|
+
void box.offsetWidth; // restart the animation
|
|
90
|
+
box.classList.add('nudge'); // focus stays in the box: a second Enter mustn't start an install
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
bus.on('agents', renderSetup);
|
|
94
|
+
bus.on('install', renderSetup);
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Ship it: worktree → commit → PR → checks → merge.
|
|
2
|
+
|
|
3
|
+
import { esc } from '../md.js';
|
|
4
|
+
import { api } from '../core/api.js';
|
|
5
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
6
|
+
import { S } from '../core/state.js';
|
|
7
|
+
import { overlay } from '../ui/overlay.js';
|
|
8
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
9
|
+
|
|
10
|
+
const prTone = (pr) =>
|
|
11
|
+
pr.state === 'MERGED' ? 'merged' : pr.state === 'CLOSED' ? 'closed' : pr.checks?.fail ? 'fail' : pr.checks?.pending ? 'pending' : pr.review === 'CHANGES_REQUESTED' ? 'fail' : 'pass';
|
|
12
|
+
const REVIEW = { APPROVED: 'Approved', CHANGES_REQUESTED: 'Changes requested', REVIEW_REQUIRED: 'Review required' };
|
|
13
|
+
function prLine(pr) {
|
|
14
|
+
const c = pr.checks || {};
|
|
15
|
+
const checks = c.fail ? `${c.fail} check${c.fail > 1 ? 's' : ''} failing` : c.pending ? `${c.pending} check${c.pending > 1 ? 's' : ''} running` : c.pass ? `${c.pass} check${c.pass > 1 ? 's' : ''} passed` : 'No checks';
|
|
16
|
+
const state = pr.state === 'MERGED' ? 'Merged' : pr.state === 'CLOSED' ? 'Closed' : pr.draft ? 'Draft' : 'Open';
|
|
17
|
+
return [state, pr.state === 'OPEN' ? checks : '', pr.state === 'OPEN' ? REVIEW[pr.review] : ''].filter(Boolean).join(' · ');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Ship it only makes sense on a finished worktree task with something to ship: uncommitted
|
|
21
|
+
// changes, or commits its open PR (or a new one) doesn't have yet. Unchecked yet: assume it might.
|
|
22
|
+
export function shippable(s) {
|
|
23
|
+
if (!s?.branch || s.agent === 'wisp' || s.state === 'running' || s.state === 'waiting' || s.pr?.state === 'MERGED') return false;
|
|
24
|
+
const t = s.toShip;
|
|
25
|
+
return !t || !!(t.dirty || (s.pr?.state === 'OPEN' ? t.unpushed : t.ahead));
|
|
26
|
+
}
|
|
27
|
+
export const shipLabel = (s) => (s.pr?.state === 'OPEN' ? `Push to PR #${s.pr.number}` : 'Ship it…');
|
|
28
|
+
|
|
29
|
+
export const prPill = (pr) => `<span class="pill pr pr-${prTone(pr)}" title="PR #${pr.number}: ${esc(prLine(pr))}"><i></i>#${pr.number}</span>`;
|
|
30
|
+
|
|
31
|
+
// The PR row under a card's bar; reads the live summary, so polled check results show up.
|
|
32
|
+
export function renderPrStrip(id) {
|
|
33
|
+
const n = S.nodes.get(id);
|
|
34
|
+
const node = n?.body && $('.pr-strip', n.body);
|
|
35
|
+
const s = S.sessions.get(id);
|
|
36
|
+
if (!node || !s) return;
|
|
37
|
+
const pr = s.pr;
|
|
38
|
+
$$('[data-a="ship"]', n.body).forEach((b) => {
|
|
39
|
+
b.hidden = !shippable(s);
|
|
40
|
+
$('span', b).textContent = pr?.state === 'OPEN' ? `Push to #${pr.number}` : pr ? 'Ship again' : 'Ship it';
|
|
41
|
+
});
|
|
42
|
+
node.hidden = !pr;
|
|
43
|
+
if (!pr) return;
|
|
44
|
+
const tone = prTone(pr);
|
|
45
|
+
node.className = `pr-strip pr-${tone}`;
|
|
46
|
+
node.innerHTML = `
|
|
47
|
+
<span class="pr-dot"></span>
|
|
48
|
+
<a href="${esc(pr.url)}" target="_blank" class="pr-num">PR #${pr.number}</a>
|
|
49
|
+
<span class="pr-line">${esc(prLine(pr))}</span>
|
|
50
|
+
<span class="grow"></span>
|
|
51
|
+
${pr.state === 'OPEN' ? `<button class="ghost xs" data-pr="refresh" title="Check now (wisp also checks every minute)">${icon('replay')}</button>` : ''}
|
|
52
|
+
<a class="ghost xs" href="${esc(pr.url)}" target="_blank">${icon('external')}<span>GitHub</span></a>
|
|
53
|
+
${pr.state === 'OPEN' ? `<button class="ghost xs pr-merge" data-pr="merge" title="Squash-merge, then fast-forward your local ${esc(s.branch ? 'base branch' : 'checkout')}">${icon('check')}<span>Merge</span></button>` : ''}`;
|
|
54
|
+
node.onclick = (e) => {
|
|
55
|
+
const a = e.target.closest('[data-pr]')?.dataset.pr;
|
|
56
|
+
if (a === 'refresh') api(`/sessions/${id}/pr`, { method: 'POST', body: {} }).catch(toastError);
|
|
57
|
+
if (a === 'merge') mergePr(id);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function shipDialog(id) {
|
|
62
|
+
const s = S.sessions.get(id);
|
|
63
|
+
if (!s) return;
|
|
64
|
+
if (s.state === 'running' || s.state === 'waiting') return toast('Still working: let it finish first.');
|
|
65
|
+
let info;
|
|
66
|
+
try {
|
|
67
|
+
info = await api(`/sessions/${id}/ship`);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return toastError(err);
|
|
70
|
+
}
|
|
71
|
+
const open = info.pr?.state === 'OPEN';
|
|
72
|
+
const nothing = !info.dirty && !info.ahead;
|
|
73
|
+
const node = el(`
|
|
74
|
+
<div class="modal ship-modal">
|
|
75
|
+
<header><h2>${open ? `Push to PR #${info.pr.number}` : 'Ship it'}</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
76
|
+
<p class="ship-route">${icon('branch')}<code>${esc(info.branch)}</code>${icon('arrow')}<code>${esc(info.remote || 'no remote')}/${esc(info.base)}</code>
|
|
77
|
+
<span class="muted">${info.dirty ? `${info.dirty} uncommitted change${info.dirty > 1 ? 's' : ''}` : 'Nothing uncommitted'} · ${info.ahead} commit${info.ahead === 1 ? '' : 's'} on the branch</span></p>
|
|
78
|
+
${info.dirty ? `<label class="ship-field"><span>Commit message${open ? '' : ' · PR title'}</span><input class="input" name="title" spellcheck="false" maxlength="100"></label>` : ''}
|
|
79
|
+
${open ? '' : `<label class="ship-field"><span>Pull request description</span><textarea class="textarea mono" name="body" rows="9" spellcheck="false"></textarea></label>
|
|
80
|
+
<label class="check ship-check"><input type="checkbox" name="draft"> Open as a draft</label>`}
|
|
81
|
+
${!info.remote ? '<p class="hint bad">This repo has no <code>origin</code> remote, so there is nowhere to push.</p>' : nothing ? '<p class="hint">Nothing to ship yet: the branch has no changes.</p>' : ''}
|
|
82
|
+
<div class="ship-foot"><span class="muted xs">Runs <code>git commit</code>, <code>git push</code>${open ? '' : ' and <code>gh pr create</code>'} in the worktree.</span><button class="primary sm" data-a="go" ${nothing || !info.remote ? 'disabled' : ''}>${icon('send')}${open ? 'Commit & push' : 'Commit, push & open PR'}</button></div>
|
|
83
|
+
</div>`);
|
|
84
|
+
const close = overlay(node);
|
|
85
|
+
$('.close', node).onclick = close;
|
|
86
|
+
const title = $('[name="title"]', node);
|
|
87
|
+
const body = $('[name="body"]', node);
|
|
88
|
+
if (title) title.value = info.title;
|
|
89
|
+
if (body) body.value = info.body;
|
|
90
|
+
const go = async () => {
|
|
91
|
+
const btn = $('[data-a="go"]', node);
|
|
92
|
+
btn.disabled = true;
|
|
93
|
+
btn.lastChild.textContent = 'Shipping…';
|
|
94
|
+
try {
|
|
95
|
+
const pr = await api(`/sessions/${id}/ship`, { method: 'POST', body: { title: title?.value || info.title, body: body?.value ?? info.body, draft: !!$('[name="draft"]', node)?.checked } });
|
|
96
|
+
close();
|
|
97
|
+
toast(`${icon('branch')} ${open ? 'Pushed to' : 'Opened'} <b>PR #${pr.number}</b>`, { action: { label: 'Open', run: () => window.open(pr.url, '_blank') }, timeout: 8000 });
|
|
98
|
+
} catch (err) {
|
|
99
|
+
btn.disabled = false;
|
|
100
|
+
btn.lastChild.textContent = open ? 'Commit & push' : 'Commit, push & open PR';
|
|
101
|
+
toastError(err, { timeout: 10_000 });
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
$('[data-a="go"]', node).onclick = go;
|
|
105
|
+
requestAnimationFrame(() => (title || $('[data-a="go"]', node)).focus());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function mergePr(id) {
|
|
109
|
+
const s = S.sessions.get(id);
|
|
110
|
+
const pr = s?.pr;
|
|
111
|
+
if (!pr || pr.state !== 'OPEN') return;
|
|
112
|
+
const c = pr.checks || {};
|
|
113
|
+
const warn = c.fail ? `\n\n⚠ ${c.fail} check${c.fail > 1 ? 's are' : ' is'} failing.` : c.pending ? `\n\n⚠ ${c.pending} check${c.pending > 1 ? 's are' : ' is'} still running.` : '';
|
|
114
|
+
if (!confirm(`Squash-merge PR #${pr.number}${pr.draft ? ' (marks it ready first)' : ''}, then fast-forward your local base branch and remove the worktree?${warn}`)) return;
|
|
115
|
+
toast(`Merging PR #${pr.number}…`);
|
|
116
|
+
try {
|
|
117
|
+
const r = await api(`/sessions/${id}/merge`, { method: 'POST', body: {} });
|
|
118
|
+
toast(`${icon('check')} Merged <b>PR #${r.pr.number}</b>. ${esc(r.sync.text)}`, { tone: r.sync.ok ? '' : 'bad', timeout: 10_000 });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
toastError(err, { timeout: 12_000 });
|
|
121
|
+
}
|
|
122
|
+
}
|