wisp-agents 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -1
- package/README.md +5 -5
- package/package.json +2 -2
- package/server/adapters/antigravity.js +5 -1
- package/server/adapters/claude.js +48 -5
- package/server/adapters/codex.js +3 -0
- package/server/adapters/cursor.js +2 -0
- package/server/adapters/grok.js +15 -0
- package/server/adapters/index.js +9 -1
- package/server/adapters/opencode.js +31 -6
- package/server/client.js +1 -1
- package/server/handoff.js +2 -1
- package/server/mcp-ask.js +113 -8
- package/server/mentions.js +39 -0
- package/server/openapi.js +3 -2
- package/server/protocol.js +8 -1
- package/server/render.js +3 -1
- package/server/routes/internal.js +121 -1
- package/server/routes/sessions.js +12 -0
- package/server/runner.js +136 -20
- package/server/services/launch.js +1 -1
- package/server/services/phone.js +1 -1
- package/server/store.js +8 -2
- package/server/triggers.js +1 -1
- package/server/usage.js +94 -0
- package/server/welcome.js +2 -2
- package/web/css/composer.css +22 -0
- package/web/css/feed.css +27 -0
- package/web/css/ship.css +2 -2
- package/web/index.html +1 -0
- package/web/js/blocks.js +46 -0
- package/web/js/features/actions.js +9 -4
- package/web/js/features/automations.js +1 -1
- package/web/js/features/compare.js +2 -1
- package/web/js/features/composer.js +11 -7
- package/web/js/features/launcher.js +11 -9
- package/web/js/features/mentions.js +125 -0
- package/web/js/features/notify.js +1 -1
- package/web/js/features/runners.js +3 -1
- package/web/js/features/turns.js +44 -13
- package/web/js/mention-text.js +59 -0
package/server/usage.js
CHANGED
|
@@ -77,3 +77,97 @@ export function usage(sessions, { days = 30, now = Date.now(), project = () => n
|
|
|
77
77
|
runners: sorted(byRunner),
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
// Tool calls across sessions, for the agent-facing `wisp_stats` tool: how often each kind of
|
|
82
|
+
// action ran (run, read, edit, search, web, …), per harness, project and day. Kinds are wisp's
|
|
83
|
+
// normalized activity kinds, not the harness's own tool names. Spend and success come from usage().
|
|
84
|
+
const NOT_TOOLS = new Set(['text', 'error', 'answer']);
|
|
85
|
+
const tally = () => ({ calls: 0, kinds: {} });
|
|
86
|
+
const count = (b, kind) => {
|
|
87
|
+
b.calls++;
|
|
88
|
+
b.kinds[kind] = (b.kinds[kind] || 0) + 1;
|
|
89
|
+
};
|
|
90
|
+
const ranked = (m) => [...m.values()].sort((a, b) => b.calls - a.calls);
|
|
91
|
+
|
|
92
|
+
// filter: { agent, project } — agent is a harness id, project a project id or folder name.
|
|
93
|
+
export function toolStats(sessions, { days = 30, now = Date.now(), project = () => null, filter = {} } = {}) {
|
|
94
|
+
const keep = sessions
|
|
95
|
+
.filter((s) => {
|
|
96
|
+
if (!filter.project) return true;
|
|
97
|
+
const p = project(s);
|
|
98
|
+
return p?.id === filter.project || (p?.name || String(s.cwd || '').split(/[\\/]/).pop()) === filter.project;
|
|
99
|
+
})
|
|
100
|
+
.map((s) => (filter.agent ? { ...s, turns: (s.turns || []).filter((t) => (t.agent || s.agent) === filter.agent) } : s));
|
|
101
|
+
const u = usage(keep, { days, now, project });
|
|
102
|
+
const since = u.since;
|
|
103
|
+
|
|
104
|
+
const total = tally();
|
|
105
|
+
const byAgent = new Map();
|
|
106
|
+
const byProject = new Map();
|
|
107
|
+
const byDay = new Map(u.daily.map((d) => [d.date, { date: d.date, ...tally() }]));
|
|
108
|
+
for (const s of keep) {
|
|
109
|
+
if (s.agent === 'wisp') continue;
|
|
110
|
+
const p = project(s);
|
|
111
|
+
for (const t of s.turns || []) {
|
|
112
|
+
if (!t.startedAt || t.startedAt < since || t.status === 'running') continue;
|
|
113
|
+
const agent = t.agent || s.agent;
|
|
114
|
+
const pk = p?.id || s.cwd;
|
|
115
|
+
if (!byAgent.has(agent)) byAgent.set(agent, { agent, ...tally() });
|
|
116
|
+
if (!byProject.has(pk)) byProject.set(pk, { id: p?.id || null, name: p?.name || String(s.cwd || '').split(/[\\/]/).pop(), ...tally() });
|
|
117
|
+
for (const a of t.activity || []) {
|
|
118
|
+
if (!a?.kind || NOT_TOOLS.has(a.kind)) continue;
|
|
119
|
+
count(total, a.kind);
|
|
120
|
+
count(byAgent.get(agent), a.kind);
|
|
121
|
+
count(byProject.get(pk), a.kind);
|
|
122
|
+
const d = byDay.get(dayKey(a.t || t.startedAt));
|
|
123
|
+
if (d) count(d, a.kind);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const outcome = (b) => ({ turns: b.turns, cost: b.cost, priced: b.priced, successRate: b.successRate });
|
|
128
|
+
const runs = new Map();
|
|
129
|
+
for (const r of u.runners) {
|
|
130
|
+
const b = runs.get(r.agent) || { turns: 0, done: 0, cost: 0, priced: 0 };
|
|
131
|
+
runs.set(r.agent, { turns: b.turns + r.turns, done: b.done + r.done, cost: b.cost + r.cost, priced: b.priced + r.priced });
|
|
132
|
+
}
|
|
133
|
+
const withRuns = (a) => {
|
|
134
|
+
const r = runs.get(a.agent) || { turns: 0, done: 0, cost: 0, priced: 0 };
|
|
135
|
+
return { ...a, turns: r.turns, cost: Math.round(r.cost * 10000) / 10000, priced: r.priced, successRate: r.turns ? r.done / r.turns : null };
|
|
136
|
+
};
|
|
137
|
+
const projectRuns = new Map(u.projects.map((p) => [p.id || p.name, p]));
|
|
138
|
+
return {
|
|
139
|
+
days: u.days,
|
|
140
|
+
since,
|
|
141
|
+
filter: { agent: filter.agent || null, project: filter.project || null },
|
|
142
|
+
total: { ...total, sessions: u.total.sessions, ...outcome(u.total), inputTokens: u.total.inputTokens, outputTokens: u.total.outputTokens, questions: u.total.questions },
|
|
143
|
+
kinds: Object.entries(total.kinds).map(([kind, calls]) => ({ kind, calls })).sort((a, b) => b.calls - a.calls),
|
|
144
|
+
agents: ranked(byAgent).map(withRuns),
|
|
145
|
+
projects: ranked(byProject).map((p) => ({ ...p, ...outcome(projectRuns.get(p.id || p.name) || { turns: 0, cost: 0, priced: 0, successRate: null }) })),
|
|
146
|
+
daily: [...byDay.values()],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// toolStats() as markdown for an agent to read.
|
|
151
|
+
export function toolStatsText(st) {
|
|
152
|
+
const pct = (r) => (r === null || r === undefined ? '—' : `${Math.round(r * 100)}%`);
|
|
153
|
+
const usd = (b) => (b.priced ? `$${b.cost.toFixed(2)}${b.priced < b.turns ? ` (${b.priced}/${b.turns} turns priced)` : ''}` : '—');
|
|
154
|
+
const kinds = (k) => Object.entries(k).sort((a, b) => b[1] - a[1]).map(([n, c]) => `${n} ${c}`).join(', ') || '—';
|
|
155
|
+
const scope = [st.filter.agent && `harness ${st.filter.agent}`, st.filter.project && `project ${st.filter.project}`].filter(Boolean).join(', ');
|
|
156
|
+
const t = st.total;
|
|
157
|
+
const out = [
|
|
158
|
+
`## wisp tool stats — last ${st.days} day${st.days === 1 ? '' : 's'}${scope ? ` (${scope})` : ''}`,
|
|
159
|
+
`${t.calls} tool calls in ${t.turns} turns across ${t.sessions} sessions. Success rate ${pct(t.successRate)}, cost ${usd(t)}, ${t.questions} questions to the user, ${t.inputTokens} input / ${t.outputTokens} output tokens.`,
|
|
160
|
+
'',
|
|
161
|
+
'Kinds are wisp’s categories, not harness tool names: run = shell commands, read / edit / search = files, web = fetch and search, agent = subagents, tool = other (MCP) tools, ask = questions to the user, wait = waiting on the user or a background task.',
|
|
162
|
+
];
|
|
163
|
+
if (!t.turns) return [...out, '', 'No finished turns in this range.'].join('\n');
|
|
164
|
+
out.push('', '### By kind', '| kind | calls | share |', '|---|---:|---:|');
|
|
165
|
+
for (const k of st.kinds) out.push(`| ${k.kind} | ${k.calls} | ${t.calls ? Math.round((k.calls / t.calls) * 100) : 0}% |`);
|
|
166
|
+
out.push('', '### By harness', '| harness | calls | turns | success | cost | top kinds |', '|---|---:|---:|---:|---:|---|');
|
|
167
|
+
for (const a of st.agents) out.push(`| ${a.agent} | ${a.calls} | ${a.turns} | ${pct(a.successRate)} | ${usd(a)} | ${kinds(a.kinds)} |`);
|
|
168
|
+
out.push('', '### By project', '| project | calls | turns | success | cost | top kinds |', '|---|---:|---:|---:|---:|---|');
|
|
169
|
+
for (const p of st.projects) out.push(`| ${p.name} | ${p.calls} | ${p.turns} | ${pct(p.successRate)} | ${usd(p)} | ${kinds(p.kinds)} |`);
|
|
170
|
+
out.push('', '### By day (days with activity)', '| day | calls | kinds |', '|---|---:|---|');
|
|
171
|
+
for (const d of st.daily.filter((d) => d.calls)) out.push(`| ${d.date} | ${d.calls} | ${kinds(d.kinds)} |`);
|
|
172
|
+
return out.join('\n');
|
|
173
|
+
}
|
package/server/welcome.js
CHANGED
|
@@ -129,8 +129,8 @@ export function welcomeSession() {
|
|
|
129
129
|
title: 'Permission modes (pick per launch)',
|
|
130
130
|
items: [
|
|
131
131
|
{ text: 'Plan — read-only, the agent proposes', done: true },
|
|
132
|
-
{ text: 'Edits — the agent may change files,
|
|
133
|
-
{ text: 'Full auto — no permission prompts at all;
|
|
132
|
+
{ text: 'Edits — the agent may change files; anything else it needs, you approve', done: true },
|
|
133
|
+
{ text: 'Full auto — no permission prompts at all; the default, each task in its own worktree', done: true },
|
|
134
134
|
],
|
|
135
135
|
},
|
|
136
136
|
{
|
package/web/css/composer.css
CHANGED
|
@@ -60,3 +60,25 @@
|
|
|
60
60
|
.c-runner.changed .rb-logo { margin-right: 4px; }
|
|
61
61
|
.c-runner:focus-visible { outline: 0; box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 1px var(--accent); }
|
|
62
62
|
.cr-label { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
63
|
+
|
|
64
|
+
/* ---------- task mentions (features/mentions.js) ---------- */
|
|
65
|
+
/* In a prompt box: the textarea's own text sits over a mirror that paints a chip under each @Title. */
|
|
66
|
+
.mn-wrap { position: relative; }
|
|
67
|
+
.mn-wrap > textarea { position: relative; z-index: 1; }
|
|
68
|
+
.mn-mirror {
|
|
69
|
+
position: absolute; inset: 0; z-index: 0; margin: 0; overflow: hidden; pointer-events: none;
|
|
70
|
+
border-style: solid; border-color: transparent; color: transparent;
|
|
71
|
+
}
|
|
72
|
+
.mn-wrap:not(.has-mn) .mn-mirror { display: none; }
|
|
73
|
+
.mn-mirror mark.mn {
|
|
74
|
+
color: transparent; border-radius: 4px;
|
|
75
|
+
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
|
76
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
|
77
|
+
}
|
|
78
|
+
.launcher.docked:not(:focus-within):not(:has(.open)) .mn-wrap { flex: 1; min-width: 0; }
|
|
79
|
+
/* In a sent prompt: the same chip, with the task's title; a link opens that task. */
|
|
80
|
+
:is(a, span).mn {
|
|
81
|
+
padding: 0 5px; border-radius: 5px; font-weight: 500; color: var(--accent-ink); text-decoration: none;
|
|
82
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent); box-decoration-break: clone; -webkit-box-decoration-break: clone;
|
|
83
|
+
}
|
|
84
|
+
a.mn:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
|
package/web/css/feed.css
CHANGED
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
.it-bar { display: flex; align-items: center; gap: 12px; padding: 8px 10px 8px 14px; }
|
|
97
97
|
.it-meta { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; }
|
|
98
98
|
.it-actions { display: flex; gap: 2px; flex: none; }
|
|
99
|
+
.it-done:hover { color: var(--good); }
|
|
99
100
|
.it-body .earlier { display: block; margin: 4px 0 0 18px; }
|
|
100
101
|
.it-dock { padding: 4px 14px 14px; }
|
|
101
102
|
|
|
@@ -206,3 +207,29 @@
|
|
|
206
207
|
.q-own { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
|
207
208
|
.ask-card.sent { opacity: 0.5; pointer-events: none; }
|
|
208
209
|
|
|
210
|
+
/* approvals: live (paused on a tool call) and after the fact (what the permissions blocked) */
|
|
211
|
+
.ask-card.perm .q-head { color: var(--warn); }
|
|
212
|
+
.ask-card.perm .q-text code { font-size: 0.86em; }
|
|
213
|
+
.perm-detail {
|
|
214
|
+
margin: -4px 0 12px; padding: 9px 12px; max-height: 180px; overflow: auto; border-radius: 8px;
|
|
215
|
+
background: var(--surface-2); border: 1px solid var(--line); font: 12.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word;
|
|
216
|
+
}
|
|
217
|
+
.ask-card.perm .opt.go { border-color: color-mix(in srgb, var(--good) 55%, var(--line-2)); }
|
|
218
|
+
.ask-card.perm .opt.no { color: var(--ink-2); }
|
|
219
|
+
.b-blocked {
|
|
220
|
+
padding: 14px 16px; border-radius: var(--radius); background: var(--surface);
|
|
221
|
+
border: 1px solid color-mix(in srgb, var(--warn) 55%, var(--line));
|
|
222
|
+
}
|
|
223
|
+
.b-blocked.resolved { background: var(--surface-2); border-color: var(--line); }
|
|
224
|
+
.b-blocked .q-head { margin-bottom: 8px; }
|
|
225
|
+
.b-blocked.resolved .q-head { color: var(--muted); }
|
|
226
|
+
.bl-state { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-weight: 500; color: var(--muted); }
|
|
227
|
+
.bl-state svg { width: 13px; height: 13px; }
|
|
228
|
+
.bl-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
|
|
229
|
+
.bl-items li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; min-width: 0; }
|
|
230
|
+
.bl-tool { font: 500 11px var(--mono); padding: 1px 6px; border-radius: 5px; background: var(--bg-2); color: var(--ink-2); flex: none; }
|
|
231
|
+
.bl-items code { font: 12.5px/1.45 var(--mono); color: var(--ink); word-break: break-all; min-width: 0; }
|
|
232
|
+
.bl-items small { flex-basis: 100%; color: var(--muted); font-size: 12.5px; }
|
|
233
|
+
.bl-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin-top: 12px; }
|
|
234
|
+
.bl-note { font-size: 12px; color: var(--muted); }
|
|
235
|
+
|
package/web/css/ship.css
CHANGED
|
@@ -53,13 +53,13 @@
|
|
|
53
53
|
.pill.pr-merged, .pr-strip.pr-merged { --tone: #8250df; }
|
|
54
54
|
.pill.pr-closed, .pr-strip.pr-closed { --tone: var(--faint); }
|
|
55
55
|
.pr-strip {
|
|
56
|
-
display: flex; align-items: center; gap: 8px; margin:
|
|
56
|
+
display: flex; align-items: center; gap: 8px; margin: 8px 14px 4px; padding: 5px 6px 5px 10px; border-radius: 9px;
|
|
57
57
|
font-size: 12.5px; background: color-mix(in srgb, var(--tone) 7%, transparent); border: 1px solid color-mix(in srgb, var(--tone) 18%, transparent);
|
|
58
58
|
}
|
|
59
59
|
.pr-strip[hidden] { display: none; }
|
|
60
60
|
.pr-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--tone); flex: none; }
|
|
61
61
|
.pr-strip.pr-pending .pr-dot { animation: pulse 1.4s ease-in-out infinite; }
|
|
62
|
-
.pr-num { font-weight: 600; color: var(--ink); }
|
|
62
|
+
.pr-num { font-weight: 600; color: var(--ink); white-space: nowrap; }
|
|
63
63
|
.pr-line { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
64
64
|
.pr-strip .ghost .i { width: 13px; height: 13px; }
|
|
65
65
|
.pr-strip .pr-merge { color: var(--good); font-weight: 500; }
|
package/web/index.html
CHANGED
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<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
43
|
<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
44
|
<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>
|
|
45
|
+
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="5" y="10.5" width="14" height="9.5" rx="2.2" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M8.5 10.5V8a3.5 3.5 0 017 0v2.5M12 14.3v2" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
|
45
46
|
<symbol id="i-pen" viewBox="0 0 24 24"><path d="M4 20l1-4L16 5l3 3L8 19l-4 1z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
|
46
47
|
<symbol id="i-spark" viewBox="0 0 24 24"><path d="M12 3l1.8 5.4L19 10l-5.2 1.6L12 17l-1.8-5.4L5 10l5.2-1.6L12 3zM18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8.8-2.2z" fill="currentColor"/></symbol>
|
|
47
48
|
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor"/></symbol>
|
package/web/js/blocks.js
CHANGED
|
@@ -788,6 +788,52 @@ R.question = (b, ctx) => {
|
|
|
788
788
|
return node;
|
|
789
789
|
};
|
|
790
790
|
|
|
791
|
+
// What the turn's permissions refused. On the latest turn: Allow & retry (exactly these where the
|
|
792
|
+
// harness can scope a grant, else one follow-up in Full auto) or Deny.
|
|
793
|
+
R.blocked = (b, ctx) => {
|
|
794
|
+
const items = (b.items || []).filter((i) => i && typeof i === 'object');
|
|
795
|
+
const t = ctx.turn || {};
|
|
796
|
+
const scoped = !!t.blocked?.length && t.blocked.every((i) => i.rule !== undefined);
|
|
797
|
+
const open = ctx.isLatest && !t.approval && !!ctx.approve && ctx.session?.state !== 'running';
|
|
798
|
+
const state = (a) => (a === 'allowed' ? `${icon('check')}Allowed and retried` : a === 'denied' ? `${icon('x')}Denied` : '');
|
|
799
|
+
const node = el(`
|
|
800
|
+
<div class="b-blocked ${open ? '' : 'resolved'}">
|
|
801
|
+
<div class="q-head">${icon('lock')}<span>Blocked by permissions</span>${t.approval ? `<span class="bl-state">${state(t.approval)}</span>` : ''}</div>
|
|
802
|
+
<ul class="bl-items">${items
|
|
803
|
+
.map((i) => `<li><span class="bl-tool">${esc(i.tool || 'tool')}</span>${i.detail ? `<code>${esc(i.detail)}</code>` : ''}${i.why ? `<small>${inlineMd(i.why)}</small>` : ''}</li>`)
|
|
804
|
+
.join('')}</ul>
|
|
805
|
+
${open ? `<div class="bl-foot">
|
|
806
|
+
<button class="primary sm bl-allow">${icon('check')}Allow & retry</button>
|
|
807
|
+
<button class="ghost sm bl-deny">Deny</button>
|
|
808
|
+
<span class="bl-note">${scoped ? 'Allows exactly these, for the retry only.' : 'This harness can’t allow single actions, so the retry runs in Full auto (that follow-up only).'}</span>
|
|
809
|
+
</div>` : ''}
|
|
810
|
+
</div>`);
|
|
811
|
+
if (!open) return node;
|
|
812
|
+
const decide = async (allow) => {
|
|
813
|
+
// Instant: the card settles at once and goes back if the server says no.
|
|
814
|
+
const foot = node.querySelector('.bl-foot');
|
|
815
|
+
const head = node.querySelector('.q-head');
|
|
816
|
+
const tag = el(`<span class="bl-state">${state(allow ? 'allowed' : 'denied')}</span>`);
|
|
817
|
+
node.classList.add('resolved');
|
|
818
|
+
foot.hidden = true;
|
|
819
|
+
head.append(tag);
|
|
820
|
+
try {
|
|
821
|
+
await ctx.approve(allow);
|
|
822
|
+
} catch {
|
|
823
|
+
node.classList.remove('resolved');
|
|
824
|
+
foot.hidden = false;
|
|
825
|
+
tag.remove();
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
node.querySelector('.bl-allow').onclick = () => {
|
|
829
|
+
decide(true);
|
|
830
|
+
};
|
|
831
|
+
node.querySelector('.bl-deny').onclick = () => {
|
|
832
|
+
decide(false);
|
|
833
|
+
};
|
|
834
|
+
return node;
|
|
835
|
+
};
|
|
836
|
+
|
|
791
837
|
R.actions = (b, ctx) => {
|
|
792
838
|
const items = (b.items || []).map((it, i) => (typeof it === 'string' ? { label: it, command: it, _i: i } : { ...it, _i: i }));
|
|
793
839
|
const node = el(`
|
|
@@ -15,8 +15,9 @@ import { saveAsRecipe } from './automations.js';
|
|
|
15
15
|
import { compareView } from './compare.js';
|
|
16
16
|
import { collapse, expand, handOff, renderFeed, setFocus } from './feed.js';
|
|
17
17
|
import { launchBehind } from './launcher.js';
|
|
18
|
+
import { copyTaskId, mentionBox } from './mentions.js';
|
|
18
19
|
import { runnerPicker } from './runner-picker.js';
|
|
19
|
-
import { agentName, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
|
|
20
|
+
import { agentName, DEFAULT_MODE, effortsFor, modeMenu, MODES, paintRunnerBtn, savedEffort, savedModel } from './runners.js';
|
|
20
21
|
import { feedAnchor, keepInPlace } from './scroll.js';
|
|
21
22
|
import { mergePr, shipDialog, shipLabel, shippable } from './ship.js';
|
|
22
23
|
|
|
@@ -143,6 +144,7 @@ export function moreMenu(id, anchor) {
|
|
|
143
144
|
const menu = el(`
|
|
144
145
|
<div class="menu" style="top:${r.bottom + 6}px;right:${innerWidth - r.right}px">
|
|
145
146
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
147
|
+
<button data-a="copy-id">${icon('copy')}Copy task ID</button>
|
|
146
148
|
${s.agentSessionId ? `<button data-a="copy">${icon('copy')}Copy resume command</button>` : ''}
|
|
147
149
|
<button data-a="folder">${icon('folder')}Open project in editor</button>
|
|
148
150
|
${canRollback ? `<button data-a="rollback">${icon(rolledBack ? 'rerun' : 'replay')}${rolledBack ? 'Re-apply latest turn' : 'Restore to before latest turn'}</button>` : ''}
|
|
@@ -153,6 +155,7 @@ export function moreMenu(id, anchor) {
|
|
|
153
155
|
const a = e.target.closest('button')?.dataset.a;
|
|
154
156
|
close();
|
|
155
157
|
if (a === 'rename') rename(id);
|
|
158
|
+
if (a === 'copy-id') copyTaskId(id);
|
|
156
159
|
if (a === 'copy') {
|
|
157
160
|
await tryApi(async () => {
|
|
158
161
|
const { command } = await api(`/sessions/${id}/takeover`);
|
|
@@ -178,6 +181,7 @@ export function taskMenu(id, e) {
|
|
|
178
181
|
<div class="menu task" style="top:${e.clientY}px;left:${e.clientX}px">
|
|
179
182
|
${S.open.has(id) ? `<button data-a="collapse">${icon('chevron')}Collapse${keyHint('O')}</button>` : `<button data-a="open">${icon('chevron')}Open${keyHint('O')}</button>`}
|
|
180
183
|
<button data-a="rename">${icon('pen')}Rename${keyHint('M')}</button>
|
|
184
|
+
<button data-a="copy-id">${icon('copy')}Copy task ID</button>
|
|
181
185
|
<button data-a="rerun">${icon('rerun')}Rerun…${keyHint('⇧R')}</button>
|
|
182
186
|
${shippable(s) ? `<button data-a="ship">${icon('send')}${shipLabel(s)}${keyHint('S')}</button>` : ''}
|
|
183
187
|
${s.pr?.state === 'OPEN' ? `<button data-a="merge">${icon('check')}Merge PR #${s.pr.number}…</button>` : ''}
|
|
@@ -193,6 +197,7 @@ export function taskMenu(id, e) {
|
|
|
193
197
|
if (a === 'open') expand(id);
|
|
194
198
|
if (a === 'collapse') collapse(id);
|
|
195
199
|
if (a === 'rename') rename(id);
|
|
200
|
+
if (a === 'copy-id') copyTaskId(id);
|
|
196
201
|
if (a === 'rerun') rerun(id);
|
|
197
202
|
if (a === 'ship') shipDialog(id);
|
|
198
203
|
if (a === 'merge') mergePr(id);
|
|
@@ -229,7 +234,7 @@ export async function rerun(id) {
|
|
|
229
234
|
const models = S.agents.find((x) => x.id === agent)?.models || [];
|
|
230
235
|
let model = agent === s.agent && models.some((m) => m.id === s.model) ? s.model : savedModel(agent);
|
|
231
236
|
let effort = agent === s.agent && effortsFor(agent, model).includes(s.effort) ? s.effort : savedEffort(agent, model);
|
|
232
|
-
let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode ||
|
|
237
|
+
let mode = MODES.some((m) => m.id === s.mode) ? s.mode : S.config.defaults.mode || DEFAULT_MODE.id;
|
|
233
238
|
const files = first.attachments || [];
|
|
234
239
|
|
|
235
240
|
const node = el(`
|
|
@@ -249,12 +254,12 @@ export async function rerun(id) {
|
|
|
249
254
|
</div>`);
|
|
250
255
|
const close = overlay(node);
|
|
251
256
|
$('.close', node).onclick = close;
|
|
252
|
-
const ta = $('.rr-prompt', node);
|
|
257
|
+
const ta = mentionBox($('.rr-prompt', node));
|
|
253
258
|
ta.value = first.prompt;
|
|
254
259
|
autosize(ta);
|
|
255
260
|
const modeBtn = $('[data-a="mode"]', node);
|
|
256
261
|
const paintMode = () => {
|
|
257
|
-
const m = MODES.find((x) => x.id === mode) ||
|
|
262
|
+
const m = MODES.find((x) => x.id === mode) || DEFAULT_MODE;
|
|
258
263
|
modeBtn.className = `mode-btn m-${m.id}`;
|
|
259
264
|
modeBtn.innerHTML = `${icon(m.icon)}<span>${m.label}</span>`;
|
|
260
265
|
modeBtn.title = m.hint;
|
|
@@ -178,7 +178,7 @@ export function openAutomations({ tab = 'recipes', edit = null, host } = {}) {
|
|
|
178
178
|
<div class="k-gh"><label><span>Check every</span><select name="every">${[2, 5, 15, 60].map((m) => `<option value="${m}" ${Number(d.every || 5) === m ? 'selected' : ''}>${m} min</option>`).join('')}</select></label></div>
|
|
179
179
|
<label><span>Run</span><select name="recipeId"><option value="">This prompt</option>${recipes.map((r) => `<option value="${r.id}" ${r.id === d.recipeId ? 'selected' : ''}>Recipe: ${esc(r.name)}</option>`).join('')}</select></label>
|
|
180
180
|
<label class="stack k-prompt"><span>Prompt <small class="muted k-vars"></small></span><textarea class="textarea mono" name="prompt" rows="6" spellcheck="false"></textarea></label>
|
|
181
|
-
<label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : '
|
|
181
|
+
<label><span>Mode</span><select name="mode">${modeOptions(d.mode || (d.kind === 'issues' ? 'plan' : 'auto'))}</select></label>
|
|
182
182
|
<div class="au-row"><span>Harness</span></div>
|
|
183
183
|
<p class="au-warn" hidden>${icon('warn')}<span>Issue text comes from GitHub: anyone who can open and label issues can steer this agent. Plan mode only reads; Full auto runs shell commands without asking.</span></p>
|
|
184
184
|
<div class="au-foot"><button type="button" class="ghost sm" data-act="cancel">Cancel</button><button class="primary sm">${d.id ? 'Save' : 'Create trigger'}</button></div>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { countChanges, fmtDuration, renderPresentation } from '../blocks.js';
|
|
4
4
|
import { esc } from '../md.js';
|
|
5
|
+
import { mentionHtml } from '../mention-text.js';
|
|
5
6
|
import { api } from '../core/api.js';
|
|
6
7
|
import { $, el, icon } from '../core/dom.js';
|
|
7
8
|
import { STATUS_LABEL } from '../core/format.js';
|
|
@@ -37,7 +38,7 @@ export async function compareView(id) {
|
|
|
37
38
|
const node = el(`
|
|
38
39
|
<div class="modal compare-modal">
|
|
39
40
|
<header><h2>Compare</h2><button class="icon-btn close">${icon('x')}</button></header>
|
|
40
|
-
<p class="cmp-prompt">${esc(fulls[0]?.turns[0]?.prompt || '')}</p>
|
|
41
|
+
<p class="cmp-prompt">${mentionHtml(esc(fulls[0]?.turns[0]?.prompt || ''))}</p>
|
|
41
42
|
<div class="cmp-grid" style="--n:${fulls.length}"></div>
|
|
42
43
|
</div>`);
|
|
43
44
|
const close = overlay(node);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The composer at the bottom of an expanded card: follow-ups, answers, queued follow-ups.
|
|
2
2
|
|
|
3
3
|
import { esc } from '../md.js';
|
|
4
|
+
import { mentionHtml } from '../mention-text.js';
|
|
4
5
|
import { api } from '../core/api.js';
|
|
5
6
|
import { $, el, icon } from '../core/dom.js';
|
|
6
7
|
import { S } from '../core/state.js';
|
|
@@ -10,9 +11,10 @@ import { toggleDone } from './actions.js';
|
|
|
10
11
|
import { acceptFiles, attachTray, autosize } from './attachments.js';
|
|
11
12
|
import { renderFeed, scheduleRefetch, setFocus } from './feed.js';
|
|
12
13
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
14
|
+
import { mentionBox } from './mentions.js';
|
|
13
15
|
import { placeName } from './places.js';
|
|
14
16
|
import { runnerPicker } from './runner-picker.js';
|
|
15
|
-
import { agentById, agentName, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
17
|
+
import { agentById, agentName, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, modeMenu, MODES, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
16
18
|
import { feedAnchor, keepInPlace, watching } from './scroll.js';
|
|
17
19
|
import { shipDialog } from './ship.js';
|
|
18
20
|
import { renderBody } from './turns.js';
|
|
@@ -47,7 +49,7 @@ export function renderDock(id) {
|
|
|
47
49
|
dock.append(comp);
|
|
48
50
|
n.tray = attachTray();
|
|
49
51
|
comp.prepend(n.tray.node);
|
|
50
|
-
const ta = $('.reply', comp);
|
|
52
|
+
const ta = mentionBox($('.reply', comp));
|
|
51
53
|
autosize(ta, 260);
|
|
52
54
|
acceptFiles(comp, ta, n.tray);
|
|
53
55
|
$('.c-attach', comp).onclick = n.tray.pick;
|
|
@@ -78,7 +80,7 @@ export function renderDock(id) {
|
|
|
78
80
|
|
|
79
81
|
// The mode the next follow-up runs in: the session's own until the user picks another here.
|
|
80
82
|
// The pick goes out with that follow-up and stays from then on.
|
|
81
|
-
const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode ||
|
|
83
|
+
const replyMode = (id) => S.replyModes.get(id) || S.full.get(id)?.mode || DEFAULT_MODE.id;
|
|
82
84
|
|
|
83
85
|
function setReplyMode(id, mode) {
|
|
84
86
|
if (mode === S.full.get(id)?.mode) S.replyModes.delete(id);
|
|
@@ -88,10 +90,10 @@ function setReplyMode(id, mode) {
|
|
|
88
90
|
|
|
89
91
|
function paintReplyMode(id, btn) {
|
|
90
92
|
const s = S.full.get(id);
|
|
91
|
-
const m = MODES.find((x) => x.id === replyMode(id)) ||
|
|
93
|
+
const m = MODES.find((x) => x.id === replyMode(id)) || DEFAULT_MODE;
|
|
92
94
|
const from = s && S.replyModes.has(id) && MODES.find((x) => x.id === s.mode);
|
|
93
95
|
btn.className = `mode-btn c-mode m-${m.id}${from ? ' changed' : ''}`;
|
|
94
|
-
btn.innerHTML = `${icon(m.icon)}${m
|
|
96
|
+
btn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE && !from ? '' : `<span>${m.label}</span>`}`;
|
|
95
97
|
btn.title = from
|
|
96
98
|
? `${m.label}: ${m.hint}\nYour next follow-up switches this task from ${from.label} to ${m.label}. Shift+Tab to switch`
|
|
97
99
|
: `${m.label}: ${m.hint}\nPick another mode for your next follow-up. Shift+Tab to switch`;
|
|
@@ -157,7 +159,9 @@ export function updateDock(id) {
|
|
|
157
159
|
ta.placeholder = tour
|
|
158
160
|
? 'This is the tour — launch a real wisp from the box at the top'
|
|
159
161
|
: s.ask
|
|
160
|
-
?
|
|
162
|
+
? s.ask.permission
|
|
163
|
+
? `Or tell ${name} what to do instead (denies this call)…`
|
|
164
|
+
: `Answer ${name}…`
|
|
161
165
|
: s.state === 'running'
|
|
162
166
|
? queueing()
|
|
163
167
|
? `Queue a follow-up — sent when ${name} finishes`
|
|
@@ -189,7 +193,7 @@ function renderQueued(s, list, name) {
|
|
|
189
193
|
const li = el(`
|
|
190
194
|
<li class="cq">
|
|
191
195
|
<span class="cq-tag">${i === 0 ? `Next · after ${esc(name)} finishes` : `Queued #${i + 1}`}${q.run ? ` · on ${esc(runnerTip(q.run.agent, q.run.model, ''))}` : ''}</span>
|
|
192
|
-
<span class="cq-text">${esc(q.text)}</span>
|
|
196
|
+
<span class="cq-text">${mentionHtml(esc(q.text), { link: false })}</span>
|
|
193
197
|
<button class="att-x" title="Cancel this follow-up">${icon('x')}</button>
|
|
194
198
|
</li>`);
|
|
195
199
|
const cancel = () => {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// machines, a project in each), on what, and shows a placeholder card until the server answers.
|
|
3
3
|
|
|
4
4
|
import { esc } from '../md.js';
|
|
5
|
+
import { plain } from '../mention-text.js';
|
|
5
6
|
import { api } from '../core/api.js';
|
|
6
7
|
import * as bus from '../core/bus.js';
|
|
7
8
|
import { $, $$, el, icon } from '../core/dom.js';
|
|
@@ -14,12 +15,13 @@ import { openAutomations, recipeParams, runRecipe } from './automations.js';
|
|
|
14
15
|
import { renderFeed } from './feed.js';
|
|
15
16
|
import { feedbackCommand, isFeedback } from './feedback.js';
|
|
16
17
|
import { connectMachine, offlineToast, openMachines } from './machines.js';
|
|
18
|
+
import { mentionBox } from './mentions.js';
|
|
17
19
|
import { askNotify } from './notify.js';
|
|
18
20
|
import { MACHINE_STATE, machineById, placeName, placeOf, projectById, projectsAt, projectTitle } from './places.js';
|
|
19
21
|
import { openProjects } from './projects.js';
|
|
20
22
|
import { runnerPicker } from './runner-picker.js';
|
|
21
23
|
import { needsSetup, nudgeSetup, renderSetup } from './setup.js';
|
|
22
|
-
import { agentName, agentsAt, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
24
|
+
import { agentName, agentsAt, DEFAULT_MODE, effortLabel, effortsFor, harnessesAt, harnessLogo, MAX_RUNNERS, modeMenu, MODES, paintRunnerBtn, runnerTip, savedEffort, savedModel } from './runners.js';
|
|
23
25
|
import { feedAnchor, keepInPlace, stage, topInset } from './scroll.js';
|
|
24
26
|
import { canLead, paintTeamBtn, teamEditor } from './team.js';
|
|
25
27
|
|
|
@@ -38,7 +40,7 @@ function syncWhere() {
|
|
|
38
40
|
|
|
39
41
|
export function renderLauncher() {
|
|
40
42
|
const box = $('#launcher');
|
|
41
|
-
const mode = S.config.defaults.mode ||
|
|
43
|
+
const mode = S.config.defaults.mode || DEFAULT_MODE.id;
|
|
42
44
|
const here = harnessesAt(launch.where, { auto: true });
|
|
43
45
|
const chosen = here.find((a) => a.id === (S.config.defaults.agent || 'auto'));
|
|
44
46
|
let agent = chosen?.available ? chosen : here.find((a) => a.available) || chosen || here[0];
|
|
@@ -63,9 +65,9 @@ export function renderLauncher() {
|
|
|
63
65
|
renderLaunchChips();
|
|
64
66
|
renderSetup();
|
|
65
67
|
|
|
66
|
-
const ta = $('#prompt', box);
|
|
68
|
+
const ta = mentionBox($('#prompt', box));
|
|
67
69
|
const tray = attachTray();
|
|
68
|
-
ta.before(tray.node);
|
|
70
|
+
ta.parentNode.before(tray.node); // before the mention box's wrapper
|
|
69
71
|
acceptFiles(box, ta, tray);
|
|
70
72
|
autosize(ta);
|
|
71
73
|
ta.value = S.drafts.get('home') || '';
|
|
@@ -88,14 +90,14 @@ export function renderLauncher() {
|
|
|
88
90
|
$('#whereBtn', box).onclick = (e) => whereMenu(e.currentTarget);
|
|
89
91
|
paintWhereBtn();
|
|
90
92
|
|
|
91
|
-
// Mode is set-and-forget, so it's one small button: just an icon on the default (
|
|
92
|
-
// spelled out when it's Plan or
|
|
93
|
+
// Mode is set-and-forget, so it's one small button: just an icon on the default (Full auto),
|
|
94
|
+
// spelled out when it's Plan or Edits so you can't miss it.
|
|
93
95
|
let curMode = mode;
|
|
94
96
|
const modeBtn = $('#mode', box);
|
|
95
97
|
const paintMode = () => {
|
|
96
|
-
const m = MODES.find((x) => x.id === curMode) ||
|
|
98
|
+
const m = MODES.find((x) => x.id === curMode) || DEFAULT_MODE;
|
|
97
99
|
modeBtn.className = `mode-btn m-${m.id}`;
|
|
98
|
-
modeBtn.innerHTML = `${icon(m.icon)}${m
|
|
100
|
+
modeBtn.innerHTML = `${icon(m.icon)}${m === DEFAULT_MODE ? '' : `<span>${m.label}</span>`}`;
|
|
99
101
|
modeBtn.title = `${m.label}: ${m.hint}\nShift+Tab to switch`;
|
|
100
102
|
};
|
|
101
103
|
const setMode = (id) => {
|
|
@@ -486,7 +488,7 @@ function launchedFx(from, to) {
|
|
|
486
488
|
let pendingSeq = 0;
|
|
487
489
|
function addPending(prompt, { project: p, m }, { agent, model, effort }) {
|
|
488
490
|
const id = `pending-${++pendingSeq}`;
|
|
489
|
-
const line = prompt.split('\n')[0].replace(/\s+/g, ' ');
|
|
491
|
+
const line = plain(prompt).split('\n')[0].replace(/\s+/g, ' ');
|
|
490
492
|
const now = Date.now();
|
|
491
493
|
S.sessions.set(id, {
|
|
492
494
|
id,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Task mentions in a prompt box, the way Linear does issue links: copy a task's id (right-click a
|
|
2
|
+
// card → Copy task ID), paste it into a prompt, and it shows as the task's title. The agent gets
|
|
3
|
+
// the id and reads that task with wisp_session.
|
|
4
|
+
//
|
|
5
|
+
// A textarea can't hold chips, so the box shows `@Title` as plain text and a mirror behind it,
|
|
6
|
+
// laid out exactly like the textarea, paints a chip under each mention. The ids stay on the box
|
|
7
|
+
// (ta.mentions). Its `value` reads and writes the stored form, `@[Title](wisp:<id>)`, so drafts,
|
|
8
|
+
// sends and restores elsewhere keep the mentions without knowing about them.
|
|
9
|
+
|
|
10
|
+
import { esc } from '../md.js';
|
|
11
|
+
import { el } from '../core/dom.js';
|
|
12
|
+
import { S } from '../core/state.js';
|
|
13
|
+
import { toast } from '../ui/toast.js';
|
|
14
|
+
import { cleanTitle, decode, encode, pastedTasks, spans } from '../mention-text.js';
|
|
15
|
+
|
|
16
|
+
const NATIVE = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
|
|
17
|
+
// What the mirror copies from the textarea, so its text wraps on the same lines.
|
|
18
|
+
const METRICS = ['font', 'letterSpacing', 'wordSpacing', 'lineHeight', 'textTransform', 'textIndent', 'tabSize', 'whiteSpace', 'overflowWrap', 'wordBreak', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'boxSizing'];
|
|
19
|
+
|
|
20
|
+
export function mentionBox(ta) {
|
|
21
|
+
if (ta.mentions) return ta;
|
|
22
|
+
ta.mentions = [];
|
|
23
|
+
const wrap = el('<div class="mn-wrap"></div>');
|
|
24
|
+
const mirror = el('<div class="mn-mirror" aria-hidden="true"></div>');
|
|
25
|
+
ta.replaceWith(wrap);
|
|
26
|
+
wrap.append(mirror, ta);
|
|
27
|
+
const shown = () => NATIVE.get.call(ta);
|
|
28
|
+
|
|
29
|
+
Object.defineProperty(ta, 'value', {
|
|
30
|
+
configurable: true,
|
|
31
|
+
get: () => encode(shown(), ta.mentions),
|
|
32
|
+
set: (v) => {
|
|
33
|
+
const d = decode(v);
|
|
34
|
+
ta.mentions = d.mentions;
|
|
35
|
+
NATIVE.set.call(ta, d.text);
|
|
36
|
+
paint();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function paint() {
|
|
41
|
+
const text = shown();
|
|
42
|
+
const found = ta.mentions.length ? spans(text, ta.mentions) : [];
|
|
43
|
+
wrap.classList.toggle('has-mn', found.length > 0);
|
|
44
|
+
if (!found.length) return (mirror.textContent = '');
|
|
45
|
+
const cs = getComputedStyle(ta);
|
|
46
|
+
for (const k of METRICS) mirror.style[k] = cs[k];
|
|
47
|
+
// A scrollbar narrows the textarea's text; narrow the mirror's the same.
|
|
48
|
+
const bar = ta.offsetWidth - ta.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
|
|
49
|
+
mirror.style.paddingRight = `${parseFloat(cs.paddingRight) + Math.max(0, bar)}px`;
|
|
50
|
+
let html = '';
|
|
51
|
+
let at = 0;
|
|
52
|
+
for (const s of found) {
|
|
53
|
+
html += `${esc(text.slice(at, s.start))}<mark class="mn">${esc(text.slice(s.start, s.end))}</mark>`;
|
|
54
|
+
at = s.end;
|
|
55
|
+
}
|
|
56
|
+
mirror.innerHTML = `${html}${esc(text.slice(at))}`;
|
|
57
|
+
sync();
|
|
58
|
+
}
|
|
59
|
+
const sync = () => {
|
|
60
|
+
mirror.scrollTop = ta.scrollTop;
|
|
61
|
+
mirror.scrollLeft = ta.scrollLeft;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
ta.addEventListener('input', paint);
|
|
65
|
+
ta.addEventListener('scroll', sync);
|
|
66
|
+
new ResizeObserver(paint).observe(ta);
|
|
67
|
+
|
|
68
|
+
ta.addEventListener('paste', (e) => {
|
|
69
|
+
if (e.clipboardData?.files?.length) return; // attachments take those
|
|
70
|
+
const ids = pastedTasks(e.clipboardData?.getData('text/plain'), (id) => S.sessions.has(id) && !S.sessions.get(id).pending);
|
|
71
|
+
if (!ids) return;
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const title = cleanTitle(S.sessions.get(id).title);
|
|
75
|
+
if (!ta.mentions.some((m) => m.id === id)) ta.mentions.push({ id, title });
|
|
76
|
+
}
|
|
77
|
+
const before = shown().slice(0, ta.selectionStart);
|
|
78
|
+
const lead = before && !/\s$/.test(before) ? ' ' : '';
|
|
79
|
+
insert(lead + ids.map((id) => `@${ta.mentions.find((m) => m.id === id).title}`).join(' ') + ' ');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Backspace right after a mention (or Delete right before one) takes the whole mention.
|
|
83
|
+
ta.addEventListener('keydown', (e) => {
|
|
84
|
+
if ((e.key !== 'Backspace' && e.key !== 'Delete') || e.isComposing || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
85
|
+
const { selectionStart: a, selectionEnd: b } = ta;
|
|
86
|
+
if (a !== b || !ta.mentions.length) return;
|
|
87
|
+
const hit = spans(shown(), ta.mentions).find((s) => (e.key === 'Backspace' ? s.end === a : s.start === a));
|
|
88
|
+
if (!hit) return;
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
ta.setSelectionRange(hit.start, hit.end);
|
|
91
|
+
insert('');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Through the editing commands, so Cmd+Z undoes it like typing.
|
|
95
|
+
function insert(text) {
|
|
96
|
+
if (!document.execCommand('insertText', false, text)) {
|
|
97
|
+
ta.setRangeText(text, ta.selectionStart, ta.selectionEnd, 'end');
|
|
98
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
paint();
|
|
103
|
+
return ta;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function copyTaskId(id) {
|
|
107
|
+
const s = S.sessions.get(id);
|
|
108
|
+
if (!s || s.pending) return;
|
|
109
|
+
try {
|
|
110
|
+
await navigator.clipboard.writeText(id);
|
|
111
|
+
toast(`Copied the ID of <b>${esc(cleanTitle(s.title))}</b>. Paste it into a prompt to point an agent at this task.`);
|
|
112
|
+
} catch {
|
|
113
|
+
toast(`Couldn’t copy. The task ID is <code>${id}</code>`, { tone: 'bad', timeout: 8000 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Clicking a mention chip in a turn's prompt opens that task, or says it's gone.
|
|
118
|
+
document.addEventListener('click', (e) => {
|
|
119
|
+
const a = e.target.closest?.('a.mn[href^="#/s/"]');
|
|
120
|
+
if (!a) return;
|
|
121
|
+
const id = a.getAttribute('href').slice(4);
|
|
122
|
+
if (S.sessions.has(id)) return; // the #/s/ route reveals it
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
toast('That task is no longer in wisp.', { tone: 'bad' });
|
|
125
|
+
});
|
|
@@ -20,7 +20,7 @@ export function askNotify() {
|
|
|
20
20
|
export function notifyFinished(s) {
|
|
21
21
|
const viewing = S.open.has(s.id) && watching(s.id) && document.hasFocus();
|
|
22
22
|
if (viewing) return;
|
|
23
|
-
let verb = s.state === 'waiting' ? 'has a question' : 'is ready';
|
|
23
|
+
let verb = s.state === 'waiting' ? (s.approval ? 'needs your approval' : 'has a question') : 'is ready';
|
|
24
24
|
// A task run in several places (or compared) says so once, when its last run finishes.
|
|
25
25
|
const runs = s.groupId ? [...S.sessions.values()].filter((x) => x.groupId === s.groupId && !x.archived) : [];
|
|
26
26
|
if (runs.length > 1 && s.state !== 'waiting') {
|