wisp-agents 0.10.0 → 0.11.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 +18 -1
- package/README.md +4 -4
- package/package.json +1 -1
- package/server/machines.js +7 -1
- package/web/apple-touch-icon.png +0 -0
- package/web/css/app.css +6 -0
- package/web/favicon.svg +12 -4
- package/web/index.html +1 -0
- package/web/js/app.js +316 -107
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.11.0] - 2026-10-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Machines shows **Update to X** next to the version when a connected machine runs an older wisp than this computer. One click restarts wisp there as this version (you're asked first if agents are working on it). Before, reconnecting kept the old wisp running, and you had to find **Restart wisp there** in the ⋯ menu. Machines with a custom start command don't show it, because that command decides the version. Machines carry it as `outdated: "<version>"`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The app responds right away instead of waiting for the server. A new task clears the prompt box and shows up in **Working** as a "Starting up" card while wisp makes its worktree. A follow-up or an answer clears the composer and moves the card to **Working** with your message as the live turn. **Mark done**, **Reopen**, **Archive**, **Delete**, **Stop**, **Rerun**, recipe runs and **Keep this** in Compare also act immediately. If the server refuses, the change is undone, your text and attachments come back, and an error toast says why.
|
|
17
|
+
- The web app uses the same icon as the site and the desktop app (the glowing orb on a dark tile) for its browser tab, home-screen shortcut and notifications.
|
|
18
|
+
- The launcher's **Run on** button sits right after the attach button, so the bar reads attachments, where it runs, then mode, harness, subagents and worktree.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- The launcher's harness picker now lists the harnesses installed on the places picked under **Run on**, not the ones on this computer. Any picked place that has a harness makes it pickable (a union), with its models taken from a place that has it; a harness missing on some of them says **Not on <machine>**, and launching names the place it's missing on. Before, running only on a machine still offered Claude when only this computer had it, and hid a harness only the machine had. Changing the places (or a machine coming online) moves the pick to an installed harness if the current one isn't there. Machines now report each harness's models, thinking levels and whether it can lead subagents.
|
|
23
|
+
|
|
8
24
|
## [0.10.0] - 2026-10-18
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -190,7 +206,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
190
206
|
- Session queue with follow-ups, answers to agent questions, terminal takeover and mark-as-done.
|
|
191
207
|
- Older turns are folded, with a floating collapse-all button and back-to-top.
|
|
192
208
|
|
|
193
|
-
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.
|
|
209
|
+
[Unreleased]: https://github.com/M0xoo/wisp/compare/v0.11.0...HEAD
|
|
210
|
+
[0.11.0]: https://github.com/M0xoo/wisp/compare/v0.10.0...v0.11.0
|
|
194
211
|
[0.10.0]: https://github.com/M0xoo/wisp/compare/v0.9.0...v0.10.0
|
|
195
212
|
[0.9.0]: https://github.com/M0xoo/wisp/compare/v0.8.0...v0.9.0
|
|
196
213
|
[0.8.0]: https://github.com/M0xoo/wisp/compare/v0.7.0...v0.8.0
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ From there you can **follow up**, **answer its questions**, **take it over** in
|
|
|
53
53
|
| 🤝 **Subagents** | Give the lead agent a team on other harnesses (e.g. Claude Code hands tests to Codex ×3). It delegates through a `delegate` tool; their edits land in its worktree. See [Subagents](#subagents). |
|
|
54
54
|
| ⏪ **Checkpoints & rollback** | Every turn is snapshotted first. Restore to before any turn, revert a single file, or re-run a command block from the presentation. |
|
|
55
55
|
| ⚖️ **Compare** | Run one task on two harnesses side by side, and keep the better run. |
|
|
56
|
-
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.
|
|
56
|
+
| 🧾 **Recipes & triggers** | Saved prompts with `{params}` (`wisp run release 0.11.0`), run by hand, on a schedule, when CI fails, or when an issue is labelled. |
|
|
57
57
|
| 📊 **Usage** | Spend, success rate and questions per project and per harness · model. |
|
|
58
58
|
| 📱 **Phone** | Questions and finished tasks on ntfy or Telegram; answer from there. |
|
|
59
59
|
| 🔐 **Permission modes** | Choose **Plan** (read-only), **Edits** (file edits auto-accepted), or **Full auto** for each launch. `Shift+Tab` in the prompt cycles modes. |
|
|
@@ -125,7 +125,7 @@ wisp reply <id> "also do X" # follow up (answers a pending question)
|
|
|
125
125
|
wisp stop <id>
|
|
126
126
|
wisp ship <id> [--draft] # commit, push and open a PR for a worktree task
|
|
127
127
|
wisp merge <id> # squash-merge its PR, fast-forward your local main
|
|
128
|
-
wisp run release 0.
|
|
128
|
+
wisp run release 0.11.0 # run a recipe; values fill its {params} (or version=0.11.0)
|
|
129
129
|
wisp recipes # list recipes
|
|
130
130
|
wisp usage [--days 7] # spend and outcomes
|
|
131
131
|
wisp mcp # MCP server for other agents and editors
|
|
@@ -305,7 +305,7 @@ Before each turn, wisp snapshots the git working tree (pinned at `refs/wisp/chec
|
|
|
305
305
|
Prepare release {version}: update CHANGELOG, README and docs/, bump package.json, tag v{version}.
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
-
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.
|
|
308
|
+
Make one from ⌘K → *Recipes*, or *Save as recipe…* on any card. A recipe may pin a project, harness, model and mode. Run it from ⌘K (*Run: Release*), from the Recipes view, or from a terminal: `wisp run release 0.11.0` fills the fields in order, `wisp run release version=0.11.0` by name.
|
|
309
309
|
|
|
310
310
|
**Triggers** launch sessions on their own while wisp is open (⌘K → *Triggers*):
|
|
311
311
|
|
|
@@ -340,7 +340,7 @@ They receive titles, summaries and questions (with their context), never code or
|
|
|
340
340
|
|
|
341
341
|
Then **Pick projects on it**: the machine's git repos are listed, and folders autocomplete. In the launcher, **Run on** (next to the harness) picks where the prompt goes: this computer, any machine, or several at once. Each place shows its own project chips; Enter launches on all of them in parallel, and the runs land as one task with a row per machine. Each run executes on its machine, with worktrees, diffs, rollback, `ask_user`, subagents, compare and Ship it all working on that machine. Its sessions join your feed with a badge, answer from your phone like any other, open in VS Code, Cursor or Windsurf through Remote - SSH, and **Take over** opens `ssh -t <host>` resuming the agent in your terminal.
|
|
342
342
|
|
|
343
|
-
Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
|
|
343
|
+
Nothing new listens on the network: wisp on the machine stays on its own `127.0.0.1`, and it keeps running (agents too) when this computer sleeps or disconnects; wisp reconnects on its own. **Stop wisp there** (the machine's ⋯ menu) stops it. When the machine runs an older wisp than this computer, **Update to X** next to its version restarts it there as this version. The machine needs Node.js 20+ and the harness CLIs, signed in. Sessions there are `<id>@<machine>` in the API, CLI, SDK and MCP; recipes and schedules work with machine projects, while CI and issue triggers, usage and memory stay per machine.
|
|
344
344
|
|
|
345
345
|
## Learning
|
|
346
346
|
|
package/package.json
CHANGED
package/server/machines.js
CHANGED
|
@@ -20,6 +20,7 @@ import path from 'node:path';
|
|
|
20
20
|
import readline from 'node:readline';
|
|
21
21
|
import { randomBytes } from 'node:crypto';
|
|
22
22
|
import { spawn, execFile, killTree } from './proc.js';
|
|
23
|
+
import { newer } from './update.js';
|
|
23
24
|
|
|
24
25
|
export const DEFAULT_PORT = 4477;
|
|
25
26
|
const READY_TIMEOUT_MS = 6 * 60_000; // the first `npx` download can take a while
|
|
@@ -45,6 +46,10 @@ export function splitId(id) {
|
|
|
45
46
|
return i > 0 ? { id: id.slice(0, i), machine: id.slice(i + 1) } : null;
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// The version an older wisp on the machine would become on Restart wisp there: this one, unless a
|
|
50
|
+
// custom start command decides what runs there.
|
|
51
|
+
export const updateTo = (local, remote, command) => (!command && remote && newer(local, remote) ? local : null);
|
|
52
|
+
|
|
48
53
|
// What runs on the machine. Sent on stdin, so the login shell there (bash, zsh, fish…) only ever
|
|
49
54
|
// sees `sh -s`. Steps come back as `@@wisp step <id> <run|done> <detail>`, `@@wisp fail <id>
|
|
50
55
|
// <message>`, and finally `@@wisp ready <version> <pid>`; then `cat` holds the session (and the
|
|
@@ -332,6 +337,7 @@ export class Machines extends EventEmitter {
|
|
|
332
337
|
attempt: c?.attempt || 0,
|
|
333
338
|
since: c?.since || null,
|
|
334
339
|
remote: c?.remote || null,
|
|
340
|
+
outdated: updateTo(this.version, c?.remote?.version, m.command),
|
|
335
341
|
log: c?.log.slice(-30) || [],
|
|
336
342
|
projects: this.store.config.projects.filter((p) => p.machine === m.id).length,
|
|
337
343
|
working: cached.filter((s) => s.state === 'running').length,
|
|
@@ -431,7 +437,7 @@ export class Machines extends EventEmitter {
|
|
|
431
437
|
this.#step(c, 'tunnel', 'done', `localhost:${c.localPort} → ${m.port}`);
|
|
432
438
|
const [agents, config] = await Promise.all([this.#raw(m, c, 'GET', '/agents').catch(() => []), this.#raw(m, c, 'GET', '/config').catch(() => ({}))]);
|
|
433
439
|
const ready = agents.filter((a) => a.available);
|
|
434
|
-
c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version }) => ({ id, name, available, version })) };
|
|
440
|
+
c.remote = { ...c.remote, version: health.version, pid: health.pid, home: config.home || '', agents: agents.map(({ id, name, available, version, models, efforts, delegates }) => ({ id, name, available, version, models, efforts, delegates })) };
|
|
435
441
|
this.#step(c, 'agents', ready.length ? 'done' : 'warn', ready.length ? ready.map((a) => a.name).join(', ') : 'None found. Install a harness CLI there (claude, codex, opencode…) and sign in.');
|
|
436
442
|
clearTimeout(c.readyTimer);
|
|
437
443
|
Object.assign(c, { state: 'online', error: '', hint: null, attempt: 0, wasOnline: true, since: Date.now() });
|
|
Binary file
|
package/web/css/app.css
CHANGED
|
@@ -408,6 +408,7 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
408
408
|
.pk-text b { font-weight: 500; font-size: 13.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
409
409
|
.pk-text small { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
410
410
|
.pk-text small:empty { display: none; }
|
|
411
|
+
.pk-text small.warn { color: var(--warn); }
|
|
411
412
|
.pk-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
|
412
413
|
.pk-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
|
413
414
|
.pk-search { flex: none; width: auto; margin: 8px 8px 2px; color: var(--faint); }
|
|
@@ -473,6 +474,10 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
473
474
|
.item.focused { border-color: var(--line-2); box-shadow: 0 0 0 1px var(--line-2); }
|
|
474
475
|
.item.open { border-color: var(--line-2); box-shadow: var(--shadow-2); }
|
|
475
476
|
.item.st-done:not(.open) { background: transparent; }
|
|
477
|
+
/* a launch the server hasn't confirmed yet: shown at once, can't be opened */
|
|
478
|
+
.item.pending { opacity: 0.75; }
|
|
479
|
+
.item.pending .it-head { cursor: default; }
|
|
480
|
+
.item.pending .chev { visibility: hidden; }
|
|
476
481
|
.item.arrived { animation: arrive 1.4s var(--ease); }
|
|
477
482
|
@keyframes arrive { from { box-shadow: 0 0 0 3px var(--accent-soft); border-color: var(--accent); } }
|
|
478
483
|
|
|
@@ -1423,6 +1428,7 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1423
1428
|
.mc-meta, .mc-steps, .mc-hint, .mc-projects, .mc-edit { margin-left: 42px; }
|
|
1424
1429
|
.mc-meta { display: flex; align-items: center; gap: 12px; margin-top: 8px; font-size: 12px; color: var(--muted); }
|
|
1425
1430
|
.mc-meta .bad { color: var(--bad); }
|
|
1431
|
+
.mc-meta .mc-update { margin-left: -6px; color: var(--accent); }
|
|
1426
1432
|
.mc-agents { display: inline-flex; gap: 7px; }
|
|
1427
1433
|
.mc-steps { list-style: none; display: flex; flex-direction: column; gap: 5px; margin-top: 10px; margin-bottom: 0; padding: 0; }
|
|
1428
1434
|
.mc-steps li { display: grid; grid-template-columns: 16px auto 1fr; align-items: center; column-gap: 8px; font-size: 12.5px; color: var(--faint); }
|
package/web/favicon.svg
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
2
|
-
<
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1d1c22"/><stop offset="1" stop-color="#0e0e10"/></linearGradient>
|
|
4
|
+
<radialGradient id="glow" cx="50%" cy="50%" r="50%"><stop offset="0" stop-color="#6a5cff" stop-opacity=".55"/><stop offset="1" stop-color="#6a5cff" stop-opacity="0"/></radialGradient>
|
|
5
|
+
<radialGradient id="orb" cx="36%" cy="32%" r="68%"><stop offset="0" stop-color="#fff"/><stop offset=".16" stop-color="#d9fff6"/><stop offset=".38" stop-color="#2fd0b4"/><stop offset=".78" stop-color="#6a5cff"/><stop offset="1" stop-color="#4b3fe0"/></radialGradient>
|
|
6
|
+
</defs>
|
|
7
|
+
<rect x="100" y="100" width="824" height="824" rx="190" fill="url(#bg)"/>
|
|
8
|
+
<circle cx="512" cy="512" r="330" fill="url(#glow)"/>
|
|
9
|
+
<circle cx="512" cy="512" r="210" fill="url(#orb)"/>
|
|
10
|
+
<circle cx="742" cy="360" r="16" fill="#fff" opacity=".9"/>
|
|
11
|
+
<circle cx="300" cy="650" r="10" fill="#bff" opacity=".7"/>
|
|
12
|
+
<circle cx="690" cy="720" r="7" fill="#fff" opacity=".6"/>
|
|
5
13
|
</svg>
|
package/web/index.html
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>wisp</title>
|
|
7
7
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
8
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
|
8
9
|
<link rel="stylesheet" href="/css/app.css">
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
package/web/js/app.js
CHANGED
|
@@ -64,6 +64,28 @@ const MODES = [
|
|
|
64
64
|
const KIND_ICON = { read: 'read', edit: 'pen', run: 'terminal', search: 'search', web: 'web', agent: 'agent', plan: 'plan', tool: 'tool', text: 'text', error: 'warn', ask: 'ask', answer: 'check', wait: 'ask' };
|
|
65
65
|
const STATUS_LABEL = { done: 'Done', partial: 'Partial', blocked: 'Blocked', info: 'Report' };
|
|
66
66
|
const agentName = (id) => S.agents.find((a) => a.id === id)?.name || (id === 'wisp' ? 'wisp' : id);
|
|
67
|
+
// Harness lists per place: '' is this computer, else a machine (what it reported when it came online).
|
|
68
|
+
const agentsAt = (k) => (k ? (machineById(k)?.state === 'online' && machineById(k).remote?.agents) || null : S.agents);
|
|
69
|
+
// A harness's models and levels: this computer's if it's installed here, else the first machine's that has it.
|
|
70
|
+
function agentById(id) {
|
|
71
|
+
const local = S.agents.find((a) => a.id === id);
|
|
72
|
+
if (local?.available) return local;
|
|
73
|
+
return S.machines.map((m) => agentsAt(m.id)?.find((a) => a.id === id && a.available)).find(Boolean) || local;
|
|
74
|
+
}
|
|
75
|
+
// The harnesses you can pick for the places under "Run on": installed on any of them counts (a union).
|
|
76
|
+
// `missing` names the picked places that don't have it. Machines not online yet don't count.
|
|
77
|
+
function harnessesAt(places) {
|
|
78
|
+
const known = places.map((k) => [k, agentsAt(k)]).filter(([, l]) => l);
|
|
79
|
+
if (!known.length) known.push(['', S.agents]);
|
|
80
|
+
const ids = [...new Set([...S.agents, ...known.flatMap(([, l]) => l)].map((a) => a.id))];
|
|
81
|
+
return ids
|
|
82
|
+
.map((id) => {
|
|
83
|
+
const hits = known.filter(([, l]) => l.some((a) => a.id === id && a.available));
|
|
84
|
+
const base = hits.length ? hits[0][1].find((a) => a.id === id) : S.agents.find((a) => a.id === id) || known.flatMap(([, l]) => l).find((a) => a.id === id);
|
|
85
|
+
return { ...base, available: hits.length > 0, missing: hits.length ? known.filter((x) => !hits.includes(x)).map(([k]) => k) : [] };
|
|
86
|
+
})
|
|
87
|
+
.filter((a) => a.available || S.agents.some((x) => x.id === a.id));
|
|
88
|
+
}
|
|
67
89
|
|
|
68
90
|
// Harness logos for the request bubble, vendored from @lobehub/icons into web/logos/.
|
|
69
91
|
// `mono` marks are drawn in the text colour so they work in both themes.
|
|
@@ -83,14 +105,14 @@ function harnessLogo(agent) {
|
|
|
83
105
|
// Reasoning levels the harness accepts for a model: the model's own list (opencode variants),
|
|
84
106
|
// else the harness-wide one. Empty means the harness has no knob for it.
|
|
85
107
|
function effortsFor(agent, model) {
|
|
86
|
-
const a =
|
|
108
|
+
const a = agentById(agent);
|
|
87
109
|
return a?.models?.find((m) => m.id === model)?.efforts ?? a?.efforts ?? [];
|
|
88
110
|
}
|
|
89
111
|
const effortLabel = (e) => ({ xhigh: 'X-High' })[e] || e.charAt(0).toUpperCase() + e.slice(1);
|
|
90
112
|
|
|
91
113
|
function modelBadge(agent, model, effort) {
|
|
92
114
|
if (agent === 'wisp') return '';
|
|
93
|
-
const label = (model &&
|
|
115
|
+
const label = (model && agentById(agent)?.models?.find((x) => x.id === model)?.label) || model || 'Default model';
|
|
94
116
|
const tip = `${agentName(agent)} · ${label}${effort ? ` · ${effortLabel(effort)} thinking` : ''}`;
|
|
95
117
|
const logo = harnessLogo(agent);
|
|
96
118
|
return `<span class="model-logo" data-tip="${esc(tip)}" aria-label="${esc(tip)}" tabindex="0">${logo}</span>`;
|
|
@@ -294,14 +316,17 @@ function item(s) {
|
|
|
294
316
|
n = { card, head: $('.it-head', card), body: null, turnNodes: new Map(), tray: null, showAll: false, unfolded: new Set(), latestTurn: null };
|
|
295
317
|
S.nodes.set(s.id, n);
|
|
296
318
|
n.head.addEventListener('click', (e) => {
|
|
297
|
-
if (n.head.querySelector('[contenteditable="true"]')) return;
|
|
319
|
+
if (s.pending || n.head.querySelector('[contenteditable="true"]')) return;
|
|
298
320
|
if (e.target.closest('[data-a="quick-done"]')) return toggleDone(s.id);
|
|
299
321
|
setFocus(s.id);
|
|
300
322
|
toggle(s.id);
|
|
301
323
|
});
|
|
302
|
-
card.addEventListener('mousedown', () =>
|
|
324
|
+
card.addEventListener('mousedown', () => {
|
|
325
|
+
if (!s.pending) setFocus(s.id, { scroll: false });
|
|
326
|
+
});
|
|
303
327
|
n.head.addEventListener('contextmenu', (e) => {
|
|
304
328
|
e.preventDefault();
|
|
329
|
+
if (s.pending) return;
|
|
305
330
|
setFocus(s.id, { scroll: false });
|
|
306
331
|
taskMenu(s.id, e);
|
|
307
332
|
});
|
|
@@ -322,7 +347,7 @@ function renderHead(n, s) {
|
|
|
322
347
|
? `<span class="pill st-${s.status}"><i></i>${STATUS_LABEL[s.status] || 'Done'}</span>`
|
|
323
348
|
: '';
|
|
324
349
|
if (runsOf(s).length > 1) return renderRunHead(n, s, { open, live, pill });
|
|
325
|
-
n.card.className = `item c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
350
|
+
n.card.className = `item ${s.pending ? 'pending' : ''} c-${s.color} st-${s.state} ${s.unread ? 'unread' : ''} ${open ? 'open' : ''} ${S.focus === s.id ? 'focused' : ''}`;
|
|
326
351
|
n.head.innerHTML = `
|
|
327
352
|
<span class="q-dot ${s.state === 'running' ? 'live' : ''} ${s.state === 'waiting' ? 'waiting' : ''}"></span>
|
|
328
353
|
<span class="it-main">
|
|
@@ -528,6 +553,17 @@ function attachTray() {
|
|
|
528
553
|
items.length = 0;
|
|
529
554
|
render();
|
|
530
555
|
},
|
|
556
|
+
// Empty the tray for an optimistic send, and put the files back if the send fails.
|
|
557
|
+
take() {
|
|
558
|
+
const list = items.splice(0);
|
|
559
|
+
render();
|
|
560
|
+
return list;
|
|
561
|
+
},
|
|
562
|
+
restore(list) {
|
|
563
|
+
if (!list?.length) return;
|
|
564
|
+
items.unshift(...list);
|
|
565
|
+
render();
|
|
566
|
+
},
|
|
531
567
|
get busy() {
|
|
532
568
|
return items.some((i) => i.status === 'uploading');
|
|
533
569
|
},
|
|
@@ -606,7 +642,7 @@ function syncWhere() {
|
|
|
606
642
|
function savedModel(id) {
|
|
607
643
|
const d = S.config.defaults;
|
|
608
644
|
const want = d.models?.[id] ?? (id === d.agent ? d.model : '');
|
|
609
|
-
const models =
|
|
645
|
+
const models = agentById(id)?.models || [];
|
|
610
646
|
return models.some((m) => m.id === want) ? want : '';
|
|
611
647
|
}
|
|
612
648
|
// Thinking level too, and only if the chosen model still accepts it.
|
|
@@ -619,21 +655,21 @@ function savedEffort(id, model) {
|
|
|
619
655
|
function renderLauncher() {
|
|
620
656
|
const box = $('#launcher');
|
|
621
657
|
const mode = S.config.defaults.mode || 'edits';
|
|
622
|
-
const
|
|
623
|
-
const chosen =
|
|
624
|
-
let agent = chosen?.available ? chosen :
|
|
658
|
+
const here = harnessesAt(launchWhere);
|
|
659
|
+
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'claude'));
|
|
660
|
+
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
625
661
|
box.innerHTML = `
|
|
626
662
|
<div class="l-projects"></div>
|
|
627
663
|
<textarea id="prompt" rows="2" spellcheck="false"></textarea>
|
|
628
664
|
<div class="l-bar">
|
|
629
665
|
<button class="icon-btn" id="attach" title="Attach files">${icon('clip')}</button>
|
|
666
|
+
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
630
667
|
<button class="mode-btn" id="mode" aria-haspopup="menu"></button>
|
|
631
668
|
<div class="runners">
|
|
632
669
|
<button class="runner-btn" id="runner" title="Harness and model" aria-haspopup="dialog"></button>
|
|
633
670
|
<button class="vs-btn" id="compareBtn" aria-pressed="false"><span>vs</span>${icon('x')}</button>
|
|
634
671
|
<button class="runner-btn" id="runner2" title="Second harness to compare with" aria-haspopup="dialog" hidden></button>
|
|
635
672
|
</div>
|
|
636
|
-
<button class="tog-btn where-btn" id="whereBtn" aria-haspopup="menu"></button>
|
|
637
673
|
<button class="tog-btn team-btn" id="teamBtn" aria-haspopup="dialog"></button>
|
|
638
674
|
<span class="grow"></span>
|
|
639
675
|
<button class="tog-btn" id="wtBtn"></button>
|
|
@@ -719,7 +755,7 @@ function renderLauncher() {
|
|
|
719
755
|
const paintTeam = () => paintTeamBtn(teamBtn, team, teamPreset, agent?.id);
|
|
720
756
|
paintTeam();
|
|
721
757
|
teamBtn.onclick = () =>
|
|
722
|
-
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset }, (members, preset) => {
|
|
758
|
+
teamEditor(teamBtn, { lead: agent?.id, team, presetId: teamPreset, places: launchWhere }, (members, preset) => {
|
|
723
759
|
if (JSON.stringify([members, preset]) === JSON.stringify([team, teamPreset])) return;
|
|
724
760
|
team = members;
|
|
725
761
|
teamPreset = preset;
|
|
@@ -743,19 +779,19 @@ function renderLauncher() {
|
|
|
743
779
|
vsBtn.onclick = () => {
|
|
744
780
|
if (compare) compare = null;
|
|
745
781
|
else {
|
|
746
|
-
const other =
|
|
782
|
+
const other = harnessesAt(launchWhere).find((a) => a.available && a.id !== agent?.id) || agent;
|
|
747
783
|
compare = { agent: other?.id, model: savedModel(other?.id), effort: savedEffort(other?.id, savedModel(other?.id)) };
|
|
748
784
|
}
|
|
749
785
|
paintCompare();
|
|
750
786
|
};
|
|
751
787
|
runner2.onclick = () =>
|
|
752
|
-
runnerPicker(runner2, { ...compare, savedModel, savedEffort }, (id, model, effort) => {
|
|
788
|
+
runnerPicker(runner2, { ...compare, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
753
789
|
compare = { agent: id, model, effort };
|
|
754
790
|
paintCompare();
|
|
755
791
|
});
|
|
756
792
|
runnerBtn.onclick = () =>
|
|
757
|
-
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort }, (id, model, effort) => {
|
|
758
|
-
agent =
|
|
793
|
+
runnerPicker(runnerBtn, { agent: agent?.id, model: curModel, effort: curEffort, savedModel, savedEffort, places: launchWhere }, (id, model, effort) => {
|
|
794
|
+
agent = harnessesAt(launchWhere).find((a) => a.id === id);
|
|
759
795
|
curModel = model;
|
|
760
796
|
curEffort = effort;
|
|
761
797
|
paintRunner();
|
|
@@ -779,21 +815,39 @@ function renderLauncher() {
|
|
|
779
815
|
if (off) return offlineToast(off.k);
|
|
780
816
|
const wanted = [agent?.id, ...(compare ? [compare.agent] : [])];
|
|
781
817
|
for (const t of targets) {
|
|
782
|
-
const
|
|
783
|
-
|
|
818
|
+
const list = agentsAt(t.k);
|
|
819
|
+
const missing = list && wanted.find((id) => !list.some((x) => x.id === id && x.available));
|
|
820
|
+
if (missing) return toast(`${esc(agentName(missing))} isn’t installed on <b>${esc(placeName(t.k))}</b>`, { tone: 'bad' });
|
|
784
821
|
}
|
|
785
822
|
const btn = $('#launch', box);
|
|
786
|
-
|
|
787
|
-
|
|
823
|
+
// Optimistic: wait only for uploads, then empty the box and show the task in Working at
|
|
824
|
+
// once. Fetching and making worktrees can take a few seconds; the real card replaces the
|
|
825
|
+
// placeholder when the server has it. On failure the prompt and files come back.
|
|
826
|
+
const attachments = await tray.ids();
|
|
827
|
+
const files = tray.take();
|
|
828
|
+
const cmp = compare;
|
|
829
|
+
const runner = { agent: agent?.id || 'claude', model: curModel, effort: curEffort };
|
|
830
|
+
const pending = targets.map((t) => addPending(prompt, t, runner));
|
|
831
|
+
if (compare) (compare = null), paintCompare();
|
|
832
|
+
ta.value = '';
|
|
833
|
+
S.drafts.delete('home');
|
|
834
|
+
ta.dispatchEvent(new Event('input'));
|
|
835
|
+
if (S.filter !== 'all' && targets.some((t) => t.project.id !== S.filter)) {
|
|
836
|
+
S.filter = 'all';
|
|
837
|
+
save('wisp.filter', S.filter);
|
|
838
|
+
}
|
|
839
|
+
renderFeed();
|
|
840
|
+
const ghost = S.nodes.get(pending[0])?.card;
|
|
841
|
+
showLaunched(btn, ghost);
|
|
842
|
+
ta.focus();
|
|
788
843
|
try {
|
|
789
|
-
const attachments = await tray.ids();
|
|
790
844
|
// Several places: one shared groupId, so the runs land as one task with a row each.
|
|
791
845
|
const groupId = targets.length > 1 ? [...crypto.getRandomValues(new Uint8Array(6))].map((b) => b.toString(16).padStart(2, '0')).join('') : undefined;
|
|
792
846
|
const results = await Promise.allSettled(
|
|
793
847
|
targets.map((t) =>
|
|
794
848
|
api('/sessions', {
|
|
795
849
|
method: 'POST',
|
|
796
|
-
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: agent
|
|
850
|
+
body: { prompt, cwd: t.project.path, machine: t.k || undefined, groupId, agent: runner.agent, mode: curMode, model: runner.model, effort: runner.effort, attachments, worktree: S.config.defaults.worktree === 'off' && !cmp ? false : undefined, compare: cmp ? [cmp] : undefined, subagents: team.length && canLead(runner.agent) ? team : undefined },
|
|
797
851
|
}),
|
|
798
852
|
),
|
|
799
853
|
);
|
|
@@ -801,31 +855,36 @@ function renderLauncher() {
|
|
|
801
855
|
if (r.status === 'rejected') toast(`${esc(placeName(targets[i].k))}: ${esc(r.reason.message)}`, { tone: 'bad' });
|
|
802
856
|
});
|
|
803
857
|
const started = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
|
|
804
|
-
|
|
858
|
+
for (const id of pending) S.sessions.delete(id);
|
|
805
859
|
for (const x of started.flatMap((y) => y.group || [y])) S.sessions.set(x.id, x);
|
|
806
|
-
|
|
807
|
-
if (
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (S.filter !== 'all' && (started.length > 1 || S.filter !== s.projectId)) {
|
|
813
|
-
S.filter = 'all';
|
|
814
|
-
save('wisp.filter', S.filter);
|
|
860
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
861
|
+
if (!started.length) {
|
|
862
|
+
if (!ta.value) ((ta.value = prompt), ta.dispatchEvent(new Event('input')));
|
|
863
|
+
tray.restore(files);
|
|
864
|
+
if (cmp && !compare) (compare = cmp), paintCompare();
|
|
865
|
+
return;
|
|
815
866
|
}
|
|
816
|
-
renderFeed();
|
|
817
|
-
const card = S.nodes.get(s.id)?.card;
|
|
818
|
-
showLaunched(btn, card?.closest('.task-item') || card);
|
|
819
867
|
askNotify();
|
|
820
868
|
} catch (err) {
|
|
869
|
+
for (const id of pending) S.sessions.delete(id);
|
|
870
|
+
renderFeed();
|
|
821
871
|
toast(esc(err.message), { tone: 'bad' });
|
|
822
|
-
} finally {
|
|
823
|
-
btn.disabled = false;
|
|
824
|
-
btn.classList.remove('busy');
|
|
825
|
-
ta.focus();
|
|
826
872
|
}
|
|
827
873
|
};
|
|
828
874
|
$('#launch', box).onclick = launch;
|
|
875
|
+
// Picking other places (or a machine coming online) changes which harnesses exist: keep the
|
|
876
|
+
// pick if one of them still has it, else move to one that's installed there.
|
|
877
|
+
box.placesChanged = () => {
|
|
878
|
+
const here = harnessesAt(launchWhere);
|
|
879
|
+
if (!agent || here.find((a) => a.id === agent.id)?.available) return paintRunner();
|
|
880
|
+
const next = here.find((a) => a.id === S.config.defaults.agent && a.available) || here.find((a) => a.available);
|
|
881
|
+
if (!next) return paintRunner();
|
|
882
|
+
agent = next;
|
|
883
|
+
curModel = savedModel(agent.id);
|
|
884
|
+
curEffort = savedEffort(agent.id, curModel);
|
|
885
|
+
paintRunner();
|
|
886
|
+
paintTeam();
|
|
887
|
+
};
|
|
829
888
|
ta.addEventListener('keydown', (e) => {
|
|
830
889
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
|
831
890
|
e.preventDefault();
|
|
@@ -840,7 +899,7 @@ function renderLauncher() {
|
|
|
840
899
|
|
|
841
900
|
// The harness · model · thinking button, as in the launcher. agent '' reads "Launcher default".
|
|
842
901
|
function paintRunnerBtn(btn, agentId, model, effort) {
|
|
843
|
-
const a =
|
|
902
|
+
const a = agentById(agentId);
|
|
844
903
|
const m = (a?.models || []).find((x) => x.id === model);
|
|
845
904
|
btn.innerHTML = agentId
|
|
846
905
|
? `<span class="rb-logo">${harnessLogo(agentId)}</span><span class="rb-h">${esc(a?.name || agentName(agentId))}</span><span class="rb-m">${esc(m && m.id ? m.label : 'Default')}</span>${effort ? `<span class="rb-e">${esc(effortLabel(effort))}</span>` : ''}${icon('chevron', 'chev')}`
|
|
@@ -856,9 +915,9 @@ const MAX_COPIES = 5; // parallel copies of one row
|
|
|
856
915
|
const MAX_INSTANCES = 12; // copies across the team
|
|
857
916
|
const copiesOf = (m) => Math.max(1, m.count || 1);
|
|
858
917
|
const teamSize = (team) => team.reduce((n, m) => n + copiesOf(m), 0);
|
|
859
|
-
const canLead = (id) =>
|
|
918
|
+
const canLead = (id) => agentById(id)?.delegates !== false;
|
|
860
919
|
const memberLabel = (m) => {
|
|
861
|
-
const a =
|
|
920
|
+
const a = agentById(m.agent);
|
|
862
921
|
const model = (m.model && a?.models?.find((x) => x.id === m.model)?.label) || m.model || 'Default model';
|
|
863
922
|
return `${a?.name || agentName(m.agent)} · ${model}${m.effort ? ` · ${effortLabel(m.effort)}` : ''}`;
|
|
864
923
|
};
|
|
@@ -905,7 +964,7 @@ function paintTeamBtn(btn, team, presetId, lead) {
|
|
|
905
964
|
: `${agentName(lead)} can't delegate (wisp can't give it tools), so this launch runs without subagents`;
|
|
906
965
|
}
|
|
907
966
|
|
|
908
|
-
function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
967
|
+
function teamEditor(anchor, { lead, team, presetId, places }, onChange) {
|
|
909
968
|
const open = $('.team-pop');
|
|
910
969
|
if (open) return open.close();
|
|
911
970
|
let members = team.map((m) => ({ ...m }));
|
|
@@ -969,7 +1028,8 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
969
1028
|
const add = () => {
|
|
970
1029
|
if (members.length >= MAX_TEAM || teamSize(members) >= MAX_INSTANCES) return;
|
|
971
1030
|
const used = new Set([lead, ...members.map((m) => m.agent)]);
|
|
972
|
-
const
|
|
1031
|
+
const pool = places ? harnessesAt(places) : S.agents;
|
|
1032
|
+
const a = pool.find((x) => x.available && !used.has(x.id)) || pool.find((x) => x.available && x.id !== lead) || pool.find((x) => x.available);
|
|
973
1033
|
if (!a) return toast('No harness is installed.', { tone: 'bad' });
|
|
974
1034
|
const model = savedModel(a.id);
|
|
975
1035
|
members.push({ name: '', agent: a.id, model, effort: savedEffort(a.id, model), use: '', count: 1 });
|
|
@@ -1072,7 +1132,7 @@ function teamEditor(anchor, { lead, team, presetId }, onChange) {
|
|
|
1072
1132
|
const rb = e.target.closest('.tp-runner');
|
|
1073
1133
|
if (rb) {
|
|
1074
1134
|
const m = members[i];
|
|
1075
|
-
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort }, (agent, model, effort) => {
|
|
1135
|
+
runnerPicker(rb, { agent: m.agent, model: m.model, effort: m.effort, savedModel, savedEffort, places }, (agent, model, effort) => {
|
|
1076
1136
|
Object.assign(m, { agent, model, effort });
|
|
1077
1137
|
paintList();
|
|
1078
1138
|
commit();
|
|
@@ -1349,6 +1409,7 @@ function whereMenu(anchor) {
|
|
|
1349
1409
|
// A machine picked while disconnected starts connecting; you asked to run there.
|
|
1350
1410
|
for (const k of launchWhere) if (k && machineById(k)?.state === 'off') connectMachine(k);
|
|
1351
1411
|
renderLaunchChips();
|
|
1412
|
+
$('#launcher')?.placesChanged?.();
|
|
1352
1413
|
paint();
|
|
1353
1414
|
saveWhere();
|
|
1354
1415
|
};
|
|
@@ -1467,6 +1528,65 @@ function launchedFx(from, to) {
|
|
|
1467
1528
|
).onfinish = () => w.remove();
|
|
1468
1529
|
}
|
|
1469
1530
|
|
|
1531
|
+
// A launch the server hasn't answered yet: a placeholder summary that shows in Working as a
|
|
1532
|
+
// "Starting up" card. It can't be opened; the real card takes its place.
|
|
1533
|
+
let pendingSeq = 0;
|
|
1534
|
+
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
1535
|
+
const id = `pending-${++pendingSeq}`;
|
|
1536
|
+
const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
|
|
1537
|
+
const now = Date.now();
|
|
1538
|
+
S.sessions.set(id, {
|
|
1539
|
+
id,
|
|
1540
|
+
pending: true,
|
|
1541
|
+
title: line.length > 64 ? `${line.slice(0, 63)}…` : line,
|
|
1542
|
+
agent,
|
|
1543
|
+
model,
|
|
1544
|
+
effort,
|
|
1545
|
+
cwd: p.path,
|
|
1546
|
+
project: p.name,
|
|
1547
|
+
projectId: p.id,
|
|
1548
|
+
color: p.color || 'slate',
|
|
1549
|
+
state: 'running',
|
|
1550
|
+
live: 'Starting up',
|
|
1551
|
+
createdAt: now,
|
|
1552
|
+
updatedAt: now,
|
|
1553
|
+
machine: m ? { id: m.id, name: m.name, target: m.target } : null,
|
|
1554
|
+
});
|
|
1555
|
+
return id;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// A launch from a dialog (rerun, recipes): the dialog closes and a placeholder shows in Working
|
|
1559
|
+
// at once; the real session replaces it, or it disappears and the error is thrown on.
|
|
1560
|
+
async function launchBehind({ prompt, cwd, runner }, request) {
|
|
1561
|
+
const sum = S.config.projects.find((p) => p.path === cwd);
|
|
1562
|
+
const project = sum || { path: cwd, name: cwd.split('/').filter(Boolean).pop() || cwd, id: null, color: 'slate' };
|
|
1563
|
+
const ghost = addPending(prompt, { project, m: project.machine ? machineById(project.machine) : null }, runner);
|
|
1564
|
+
if (S.filter !== 'all' && S.filter !== project.id) {
|
|
1565
|
+
S.filter = 'all';
|
|
1566
|
+
save('wisp.filter', S.filter);
|
|
1567
|
+
}
|
|
1568
|
+
renderFeed();
|
|
1569
|
+
const card = S.nodes.get(ghost)?.card;
|
|
1570
|
+
if (card) (card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }), card.classList.add('arrived'), setTimeout(() => card.classList.remove('arrived'), 1600));
|
|
1571
|
+
try {
|
|
1572
|
+
const s = await request();
|
|
1573
|
+
S.sessions.delete(ghost);
|
|
1574
|
+
for (const x of s.group || [s]) S.sessions.set(x.id, x);
|
|
1575
|
+
keepInPlace(feedAnchor(), renderFeed);
|
|
1576
|
+
return s;
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
S.sessions.delete(ghost);
|
|
1579
|
+
renderFeed();
|
|
1580
|
+
throw err;
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// The real session showed up (live events can beat the launch response): drop its placeholder.
|
|
1585
|
+
function dropPending(s) {
|
|
1586
|
+
if (s.pending || s.parentId) return;
|
|
1587
|
+
for (const x of S.sessions.values()) if (x.pending && x.projectId === s.projectId) return S.sessions.delete(x.id);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1470
1590
|
// A new card lands in Working, below everything in "Needs you", so it's often off-screen. Scroll
|
|
1471
1591
|
// it into view (just enough) and flash it; the flying wisp only plays when the card is already
|
|
1472
1592
|
// on screen, since the launcher it starts from may scroll away or fold into the dock.
|
|
@@ -1807,8 +1927,18 @@ function renderWorking(s, t) {
|
|
|
1807
1927
|
.filter((a) => a.kind !== 'text')
|
|
1808
1928
|
.slice(-5)
|
|
1809
1929
|
.forEach((a) => ticker.prepend(actItem(a)));
|
|
1810
|
-
$('.stop', node).onclick = async () => {
|
|
1811
|
-
|
|
1930
|
+
$('.stop', node).onclick = async (e) => {
|
|
1931
|
+
// Shows at once; the run winds down and the live update replaces this panel.
|
|
1932
|
+
const btn = e.currentTarget;
|
|
1933
|
+
btn.disabled = true;
|
|
1934
|
+
btn.lastChild.textContent = 'Stopping…';
|
|
1935
|
+
$('.w-status b', node).textContent = `Stopping ${agentName(s.agent)}…`;
|
|
1936
|
+
await api(`/sessions/${s.id}/stop`, { method: 'POST', body: {} }).catch((err) => {
|
|
1937
|
+
btn.disabled = false;
|
|
1938
|
+
btn.lastChild.textContent = 'Stop';
|
|
1939
|
+
$('.w-status b', node).textContent = `${agentName(s.agent)} is working`;
|
|
1940
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
1941
|
+
});
|
|
1812
1942
|
};
|
|
1813
1943
|
$('.peek', node).onclick = (e) => {
|
|
1814
1944
|
const log = $('.log', node);
|
|
@@ -1836,7 +1966,15 @@ function renderAskCard(s) {
|
|
|
1836
1966
|
const answer = async (text) => {
|
|
1837
1967
|
if (!text.trim()) return;
|
|
1838
1968
|
node.classList.add('sent');
|
|
1839
|
-
|
|
1969
|
+
const undo = startOptimistic(s.id, text, null, { answering: true });
|
|
1970
|
+
try {
|
|
1971
|
+
await api(`/sessions/${s.id}/answer`, { method: 'POST', body: { text } });
|
|
1972
|
+
if (undo) scheduleRefetch(s.id);
|
|
1973
|
+
} catch (e) {
|
|
1974
|
+
node.classList.remove('sent');
|
|
1975
|
+
undo?.();
|
|
1976
|
+
toast(esc(e.message), { tone: 'bad' });
|
|
1977
|
+
}
|
|
1840
1978
|
};
|
|
1841
1979
|
node.addEventListener('click', (e) => {
|
|
1842
1980
|
const b = e.target.closest('.opt');
|
|
@@ -2063,21 +2201,58 @@ async function sendReply(id, text) {
|
|
|
2063
2201
|
if (!s || !text) return;
|
|
2064
2202
|
if (n?.tray?.busy) toast('Waiting for uploads…');
|
|
2065
2203
|
const attachments = n?.tray ? await n.tray.ids() : [];
|
|
2204
|
+
const answering = !!s.ask && !attachments.length;
|
|
2205
|
+
// Optimistic: the composer empties and the card goes to Working before the server answers
|
|
2206
|
+
// (it may first have to bring back an archived worktree, or reach another machine).
|
|
2207
|
+
const ta = n && $('.reply', n.card);
|
|
2208
|
+
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2209
|
+
S.drafts.delete(id);
|
|
2210
|
+
const files = n?.tray?.take();
|
|
2211
|
+
const undo = startOptimistic(id, text, files, { answering });
|
|
2066
2212
|
try {
|
|
2067
|
-
const r =
|
|
2213
|
+
const r = answering
|
|
2068
2214
|
? await api(`/sessions/${id}/answer`, { method: 'POST', body: { text } })
|
|
2069
2215
|
: await api(`/sessions/${id}/reply`, { method: 'POST', body: { text, attachments, queue: queueing() } });
|
|
2070
|
-
|
|
2071
|
-
if (ta) ((ta.value = ''), ta.dispatchEvent(new Event('input')));
|
|
2072
|
-
S.drafts.delete(id);
|
|
2073
|
-
n?.tray?.clear();
|
|
2216
|
+
if (undo) scheduleRefetch(id); // the summary that follows says "running" too, so it won't refetch by itself
|
|
2074
2217
|
if (r.queued)
|
|
2075
2218
|
toast(r.ahead ? `Queued behind ${r.ahead} earlier follow-up${r.ahead > 1 ? 's' : ''} — they go out first, in order.` : 'Queued — it goes out as soon as the agent finishes.');
|
|
2076
2219
|
} catch (err) {
|
|
2220
|
+
undo?.();
|
|
2221
|
+
// Give the message back, unless the user has already started typing another one.
|
|
2222
|
+
if (ta?.isConnected && !ta.value) ((ta.value = text), ta.dispatchEvent(new Event('input')));
|
|
2223
|
+
n?.tray?.restore(files);
|
|
2077
2224
|
toast(esc(err.message), { tone: 'bad' });
|
|
2078
2225
|
}
|
|
2079
2226
|
}
|
|
2080
2227
|
|
|
2228
|
+
// Show a follow-up as started right away: the card moves to Working with the prompt as a live
|
|
2229
|
+
// turn. Only when it will start now, not when it queues or interrupts a run already working.
|
|
2230
|
+
// Returns an undo for a failed send, or null when nothing changed.
|
|
2231
|
+
function startOptimistic(id, text, files, { answering }) {
|
|
2232
|
+
const full = S.full.get(id);
|
|
2233
|
+
const sum = S.sessions.get(id);
|
|
2234
|
+
if (!full || !sum) return null;
|
|
2235
|
+
const live = full.state === 'running' || full.state === 'waiting';
|
|
2236
|
+
if (live && !answering) return null;
|
|
2237
|
+
const now = Date.now();
|
|
2238
|
+
const turn = { id: `pending-${now}`, prompt: text, status: 'running', startedAt: now, activity: [], attachments: (files || []).filter((f) => f.id).map((f) => ({ id: f.id, name: f.name, mime: f.mime })) };
|
|
2239
|
+
const nextFull = { ...full, state: 'running', ask: null, turns: answering ? full.turns : [...full.turns, turn] };
|
|
2240
|
+
const nextSum = { ...sum, state: 'running', live: answering ? 'Picking up your answer' : 'Starting up', updatedAt: now };
|
|
2241
|
+
const apply = (f, sm) => {
|
|
2242
|
+
S.full.set(id, f);
|
|
2243
|
+
S.sessions.set(id, sm);
|
|
2244
|
+
const card = S.nodes.get(id)?.card;
|
|
2245
|
+
keepInPlace(watching(id) ? card : feedAnchor(id), renderFeed);
|
|
2246
|
+
renderBody(id, { fresh: false });
|
|
2247
|
+
};
|
|
2248
|
+
apply(nextFull, nextSum);
|
|
2249
|
+
return () => {
|
|
2250
|
+
// A live update may already have replaced the guess; only roll back what is still ours.
|
|
2251
|
+
if (S.full.get(id) !== nextFull || S.sessions.get(id) !== nextSum) return scheduleRefetch(id);
|
|
2252
|
+
apply(full, sum);
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2081
2256
|
// --- item actions ------------------------------------------------------------------
|
|
2082
2257
|
|
|
2083
2258
|
async function toggleDone(id) {
|
|
@@ -2085,23 +2260,48 @@ async function toggleDone(id) {
|
|
|
2085
2260
|
if (!s) return;
|
|
2086
2261
|
if (s.state === 'running' || s.state === 'waiting') return toast('Still working — stop it first or let it finish.');
|
|
2087
2262
|
const next = s.state === 'done' ? 'review' : 'done';
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
if (
|
|
2091
|
-
|
|
2092
|
-
|
|
2263
|
+
// Optimistic: the card moves now; a failed save puts it back.
|
|
2264
|
+
move(id, { ...s, state: next }, next === 'done');
|
|
2265
|
+
if (next === 'done') toast(`Marked done`, { action: { label: 'Undo', run: () => toggleDone(id) } });
|
|
2266
|
+
try {
|
|
2267
|
+
await api(`/sessions/${id}`, { method: 'PATCH', body: { state: next } });
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
revertSummary(id, s);
|
|
2270
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2093
2271
|
}
|
|
2094
2272
|
}
|
|
2095
2273
|
|
|
2274
|
+
// Apply a summary change locally before the server confirms it. Leaving the list (done, archived)
|
|
2275
|
+
// hands focus to the next card; coming back just re-renders around the reader.
|
|
2276
|
+
function move(id, next, leaving) {
|
|
2277
|
+
if (leaving) return handOff(id)(() => S.sessions.set(id, next));
|
|
2278
|
+
S.sessions.set(id, next);
|
|
2279
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Put back a summary changed optimistically, unless a live update has replaced it since.
|
|
2283
|
+
function revertSummary(id, prev) {
|
|
2284
|
+
const cur = S.sessions.get(id);
|
|
2285
|
+
if (cur && cur.updatedAt > prev.updatedAt) return;
|
|
2286
|
+
S.sessions.set(id, prev);
|
|
2287
|
+
keepInPlace(feedAnchor(id), renderFeed);
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2096
2290
|
// Every finished run of a compare task at once, with one toast to undo them all.
|
|
2097
2291
|
async function taskDone(ids) {
|
|
2098
2292
|
if (!ids.length) return;
|
|
2099
2293
|
const card = S.nodes.get(ids[0])?.card.closest('.task-item');
|
|
2100
2294
|
const next = card?.nextElementSibling?.querySelector('.item')?.dataset.id || card?.nextElementSibling?.dataset.id;
|
|
2101
|
-
const set = (state) =>
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2295
|
+
const set = (state) => {
|
|
2296
|
+
const prev = ids.map((id) => S.sessions.get(id));
|
|
2297
|
+
for (const id of ids) S.sessions.set(id, { ...S.sessions.get(id), state });
|
|
2298
|
+
renderFeed();
|
|
2299
|
+
Promise.all(ids.map((id) => api(`/sessions/${id}`, { method: 'PATCH', body: { state } }))).catch((err) => {
|
|
2300
|
+
prev.forEach((p, i) => p && revertSummary(ids[i], p));
|
|
2301
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2302
|
+
});
|
|
2303
|
+
};
|
|
2304
|
+
set('done');
|
|
2105
2305
|
if (next && S.nodes.has(next)) setFocus(next);
|
|
2106
2306
|
toast(`Marked ${ids.length} runs done`, { action: { label: 'Undo', run: () => set('review') } });
|
|
2107
2307
|
}
|
|
@@ -2182,10 +2382,17 @@ function rename(id) {
|
|
|
2182
2382
|
// One popover for all three choices: harnesses on the left, the focused harness's models on the
|
|
2183
2383
|
// right, and its thinking levels underneath. Clicking (or arrowing to) a harness previews its
|
|
2184
2384
|
// models; picking a model commits harness + model, picking a thinking level commits it in place.
|
|
2185
|
-
|
|
2385
|
+
// places: the "Run on" picks, so the list is what's installed there rather than only on this computer.
|
|
2386
|
+
function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort, places }, pick) {
|
|
2186
2387
|
const open = $('.picker');
|
|
2187
2388
|
if (open) return open.close();
|
|
2188
|
-
|
|
2389
|
+
const agents = places ? harnessesAt(places) : S.agents;
|
|
2390
|
+
const sub = (a) => {
|
|
2391
|
+
if (!a.available) return places?.length > 1 || places?.[0] ? 'Not installed there' : 'Not installed';
|
|
2392
|
+
const models = (a.models || []).length > 1 ? `${a.models.length - 1} models` : 'Default model';
|
|
2393
|
+
return a.missing?.length ? `Not on ${a.missing.map(placeName).join(', ')}` : models;
|
|
2394
|
+
};
|
|
2395
|
+
let focusAgent = agents.find((a) => a.id === agent && a.available) ? agent : agents.find((a) => a.available)?.id;
|
|
2189
2396
|
|
|
2190
2397
|
const r = anchor.getBoundingClientRect();
|
|
2191
2398
|
const width = Math.min(580, innerWidth - 24);
|
|
@@ -2198,11 +2405,11 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2198
2405
|
<div class="picker" role="dialog" aria-label="Harness, model and thinking level" style="left:${left}px;width:${width}px;${below ? `top:${r.bottom + 6}px` : `bottom:${innerHeight - r.top + 6}px`}">
|
|
2199
2406
|
<div class="pk-side" role="listbox" aria-label="Harness">
|
|
2200
2407
|
<div class="pk-head">Harness</div>
|
|
2201
|
-
${
|
|
2408
|
+
${agents
|
|
2202
2409
|
.map(
|
|
2203
2410
|
(a) => `<button class="pk-h${a.available ? '' : ' off'}" role="option" data-id="${esc(a.id)}" ${a.available ? '' : 'disabled'}>
|
|
2204
2411
|
<span class="pk-logo">${harnessLogo(a.id)}</span>
|
|
2205
|
-
<span class="pk-text"><b>${esc(a.name)}</b><small
|
|
2412
|
+
<span class="pk-text"><b>${esc(a.name)}</b><small${a.missing?.length ? ' class="warn"' : ''}>${esc(sub(a))}</small></span>
|
|
2206
2413
|
${a.id === agent ? '<span class="pk-dot" title="In use"></span>' : ''}
|
|
2207
2414
|
</button>`,
|
|
2208
2415
|
)
|
|
@@ -2246,7 +2453,7 @@ function runnerPicker(anchor, { agent, model, effort, savedModel, savedEffort },
|
|
|
2246
2453
|
};
|
|
2247
2454
|
|
|
2248
2455
|
const paintModels = () => {
|
|
2249
|
-
const a =
|
|
2456
|
+
const a = agents.find((x) => x.id === focusAgent);
|
|
2250
2457
|
sideRows.forEach((b) => b.classList.toggle('on', b.dataset.id === focusAgent));
|
|
2251
2458
|
const models = a?.models || [{ id: '', label: 'Default' }];
|
|
2252
2459
|
const current = focusAgent === agent ? model : savedModel(focusAgent);
|
|
@@ -2476,9 +2683,12 @@ const keyHint = (k) => `<kbd class="menu-kbd">${k}</kbd>`;
|
|
|
2476
2683
|
|
|
2477
2684
|
async function deleteSession(id) {
|
|
2478
2685
|
if (!confirm('Delete this session from wisp? (The agent’s own history is kept.)')) return;
|
|
2479
|
-
const
|
|
2480
|
-
|
|
2481
|
-
|
|
2686
|
+
const prev = S.sessions.get(id);
|
|
2687
|
+
handOff(id)(() => S.sessions.delete(id));
|
|
2688
|
+
await api(`/sessions/${id}`, { method: 'DELETE' }).catch((err) => {
|
|
2689
|
+
if (prev && !S.sessions.has(id)) (S.sessions.set(id, prev), keepInPlace(feedAnchor(id), renderFeed));
|
|
2690
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2691
|
+
});
|
|
2482
2692
|
}
|
|
2483
2693
|
|
|
2484
2694
|
// Launch the task again as a new session in the same folder: same prompt and attachments,
|
|
@@ -2537,22 +2747,13 @@ async function rerun(id) {
|
|
|
2537
2747
|
const go = async () => {
|
|
2538
2748
|
const prompt = ta.value.trim();
|
|
2539
2749
|
if (!prompt) return ta.focus();
|
|
2540
|
-
|
|
2541
|
-
btn.disabled = true;
|
|
2750
|
+
close();
|
|
2542
2751
|
try {
|
|
2543
|
-
const
|
|
2544
|
-
|
|
2545
|
-
close();
|
|
2546
|
-
if (S.filter !== 'all' && S.filter !== next.projectId) {
|
|
2547
|
-
S.filter = 'all';
|
|
2548
|
-
save('wisp.filter', S.filter);
|
|
2549
|
-
}
|
|
2550
|
-
renderFeed();
|
|
2752
|
+
const body = { prompt, cwd: s.cwd, agent, mode, model, effort, attachments: files.map((f) => f.id) };
|
|
2753
|
+
const next = await launchBehind({ prompt, cwd: s.cwd, runner: { agent, model, effort } }, () => api('/sessions', { method: 'POST', body }));
|
|
2551
2754
|
setFocus(next.id);
|
|
2552
|
-
toast(`Rerunning on ${esc(agentName(agent))}`);
|
|
2553
2755
|
} catch (err) {
|
|
2554
|
-
|
|
2555
|
-
toast(esc(err.message), { tone: 'bad' });
|
|
2756
|
+
toast(`Rerun failed: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Edit again', run: () => rerun(id) } });
|
|
2556
2757
|
}
|
|
2557
2758
|
};
|
|
2558
2759
|
$('[data-a="go"]', node).onclick = go;
|
|
@@ -2568,15 +2769,16 @@ async function rerun(id) {
|
|
|
2568
2769
|
}
|
|
2569
2770
|
|
|
2570
2771
|
async function setArchived(id, archived) {
|
|
2571
|
-
const
|
|
2772
|
+
const prev = S.sessions.get(id);
|
|
2773
|
+
if (!prev) return;
|
|
2774
|
+
// Optimistic: the card leaves (or comes back) now; a failed save puts it back.
|
|
2775
|
+
move(id, { ...prev, archived }, archived);
|
|
2776
|
+
if (archived) toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2572
2777
|
try {
|
|
2573
2778
|
await api(`/sessions/${id}`, { method: 'PATCH', body: { archived } });
|
|
2574
2779
|
} catch (err) {
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
if (leave) {
|
|
2578
|
-
leave(() => S.sessions.set(id, { ...S.sessions.get(id), archived: true }));
|
|
2579
|
-
toast('Archived', { action: { label: 'Undo', run: () => setArchived(id, false) } });
|
|
2780
|
+
revertSummary(id, prev);
|
|
2781
|
+
toast(esc(err.message), { tone: 'bad' });
|
|
2580
2782
|
}
|
|
2581
2783
|
}
|
|
2582
2784
|
|
|
@@ -3008,16 +3210,21 @@ async function compareView(id) {
|
|
|
3008
3210
|
if (a === 'open') return close(), reveal(keep);
|
|
3009
3211
|
const others = group.filter((g) => g.id !== keep);
|
|
3010
3212
|
if (!confirm(`Keep ${agentName(S.sessions.get(keep)?.agent)}'s result?\n\nThe other ${others.length === 1 ? 'run is' : `${others.length} runs are`} archived, and ${others.length === 1 ? 'its worktree is' : 'their worktrees are'} deleted with their changes.`)) return;
|
|
3213
|
+
// Optimistic: the others leave the feed now; stopping and cleaning them up goes on behind.
|
|
3214
|
+
for (const o of others) S.sessions.set(o.id, { ...S.sessions.get(o.id), archived: true });
|
|
3215
|
+
close();
|
|
3216
|
+
renderFeed();
|
|
3217
|
+
reveal(keep);
|
|
3218
|
+
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3219
|
+
let failed = false;
|
|
3011
3220
|
for (const o of others) {
|
|
3012
3221
|
if (o.state === 'running' || o.state === 'waiting') {
|
|
3013
3222
|
await api(`/sessions/${o.id}/stop`, { method: 'POST', body: {} }).catch(() => {});
|
|
3014
3223
|
await until(() => S.sessions.get(o.id)?.state !== 'running', 5000);
|
|
3015
3224
|
}
|
|
3016
|
-
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => toast(esc(err.message), { tone: 'bad' }));
|
|
3225
|
+
await api(`/sessions/${o.id}`, { method: 'PATCH', body: { archived: true, discardWorktree: true } }).catch((err) => ((failed = true), toast(esc(err.message), { tone: 'bad' })));
|
|
3017
3226
|
}
|
|
3018
|
-
|
|
3019
|
-
reveal(keep);
|
|
3020
|
-
toast(`Kept ${esc(agentName(S.sessions.get(keep)?.agent))}'s run. Ship it when you're ready.`);
|
|
3227
|
+
if (failed) loadSessions(); // show what really got archived
|
|
3021
3228
|
};
|
|
3022
3229
|
}
|
|
3023
3230
|
|
|
@@ -3282,18 +3489,17 @@ function runRecipe(r) {
|
|
|
3282
3489
|
const params = Object.fromEntries($$('input[name^="p:"]', node).map((i) => [i.name.slice(2), i.value]));
|
|
3283
3490
|
const d = S.config.defaults;
|
|
3284
3491
|
const agent = r.agent || d.agent;
|
|
3492
|
+
const cwd = needsProject ? $('[name="cwd"]', node).value : r.cwd;
|
|
3493
|
+
const model = r.agent ? r.model : savedModel(agent);
|
|
3494
|
+
const effort = r.agent ? r.effort : savedEffort(agent, model);
|
|
3495
|
+
const body = { params, cwd: needsProject ? cwd : undefined, agent, model: r.agent ? undefined : model, effort: r.agent ? undefined : effort, mode: r.mode || d.mode };
|
|
3496
|
+
close();
|
|
3285
3497
|
try {
|
|
3286
|
-
const
|
|
3287
|
-
|
|
3288
|
-
body: { params, cwd: needsProject ? $('[name="cwd"]', node).value : undefined, agent, model: r.agent ? undefined : savedModel(agent), effort: r.agent ? undefined : savedEffort(agent, savedModel(agent)), mode: r.mode || d.mode },
|
|
3289
|
-
});
|
|
3290
|
-
S.sessions.set(s.id, s);
|
|
3291
|
-
close();
|
|
3292
|
-
renderFeed();
|
|
3498
|
+
const prompt = r.prompt.replace(/\{([a-zA-Z][\w-]{0,30})\}/g, (_, k) => params[k] || ''); // as the server fills it
|
|
3499
|
+
const s = await launchBehind({ prompt, cwd, runner: { agent, model, effort } }, () => api(`/recipes/${r.id}/run`, { method: 'POST', body }));
|
|
3293
3500
|
setFocus(s.id);
|
|
3294
|
-
toast(`Running “${esc(r.name)}”`);
|
|
3295
3501
|
} catch (err) {
|
|
3296
|
-
toast(esc(err.message)
|
|
3502
|
+
toast(`“${esc(r.name)}” didn’t start: ${esc(err.message)}`, { tone: 'bad', action: { label: 'Try again', run: () => runRecipe(r) } });
|
|
3297
3503
|
}
|
|
3298
3504
|
};
|
|
3299
3505
|
// Always wait for Run, even with nothing to fill in, so a click on a recipe never launches it outright.
|
|
@@ -3500,6 +3706,7 @@ function setMachines(list) {
|
|
|
3500
3706
|
S.machines = list;
|
|
3501
3707
|
paintMachinesBtn();
|
|
3502
3708
|
renderLaunchChips();
|
|
3709
|
+
$('#launcher')?.placesChanged?.();
|
|
3503
3710
|
renderProjects();
|
|
3504
3711
|
$('.machines-modal')?.refresh();
|
|
3505
3712
|
$('.projects-modal')?.refresh?.();
|
|
@@ -3624,7 +3831,7 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3624
3831
|
</div>
|
|
3625
3832
|
${
|
|
3626
3833
|
st === 'online'
|
|
3627
|
-
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
3834
|
+
? `<div class="mc-meta"><span>wisp ${esc(m.remote?.version || '')}</span>${m.outdated ? `<button class="ghost xs mc-update" data-a="update" title="Restart wisp on ${esc(m.name)} as ${esc(m.outdated)}">${icon('rerun')}Update to ${esc(m.outdated)}</button>` : ''}${ready.length ? `<span class="mc-agents">${ready.map((a) => `<span class="model-logo" title="${esc(a.name)} ${esc(a.version || '')}">${harnessLogo(a.id)}</span>`).join('')}</span>` : '<span class="bad">no harnesses found</span>'}${m.working ? `<span>${m.working} working</span>` : ''}</div>`
|
|
3628
3835
|
: ''
|
|
3629
3836
|
}
|
|
3630
3837
|
${busy || st === 'error' ? steps(m) : ''}
|
|
@@ -3697,8 +3904,9 @@ function openMachines({ host, focusAdd = false } = {}) {
|
|
|
3697
3904
|
paint();
|
|
3698
3905
|
$(`.mc[data-id="${m.id}"] input[name="target"]`, node)?.focus();
|
|
3699
3906
|
}
|
|
3700
|
-
if (a === 'restart') {
|
|
3701
|
-
|
|
3907
|
+
if (a === 'restart' || a === 'update') {
|
|
3908
|
+
const verb = a === 'update' ? `Updating wisp there to ${m.outdated}` : 'Restarting wisp there';
|
|
3909
|
+
if (m.working && !confirm(`${m.working} agent${m.working === 1 ? ' is' : 's are'} working on ${m.name}. ${verb} stops ${m.working === 1 ? 'it' : 'them'}. ${a === 'update' ? 'Update' : 'Restart'}?`)) return;
|
|
3702
3910
|
await api(`/machines/${m.id}/stop`, { method: 'POST', body: { restart: true } });
|
|
3703
3911
|
}
|
|
3704
3912
|
if (a === 'stop') {
|
|
@@ -4250,7 +4458,7 @@ function paletteItems() {
|
|
|
4250
4458
|
items.push({ group: g, label: 'Delete session', sub, icon: 'trash', kbd: '⌫', keywords: 'remove', run: () => deleteSession(id) });
|
|
4251
4459
|
}
|
|
4252
4460
|
|
|
4253
|
-
const sessions = [...S.sessions.values()].sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4461
|
+
const sessions = [...S.sessions.values()].filter((s) => !s.pending).sort((a, b) => a.archived - b.archived || b.updatedAt - a.updatedAt);
|
|
4254
4462
|
for (const s of sessions) {
|
|
4255
4463
|
const state = s.archived ? 'Archived' : s.state === 'review' ? STATUS_LABEL[s.status] || 'Review' : STATE_LABEL[s.state] || s.state;
|
|
4256
4464
|
items.push({
|
|
@@ -4336,7 +4544,7 @@ function notifyFinished(s) {
|
|
|
4336
4544
|
}
|
|
4337
4545
|
toast(`${icon(s.state === 'waiting' ? 'ask' : 'spark')} <b>${esc(s.title)}</b> ${verb}`, { action: { label: 'Open', run: () => reveal(s.id) }, timeout: 7000 });
|
|
4338
4546
|
if (notifyOn() && !document.hasFocus()) {
|
|
4339
|
-
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/
|
|
4547
|
+
const note = new Notification(`${s.project}: ${s.title}`, { body: s.state === 'waiting' ? s.question : s.preview || 'Ready for review', icon: '/apple-touch-icon.png', tag: s.id });
|
|
4340
4548
|
note.onclick = () => (window.focus(), reveal(s.id));
|
|
4341
4549
|
}
|
|
4342
4550
|
}
|
|
@@ -4349,6 +4557,7 @@ function connect() {
|
|
|
4349
4557
|
const s = JSON.parse(e.data);
|
|
4350
4558
|
if (s.parentId) return onChildSummary(s); // a subagent: lives inside its lead's card
|
|
4351
4559
|
const prev = S.sessions.get(s.id);
|
|
4560
|
+
if (!prev) dropPending(s);
|
|
4352
4561
|
const was = prev && sectionOf(prev); // before the update: a compare task's section depends on all its runs
|
|
4353
4562
|
S.sessions.set(s.id, s);
|
|
4354
4563
|
// A section change moves the card (a follow-up sends it from "Needs you" to "Working").
|
|
@@ -4448,7 +4657,7 @@ async function installUpdate() {
|
|
|
4448
4657
|
|
|
4449
4658
|
// --- keyboard ---------------------------------------------------------------------------
|
|
4450
4659
|
|
|
4451
|
-
const feedIds = () => $$('#feed .item').map((x) => x.dataset.id);
|
|
4660
|
+
const feedIds = () => $$('#feed .item:not(.pending)').map((x) => x.dataset.id);
|
|
4452
4661
|
// The user takes a card out of its list (done, archive, delete). The next card — the one below,
|
|
4453
4662
|
// or the one above at the end of the list — gets focus and lands where the old one sat, so
|
|
4454
4663
|
// triage continues on the same spot. Call before the request, while the card is still laid
|