samagotchi 0.30.0 → 0.32.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -1
- data/README.md +13 -0
- data/bin/chi +3 -0
- data/docs/cli.md +1 -0
- data/docs/configuration.md +7 -1
- data/docs/context.md +218 -0
- data/docs/guardrails.md +12 -4
- data/docs/hooks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/plugins.md +77 -1
- data/docs/releasing.md +1 -1
- data/docs/sessions.md +9 -2
- data/lib/samagotchi/bootstrap_command.rb +4 -1
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/bundles/dev/manifest.yml +3 -2
- data/lib/samagotchi/bundles/github-pr/manifest.yml +22 -0
- data/lib/samagotchi/bundles/github-pr/plugin.rb +65 -0
- data/lib/samagotchi/bundles/github-pr/scripts/pr_context.rb +199 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +15 -2
- data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +1 -1
- data/lib/samagotchi/bundles/system/delegated.md +1 -0
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -1
- data/lib/samagotchi/child_reports.rb +212 -0
- data/lib/samagotchi/client.rb +35 -4
- data/lib/samagotchi/config.rb +11 -0
- data/lib/samagotchi/context_absorber.rb +179 -0
- data/lib/samagotchi/context_command.rb +473 -0
- data/lib/samagotchi/context_fetch.rb +226 -0
- data/lib/samagotchi/context_note.rb +11 -5
- data/lib/samagotchi/context_poller.rb +100 -0
- data/lib/samagotchi/context_providers.rb +159 -0
- data/lib/samagotchi/context_sources.rb +452 -0
- data/lib/samagotchi/engine.rb +12 -5
- data/lib/samagotchi/guardrail_wiring.rb +3 -1
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -7
- data/lib/samagotchi/guardrails/protected_paths.rb +8 -5
- data/lib/samagotchi/guardrails/shell_lex.rb +0 -9
- data/lib/samagotchi/guardrails/verdict.rb +21 -3
- data/lib/samagotchi/idle_recap.rb +4 -0
- data/lib/samagotchi/kernel_loop.rb +44 -5
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/errors.rb +9 -0
- data/lib/samagotchi/log_line.rb +1 -1
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/installer.rb +43 -1
- data/lib/samagotchi/memory_bundle/manifest.rb +39 -3
- data/lib/samagotchi/memory_bundle/provenance.rb +17 -1
- data/lib/samagotchi/plugin/attached_context.rb +100 -0
- data/lib/samagotchi/plugin/context.rb +22 -0
- data/lib/samagotchi/prompt_warmup.rb +30 -5
- data/lib/samagotchi/question_desk.rb +0 -3
- data/lib/samagotchi/reply_wait.rb +39 -8
- data/lib/samagotchi/session.rb +30 -7
- data/lib/samagotchi/session_commands.rb +18 -1
- data/lib/samagotchi/session_inbox.rb +34 -1
- data/lib/samagotchi/session_manager.rb +37 -5
- data/lib/samagotchi/session_retention.rb +3 -0
- data/lib/samagotchi/steer.rb +48 -7
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -7
- data/lib/samagotchi/terminal_ui.rb +9 -3
- data/lib/samagotchi/tool_activity.rb +8 -0
- data/lib/samagotchi/tool_call_parser.rb +40 -0
- data/lib/samagotchi/tool_declarations.rb +28 -7
- data/lib/samagotchi/tools/builtin_calls.rb +1 -2
- data/lib/samagotchi/tools/builtins.rb +5 -0
- data/lib/samagotchi/tools/context_read.rb +163 -0
- data/lib/samagotchi/tools/delegate.rb +13 -4
- data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
- data/lib/samagotchi/tools/delegate_wait.rb +50 -31
- data/lib/samagotchi/tools/task_runtime.rb +21 -2
- data/lib/samagotchi/tools/task_wait.rb +2 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +117 -3
- data/lib/samagotchi/web/public/app.js +52 -14
- data/lib/samagotchi/web/public/composer_size.js +7 -6
- data/lib/samagotchi/web/public/context_chips.js +285 -0
- data/lib/samagotchi/web/public/data.js +21 -0
- data/lib/samagotchi/web/public/format.js +8 -1
- data/lib/samagotchi/web/public/index.html +49 -3
- data/lib/samagotchi/web/public/notify.js +6 -2
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/turn_events.js +34 -10
- data/lib/samagotchi/web/public/turn_view.js +26 -4
- data/lib/samagotchi/worker.rb +343 -8
- metadata +16 -1
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The session bar's attached context (chi context): a chip per source the
|
|
2
|
+
// open session sees (name · age; a dot for a change the agent hasn't read,
|
|
3
|
+
// red for a failing refresh), and a popover with its summary, hint link,
|
|
4
|
+
// text and detach. The chips refresh when a context note arrives in the
|
|
5
|
+
// stream and when the page gets focus (no event of their own), and their
|
|
6
|
+
// ages tick every minute. A "+ URL" chip (when an installed bundle's
|
|
7
|
+
// provider can attach one: GET …/context's can_add_url) opens a form that
|
|
8
|
+
// attaches a URL (a GitHub PR); the web never adds a command.
|
|
9
|
+
|
|
10
|
+
// "now", "3m", "2h", "4d" since an ISO time; "" for none.
|
|
11
|
+
export function ageText(iso, now = Date.now()) {
|
|
12
|
+
const at = Date.parse(iso || "");
|
|
13
|
+
if (Number.isNaN(at)) return "";
|
|
14
|
+
const s = Math.max(0, Math.round((now - at) / 1000));
|
|
15
|
+
if (s < 60) return "now";
|
|
16
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
17
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
18
|
+
return `${Math.floor(s / 86400)}d`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A hint the popover may link to: http(s) only.
|
|
22
|
+
export function hintUrl(hint) {
|
|
23
|
+
const text = String(hint || "").trim();
|
|
24
|
+
return /^https?:\/\/\S+$/i.test(text) ? text : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// GET …/context's rows as chips: the muted ones left out. state: "error"
|
|
28
|
+
// (the last refresh failed), "unread" (a text the agent hasn't read),
|
|
29
|
+
// "empty" (no text yet) or "ok".
|
|
30
|
+
export function chipModel(rows, now = Date.now()) {
|
|
31
|
+
return (rows || []).filter((row) => row && !row.muted).map((row) => {
|
|
32
|
+
const state = row.error ? "error" : !row.has_text ? "empty" : row.unread ? "unread" : "ok";
|
|
33
|
+
const age = ageText(row.fetched_at, now);
|
|
34
|
+
const why = { error: `last refresh failed: ${row.error}`, unread: "changed; the agent hasn't read it yet",
|
|
35
|
+
empty: "no text yet", ok: "the agent has read the latest" }[state];
|
|
36
|
+
const title = [row.summary, why].filter(Boolean).join(" · ");
|
|
37
|
+
return { name: row.name, label: age ? `${row.name} · ${age}` : row.name, state, title };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// addUrl: end with the "+ URL" chip.
|
|
42
|
+
export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
|
|
43
|
+
return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
|
|
44
|
+
`title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
|
|
45
|
+
`${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
|
|
46
|
+
(addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
|
|
50
|
+
// after a try that failed.
|
|
51
|
+
export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
|
|
52
|
+
return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
|
|
53
|
+
`<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
|
|
54
|
+
`<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
|
|
55
|
+
`<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
|
|
56
|
+
`<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
|
|
57
|
+
(error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
|
|
58
|
+
`<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The popover's inside for one row (GET …/context's).
|
|
62
|
+
export function popoverHtml(row, escapeHtml, now = Date.now()) {
|
|
63
|
+
const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
|
|
64
|
+
const url = hintUrl(row.hint);
|
|
65
|
+
const hint = url
|
|
66
|
+
? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
|
|
67
|
+
: (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
|
|
68
|
+
const age = ageText(row.fetched_at, now);
|
|
69
|
+
const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
|
|
70
|
+
row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
|
|
71
|
+
.filter(Boolean).join(" · ");
|
|
72
|
+
const detach = row.scope === "project" ? "mute here" : "detach";
|
|
73
|
+
const detachTitle = row.scope === "project"
|
|
74
|
+
? "The project's source: this session stops seeing it (chi context unmute brings it back)"
|
|
75
|
+
: "Remove it from this session";
|
|
76
|
+
return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
|
|
77
|
+
`<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
|
|
78
|
+
`<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
|
|
79
|
+
(row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
|
|
80
|
+
hint +
|
|
81
|
+
(row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
|
|
82
|
+
`<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
|
|
83
|
+
(row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
|
|
84
|
+
`<pre class="ctx-pop-text" hidden></pre>` +
|
|
85
|
+
`<div class="ctx-pop-actions">` +
|
|
86
|
+
(row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
|
|
87
|
+
`<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The chips for the session bar. el: #contextChips; api: {list(id),
|
|
91
|
+
// show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
|
|
92
|
+
// toast(text).
|
|
93
|
+
export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
|
|
94
|
+
let sessionId = null;
|
|
95
|
+
let rows = [];
|
|
96
|
+
let canAdd = false;
|
|
97
|
+
let openName = null;
|
|
98
|
+
let loadSeq = 0;
|
|
99
|
+
const pop = doc.createElement("div");
|
|
100
|
+
pop.id = "contextPopover";
|
|
101
|
+
pop.className = "ctx-popover";
|
|
102
|
+
pop.setAttribute("role", "dialog");
|
|
103
|
+
pop.hidden = true;
|
|
104
|
+
doc.body.appendChild(pop);
|
|
105
|
+
|
|
106
|
+
// openName ADD: the "+ URL" form is open.
|
|
107
|
+
const ADD = "\u0000add";
|
|
108
|
+
|
|
109
|
+
function render() {
|
|
110
|
+
const chips = chipModel(rows);
|
|
111
|
+
el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
|
|
112
|
+
el.hidden = chips.length === 0 && !canAdd;
|
|
113
|
+
// The "+ URL" chip alone sits inline, not on a row of its own.
|
|
114
|
+
el.classList.toggle("only-add", chips.length === 0 && canAdd);
|
|
115
|
+
if (openName === ADD) {
|
|
116
|
+
if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
|
|
117
|
+
else close();
|
|
118
|
+
} else if (openName && !chips.some((chip) => chip.name === openName)) close();
|
|
119
|
+
else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function refresh() {
|
|
123
|
+
const id = sessionId;
|
|
124
|
+
if (!id) return;
|
|
125
|
+
const seq = ++loadSeq;
|
|
126
|
+
try {
|
|
127
|
+
const data = await api.list(id);
|
|
128
|
+
if (seq !== loadSeq || id !== sessionId) return;
|
|
129
|
+
rows = data.sources || [];
|
|
130
|
+
canAdd = data.can_add_url === true;
|
|
131
|
+
} catch {
|
|
132
|
+
// A session the server doesn't know (gone, or an older chi web): no chips.
|
|
133
|
+
if (seq !== loadSeq || id !== sessionId) return;
|
|
134
|
+
rows = [];
|
|
135
|
+
canAdd = false;
|
|
136
|
+
}
|
|
137
|
+
render();
|
|
138
|
+
if (openName && openName !== ADD) {
|
|
139
|
+
const row = rows.find((r) => r.name === openName);
|
|
140
|
+
if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function load(id) {
|
|
145
|
+
sessionId = id;
|
|
146
|
+
rows = [];
|
|
147
|
+
canAdd = false;
|
|
148
|
+
close();
|
|
149
|
+
render();
|
|
150
|
+
return refresh();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clear() {
|
|
154
|
+
sessionId = null;
|
|
155
|
+
rows = [];
|
|
156
|
+
canAdd = false;
|
|
157
|
+
close();
|
|
158
|
+
render();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function fill(row) {
|
|
162
|
+
pop.innerHTML = popoverHtml(row, escapeHtml);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Above the chip, inside the window with a 16px gutter.
|
|
166
|
+
function place(chip) {
|
|
167
|
+
const rect = chip.getBoundingClientRect();
|
|
168
|
+
const width = Math.min(380, win.innerWidth - 32);
|
|
169
|
+
pop.style.width = `${width}px`;
|
|
170
|
+
pop.style.left = `${Math.max(16, Math.min(rect.left, win.innerWidth - width - 16))}px`;
|
|
171
|
+
pop.style.bottom = `${Math.max(16, win.innerHeight - rect.top + 8)}px`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function open(name, chip) {
|
|
175
|
+
const row = rows.find((r) => r.name === name);
|
|
176
|
+
if (!row) return;
|
|
177
|
+
openName = name;
|
|
178
|
+
fill(row);
|
|
179
|
+
pop.hidden = false;
|
|
180
|
+
place(chip);
|
|
181
|
+
el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function openAdd(chip, state = {}) {
|
|
185
|
+
openName = ADD;
|
|
186
|
+
pop.innerHTML = addFormHtml(escapeHtml, state);
|
|
187
|
+
pop.hidden = false;
|
|
188
|
+
place(chip);
|
|
189
|
+
el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
|
|
190
|
+
pop.querySelector('input[name="url"]')?.focus();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function submitAdd(form) {
|
|
194
|
+
const id = sessionId;
|
|
195
|
+
const url = form.elements.url.value.trim();
|
|
196
|
+
const why = form.elements.why.value.trim();
|
|
197
|
+
if (!id || !url) return;
|
|
198
|
+
const button = form.querySelector(".ctx-add-submit");
|
|
199
|
+
if (button) button.disabled = true;
|
|
200
|
+
try {
|
|
201
|
+
const data = await api.add(id, { url, why: why || undefined });
|
|
202
|
+
if (id !== sessionId) return;
|
|
203
|
+
close();
|
|
204
|
+
await refresh();
|
|
205
|
+
toast(`Attached ${data.name}: the agent gets a note before your next message`);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
if (id !== sessionId || openName !== ADD) return;
|
|
208
|
+
const chip = el.querySelector(".ctx-add");
|
|
209
|
+
if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function close() {
|
|
214
|
+
openName = null;
|
|
215
|
+
pop.hidden = true;
|
|
216
|
+
el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function viewText() {
|
|
220
|
+
const name = openName;
|
|
221
|
+
const pre = pop.querySelector(".ctx-pop-text");
|
|
222
|
+
if (!name || !pre) return;
|
|
223
|
+
try {
|
|
224
|
+
const data = await api.show(sessionId, name);
|
|
225
|
+
if (name !== openName) return;
|
|
226
|
+
pre.textContent = data.text || "";
|
|
227
|
+
pre.hidden = false;
|
|
228
|
+
pop.querySelector('[data-act="view"]')?.remove();
|
|
229
|
+
} catch (e) {
|
|
230
|
+
toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function detach() {
|
|
235
|
+
const row = rows.find((r) => r.name === openName);
|
|
236
|
+
if (!row) return;
|
|
237
|
+
const ask = row.scope === "project"
|
|
238
|
+
? `Mute ${row.name} for this session? It is the project's: other sessions keep it.`
|
|
239
|
+
: `Detach ${row.name} from this session?`;
|
|
240
|
+
if (!win.confirm(ask)) return;
|
|
241
|
+
try {
|
|
242
|
+
await api.detach(sessionId, row.name);
|
|
243
|
+
close();
|
|
244
|
+
await refresh();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
toast(`Can't detach ${row.name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
el.addEventListener("click", (e) => {
|
|
251
|
+
const chip = e.target.closest(".ctx-chip");
|
|
252
|
+
if (!chip) return;
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
if (chip.dataset.act === "add") {
|
|
255
|
+
if (openName === ADD) close();
|
|
256
|
+
else openAdd(chip);
|
|
257
|
+
} else if (openName === chip.dataset.name) close();
|
|
258
|
+
else open(chip.dataset.name, chip);
|
|
259
|
+
});
|
|
260
|
+
pop.addEventListener("submit", (e) => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
submitAdd(e.target);
|
|
263
|
+
});
|
|
264
|
+
pop.addEventListener("click", (e) => {
|
|
265
|
+
const act = e.target.closest("[data-act]")?.dataset.act;
|
|
266
|
+
if (act === "close") close();
|
|
267
|
+
else if (act === "view") viewText();
|
|
268
|
+
else if (act === "detach") detach();
|
|
269
|
+
});
|
|
270
|
+
doc.addEventListener("click", (e) => {
|
|
271
|
+
if (openName && !pop.contains(e.target) && !el.contains(e.target)) close();
|
|
272
|
+
});
|
|
273
|
+
doc.addEventListener("keydown", (e) => {
|
|
274
|
+
if (e.key === "Escape" && openName) close();
|
|
275
|
+
});
|
|
276
|
+
win.addEventListener("resize", () => {
|
|
277
|
+
const chip = openName && el.querySelector(".ctx-chip.open");
|
|
278
|
+
if (chip) place(chip);
|
|
279
|
+
});
|
|
280
|
+
win.addEventListener("focus", () => refresh());
|
|
281
|
+
// The ages move on by themselves.
|
|
282
|
+
win.setInterval(() => { if (rows.length) render(); }, 60_000);
|
|
283
|
+
|
|
284
|
+
return { load, clear, refresh, close, get rows() { return rows; } };
|
|
285
|
+
}
|
|
@@ -251,3 +251,24 @@ export function openEvents(handlers = {}, opts = {}) {
|
|
|
251
251
|
},
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
+
|
|
255
|
+
// A session's attached context (chi context): the sources it sees, one
|
|
256
|
+
// with its text, and detach (a session's) or mute here (a project's).
|
|
257
|
+
export function listContext(id, opts = {}) {
|
|
258
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context`, opts);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function getContext(id, name, opts = {}) {
|
|
262
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, opts);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A URL an installed bundle's provider attaches: {url, why} → {name}.
|
|
266
|
+
export function addContext(id, body, opts = {}) {
|
|
267
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context`, {
|
|
268
|
+
method: "POST", body: JSON.stringify(body), ...opts,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function detachContext(id, name, opts = {}) {
|
|
273
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/context/${encodeURIComponent(name)}`, { method: "DELETE", ...opts });
|
|
274
|
+
}
|
|
@@ -26,12 +26,19 @@ export function noteHtml(note) {
|
|
|
26
26
|
// Who a steer names as its sender: a plugin's own label, chi's sender ids
|
|
27
27
|
// in words (the TUI's Formatting#steer_sender agrees;
|
|
28
28
|
// spec/shared/labels_matrix.json). "": no one named.
|
|
29
|
-
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin" };
|
|
29
|
+
const STEER_SENDERS = { parent_agent: "parent agent", chi_send: "chi send", plugin_send: "plugin", delegate_report: "delegate report" };
|
|
30
30
|
export function steerSender(source) {
|
|
31
31
|
const id = String(source ?? "");
|
|
32
32
|
return STEER_SENDERS[id] || id;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// The label a user-role line gets on reload for who sent it, by its saved
|
|
36
|
+
// source: a delegate report (the live bubble's clientLabel of child:<id>);
|
|
37
|
+
// null for the user's own (and anything else, as before).
|
|
38
|
+
export function sourceLabel(source) {
|
|
39
|
+
return source === "delegate_report" ? steerSender(source) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
36
43
|
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
37
44
|
// own between bubbles (no step holds it), not a row of a step.
|
|
@@ -472,6 +472,12 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
472
472
|
#composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
|
|
473
473
|
#composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
|
|
474
474
|
#composer #actionBtn{min-width:64px;font-weight:600}
|
|
475
|
+
/* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
|
|
476
|
+
and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
|
|
477
|
+
body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
|
|
478
|
+
body.has-session #composer textarea{min-height:0}
|
|
479
|
+
body.has-session #composeHint{display:none}
|
|
480
|
+
body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
475
481
|
/* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
|
|
476
482
|
/* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
|
|
477
483
|
#composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
|
|
@@ -651,7 +657,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
651
657
|
#infoBar .meta .copy-attach{padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
|
|
652
658
|
#infoBar .meta .copy-attach:hover{color:var(--text-hover);border-color:var(--border-strong)}
|
|
653
659
|
#infoBar .meta .status{font-size:11px;padding:2px 6px;border-radius:999px}
|
|
654
|
-
|
|
660
|
+
/* The whole path on one line: a long one loses its start ("…/project/dir", the end is what tells dirs apart); the title holds all of it. */
|
|
661
|
+
#infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);max-width:48ch;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left}
|
|
655
662
|
/* The footer sits on the card's glass: its chips are tinted, not solid, so the glass reads through them. */
|
|
656
663
|
#infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3)}
|
|
657
664
|
/* The memory chip ("mem 2", when the session used some): phones only, where the "mem: …" line is hidden. */
|
|
@@ -663,7 +670,7 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
663
670
|
#infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
|
|
664
671
|
#infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:var(--on-warn)}
|
|
665
672
|
#infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
|
|
666
|
-
#infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:
|
|
673
|
+
#infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:1;line-clamp:1;overflow:hidden;line-height:1.4;max-height:1.4em}
|
|
667
674
|
#infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
|
|
668
675
|
#infoBar .mem{font-size:12px;color:var(--text-muted)}
|
|
669
676
|
#infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
|
|
@@ -673,6 +680,38 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
673
680
|
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
674
681
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
675
682
|
#infoBar #infoDeleteBtn:hover{color:var(--on-solid);background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
683
|
+
/* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
|
|
684
|
+
#contextChips{order:5;flex-basis:100%;display:flex;flex-wrap:wrap;gap:6px;align-items:center;min-width:0}
|
|
685
|
+
#contextChips[hidden]{display:none}
|
|
686
|
+
#contextChips.only-add{flex-basis:auto}
|
|
687
|
+
#infoBar .ctx-chip.ctx-add{border-style:dashed;color:var(--text-muted);background:transparent}
|
|
688
|
+
.ctx-add-form{display:flex;flex-direction:column;gap:6px;margin:0}
|
|
689
|
+
.ctx-add-form .ctx-pop-head strong{font-family:inherit}
|
|
690
|
+
.ctx-add-input{width:100%;box-sizing:border-box;padding:6px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
|
|
691
|
+
.ctx-add-input:focus{outline:none;border-color:var(--accent-border)}
|
|
692
|
+
#infoBar .ctx-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font:600 11px/1.5 ui-monospace,monospace;background:color-mix(in srgb,var(--bg-hover) 55%,transparent);color:var(--text-3);border:1px solid var(--border-strong);white-space:nowrap}
|
|
693
|
+
#infoBar .ctx-chip:hover,#infoBar .ctx-chip.open{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
|
|
694
|
+
#infoBar .ctx-chip.empty{color:var(--text-faint);border-style:dashed}
|
|
695
|
+
#infoBar .ctx-chip.error{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent);background:var(--danger-bg-dim)}
|
|
696
|
+
.ctx-dot{width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
|
|
697
|
+
.ctx-popover{position:fixed;z-index:60;max-height:min(60vh,520px);overflow:auto;padding:12px 14px;border-radius:12px;background:var(--bg-panel);color:var(--text-2);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px;line-height:1.45;display:flex;flex-direction:column;gap:6px}
|
|
698
|
+
.ctx-popover[hidden]{display:none}
|
|
699
|
+
.ctx-pop-head{display:flex;align-items:baseline;gap:8px}
|
|
700
|
+
.ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
|
|
701
|
+
.ctx-pop-kind{font-size:11px;color:var(--text-muted)}
|
|
702
|
+
.ctx-pop-close{margin-left:auto;padding:0 6px;border:none;background:transparent;color:var(--text-muted);font-size:16px;line-height:1}
|
|
703
|
+
.ctx-pop-close:hover{color:var(--text)}
|
|
704
|
+
.ctx-pop-why{color:var(--text)}
|
|
705
|
+
.ctx-pop-hint{font-size:12px;color:var(--accent);word-break:break-all}
|
|
706
|
+
.ctx-pop-summary{padding:6px 8px;border-radius:8px;background:var(--bg-inset);border-left:3px solid var(--accent-border)}
|
|
707
|
+
.ctx-pop-meta{font-size:11px;color:var(--text-muted)}
|
|
708
|
+
.ctx-pop-error{font-size:12px;color:var(--danger-text)}
|
|
709
|
+
.ctx-pop-text{margin:0;max-height:240px;overflow:auto;padding:8px;border-radius:8px;background:var(--bg-inset);font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word;color:var(--text-2)}
|
|
710
|
+
.ctx-pop-actions{display:flex;gap:8px;justify-content:flex-end}
|
|
711
|
+
.ctx-pop-actions button{padding:3px 10px;border-radius:7px;font-size:12px;font-weight:600;background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong)}
|
|
712
|
+
.ctx-pop-actions button:hover{color:var(--text-hover)}
|
|
713
|
+
.ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
|
|
714
|
+
.ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}
|
|
676
715
|
/* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable) and the first-message line, the strip card's title, goes. */
|
|
677
716
|
@media(max-width:600px){
|
|
678
717
|
/* The key hint: cut off at this width, and phones have no Shift+Enter. */
|
|
@@ -688,7 +727,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
688
727
|
#infoBar .meta .status{order:-1}
|
|
689
728
|
/* After the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
|
|
690
729
|
#infoBar .meta .mem-chip{display:inline;order:-1}
|
|
691
|
-
|
|
730
|
+
/* The chips get a second line of their own (only when there are some), scrolling sideways. */
|
|
731
|
+
#infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
|
|
732
|
+
#contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
|
|
733
|
+
/* The meta scrolls here: a lone "+ URL" would take its width, so it gets the row too. */
|
|
734
|
+
#contextChips.only-add{flex-basis:100%}
|
|
735
|
+
#contextChips::-webkit-scrollbar{display:none}
|
|
736
|
+
#contextChips>*{flex-shrink:0}
|
|
692
737
|
#infoBar button{padding:4px 8px}
|
|
693
738
|
/* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
|
|
694
739
|
#infoBar>button{font-size:0;line-height:0;padding:5px 7px}
|
|
@@ -892,6 +937,7 @@ a{color:var(--accent)}
|
|
|
892
937
|
</div>
|
|
893
938
|
<div id="infoBar">
|
|
894
939
|
<div class="info" id="infoText"></div>
|
|
940
|
+
<div id="contextChips" aria-label="Attached context" hidden></div>
|
|
895
941
|
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
896
942
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
|
|
897
943
|
<button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
|
|
@@ -12,8 +12,12 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
12
12
|
return wanted && permission === "granted" ? "on" : "off";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// A finished turn shorter than this was watched: no "done".
|
|
15
|
+
// A finished turn shorter than this was watched: no "done". Not a turn
|
|
16
|
+
// chi ran for a delegate child's report (last_turn origin delegate_report)
|
|
17
|
+
// or an attached context change (context): nobody started it, so nobody
|
|
18
|
+
// watched it.
|
|
16
19
|
export const DONE_MIN_SECONDS = 10;
|
|
20
|
+
const UNWATCHED_ORIGINS = ["delegate_report", "context"];
|
|
17
21
|
|
|
18
22
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
19
23
|
// and asks whether to go on) has its own reason: "out of steps".
|
|
@@ -66,7 +70,7 @@ export function attentionFor(prev, next) {
|
|
|
66
70
|
if (next.parent_id) return null;
|
|
67
71
|
const key = `${next.id}:${turn.ended_at}`;
|
|
68
72
|
if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
|
|
69
|
-
if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
|
|
73
|
+
if (turn.outcome === "completed" && (Number(turn.seconds) >= DONE_MIN_SECONDS || UNWATCHED_ORIGINS.includes(turn.origin))) {
|
|
70
74
|
return { reason: "done", sessionId: next.id, key };
|
|
71
75
|
}
|
|
72
76
|
return null;
|
|
@@ -112,6 +112,10 @@ export function turnRecordAt(timing, index) {
|
|
|
112
112
|
return Array.isArray(records) ? records[index] || null : null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// A history item that starts a turn: a prompt, or the note a context wake
|
|
116
|
+
// turn starts with (turn_start).
|
|
117
|
+
const startsTurn = (item) => item?.role === "user" || (item?.role === "note" && item.turn_start === true);
|
|
118
|
+
|
|
115
119
|
// For each history item, the turn index its timing shows under, or null. A
|
|
116
120
|
// turn with tool calls holds several assistant messages; only the last one
|
|
117
121
|
// before the next user message carries the turn's timing. A turn with none
|
|
@@ -122,7 +126,7 @@ export function timedTurnIndexes(items) {
|
|
|
122
126
|
let turnIndex = -1;
|
|
123
127
|
let carrier = null;
|
|
124
128
|
items.forEach((item, i) => {
|
|
125
|
-
if (item
|
|
129
|
+
if (startsTurn(item)) {
|
|
126
130
|
if (carrier !== null) result[carrier] = turnIndex;
|
|
127
131
|
turnIndex += 1;
|
|
128
132
|
carrier = i;
|
|
@@ -150,14 +154,14 @@ export function turnGroups(items, timing) {
|
|
|
150
154
|
let turnIndex = -1;
|
|
151
155
|
let turn = null;
|
|
152
156
|
items.forEach((item, i) => {
|
|
153
|
-
if (item?.role === "note") {
|
|
157
|
+
if (item?.role === "note" && !startsTurn(item)) {
|
|
154
158
|
groups.push({ kind: "note", i });
|
|
155
159
|
} else if (item?.role === "steer") {
|
|
156
160
|
if (turn) turn.steers.push(i);
|
|
157
161
|
else groups.push({ kind: "steer", i });
|
|
158
162
|
} else if (item?.role === "user" && item.merged && turn) {
|
|
159
163
|
turn.merged.push(i);
|
|
160
|
-
} else if (item
|
|
164
|
+
} else if (startsTurn(item)) {
|
|
161
165
|
turnIndex += 1;
|
|
162
166
|
turn = { kind: "turn", turnIndex, turnId: item.turn_id, user: i, texts: [], steers: [], merged: [] };
|
|
163
167
|
groups.push(turn);
|
|
@@ -93,12 +93,24 @@ export function isOwn(clientId, myId) {
|
|
|
93
93
|
|
|
94
94
|
// As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
|
|
95
95
|
// helpers and the TUI's agree per spec/shared/labels_matrix.json.
|
|
96
|
-
const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
|
|
96
|
+
const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// A delegate child's report comes in as client child:<id8> (ChildReports).
|
|
99
|
+
const REPORT_CLIENT = "child:";
|
|
100
|
+
export function isReportClient(clientId) {
|
|
101
|
+
return String(clientId || "").startsWith(REPORT_CLIENT);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A turn chi ran because an attached context source changed: context:<name>.
|
|
105
|
+
const CONTEXT_CLIENT = "context:";
|
|
106
|
+
|
|
107
|
+
// The label on another client's prompt, by its client_id prefix; a context
|
|
108
|
+
// change's turn as "context <name> changed".
|
|
99
109
|
export function clientLabel(clientId) {
|
|
100
110
|
if (!clientId) return null;
|
|
101
|
-
|
|
111
|
+
const id = String(clientId);
|
|
112
|
+
if (id.startsWith(CONTEXT_CLIENT) && id.length > CONTEXT_CLIENT.length) return `context ${id.slice(CONTEXT_CLIENT.length)} changed`;
|
|
113
|
+
return CLIENT_LABELS[id.split(":", 1)[0]] || "user";
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
// The note for reminders a turn got (a reminder turn has no prompt).
|
|
@@ -119,9 +131,10 @@ export function hookNoticeLabel(hook) {
|
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
// The row a step gets when the loop asks again after an empty answer, or
|
|
122
|
-
// after a plugin cut the generation (stopped_by: the bundle)
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
// after a plugin cut the generation (stopped_by: the bundle), or after a
|
|
135
|
+
// corrupt native generation (malformed).
|
|
136
|
+
export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy, malformed } = {}) {
|
|
137
|
+
const what = stoppedBy ? `cut by ${stoppedBy}` : malformed ? "malformed answer" : "empty answer";
|
|
125
138
|
return `↻ ${what}, asking again (${attempt}/${of})`;
|
|
126
139
|
}
|
|
127
140
|
|
|
@@ -381,9 +394,14 @@ export function composerAfterAck({ sent = "", current = "", refill = "" } = {})
|
|
|
381
394
|
// tag — this tab's own local echo gets its enqueued_id
|
|
382
395
|
// start — the bubble's turn started (drops the "queued" badge)
|
|
383
396
|
// steer — the bubble was merged into the running turn
|
|
397
|
+
// report — the merge brought a delegate child's report (origin
|
|
398
|
+
// child:<id8>, no bubble anywhere): its text gets a labelled one
|
|
384
399
|
// `known(enqueuedId)` says whether a bubble carries that id already;
|
|
385
|
-
// `unmatchedMerge` whether the last input_merged had an origin with no
|
|
386
|
-
|
|
400
|
+
// `unmatchedMerge` whether the last input_merged had an origin with no
|
|
401
|
+
// bubble ("report": a delegate report's); `wakeStart` whether this is the
|
|
402
|
+
// first merge of a turn chi ran for reports (its bubble starts the turn,
|
|
403
|
+
// no "steered" badge, as a reload shows it).
|
|
404
|
+
export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStart = false }) {
|
|
387
405
|
if (!event) return [];
|
|
388
406
|
switch (event.type) {
|
|
389
407
|
case "turn_enqueued": {
|
|
@@ -403,12 +421,18 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
|
|
|
403
421
|
}
|
|
404
422
|
return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
|
|
405
423
|
}
|
|
406
|
-
case "input_merged":
|
|
407
|
-
|
|
424
|
+
case "input_merged": {
|
|
425
|
+
const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
|
|
408
426
|
known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
|
|
409
427
|
);
|
|
428
|
+
if ((event.origins || []).some((o) => o && !o.enqueued_id && isReportClient(o.client_id))) ops.push({ op: "report" });
|
|
429
|
+
return ops;
|
|
430
|
+
}
|
|
410
431
|
case "pending_input_merged":
|
|
411
432
|
if (!unmatchedMerge || !event.content) return [];
|
|
433
|
+
if (unmatchedMerge === "report") {
|
|
434
|
+
return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
|
|
435
|
+
}
|
|
412
436
|
return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
|
|
413
437
|
default:
|
|
414
438
|
return [];
|
|
@@ -14,7 +14,7 @@ import { leavesBlock } from "./card.js";
|
|
|
14
14
|
import { copySourceAttr } from "./copy.js";
|
|
15
15
|
import { commandBlockHtml, rowHover } from "./command_view.js";
|
|
16
16
|
import { activityDiffHtml } from "./diff_view.js";
|
|
17
|
-
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
17
|
+
import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
|
|
18
18
|
import { thumbsHtml } from "./images.js";
|
|
19
19
|
import { shouldFollowScroll } from "./scroll.js";
|
|
20
20
|
import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
|
|
@@ -805,13 +805,31 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
805
805
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
806
806
|
return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
|
|
807
807
|
};
|
|
808
|
+
// The live bubble's origin label (app.js makeUserBubble), for a line
|
|
809
|
+
// chi brought in: a delegate report.
|
|
810
|
+
const originLabelHtml = (m) => {
|
|
811
|
+
const label = sourceLabel(m?.source);
|
|
812
|
+
return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
|
|
813
|
+
};
|
|
814
|
+
// The first text after a delegate report merged mid-turn, unless it is
|
|
815
|
+
// the turn's answer (shown anyway).
|
|
816
|
+
const reportReplyHtml = (i, group) => {
|
|
817
|
+
if (items[i]?.source !== "delegate_report") return "";
|
|
818
|
+
const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
|
|
819
|
+
if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
|
|
820
|
+
const { body, renderedMarkdown } = messageBodyHtml(items[j]);
|
|
821
|
+
return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
|
|
822
|
+
};
|
|
808
823
|
const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
|
|
809
824
|
return turnGroups(items, timing).map((group) => {
|
|
810
825
|
if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
|
|
811
826
|
if (group.kind === "steer") return steerHtml(group.i, true);
|
|
812
827
|
if (group.kind === "answer") return answerHtml(items[group.answer], "");
|
|
813
828
|
const prompt = items[group.user];
|
|
814
|
-
|
|
829
|
+
// A context wake turn starts with its note.
|
|
830
|
+
const parts = [prompt.role === "note"
|
|
831
|
+
? `<div class="bubble note">${noteHtml(prompt)}</div>`
|
|
832
|
+
: `<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
|
|
815
833
|
const [only] = group.steps;
|
|
816
834
|
const ended = group.answer !== null || group.emptyAnswer != null;
|
|
817
835
|
const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
|
|
@@ -843,9 +861,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
|
|
|
843
861
|
const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
|
|
844
862
|
parts.push(loose.map((i) => steerHtml(i, true)).join(""));
|
|
845
863
|
// The user's lines merged into the turn: the live "steered" bubbles,
|
|
846
|
-
// after its steps and before its answer.
|
|
864
|
+
// after its steps and before its answer. A delegate report's is
|
|
865
|
+
// followed by the text that answered it when that isn't the answer: a
|
|
866
|
+
// small model tells its user about a report before its next tool call,
|
|
867
|
+
// and that step's text would stay folded in the block.
|
|
847
868
|
parts.push((group.merged || []).map((i) =>
|
|
848
|
-
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}
|
|
869
|
+
`<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}>${originLabelHtml(items[i])}<div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>` +
|
|
870
|
+
reportReplyHtml(i, group)).join(""));
|
|
849
871
|
// A turn that ended with no answer: chi's muted notice where the answer
|
|
850
872
|
// would be, before the timing, as live.
|
|
851
873
|
if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
|