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.
@@ -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 }) => recheck(store.get(session.id)));
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 });
@@ -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; }
@@ -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; overflow-x: auto; scrollbar-width: none; }
49
- .projects::-webkit-scrollbar { display: none; }
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;
@@ -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); }
@@ -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"><section class="launcher card" id="launcher"></section></div>
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
- [S.agents] = await Promise.all([api('/agents').catch(() => []), loadConfig(), loadSessions(), loadMachines()]);
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
  })();
@@ -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
+ }
@@ -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>`;