wisp-agents 0.17.0 → 0.18.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 +22 -1
- package/README.md +5 -4
- package/bin/wisp.js +4 -2
- package/package.json +2 -2
- package/server/git.js +114 -2
- package/server/machines.js +2 -2
- package/server/openapi.js +18 -1
- package/server/routes/meta.js +1 -1
- package/server/routes/settings.js +51 -4
- package/server/routes/ship.js +24 -2
- package/server/runner.js +6 -2
- package/server/services/auto.js +12 -1
- package/server/services/launch.js +11 -7
- package/server/services/ship.js +83 -4
- package/server/store.js +15 -0
- package/web/css/launcher.css +17 -0
- package/web/css/layout.css +10 -2
- package/web/css/modals.css +8 -0
- package/web/css/palette.css +22 -0
- package/web/css/ship.css +40 -0
- package/web/index.html +15 -5
- package/web/js/app.js +14 -3
- package/web/js/core/state.js +2 -1
- package/web/js/features/clones.js +79 -0
- package/web/js/features/feed.js +3 -3
- package/web/js/features/launcher.js +139 -19
- package/web/js/features/live.js +3 -3
- package/web/js/features/project-list.js +99 -0
- package/web/js/features/projects.js +218 -34
- package/web/js/features/settings.js +16 -2
- package/web/js/features/setup.js +3 -3
- package/web/js/features/ship.js +100 -24
package/server/services/ship.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
// Ship it: whether a worktree session has something to ship, the draft commit message and PR
|
|
2
|
-
// text, and following open PRs (checks, reviews, merges) so their cards stay current.
|
|
2
|
+
// text, and following open PRs (checks, reviews, merges) so their cards stay current. A PR that
|
|
3
|
+
// starts conflicting with its base gets fixed on its own: the task is sent a follow-up to merge the
|
|
4
|
+
// base in, and the result is pushed to the PR when it's done (defaults.fixConflicts: 'off' to stop).
|
|
3
5
|
|
|
4
6
|
import fs from 'node:fs';
|
|
5
|
-
import { worktreeStatus, prView } from '../git.js';
|
|
7
|
+
import { worktreeStatus, prView, ship, baseMerged, conflictsWithBase } from '../git.js';
|
|
6
8
|
import { blocksToMarkdown } from '../render.js';
|
|
7
9
|
import { summarize } from '../runner.js';
|
|
8
10
|
|
|
9
11
|
const PR_POLL_MS = 60_000;
|
|
10
12
|
const SHIP_RECHECK_MS = 5_000;
|
|
13
|
+
const MAX_CONFLICT_FIXES = 3; // per task, so a base that keeps moving can't run up the bill
|
|
14
|
+
|
|
15
|
+
export const conflictPrompt = (s) => {
|
|
16
|
+
const { remote, base } = s.worktree;
|
|
17
|
+
return [
|
|
18
|
+
`PR #${s.pr.number} (${s.pr.url}) can no longer merge into \`${base}\`: changes that landed there since conflict with this branch. Bring the branch up to date:`,
|
|
19
|
+
'',
|
|
20
|
+
`1. \`git fetch ${remote}\`, then \`git merge ${remote}/${base}\`. Don't rebase: the branch is already pushed.`,
|
|
21
|
+
`2. Resolve every conflict, keeping the intent of both sides: what landed on \`${base}\` and what this task changed. Don't drop either side's changes to make a conflict go away.`,
|
|
22
|
+
'3. Run the checks that are cheap here (tests, type-check, build) on the merged result and fix what the merge broke.',
|
|
23
|
+
"4. Finish with `git commit --no-edit`. Don't push: wisp pushes to the PR when you're done.",
|
|
24
|
+
'',
|
|
25
|
+
'If a conflict needs a decision only the user can make, ask them.',
|
|
26
|
+
].join('\n');
|
|
27
|
+
};
|
|
11
28
|
|
|
12
29
|
export function createShip(app) {
|
|
13
30
|
const { store, runner } = app;
|
|
@@ -48,14 +65,68 @@ export function createShip(app) {
|
|
|
48
65
|
const pr = await prView(dir, prev.number);
|
|
49
66
|
const changed = (k) => JSON.stringify(k(pr)) !== JSON.stringify(k(prev));
|
|
50
67
|
s.pr = pr;
|
|
68
|
+
if (s.conflictFix && !pr.conflict && !['fixing', 'pushing'].includes(s.conflictFix.step)) delete s.conflictFix;
|
|
51
69
|
if (pr.state !== prev.state) app.auto.signals(summarize(s, store));
|
|
52
70
|
// Things worth coming back for: CI finished either way, a review landed, it merged or closed.
|
|
53
71
|
const settledChecks = (x) => (x.checks.pending ? 'pending' : x.checks.fail ? 'fail' : 'pass');
|
|
54
72
|
if (changed((x) => x.state) || changed((x) => x.review) || (changed(settledChecks) && settledChecks(pr) !== 'pending')) s.unread = true;
|
|
55
73
|
app.sessionChanged(s);
|
|
74
|
+
await fixConflicts(s).catch(() => {});
|
|
56
75
|
return pr;
|
|
57
76
|
};
|
|
58
77
|
|
|
78
|
+
// The PR conflicts with its base: send the task a follow-up to merge the base in. Once per PR
|
|
79
|
+
// head, so a fix that didn't take isn't retried until something changes the branch.
|
|
80
|
+
const starting = new Set(); // a poll and a refresh at once must not both send it
|
|
81
|
+
const fixConflicts = async (s) => {
|
|
82
|
+
const pr = s.pr;
|
|
83
|
+
if (store.config.defaults.fixConflicts === 'off' || pr?.state !== 'OPEN' || !pr.conflict || !s.worktree?.remote) return;
|
|
84
|
+
if (runner.live.has(s.id) || s.shipping || s.ask || s.archived || starting.has(s.id)) return;
|
|
85
|
+
const tries = s.conflictFix?.tries || 0;
|
|
86
|
+
if (s.conflictFix?.head === pr.head || tries >= MAX_CONFLICT_FIXES) return;
|
|
87
|
+
starting.add(s.id);
|
|
88
|
+
try {
|
|
89
|
+
if ((await conflictsWithBase(s.worktree)) === false || runner.live.has(s.id)) return;
|
|
90
|
+
} finally {
|
|
91
|
+
starting.delete(s.id);
|
|
92
|
+
}
|
|
93
|
+
s.conflictFix = { step: 'fixing', head: pr.head, tries: tries + 1, startedAt: Date.now() };
|
|
94
|
+
await app.launcher.replyTo(s, conflictPrompt(s), [], true);
|
|
95
|
+
app.telemetry?.track?.('conflict_fix', { agent: s.agent });
|
|
96
|
+
app.sessionChanged(s, { now: true });
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// The fix-up turn ended: push it to the PR if the base really is merged in, else leave the task
|
|
100
|
+
// for the user with why.
|
|
101
|
+
const finishFix = async (s, { interrupted, errorText }) => {
|
|
102
|
+
const fix = s.conflictFix;
|
|
103
|
+
if (fix?.step !== 'fixing') return;
|
|
104
|
+
const fail = (why) => {
|
|
105
|
+
Object.assign(fix, { step: 'failed', why });
|
|
106
|
+
s.unread = true;
|
|
107
|
+
app.sessionChanged(s, { now: true });
|
|
108
|
+
};
|
|
109
|
+
if (interrupted || s.queue?.length) return fail('Stopped: the task moved on before the conflicts were fixed.');
|
|
110
|
+
if (errorText) return fail(`The agent run failed: ${errorText}`.slice(0, 300));
|
|
111
|
+
const check = await baseMerged(s.worktree).catch((err) => ({ ok: false, why: err.message }));
|
|
112
|
+
if (!check.ok) return fail(check.why);
|
|
113
|
+
fix.step = 'pushing';
|
|
114
|
+
s.shipping = { step: 'start', update: true, startedAt: Date.now() };
|
|
115
|
+
app.sessionChanged(s);
|
|
116
|
+
const onStep = (step) => ((s.shipping = { ...s.shipping, step }), app.sessionChanged(s));
|
|
117
|
+
try {
|
|
118
|
+
s.pr = await ship(s.worktree, { title: `Merge ${s.worktree.remote}/${s.worktree.base} into ${s.worktree.branch}`, pr: s.pr, onStep });
|
|
119
|
+
fix.step = 'done';
|
|
120
|
+
} catch (err) {
|
|
121
|
+
Object.assign(fix, { step: 'failed', why: `Push failed: ${err.message}` });
|
|
122
|
+
s.unread = true;
|
|
123
|
+
} finally {
|
|
124
|
+
delete s.shipping;
|
|
125
|
+
app.sessionChanged(s, { now: true });
|
|
126
|
+
}
|
|
127
|
+
recheck(s);
|
|
128
|
+
};
|
|
129
|
+
|
|
59
130
|
// Default commit message and PR text, from the session's latest presentation.
|
|
60
131
|
const draft = (s) => {
|
|
61
132
|
const turn = s.turns.findLast((t) => t.status === 'done') || s.turns.at(-1);
|
|
@@ -70,7 +141,15 @@ export function createShip(app) {
|
|
|
70
141
|
return { title: title.slice(0, 100), body: `${body}\n\n_Shipped from [wisp](https://github.com/M0xoo/wisp)._`.trim() };
|
|
71
142
|
};
|
|
72
143
|
|
|
73
|
-
runner.on('ended', ({ session }) =>
|
|
144
|
+
runner.on('ended', ({ session, interrupted, errorText }) => {
|
|
145
|
+
const s = store.get(session.id);
|
|
146
|
+
recheck(s);
|
|
147
|
+
if (s?.conflictFix?.step === 'fixing') finishFix(s, { interrupted, errorText }).catch(() => {});
|
|
148
|
+
});
|
|
149
|
+
for (const s of store.all()) {
|
|
150
|
+
delete s.shipping; // a ship cut short by a restart isn't running anymore
|
|
151
|
+
if (s.conflictFix?.step === 'pushing') Object.assign(s.conflictFix, { step: 'failed', why: 'wisp restarted while pushing the fix.' });
|
|
152
|
+
}
|
|
74
153
|
for (const s of store.all()) if (s.worktree && !s.archived && s.pr?.state !== 'MERGED') recheck(s);
|
|
75
154
|
|
|
76
155
|
const timer = setInterval(async () => {
|
|
@@ -78,5 +157,5 @@ export function createShip(app) {
|
|
|
78
157
|
}, PR_POLL_MS);
|
|
79
158
|
timer.unref();
|
|
80
159
|
|
|
81
|
-
return { recheck, refreshPr, draft, stop: () => clearInterval(timer) };
|
|
160
|
+
return { recheck, refreshPr, fixConflicts, draft, stop: () => clearInterval(timer) };
|
|
82
161
|
}
|
package/server/store.js
CHANGED
|
@@ -88,6 +88,7 @@ export class Store {
|
|
|
88
88
|
|
|
89
89
|
// machine: a project on another computer (paths there are POSIX); null = this one.
|
|
90
90
|
projectFor(cwd = '', machine = null) {
|
|
91
|
+
if (!machine && this.isScratch(cwd)) return null; // never inside a project, even one at ~
|
|
91
92
|
const sep = machine ? '/' : path.sep;
|
|
92
93
|
let best = null;
|
|
93
94
|
for (const p of this.config.projects)
|
|
@@ -112,7 +113,19 @@ export class Store {
|
|
|
112
113
|
return project;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// Folder-less tasks run in a throwaway directory of their own under ~/.wisp/scratch.
|
|
117
|
+
scratchDir(id) {
|
|
118
|
+
const dir = path.join(this.home, 'scratch', id);
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
return dir;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
isScratch(dir = '') {
|
|
124
|
+
return String(dir).startsWith(path.join(this.home, 'scratch') + path.sep);
|
|
125
|
+
}
|
|
126
|
+
|
|
115
127
|
touchProject(dir) {
|
|
128
|
+
if (this.isScratch(dir)) return null;
|
|
116
129
|
const p = this.projectFor(dir) || this.addProject(dir);
|
|
117
130
|
p.lastUsedAt = Date.now();
|
|
118
131
|
this.saveConfig();
|
|
@@ -147,6 +160,8 @@ export class Store {
|
|
|
147
160
|
|
|
148
161
|
delete(id) {
|
|
149
162
|
clearTimeout(this.timers.get(id));
|
|
163
|
+
const cwd = this.sessions.get(id)?.cwd;
|
|
164
|
+
if (this.isScratch(cwd)) fs.rmSync(cwd, { recursive: true, force: true }); // a folder-less task's files go with it
|
|
150
165
|
this.sessions.delete(id);
|
|
151
166
|
fs.rmSync(path.join(this.dir, `${id}.json`), { force: true });
|
|
152
167
|
fs.rmSync(path.join(this.shots, id), { recursive: true, force: true });
|
package/web/css/launcher.css
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
while it's open so switching between the two harness pickers doesn't flash the ring off and on. */
|
|
5
5
|
.launcher:is(:focus-within, :has(.open)) { border-color: color-mix(in srgb, var(--accent) 55%, var(--line-2)); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
6
6
|
.launcher.drop, .composer.drop { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
7
|
+
/* index.html's first paint: same height as the real launcher, so nothing jumps when it fills in */
|
|
8
|
+
.launcher.skel .l-projects { height: 35px; }
|
|
9
|
+
.launcher.skel .l-bar { height: 38px; }
|
|
10
|
+
.launcher.skel .l-recipes { height: 40px; }
|
|
11
|
+
@container launcher (max-width: 480px) { .launcher.skel .l-recipes { height: 90px; } } /* its hint wraps on phones */
|
|
7
12
|
.l-projects { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 2px 8px; }
|
|
8
13
|
/* no harness yet: one quiet line offering to install opencode */
|
|
9
14
|
.l-setup { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; padding: 6px 6px 6px 10px; border-radius: 9px; background: var(--accent-soft); font-size: 13px; color: var(--ink-2); }
|
|
@@ -24,8 +29,20 @@
|
|
|
24
29
|
}
|
|
25
30
|
.lp-chip:hover { border-color: var(--line-2); background: var(--surface-2); }
|
|
26
31
|
.lp-chip.on { background: var(--bg-2); border-color: var(--line-2); color: var(--ink); font-weight: 500; }
|
|
32
|
+
#wtBtn[hidden] { display: none; }
|
|
33
|
+
.lp-chip.none .i { width: 13px; height: 13px; color: var(--muted); }
|
|
34
|
+
.lp-chip.none.on .i { color: var(--ink); }
|
|
27
35
|
.lp-chip.add { color: var(--muted); border-style: dashed; }
|
|
36
|
+
.lp-chip.cloning .orb.live { --s: 11px; margin: 0; }
|
|
37
|
+
.lp-chip .lp-note { color: var(--faint); font-weight: 400; font-size: 11.5px; }
|
|
28
38
|
.lp-chip.add .i { width: 14px; height: 14px; }
|
|
39
|
+
/* "+N": the projects that didn't get a chip, one search away */
|
|
40
|
+
.lp-chip.more { gap: 5px; padding: 0 9px; color: var(--muted); }
|
|
41
|
+
.lp-chip.more:hover, .lp-chip.more.open { color: var(--ink); border-color: var(--line-2); }
|
|
42
|
+
.lp-chip.more .i { width: 12px; height: 12px; }
|
|
43
|
+
.l-projects .lp-chip[hidden] { display: none; }
|
|
44
|
+
/* narrow, with "+N" (whose list has No folder too): it goes icon-only unless picked */
|
|
45
|
+
@container launcher (max-width: 520px) { .l-projects:has(.more:not([hidden])) .lp-chip.none:not(.on) span { display: none; } }
|
|
29
46
|
/* recipes: one-click prompts under the launch bar */
|
|
30
47
|
.l-recipes { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 8px -12px -10px; padding: 7px 14px 8px; border-top: 1px solid var(--line); }
|
|
31
48
|
.l-recipes .l-in { display: inline-flex; align-items: center; gap: 5px; }
|
package/web/css/layout.css
CHANGED
|
@@ -45,8 +45,16 @@
|
|
|
45
45
|
.dot.bad { background: var(--bad); }
|
|
46
46
|
|
|
47
47
|
/* project filter chips in the top bar */
|
|
48
|
-
.projects { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px;
|
|
49
|
-
|
|
48
|
+
.projects { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; }
|
|
49
|
+
/* only whole chips: those that don't fit the bar fold into "+N" (projects.js fits them) */
|
|
50
|
+
.p-scroll { flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 4px; overflow: hidden; }
|
|
51
|
+
.p-chip[hidden], .p-chip > [hidden], .p-scroll[hidden] { display: none; }
|
|
52
|
+
.p-chip.more.solo { min-width: 0; flex: 0 1 auto; gap: 6px; padding-left: 10px; color: var(--ink); font-weight: 500; }
|
|
53
|
+
.p-chip.more.solo .p-more-n { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
54
|
+
.p-chip.more { flex: none; gap: 4px; padding: 0 7px 0 9px; color: var(--muted); border-color: var(--line); }
|
|
55
|
+
.p-chip.more:hover, .p-chip.more.open { border-color: var(--line-2); color: var(--ink); }
|
|
56
|
+
.p-chip.more .chev { width: 11px; height: 11px; transform: rotate(90deg); transition: transform 0.2s var(--ease); }
|
|
57
|
+
.p-chip.more.open .chev { transform: rotate(-90deg); }
|
|
50
58
|
.p-chip {
|
|
51
59
|
display: inline-flex; align-items: center; gap: 6px; height: 26px; padding: 0 10px; border-radius: 99px; flex: none;
|
|
52
60
|
font-size: 12.5px; color: var(--muted); border: 1px solid transparent; transition: background 0.15s, border-color 0.15s, color 0.15s;
|
package/web/css/modals.css
CHANGED
|
@@ -43,6 +43,14 @@
|
|
|
43
43
|
.pname:focus { box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
44
44
|
.ppath { font: 11.5px var(--mono); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
45
45
|
.pcount { font-size: 12px; color: var(--muted); }
|
|
46
|
+
.discover h4 { display: flex; align-items: center; gap: 6px; }
|
|
47
|
+
.discover h4 .i { width: 14px; height: 14px; }
|
|
48
|
+
.discover h4 .muted { font-weight: 400; }
|
|
49
|
+
.prow.cloning .ppath { color: var(--muted); }
|
|
50
|
+
.discover h4 .link { margin-left: auto; font-size: 12px; }
|
|
51
|
+
.prow.cloning .orb.live { margin: 0; }
|
|
52
|
+
.hint .clonedir { width: 220px; height: 24px; padding: 0 7px; margin: 0 4px; border-radius: 7px; border: 1px solid var(--line-2); background: var(--surface); color: var(--ink); font-size: 12px; outline: 0; }
|
|
53
|
+
.hint .clonedir:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
46
54
|
.found { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 6px; }
|
|
47
55
|
.found-item { display: grid; grid-template-columns: 16px 1fr; column-gap: 8px; text-align: left; padding: 8px 10px; border-radius: 11px; border: 1px solid var(--line); transition: all 0.15s; }
|
|
48
56
|
.found-item:hover { border-color: var(--accent); background: var(--accent-soft); }
|
package/web/css/palette.css
CHANGED
|
@@ -53,6 +53,28 @@
|
|
|
53
53
|
.menu { position: fixed; z-index: 40; min-width: 220px; padding: 5px; border-radius: 13px; background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-2); animation: pop 0.2s var(--ease); }
|
|
54
54
|
.menu button { display: flex; align-items: center; gap: 10px; width: 100%; padding: 7px 10px; border-radius: 8px; font-size: 13.5px; color: var(--ink-2); }
|
|
55
55
|
.menu button:hover { background: var(--bg-2); color: var(--ink); }
|
|
56
|
+
/* the project list: search over every project, rows below, fixed extras at the bottom */
|
|
57
|
+
.menu.pm { display: flex; flex-direction: column; width: 340px; max-width: calc(100vw - 16px); padding: 0; overflow: hidden; }
|
|
58
|
+
.menu .pm-search, .menu .pm-search:is(:hover, :focus-within) {
|
|
59
|
+
flex: none; height: 42px; padding: 0 12px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; box-shadow: none; font-size: 13.5px;
|
|
60
|
+
}
|
|
61
|
+
.pm-list { max-height: min(340px, 48vh); overflow-y: auto; padding: 5px; }
|
|
62
|
+
.pm-extras { flex: none; padding: 5px; border-top: 1px solid var(--line); }
|
|
63
|
+
.menu .pm-row { gap: 8px; padding: 6px 9px; text-align: left; }
|
|
64
|
+
.menu .pm-row:hover { background: none; }
|
|
65
|
+
.menu .pm-row.act { background: var(--bg-2); color: var(--ink); }
|
|
66
|
+
.pm-row .p-dot { flex: none; }
|
|
67
|
+
.pm-name { flex: none; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
.pm-row.on .pm-name { font-weight: 500; color: var(--ink); }
|
|
69
|
+
.pm-name mark { background: none; color: var(--accent-ink); font-weight: 600; }
|
|
70
|
+
.pm-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--faint); }
|
|
71
|
+
.pm-row .pm-where { width: 11px; height: 11px; color: var(--muted); flex: none; }
|
|
72
|
+
.pm-row .p-need { flex: none; }
|
|
73
|
+
.pm-row .p-run { flex: none; }
|
|
74
|
+
.pm-row .pm-check { flex: none; width: 14px; height: 14px; color: var(--accent); visibility: hidden; }
|
|
75
|
+
.pm-row.on .pm-check { visibility: visible; }
|
|
76
|
+
.pm-x .i { width: 14px; height: 14px; color: var(--muted); }
|
|
77
|
+
.pm-empty { padding: 18px 10px; text-align: center; font-size: 13px; color: var(--muted); }
|
|
56
78
|
.menu .danger:hover { color: var(--bad); background: var(--del-bg); }
|
|
57
79
|
.menu-kbd { margin-left: auto; }
|
|
58
80
|
|
package/web/css/ship.css
CHANGED
|
@@ -93,3 +93,43 @@
|
|
|
93
93
|
.cmp-body { flex: 1; min-height: 160px; max-height: 58vh; overflow: auto; padding: 12px; background: var(--surface); }
|
|
94
94
|
.cmp-foot { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-top: 1px solid var(--line); }
|
|
95
95
|
|
|
96
|
+
|
|
97
|
+
/* Ship it in flight: the PR row turns into a track of stages that fills in as they run */
|
|
98
|
+
.pr-strip.ship-run {
|
|
99
|
+
--tone: var(--accent); position: relative; overflow: hidden; animation: ship-in 0.32s var(--ease);
|
|
100
|
+
background: color-mix(in srgb, var(--tone) 6%, transparent); border-color: color-mix(in srgb, var(--tone) 22%, transparent);
|
|
101
|
+
}
|
|
102
|
+
.ship-icon { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px; flex: none; color: var(--accent-ink); background: color-mix(in srgb, var(--tone) 14%, transparent); }
|
|
103
|
+
.ship-icon .i { width: 12px; height: 12px; animation: ship-fly 1.1s ease-in-out infinite; }
|
|
104
|
+
.ship-what { font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
|
|
105
|
+
.ship-what .dots::after { content: '…'; animation: pulse 1.1s ease-in-out infinite; }
|
|
106
|
+
.ship-track { display: flex; align-items: center; gap: 4px; margin: 0 0 0 4px; padding: 0; list-style: none; font-size: 11.5px; color: var(--faint); flex: none; }
|
|
107
|
+
.ship-track li { display: inline-flex; align-items: center; gap: 4px; transition: color 0.25s; }
|
|
108
|
+
.ship-track li + li::before { content: ''; width: 14px; height: 1.5px; margin-right: 2px; border-radius: 1px; background: var(--line-2); transition: background 0.3s; }
|
|
109
|
+
.ship-track li.done::before, .ship-track li.now::before, .ship-track li.bad::before { background: var(--tone); }
|
|
110
|
+
.ship-track i { display: grid; place-items: center; width: 13px; height: 13px; border-radius: 50%; border: 1.5px solid var(--line-2); transition: background 0.25s, border-color 0.25s; }
|
|
111
|
+
.ship-track i .i { width: 8px; height: 8px; color: #fff; opacity: 0; transform: scale(0.4); transition: opacity 0.2s, transform 0.25s var(--ease); }
|
|
112
|
+
.ship-track li.done { color: var(--ink-2); }
|
|
113
|
+
.ship-track li.done i { background: var(--good); border-color: var(--good); }
|
|
114
|
+
.ship-track li.done i .i { opacity: 1; transform: none; }
|
|
115
|
+
.ship-track li.now { color: var(--ink); font-weight: 500; }
|
|
116
|
+
.ship-track li.now i { border-color: var(--tone); border-top-color: transparent; animation: spin 0.8s linear infinite; }
|
|
117
|
+
.ship-track li.bad { color: var(--bad); font-weight: 500; }
|
|
118
|
+
.ship-track li.bad i { background: var(--bad); border-color: var(--bad); }
|
|
119
|
+
.ship-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: linear-gradient(90deg, transparent, var(--tone), transparent) 0 0 / 40% 100% no-repeat; animation: ship-sweep 1.3s ease-in-out infinite; }
|
|
120
|
+
.ship-run.failed .ship-bar, .ship-run.failed .ship-icon .i { animation: none; }
|
|
121
|
+
.ship-run.failed .ship-bar { display: none; }
|
|
122
|
+
.ship-run.failed .ship-icon { color: var(--bad); }
|
|
123
|
+
.ship-run.failed { animation: nudge 0.4s ease; }
|
|
124
|
+
.ship-err { font-weight: 400; color: var(--ink-2); }
|
|
125
|
+
.ship-acts { display: inline-flex; gap: 2px; }
|
|
126
|
+
.ship-acts .i { width: 13px; height: 13px; }
|
|
127
|
+
.pr-strip.landed { animation: ship-land 0.9s var(--ease); }
|
|
128
|
+
.pill.ship-pill { --tone: var(--accent); }
|
|
129
|
+
.pill.ship-pill i { animation: pulse 1.1s ease-in-out infinite; }
|
|
130
|
+
@keyframes ship-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } }
|
|
131
|
+
@keyframes ship-fly { 50% { transform: translate(2px, -2px); } }
|
|
132
|
+
@keyframes ship-sweep { from { background-position: -40% 0; } to { background-position: 140% 0; } }
|
|
133
|
+
@keyframes ship-land { from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--good) 30%, transparent); transform: scale(1.015); } }
|
|
134
|
+
@media (max-width: 640px) { .ship-track span { display: none; } }
|
|
135
|
+
@media (prefers-reduced-motion: reduce) { .ship-run, .ship-run *, .pr-strip.landed { animation: none !important; } }
|
package/web/index.html
CHANGED
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="6" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="18" cy="12" r="1.6" fill="currentColor"/></symbol>
|
|
40
40
|
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
|
|
41
41
|
<symbol id="i-branch" viewBox="0 0 24 24"><circle cx="6" cy="5" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="6" cy="19" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="18" cy="7" r="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M6 7.2v9.6M18 9.2c0 5-12 3-12 7.6" fill="none" stroke="currentColor" stroke-width="1.7"/></symbol>
|
|
42
|
+
<symbol id="i-github" viewBox="0 0 24 24"><path d="M12 2.5a9.5 9.5 0 00-3 18.52c.48.09.65-.2.65-.46v-1.62c-2.64.57-3.2-1.27-3.2-1.27-.43-1.1-1.05-1.39-1.05-1.39-.86-.59.07-.58.07-.58.95.07 1.45.98 1.45.98.85 1.45 2.22 1.03 2.76.79.09-.61.33-1.03.6-1.27-2.1-.24-4.32-1.05-4.32-4.68 0-1.03.37-1.88.98-2.54-.1-.24-.42-1.2.09-2.5 0 0 .8-.26 2.61.97a9.1 9.1 0 014.76 0c1.81-1.23 2.61-.97 2.61-.97.51 1.3.19 2.26.09 2.5.61.66.98 1.51.98 2.54 0 3.64-2.22 4.44-4.34 4.67.34.3.64.88.64 1.77v2.63c0 .26.17.56.66.46A9.5 9.5 0 0012 2.5z" fill="currentColor"/></symbol>
|
|
42
43
|
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M12 11v6M12 7.5v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
|
|
43
44
|
<symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3.5l9.5 16.5h-19L12 3.5z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 10v4.5M12 17.2v.3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
|
|
44
45
|
<symbol id="i-ask" viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 016.5 3h11A2.5 2.5 0 0120 5.5v8a2.5 2.5 0 01-2.5 2.5H10l-4.5 4v-4h0A1.5 1.5 0 014 14.5v-9z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M10 8.2a2 2 0 113 1.7c-.7.4-1 .8-1 1.6M12 13.2v.3" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
|
@@ -102,13 +103,22 @@
|
|
|
102
103
|
</button>
|
|
103
104
|
</header>
|
|
104
105
|
<div class="feed-wrap">
|
|
105
|
-
<div class="launch-slot" id="launchSlot"
|
|
106
|
+
<div class="launch-slot" id="launchSlot">
|
|
107
|
+
<!-- First paint, before the scripts load: the launcher's shape, and a box you can already type
|
|
108
|
+
in. renderLauncher() fills in the rest and keeps what was typed. -->
|
|
109
|
+
<section class="launcher card skel" id="launcher">
|
|
110
|
+
<div class="l-projects"><span class="l-in">in</span></div>
|
|
111
|
+
<textarea id="prompt" rows="2" spellcheck="false" autofocus placeholder="What should I go do? Paste screenshots, drop logs or videos…"></textarea>
|
|
112
|
+
<div class="l-bar"><span class="grow"></span><button class="send" tabindex="-1" aria-hidden="true"><svg class="i"><use href="#i-enter"/></svg></button></div>
|
|
113
|
+
<div class="l-recipes"></div>
|
|
114
|
+
</section>
|
|
115
|
+
</div>
|
|
106
116
|
<div class="feed-head" id="feedHead"></div>
|
|
107
117
|
<div class="feed" id="feed">
|
|
108
|
-
<section class="f-group needs" id="secNeeds"><h4>Needs you<span class="n"></span></h4><div class="f-list"></div></section>
|
|
109
|
-
<section class="f-group working" id="secWorking"><h4>Working<span class="n"></span></h4><div class="f-list"></div></section>
|
|
110
|
-
<section class="f-group done" id="secDone"><h4 class="toggle-done">Done<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
|
|
111
|
-
<section class="f-group done archived" id="secArchived"><h4 class="toggle-done">Archived<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
|
|
118
|
+
<section class="f-group needs" id="secNeeds" hidden><h4>Needs you<span class="n"></span></h4><div class="f-list"></div></section>
|
|
119
|
+
<section class="f-group working" id="secWorking" hidden><h4>Working<span class="n"></span></h4><div class="f-list"></div></section>
|
|
120
|
+
<section class="f-group done" id="secDone" hidden><h4 class="toggle-done">Done<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
|
|
121
|
+
<section class="f-group done archived" id="secArchived" hidden><h4 class="toggle-done">Archived<span class="n"></span><svg class="i chev"><use href="#i-chevron"/></svg></h4><div class="f-list"></div></section>
|
|
112
122
|
</div>
|
|
113
123
|
<div class="feed-empty" id="feedEmpty" hidden></div>
|
|
114
124
|
</div>
|
package/web/js/app.js
CHANGED
|
@@ -7,7 +7,7 @@ import { api } from './core/api.js';
|
|
|
7
7
|
import * as bus from './core/bus.js';
|
|
8
8
|
import { $, $$ } from './core/dom.js';
|
|
9
9
|
import { ago } from './core/format.js';
|
|
10
|
-
import { S, save } from './core/state.js';
|
|
10
|
+
import { S, save, setAgents } from './core/state.js';
|
|
11
11
|
import { combo } from './ui/kbd.js';
|
|
12
12
|
import { openAutomations } from './features/automations.js';
|
|
13
13
|
import { openPalette } from './features/commands.js';
|
|
@@ -70,12 +70,23 @@ window.addEventListener('focus', () => {
|
|
|
70
70
|
for (const id of S.open) if (S.sessions.get(id)?.unread) scheduleRefetch(id);
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
+
// The launcher paints as soon as config and machines are in (local, fast), from the last harness
|
|
74
|
+
// list seen. Detecting harnesses can take seconds on a cold start: it repaints the launcher when
|
|
75
|
+
// it lands, only if the list changed.
|
|
73
76
|
(async () => {
|
|
74
|
-
|
|
77
|
+
const agents = api('/agents').catch(() => null);
|
|
78
|
+
const sessions = loadSessions();
|
|
79
|
+
await Promise.all([loadConfig(), loadMachines()]);
|
|
75
80
|
renderLauncher();
|
|
76
81
|
bus.emit('agents');
|
|
82
|
+
$('#prompt')?.focus();
|
|
83
|
+
await sessions;
|
|
77
84
|
connect();
|
|
78
85
|
route();
|
|
79
|
-
$('#prompt')?.focus();
|
|
80
86
|
telemetryNotice();
|
|
87
|
+
const list = await agents;
|
|
88
|
+
if (!list || JSON.stringify(list) === JSON.stringify(S.agents)) return;
|
|
89
|
+
setAgents(list);
|
|
90
|
+
renderLauncher();
|
|
91
|
+
bus.emit('agents');
|
|
81
92
|
})();
|
package/web/js/core/state.js
CHANGED
|
@@ -22,7 +22,7 @@ export const S = {
|
|
|
22
22
|
tasks: new Map(), // compare task key -> { card, head, runs, lead }
|
|
23
23
|
focus: null, // id of the keyboard-focused item
|
|
24
24
|
config: { defaults: {}, projects: [] },
|
|
25
|
-
agents: [],
|
|
25
|
+
agents: load('wisp.agents', []), // the last list seen, so the launcher paints before detection answers
|
|
26
26
|
machines: [], // other computers over SSH: GET /api/machines, kept fresh by the 'machines' event
|
|
27
27
|
filter: load('wisp.filter', 'all'),
|
|
28
28
|
showDone: load('wisp.showDone', false),
|
|
@@ -32,6 +32,7 @@ export const S = {
|
|
|
32
32
|
replyRunners: new Map(), // session id → { agent, model, effort } its next follow-up switches to
|
|
33
33
|
};
|
|
34
34
|
export const saveOpen = () => save('wisp.open', [...S.open]);
|
|
35
|
+
export const setAgents = (list) => ((S.agents = list), save('wisp.agents', list));
|
|
35
36
|
|
|
36
37
|
// Where the launcher sends a prompt: '' = this computer, else machine ids. Several run in parallel,
|
|
37
38
|
// each in the project picked for it (launch.picks[place]), and show up as one task.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Repos being cloned. Clone in the Projects dialog hands the repo off here and the dialog closes:
|
|
2
|
+
// the launcher picks it at once (a spinner chip) so you can write the task while it clones, and a
|
|
3
|
+
// launch into it waits for the clone. Success swaps the chip for the real project.
|
|
4
|
+
|
|
5
|
+
import { esc } from '../md.js';
|
|
6
|
+
import { api } from '../core/api.js';
|
|
7
|
+
import * as bus from '../core/bus.js';
|
|
8
|
+
import { launch, S, save, saveLaunch } from '../core/state.js';
|
|
9
|
+
import { toast } from '../ui/toast.js';
|
|
10
|
+
import { renderFeed } from './feed.js';
|
|
11
|
+
import { renderLaunchChips } from './launcher.js';
|
|
12
|
+
import { loadConfig } from './live.js';
|
|
13
|
+
import { machineById, projectById } from './places.js';
|
|
14
|
+
import { renderProjects } from './projects.js';
|
|
15
|
+
|
|
16
|
+
// A pasted repo URL, which the server clones (server/git.js parseRepo has the full rules), vs a path.
|
|
17
|
+
export const isRepo = (s) => /^((https?|ssh|git):\/\/|[\w.-]+@[\w.-]+:|(www\.)?github\.com\/)/i.test(String(s).trim());
|
|
18
|
+
export const repoLabel = (s) =>
|
|
19
|
+
String(s)
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^((https?|ssh):\/\/)?([\w.-]+@)?(www\.)?github\.com[/:]([^/]+\/[^/#?]+).*$/i, '$5')
|
|
22
|
+
.replace(/\.git$|\/+$/i, '');
|
|
23
|
+
export const repoName = (s) => repoLabel(s).split(/[/:]/).pop();
|
|
24
|
+
|
|
25
|
+
export const clones = []; // { id, url, where, name, label, promise }
|
|
26
|
+
export const cloneById = (id) => clones.find((c) => c.id === id);
|
|
27
|
+
let seq = 0;
|
|
28
|
+
|
|
29
|
+
const tilde = (p) => (S.config.home && p.startsWith(S.config.home) ? `~${p.slice(S.config.home.length)}` : p);
|
|
30
|
+
|
|
31
|
+
// Start cloning `url` on `where` (a machine id, or null for this computer) and point the launcher
|
|
32
|
+
// at it. job.promise resolves with the project, or rejects (already toasted) and the launcher
|
|
33
|
+
// goes back to what it had picked.
|
|
34
|
+
export function startClone(url, where = null) {
|
|
35
|
+
const k = where || '';
|
|
36
|
+
const prev = launch.picks[k];
|
|
37
|
+
const job = { id: `clone-${++seq}`, url, where: where || null, name: repoName(url), label: repoLabel(url) };
|
|
38
|
+
clones.push(job);
|
|
39
|
+
// A feed filtered to another project would pull the launcher back to that project.
|
|
40
|
+
if (S.filter !== 'all') {
|
|
41
|
+
S.filter = 'all';
|
|
42
|
+
save('wisp.filter', S.filter);
|
|
43
|
+
renderFeed();
|
|
44
|
+
renderProjects();
|
|
45
|
+
}
|
|
46
|
+
launch.picks[k] = job.id;
|
|
47
|
+
if (!launch.where.includes(k)) launch.where = [k];
|
|
48
|
+
if (k === launch.where[0]) launch.project = job.id;
|
|
49
|
+
const repick = (id) => {
|
|
50
|
+
if (launch.picks[k] !== job.id) return; // picked something else meanwhile
|
|
51
|
+
launch.picks[k] = id;
|
|
52
|
+
if (k === launch.where[0]) launch.project = id;
|
|
53
|
+
saveLaunch();
|
|
54
|
+
};
|
|
55
|
+
const on = where ? ` on ${esc(machineById(where)?.name || 'the machine')}` : '';
|
|
56
|
+
job.promise = api('/projects', { method: 'POST', body: { repo: url, ...(where && { machine: where }) } })
|
|
57
|
+
.then(
|
|
58
|
+
async (proj) => {
|
|
59
|
+
await loadConfig();
|
|
60
|
+
repick(proj.id);
|
|
61
|
+
toast(proj.cloned?.existed ? `<b>${esc(proj.name)}</b> was already cloned at ${esc(tilde(proj.path))}: added it` : `Cloned <b>${esc(proj.name)}</b>${on}${where ? '' : ` into ${esc(tilde(proj.path))}`}`);
|
|
62
|
+
return proj;
|
|
63
|
+
},
|
|
64
|
+
(err) => {
|
|
65
|
+
repick(projectById(prev) ? prev : null);
|
|
66
|
+
toast(esc(err.message), { tone: 'bad', timeout: 8000 });
|
|
67
|
+
throw Object.assign(err, { shown: true });
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
.finally(() => {
|
|
71
|
+
clones.splice(clones.indexOf(job), 1);
|
|
72
|
+
renderLaunchChips();
|
|
73
|
+
bus.emit('clones');
|
|
74
|
+
});
|
|
75
|
+
job.promise.catch(() => {}); // the launcher or dialog may not be waiting on it
|
|
76
|
+
renderLaunchChips();
|
|
77
|
+
bus.emit('clones');
|
|
78
|
+
return job;
|
|
79
|
+
}
|
package/web/js/features/feed.js
CHANGED
|
@@ -13,7 +13,7 @@ import { machinePill } from './places.js';
|
|
|
13
13
|
import { renderProjects } from './projects.js';
|
|
14
14
|
import { agentName, effortLabel, harnessLogo, modelBadge } from './runners.js';
|
|
15
15
|
import { scrollByPx, stage, topInset } from './scroll.js';
|
|
16
|
-
import { prPill } from './ship.js';
|
|
16
|
+
import { prPill, shipPill } from './ship.js';
|
|
17
17
|
import { teamStack } from './team.js';
|
|
18
18
|
import { renderBody } from './turns.js';
|
|
19
19
|
|
|
@@ -208,7 +208,7 @@ function renderHead(n, s) {
|
|
|
208
208
|
<span class="it-side">
|
|
209
209
|
${pill}
|
|
210
210
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
211
|
-
${s.pr ? prPill(s.pr) : ''}
|
|
211
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
212
212
|
${s.team?.length ? teamStack(s.agent, s.team) : modelBadge(s.agent, s.model, s.effort, s.route?.switched ? null : s.route)}
|
|
213
213
|
${s.machine ? machinePill(s.machine) : ''}
|
|
214
214
|
<span class="q-proj">${esc(s.project)}</span>
|
|
@@ -233,7 +233,7 @@ function renderRunHead(n, s, { open, live, pill }) {
|
|
|
233
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
234
|
${live && s.state === 'running' ? `<span class="pill st-running"><i></i>Working</span>` : pill}
|
|
235
235
|
${s.queued ? `<span class="q-queued" title="Follow-ups queued">+${s.queued}</span>` : ''}
|
|
236
|
-
${s.pr ? prPill(s.pr) : ''}
|
|
236
|
+
${s.shipping ? shipPill(s) : s.pr ? prPill(s.pr, s.conflictFix) : ''}
|
|
237
237
|
<span class="q-time">${ago(s.updatedAt)}</span>
|
|
238
238
|
${icon('chevron', 'chev')}
|
|
239
239
|
</span>`;
|