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,400 @@
|
|
|
1
|
+
// The feed: every session as a card, grouped into Needs you, Working, Done and Archived. Cards
|
|
2
|
+
// expand in place; nothing navigates away.
|
|
3
|
+
|
|
4
|
+
import { esc } from '../md.js';
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
7
|
+
import { ago, STATUS_LABEL } from '../core/format.js';
|
|
8
|
+
import { S, save, saveOpen } from '../core/state.js';
|
|
9
|
+
import { taskDone, taskMenu, toggleDone } from './actions.js';
|
|
10
|
+
import { compareView } from './compare.js';
|
|
11
|
+
import { renderLaunchChips } from './launcher.js';
|
|
12
|
+
import { machinePill } from './places.js';
|
|
13
|
+
import { renderProjects } from './projects.js';
|
|
14
|
+
import { agentName, effortLabel, harnessLogo, modelBadge } from './runners.js';
|
|
15
|
+
import { scrollByPx, stage, topInset } from './scroll.js';
|
|
16
|
+
import { prPill } from './ship.js';
|
|
17
|
+
import { teamStack } from './team.js';
|
|
18
|
+
import { renderBody } from './turns.js';
|
|
19
|
+
|
|
20
|
+
function visible(list) {
|
|
21
|
+
return S.filter === 'all' ? list : list.filter((s) => s.projectId === S.filter);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Compare runs (one launch on several harnesses, sharing a groupId) are one task in the feed
|
|
25
|
+
// while two or more of them are on the same side of archived: a card with a row per run.
|
|
26
|
+
// Keeping one archives the rest, and what's left is an ordinary card again.
|
|
27
|
+
function runsOf(s) {
|
|
28
|
+
if (!s.groupId) return [s];
|
|
29
|
+
const runs = [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived === !s.archived);
|
|
30
|
+
return runs.length > 1 ? runs.sort((a, b) => a.createdAt - b.createdAt) : [s];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The task as a whole: asking if any run asks, working while any works, done once all are.
|
|
34
|
+
function taskOf(s) {
|
|
35
|
+
const runs = runsOf(s);
|
|
36
|
+
if (runs.length < 2) return s;
|
|
37
|
+
const state = runs.some((r) => r.state === 'waiting') ? 'waiting' : runs.some((r) => r.state === 'running') ? 'running' : runs.every((r) => r.state === 'done') ? 'done' : 'review';
|
|
38
|
+
return {
|
|
39
|
+
...runs[0],
|
|
40
|
+
key: `g:${s.groupId}${s.archived ? ':a' : ''}`,
|
|
41
|
+
runs,
|
|
42
|
+
state,
|
|
43
|
+
unread: runs.some((r) => r.unread),
|
|
44
|
+
createdAt: Math.min(...runs.map((r) => r.createdAt)),
|
|
45
|
+
updatedAt: Math.max(...runs.map((r) => r.updatedAt)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function groups() {
|
|
50
|
+
const every = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const s of visible([...S.sessions.values()])) {
|
|
53
|
+
const t = taskOf(s);
|
|
54
|
+
if (t.runs && seen.has(t.key)) continue;
|
|
55
|
+
if (t.runs) seen.add(t.key);
|
|
56
|
+
every.push(t);
|
|
57
|
+
}
|
|
58
|
+
const all = every.filter((s) => !s.archived);
|
|
59
|
+
const needs = all
|
|
60
|
+
.filter((s) => s.state === 'review' || s.state === 'waiting')
|
|
61
|
+
.sort((a, b) => (b.state === 'waiting') - (a.state === 'waiting') || b.updatedAt - a.updatedAt);
|
|
62
|
+
// Working items keep launch order so the feed doesn't reshuffle on every tool call.
|
|
63
|
+
const working = all.filter((s) => s.state === 'running').sort((a, b) => b.createdAt - a.createdAt);
|
|
64
|
+
const done = all.filter((s) => s.state === 'done').sort((a, b) => b.updatedAt - a.updatedAt);
|
|
65
|
+
const archived = every.filter((s) => s.archived).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
66
|
+
return { needs, working, done, archived };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderFeed() {
|
|
70
|
+
const { needs, working, done, archived } = groups();
|
|
71
|
+
const place = (sec, list, show = true) => {
|
|
72
|
+
const box = $('.f-list', sec);
|
|
73
|
+
sec.hidden = !list.length;
|
|
74
|
+
$('h4 .n', sec).textContent = list.length || '';
|
|
75
|
+
const want = show ? list : [];
|
|
76
|
+
want.forEach((s, i) => {
|
|
77
|
+
const { card } = s.runs ? taskItem(s) : item(s);
|
|
78
|
+
// Only touch the DOM when the order actually changed: moving a node
|
|
79
|
+
// would blur a composer the user is typing in.
|
|
80
|
+
if (box.children[i] !== card) box.insertBefore(card, box.children[i] || null);
|
|
81
|
+
});
|
|
82
|
+
while (box.children.length > want.length) box.lastElementChild.remove();
|
|
83
|
+
};
|
|
84
|
+
place($('#secNeeds'), needs);
|
|
85
|
+
place($('#secWorking'), working);
|
|
86
|
+
place($('#secDone'), done.slice(0, 60), S.showDone);
|
|
87
|
+
$('#secDone').classList.toggle('collapsed', !S.showDone);
|
|
88
|
+
place($('#secArchived'), archived.slice(0, 60), S.showArchived);
|
|
89
|
+
$('#secArchived').classList.toggle('collapsed', !S.showArchived);
|
|
90
|
+
|
|
91
|
+
// Drop nodes for sessions that are gone or filtered out of the feed entirely.
|
|
92
|
+
const tasks = [...needs, ...working, ...done, ...archived];
|
|
93
|
+
const shown = new Set(tasks.flatMap((s) => (s.runs || [s]).map((r) => r.id)));
|
|
94
|
+
for (const [id, n] of S.nodes) if (!shown.has(id)) (n.card.remove(), S.nodes.delete(id), S.full.delete(id));
|
|
95
|
+
const keys = new Set(tasks.map((t) => t.key));
|
|
96
|
+
for (const [key, g] of S.tasks) if (!keys.has(key)) (g.card.remove(), S.tasks.delete(key));
|
|
97
|
+
|
|
98
|
+
const empty = !needs.length && !working.length && !done.length && !archived.length;
|
|
99
|
+
$('#feedEmpty').hidden = !empty;
|
|
100
|
+
if (empty)
|
|
101
|
+
$('#feedEmpty').innerHTML = `<div class="orb big"><i></i><i></i><i></i></div><p>${S.filter === 'all' ? 'Nothing yet. Describe a task above and send it off.' : 'Nothing in this project yet.'}</p>`;
|
|
102
|
+
renderFeedHead(needs, working);
|
|
103
|
+
|
|
104
|
+
const n = [...S.sessions.values()].filter((s) => (s.state === 'review' && s.unread) || s.state === 'waiting').length;
|
|
105
|
+
document.title = n ? `(${n}) wisp` : 'wisp';
|
|
106
|
+
renderProjects();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A new project filter swaps the whole list; staying at the old offset would drop the user
|
|
110
|
+
// somewhere arbitrary in it, so bring the top of the feed back into view.
|
|
111
|
+
export function feedToTop() {
|
|
112
|
+
const top = $('#feed').getBoundingClientRect().top;
|
|
113
|
+
if (top < topInset()) scrollByPx(top - topInset());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderFeedHead(needs, working) {
|
|
117
|
+
const parts = [];
|
|
118
|
+
if (working.length) parts.push(`<span class="hs working"><i></i>${working.length} working</span>`);
|
|
119
|
+
if (needs.length) parts.push(`<span class="hs needs"><i></i>${needs.length} need${needs.length === 1 ? 's' : ''} you</span>`);
|
|
120
|
+
// Group headings already carry the counts; only say something when the feed is calm.
|
|
121
|
+
$('#feedHead').innerHTML = parts.length ? '' : '<span class="hs calm"><i></i>Nothing needs you</span>';
|
|
122
|
+
$('#feedHead').hidden = !!parts.length;
|
|
123
|
+
placeCollapseAll();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// "Collapse all" floats over the page instead of living in the flow: showing it
|
|
127
|
+
// never shifts the feed, and it stays reachable deep inside a long open card.
|
|
128
|
+
const collapseAllBtn = el(`<button class="collapse-all" title="Collapse all (C) · Esc collapses the focused one"><span></span><kbd>C</kbd></button>`);
|
|
129
|
+
document.body.append(collapseAllBtn);
|
|
130
|
+
|
|
131
|
+
// Collapsing shrinks everything above the reader, which would leave them staring
|
|
132
|
+
// at unrelated cards far down the feed. Keep the card they were reading in view.
|
|
133
|
+
export const collapseAll = () => {
|
|
134
|
+
const anchor = readingCard();
|
|
135
|
+
[...S.open].forEach((id) => collapse(id));
|
|
136
|
+
if (!anchor) return;
|
|
137
|
+
setFocus(anchor, { scroll: false });
|
|
138
|
+
const card = S.nodes.get(anchor)?.card;
|
|
139
|
+
if (card?.isConnected) card.scrollIntoView({ block: 'center' });
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// The open card covering the most of the viewport; the focused one breaks ties.
|
|
143
|
+
function readingCard() {
|
|
144
|
+
let best = null;
|
|
145
|
+
let bestArea = 0;
|
|
146
|
+
for (const id of S.open) {
|
|
147
|
+
const card = S.nodes.get(id)?.card;
|
|
148
|
+
if (!card?.isConnected) continue;
|
|
149
|
+
const r = card.getBoundingClientRect();
|
|
150
|
+
const seen = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0) + (id === S.focus ? 1 : 0);
|
|
151
|
+
if (seen > bestArea) (best = id), (bestArea = seen);
|
|
152
|
+
}
|
|
153
|
+
return best || (S.open.has(S.focus) ? S.focus : null);
|
|
154
|
+
}
|
|
155
|
+
collapseAllBtn.addEventListener('click', collapseAll);
|
|
156
|
+
function placeCollapseAll() {
|
|
157
|
+
const open = [...S.open].filter((id) => S.nodes.get(id)?.card.isConnected).length;
|
|
158
|
+
collapseAllBtn.classList.toggle('on', open > 0);
|
|
159
|
+
if (open) collapseAllBtn.firstChild.textContent = open > 1 ? `Collapse all · ${open}` : 'Collapse';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Get (or create) the card for a summary and refresh its header.
|
|
163
|
+
function item(s) {
|
|
164
|
+
let n = S.nodes.get(s.id);
|
|
165
|
+
if (!n) {
|
|
166
|
+
const card = el(`<article class="item" data-id="${s.id}"><div class="it-head" role="button" tabindex="-1"></div></article>`);
|
|
167
|
+
n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
|
|
168
|
+
S.nodes.set(s.id, n);
|
|
169
|
+
n.head.addEventListener('click', (e) => {
|
|
170
|
+
if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
|
|
171
|
+
if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
|
|
172
|
+
setFocus(s.id);
|
|
173
|
+
toggle(s.id);
|
|
174
|
+
});
|
|
175
|
+
card.addEventListener('mousedown', () => {
|
|
176
|
+
if (!s.pending) setFocus(s.id, { scroll: false });
|
|
177
|
+
});
|
|
178
|
+
n.head.addEventListener('contextmenu', (e) => {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
if (s.pending) return;
|
|
181
|
+
setFocus(s.id, { scroll: false });
|
|
182
|
+
taskMenu(s.id, e);
|
|
183
|
+
});
|
|
184
|
+
if (S.open.has(s.id)) expand(s.id, { scroll: false });
|
|
185
|
+
}
|
|
186
|
+
renderHead(n, s);
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function renderHead(n, s) {
|
|
191
|
+
if (n.head.querySelector('[contenteditable="true"]')) return;
|
|
192
|
+
const open = S.open.has(s.id);
|
|
193
|
+
const live = s.state === 'running' || s.state === 'waiting';
|
|
194
|
+
const pill =
|
|
195
|
+
s.state === 'waiting'
|
|
196
|
+
? `<span class="pill st-waiting"><i></i>Asking</span>`
|
|
197
|
+
: s.state === 'review'
|
|
198
|
+
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
199
|
+
: '';
|
|
200
|
+
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
201
|
+
n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
202
|
+
n.head.innerHTML = `
|
|
203
|
+
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
204
|
+
<span class="it-main">
|
|
205
|
+
<span class="it-title">${esc(s.title)}</span>
|
|
206
|
+
${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
|
|
207
|
+
</span>
|
|
208
|
+
<span class="it-side">
|
|
209
|
+
${pill}
|
|
210
|
+
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
211
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
212
|
+
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
|
|
213
|
+
${s.machine ? machinePill(s.machine) : ''}
|
|
214
|
+
<span class="q-proj">${esc(s.project)}</span>
|
|
215
|
+
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
216
|
+
${s.state === 'review' && !open && !s.archived ? `<button class="it-quick" data-a="quick-done" title="Mark done (E)">${icon('done')}</button>` : ''}
|
|
217
|
+
${icon('chevron', 'chev')}
|
|
218
|
+
</span>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// One run inside a compare task: the harness and model stand in for the title the task shows.
|
|
222
|
+
function renderRunHead(n, s, { open, live, pill }) {
|
|
223
|
+
const a = S.agents.find((x) => x.id === s.agent);
|
|
224
|
+
const model = (s.model && a?.models?.find((m) => m.id === s.model)?.label) || s.model || 'Default model';
|
|
225
|
+
n.card.className = `item run c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
226
|
+
n.head.innerHTML = `
|
|
227
|
+
<span class="run-logo">${harnessLogo(s.agent)}</span>
|
|
228
|
+
<span class="it-main">
|
|
229
|
+
<span class="it-title"><b>${esc(a?.name || agentName(s.agent))}</b><span class="run-model">${esc(model)}${s.effort ? ` · ${esc(effortLabel(s.effort))}` : ''}</span></span>
|
|
230
|
+
${live ? `<span class="q-live ${s.state}">${esc(s.live)}</span>` : open ? '' : `<span class="it-sub">${esc(s.preview || '')}</span>`}
|
|
231
|
+
</span>
|
|
232
|
+
<span class="it-side">
|
|
233
|
+
${runsOf(s).some((r) => (r.machine?.id || '') !== (s.machine?.id || '')) ? `${s.machine ? machinePill(s.machine) : `<span class="q-machine" title="Runs on this computer">${icon('laptop')}<span>This computer</span></span>`}<span class="q-proj">${esc(s.project)}</span>` : ''}
|
|
234
|
+
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
235
|
+
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
236
|
+
${s.pr ? prPill(s.pr) : ''}
|
|
237
|
+
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
238
|
+
${icon('chevron', 'chev')}
|
|
239
|
+
</span>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The card for a compare task: the shared prompt on top, one row per run (each an ordinary,
|
|
243
|
+
// expandable item), and the side-by-side view one click away.
|
|
244
|
+
function taskItem(t) {
|
|
245
|
+
let g = S.tasks.get(t.key);
|
|
246
|
+
if (!g) {
|
|
247
|
+
const card = el(`<section class="task-item"><header class="task-head"></header><div class="task-runs"></div></section>`);
|
|
248
|
+
g = { card, head: $('.task-head', card), runs: $('.task-runs', card) };
|
|
249
|
+
S.tasks.set(t.key, g);
|
|
250
|
+
g.head.addEventListener('click', (e) => {
|
|
251
|
+
const a = e.target.closest('[data-a]')?.dataset.a;
|
|
252
|
+
const ids = (taskOf(S.sessions.get(g.lead) || {}).runs || []).map((r) => r.id);
|
|
253
|
+
if (a === 'compare') return compareView(g.lead);
|
|
254
|
+
if (a === 'quick-done') return taskDone(ids.filter((id) => S.sessions.get(id)?.state === 'review'));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
g.lead = t.id;
|
|
258
|
+
const finished = t.runs.filter((r) => r.state === 'review' || r.state === 'done').length;
|
|
259
|
+
const places = new Set(t.runs.map((r) => r.machine?.id || '')).size;
|
|
260
|
+
const status = t.state === 'waiting' ? 'A run is asking you' : t.state === 'running' ? `${finished} of ${t.runs.length} finished${places > 1 ? ` · on ${places} machines` : ''}` : t.state === 'done' ? 'Done' : places > 1 ? `All finished on ${places} machines` : 'All finished · pick one to keep';
|
|
261
|
+
g.card.className = `task-item c-${t.color} st-${t.state} ${t.unread ? 'unread' : ''}`;
|
|
262
|
+
g.head.innerHTML = `
|
|
263
|
+
<span class="q-dot ${t.state === 'running' ? 'live' : ''} ${t.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
264
|
+
<span class="it-main">
|
|
265
|
+
<span class="it-title">${esc(t.title)}</span>
|
|
266
|
+
<span class="it-sub">${esc(status)}</span>
|
|
267
|
+
</span>
|
|
268
|
+
<span class="it-side">
|
|
269
|
+
<button class="ghost xs" data-a="compare" title="Side by side: time, cost, changes, tests and each presentation"><b class="vs-mark">vs</b><span>Compare</span></button>
|
|
270
|
+
${places > 1 ? '' : `<span class="q-proj">${esc(t.project)}</span>`}
|
|
271
|
+
<span class="q-time">${ago(t.updatedAt)}</span>
|
|
272
|
+
${t.state === 'review' && !t.archived ? `<button class="it-quick" data-a="quick-done" title="Mark every run done">${icon('done')}</button>` : ''}
|
|
273
|
+
</span>`;
|
|
274
|
+
t.runs.forEach((r, i) => {
|
|
275
|
+
const { card } = item(r);
|
|
276
|
+
if (g.runs.children[i] !== card) g.runs.insertBefore(card, g.runs.children[i] || null);
|
|
277
|
+
});
|
|
278
|
+
while (g.runs.children.length > t.runs.length) g.runs.lastElementChild.remove();
|
|
279
|
+
return g;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function setFocus(id, { scroll = true } = {}) {
|
|
283
|
+
if (S.focus === id) return;
|
|
284
|
+
S.nodes.get(S.focus)?.card.classList.remove('focused');
|
|
285
|
+
S.focus = id;
|
|
286
|
+
const n = S.nodes.get(id);
|
|
287
|
+
n?.card.classList.add('focused');
|
|
288
|
+
// A card taller than the screen would land with its header out of view ('nearest' aligns
|
|
289
|
+
// its far edge when moving up), so bring its top in instead.
|
|
290
|
+
if (scroll && n) n.card.scrollIntoView({ block: n.card.offsetHeight > stage.clientHeight - topInset() ? 'start' : 'nearest', behavior: 'smooth' });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function toggle(id) {
|
|
294
|
+
S.open.has(id) ? collapse(id) : expand(id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function expand(id, { scroll = true } = {}) {
|
|
298
|
+
const n = S.nodes.get(id);
|
|
299
|
+
if (!n) return;
|
|
300
|
+
S.open.add(id);
|
|
301
|
+
saveOpen();
|
|
302
|
+
n.card.classList.add('open');
|
|
303
|
+
renderHead(n, S.sessions.get(id));
|
|
304
|
+
if (!n.body) {
|
|
305
|
+
n.body = el(`<div class="it-body"><div class="it-loading"><span class="orb tiny"></span>Loading…</div></div>`);
|
|
306
|
+
n.card.append(n.body);
|
|
307
|
+
}
|
|
308
|
+
renderFeedHead(groups().needs, groups().working);
|
|
309
|
+
if (scroll) requestAnimationFrame(() => n.card.scrollIntoView({ block: 'start', behavior: 'smooth' }));
|
|
310
|
+
if (!S.full.has(id)) await fetchFull(id);
|
|
311
|
+
else renderBody(id, { fresh: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function collapse(id) {
|
|
315
|
+
const n = S.nodes.get(id);
|
|
316
|
+
S.open.delete(id);
|
|
317
|
+
saveOpen();
|
|
318
|
+
S.full.delete(id);
|
|
319
|
+
if (n) {
|
|
320
|
+
n.body?.remove();
|
|
321
|
+
n.body = null;
|
|
322
|
+
n.turnNodes.clear();
|
|
323
|
+
n.tray = null;
|
|
324
|
+
n.showAll = false;
|
|
325
|
+
n.card.classList.remove('open');
|
|
326
|
+
const s = S.sessions.get(id);
|
|
327
|
+
if (s) renderHead(n, s);
|
|
328
|
+
}
|
|
329
|
+
renderFeedHead(groups().needs, groups().working);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function fetchFull(id) {
|
|
333
|
+
const s = await api(`/sessions/${id}`).catch(() => null);
|
|
334
|
+
if (!s) {
|
|
335
|
+
S.open.delete(id);
|
|
336
|
+
saveOpen();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!S.open.has(id)) return; // collapsed while loading
|
|
340
|
+
const fresh = !S.full.has(id);
|
|
341
|
+
S.full.set(id, s);
|
|
342
|
+
renderBody(id, { fresh });
|
|
343
|
+
if (s.unread && s.state !== 'running' && document.hasFocus()) api(`/sessions/${id}`, { method: 'PATCH', body: { unread: false } }).catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const refetchTimers = new Map();
|
|
347
|
+
export function scheduleRefetch(id) {
|
|
348
|
+
if (!S.open.has(id)) return;
|
|
349
|
+
clearTimeout(refetchTimers.get(id));
|
|
350
|
+
refetchTimers.set(id, setTimeout(() => (refetchTimers.delete(id), fetchFull(id)), 120));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export const sectionOf = (s) => {
|
|
354
|
+
const t = taskOf(s);
|
|
355
|
+
return t.archived ? 'archived' : t.state === 'running' ? 'working' : t.state === 'done' ? 'done' : 'needs';
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Bring an item into view, expanded — used by notifications and links.
|
|
359
|
+
export function reveal(id) {
|
|
360
|
+
const s = S.sessions.get(id);
|
|
361
|
+
if (!s) return;
|
|
362
|
+
if (s.archived) S.showArchived = true;
|
|
363
|
+
else if (s.state === 'done' && !S.showDone) {
|
|
364
|
+
S.showDone = true;
|
|
365
|
+
save('wisp.showDone', true);
|
|
366
|
+
}
|
|
367
|
+
if (S.filter !== 'all' && S.filter !== s.projectId) {
|
|
368
|
+
S.filter = 'all';
|
|
369
|
+
save('wisp.filter', S.filter);
|
|
370
|
+
renderLaunchChips();
|
|
371
|
+
}
|
|
372
|
+
renderFeed();
|
|
373
|
+
setFocus(id, { scroll: false });
|
|
374
|
+
if (S.open.has(id)) S.nodes.get(id)?.card.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
|
375
|
+
else expand(id);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
|
|
379
|
+
|
|
380
|
+
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
381
|
+
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
382
|
+
// triage continues on the same spot. Call before the request, while the card is still laid
|
|
383
|
+
// out; the returned function applies the change locally and places the next card.
|
|
384
|
+
export function handOff(id) {
|
|
385
|
+
const card = S.nodes.get(id)?.card;
|
|
386
|
+
if (!card?.isConnected) return (apply) => (apply(), renderFeed());
|
|
387
|
+
const nextUp = (card.nextElementSibling || card.previousElementSibling)?.dataset.id || feedIds().find((x) => x !== id);
|
|
388
|
+
const r = card.getBoundingClientRect();
|
|
389
|
+
const inset = topInset();
|
|
390
|
+
const slot = r.bottom > inset && r.top < innerHeight ? Math.max(inset, r.top) : null;
|
|
391
|
+
return (apply) => {
|
|
392
|
+
if (S.open.has(id)) collapse(id);
|
|
393
|
+
apply(); // don't wait for the live update to drop the card
|
|
394
|
+
renderFeed();
|
|
395
|
+
if (!nextUp || !S.nodes.has(nextUp)) return;
|
|
396
|
+
setFocus(nextUp, { scroll: slot === null });
|
|
397
|
+
const next = S.nodes.get(nextUp).card;
|
|
398
|
+
if (slot !== null && next.isConnected) scrollByPx(next.getBoundingClientRect().top - slot);
|
|
399
|
+
};
|
|
400
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Feedback. Three ways in, none of them taking room: thumbs at the end of a turn's trail, "/feedback …"
|
|
2
|
+
// in any prompt box, and "Send feedback" in ⌘K. It all goes to the wisp team (PostHog), with the
|
|
3
|
+
// run's harness, model and status but never the prompt or the result.
|
|
4
|
+
|
|
5
|
+
import { api } from '../core/api.js';
|
|
6
|
+
import { $, $$, el, icon } from '../core/dom.js';
|
|
7
|
+
import { S } from '../core/state.js';
|
|
8
|
+
import { overlay } from '../ui/overlay.js';
|
|
9
|
+
import { toast, toastError } from '../ui/toast.js';
|
|
10
|
+
|
|
11
|
+
export const feedbackOn = () => S.config?.feedback !== false;
|
|
12
|
+
const FEEDBACK_CMD = /^\/feedback(?:\s|$)/i;
|
|
13
|
+
export const isFeedback = (text) => FEEDBACK_CMD.test(text);
|
|
14
|
+
const THANKS = 'Thanks! Sent to the wisp team.';
|
|
15
|
+
|
|
16
|
+
// The box is already empty; `restore` puts the text back if the server refuses it.
|
|
17
|
+
export function feedbackCommand(text, { sessionId, restore } = {}) {
|
|
18
|
+
if (!feedbackOn()) return (restore?.(), toast('Feedback is turned off on this install.', { tone: 'bad' }));
|
|
19
|
+
const note = text.replace(FEEDBACK_CMD, '').trim();
|
|
20
|
+
if (!note) return openFeedback({ sessionId });
|
|
21
|
+
postFeedback({ text: note, sessionId, source: 'command' }, { onFail: restore });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Optimistic: thanks at once. Offline, the server keeps it and sends it later.
|
|
25
|
+
function postFeedback(body, { onFail, quiet = false } = {}) {
|
|
26
|
+
if (!quiet) toast(THANKS);
|
|
27
|
+
return api('/feedback', { method: 'POST', body })
|
|
28
|
+
.then((r) => {
|
|
29
|
+
if (!r.sent && r.available !== false) toast('Saved: it goes out once wisp is back online.');
|
|
30
|
+
})
|
|
31
|
+
.catch((err) => {
|
|
32
|
+
onFail?.();
|
|
33
|
+
toastError(err);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function paintRate(node, rating) {
|
|
38
|
+
$('.rate', node)?.classList.toggle('rated', !!rating);
|
|
39
|
+
$$('[data-rate]', node).forEach((b) => {
|
|
40
|
+
b.classList.toggle('on', b.dataset.rate === rating);
|
|
41
|
+
b.setAttribute('aria-pressed', b.dataset.rate === rating);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function rateTurn(s, t, node, rating) {
|
|
46
|
+
const prev = t.feedback;
|
|
47
|
+
t.feedback = rating ? { rating, at: Date.now() } : undefined;
|
|
48
|
+
paintRate(node, rating);
|
|
49
|
+
if (rating === 'up') {
|
|
50
|
+
const tip = $('.rate', node);
|
|
51
|
+
tip.classList.remove('thanked');
|
|
52
|
+
void tip.offsetWidth; // restart the animation
|
|
53
|
+
tip.classList.add('thanked');
|
|
54
|
+
}
|
|
55
|
+
postFeedback({ rating, sessionId: s.id, turnId: t.id, source: 'turn' }, {
|
|
56
|
+
quiet: true,
|
|
57
|
+
onFail: () => ((t.feedback = prev), paintRate(node, prev?.rating)),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// After 👎: one optional line on what went wrong. The rating is already sent.
|
|
62
|
+
export function whyForm(s, t, close) {
|
|
63
|
+
const f = el(`
|
|
64
|
+
<form class="why">
|
|
65
|
+
<textarea class="textarea" rows="2" placeholder="What went wrong? Optional, goes to the wisp team" maxlength="4000"></textarea>
|
|
66
|
+
<div class="why-foot"><span class="muted xs">Sent with the harness and model, not your prompt or code.</span><button type="button" class="link xs" data-a="skip">Skip</button><button class="primary xs">Send</button></div>
|
|
67
|
+
</form>`);
|
|
68
|
+
const ta = $('textarea', f);
|
|
69
|
+
f.onsubmit = (e) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
const text = ta.value.trim();
|
|
72
|
+
if (!text) return close();
|
|
73
|
+
close();
|
|
74
|
+
postFeedback({ rating: 'down', text, sessionId: s.id, turnId: t.id, source: 'turn' }, { onFail: () => openFeedback({ sessionId: s.id, text, rating: 'down' }) });
|
|
75
|
+
};
|
|
76
|
+
ta.onkeydown = (e) => {
|
|
77
|
+
if (e.key === 'Enter' && !e.shiftKey) (e.preventDefault(), f.requestSubmit());
|
|
78
|
+
if (e.key === 'Escape') (e.stopPropagation(), close());
|
|
79
|
+
};
|
|
80
|
+
$('[data-a="skip"]', f).onclick = () => {
|
|
81
|
+
close();
|
|
82
|
+
toast(THANKS);
|
|
83
|
+
};
|
|
84
|
+
requestAnimationFrame(() => ta.focus());
|
|
85
|
+
return f;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function openFeedback({ sessionId, text = '', rating = null } = {}) {
|
|
89
|
+
if (!feedbackOn()) return toast('Feedback is turned off on this install.', { tone: 'bad' });
|
|
90
|
+
const node = el(`
|
|
91
|
+
<form class="modal fb-modal">
|
|
92
|
+
<header><h2>Send feedback</h2><button type="button" class="icon-btn close">${icon('x')}</button></header>
|
|
93
|
+
<div class="fb-rate"><span>How is wisp working for you?</span>
|
|
94
|
+
${['up', 'down'].map((r) => `<button type="button" class="rate-btn" data-rate="${r}" aria-pressed="false" title="${r === 'up' ? 'Good' : 'Not good'}">${icon(`thumb-${r}`)}</button>`).join('')}
|
|
95
|
+
</div>
|
|
96
|
+
<textarea class="textarea" name="text" rows="5" maxlength="4000" placeholder="What works, what breaks, what you wish it did…"></textarea>
|
|
97
|
+
<div class="ship-foot"><span class="muted xs">Goes to the wisp team with your wisp version and OS. Not your code or prompts. Tip: type <code>/feedback …</code> in any prompt box.</span><button class="primary sm" data-a="send">${icon('send')}Send</button></div>
|
|
98
|
+
</form>`);
|
|
99
|
+
const close = overlay(node);
|
|
100
|
+
$('.close', node).onclick = close;
|
|
101
|
+
const ta = $('textarea', node);
|
|
102
|
+
const send = $('[data-a="send"]', node);
|
|
103
|
+
ta.value = text;
|
|
104
|
+
const paint = () => {
|
|
105
|
+
paintRate(node, rating);
|
|
106
|
+
send.disabled = !rating && !ta.value.trim();
|
|
107
|
+
};
|
|
108
|
+
$$('[data-rate]', node).forEach((b) => {
|
|
109
|
+
b.onclick = () => {
|
|
110
|
+
rating = rating === b.dataset.rate ? null : b.dataset.rate;
|
|
111
|
+
paint();
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
ta.oninput = paint;
|
|
115
|
+
ta.onkeydown = (e) => {
|
|
116
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) (e.preventDefault(), node.requestSubmit());
|
|
117
|
+
};
|
|
118
|
+
node.onsubmit = (e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
const note = ta.value.trim();
|
|
121
|
+
if (!rating && !note) return;
|
|
122
|
+
close();
|
|
123
|
+
postFeedback({ rating: rating || undefined, text: note, sessionId, source: 'dialog' }, { onFail: () => openFeedback({ sessionId, text: note, rating }) });
|
|
124
|
+
};
|
|
125
|
+
paint();
|
|
126
|
+
requestAnimationFrame(() => ta.focus());
|
|
127
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The page-wide key bindings. Popovers handle their own Escape first (ui/popover.js).
|
|
2
|
+
|
|
3
|
+
import { $, $$ } from '../core/dom.js';
|
|
4
|
+
import { S } from '../core/state.js';
|
|
5
|
+
import { modKey, shortcuts } from '../ui/kbd.js';
|
|
6
|
+
import { closeOverlay } from '../ui/overlay.js';
|
|
7
|
+
import { deleteSession, rename, rerun, setArchived, takeOver, toggleDone } from './actions.js';
|
|
8
|
+
import { openPalette } from './commands.js';
|
|
9
|
+
import { collapse, collapseAll, expand, feedIds, setFocus, toggle } from './feed.js';
|
|
10
|
+
import { focusLauncher } from './launcher.js';
|
|
11
|
+
import { backToTop } from './scroll.js';
|
|
12
|
+
import { shipDialog, shippable } from './ship.js';
|
|
13
|
+
import { replay } from './turns.js';
|
|
14
|
+
|
|
15
|
+
export function onKey(e) {
|
|
16
|
+
// ⌘K / Ctrl+K works from anywhere, even mid-sentence in the launcher.
|
|
17
|
+
if (modKey(e, 'k')) {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
return openPalette();
|
|
20
|
+
}
|
|
21
|
+
// ⌘L / Ctrl+L jumps to the launcher from anywhere, including another composer.
|
|
22
|
+
if (modKey(e, 'l')) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
if (!$('#overlay').hidden) closeOverlay();
|
|
25
|
+
return focusLauncher();
|
|
26
|
+
}
|
|
27
|
+
const typing = e.target.closest('input, textarea, select, [contenteditable="true"]');
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
if (!$('#overlay').hidden) return $('#overlay .sx-back') ? $('#overlay .sx-back').click() : closeOverlay();
|
|
30
|
+
if (typing) return e.target.blur();
|
|
31
|
+
if (S.focus && S.open.has(S.focus)) {
|
|
32
|
+
collapse(S.focus);
|
|
33
|
+
S.nodes.get(S.focus)?.card.scrollIntoView({ block: 'nearest' });
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (typing || e.metaKey || e.ctrlKey || e.altKey || !$('#overlay').hidden) return;
|
|
38
|
+
const k = e.key.toLowerCase();
|
|
39
|
+
const id = S.focus && S.nodes.has(S.focus) ? S.focus : null;
|
|
40
|
+
if (k === 'n') {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
focusLauncher();
|
|
43
|
+
} else if (k === 'j' || k === 'k') {
|
|
44
|
+
const ids = feedIds();
|
|
45
|
+
if (!ids.length) return;
|
|
46
|
+
const i = ids.indexOf(id);
|
|
47
|
+
const next = i < 0 ? ids[0] : ids[k === 'j' ? Math.min(ids.length - 1, i + 1) : Math.max(0, i - 1)];
|
|
48
|
+
setFocus(next);
|
|
49
|
+
} else if ((k === 'o' || (k === 'enter' && !e.target.closest('button, a'))) && id) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
toggle(id);
|
|
52
|
+
} else if (k === 'e' && id) toggleDone(id);
|
|
53
|
+
else if (k === 'a' && id) {
|
|
54
|
+
const s = S.sessions.get(id);
|
|
55
|
+
if (s && (s.archived || (s.state !== 'running' && s.state !== 'waiting'))) setArchived(id, !s.archived);
|
|
56
|
+
}
|
|
57
|
+
else if (k === 'c' && S.open.size) collapseAll();
|
|
58
|
+
else if (k === 'u' || k === 'home') {
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
backToTop();
|
|
61
|
+
}
|
|
62
|
+
else if (k === 'm' && id) {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
rename(id);
|
|
65
|
+
} else if ((k === 'delete' || k === 'backspace') && id) {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
deleteSession(id);
|
|
68
|
+
} else if (k === 't' && id) takeOver(id);
|
|
69
|
+
else if (k === 'r' && e.shiftKey && id) rerun(id);
|
|
70
|
+
else if (k === 's' && id && shippable(S.sessions.get(id))) shipDialog(id);
|
|
71
|
+
else if (k === 'r' && id) replay(id);
|
|
72
|
+
else if (k === '/' && id) {
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
const focusReply = () => $('.reply', S.nodes.get(id)?.card)?.focus();
|
|
75
|
+
if (S.open.has(id)) focusReply();
|
|
76
|
+
else expand(id).then(focusReply);
|
|
77
|
+
} else if (k === '?') shortcuts();
|
|
78
|
+
else if (/^[1-9]$/.test(k) && id && S.open.has(id)) {
|
|
79
|
+
const card = S.nodes.get(id).card;
|
|
80
|
+
const opts = $$('.ask-card .opt', card).length ? $$('.ask-card .opt', card) : $$('.turn:last-child .b-question:not(.answered) .opt:not(.own)', card);
|
|
81
|
+
opts[Number(k) - 1]?.click();
|
|
82
|
+
}
|
|
83
|
+
}
|