samagotchi 0.2.0 → 0.4.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 +198 -1
- data/README.md +56 -4
- data/bin/chi +118 -50
- data/docs/cli.md +184 -9
- data/docs/configuration.md +333 -47
- data/docs/desktop.md +45 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +208 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +23 -13
- data/docs/sessions.md +45 -17
- data/lib/samagotchi/answer_display.rb +95 -0
- data/lib/samagotchi/archive_store.rb +90 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
- data/lib/samagotchi/bootstrap/probe.rb +262 -0
- data/lib/samagotchi/bootstrap_command.rb +347 -0
- data/lib/samagotchi/bridge/pending_card.rb +89 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
- data/lib/samagotchi/bridge.rb +13 -1
- data/lib/samagotchi/bridge_client.rb +6 -2
- data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
- data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
- data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +81 -19
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +252 -48
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +434 -140
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +35 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +24 -5
- data/lib/samagotchi/host_registry.rb +9 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +73 -94
- data/lib/samagotchi/live_versions.rb +59 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +132 -29
- data/lib/samagotchi/llm/errors.rb +41 -9
- data/lib/samagotchi/llm/http.rb +57 -17
- data/lib/samagotchi/llm/openai_chat.rb +17 -30
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +24 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +18 -3
- data/lib/samagotchi/send_command.rb +252 -11
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +46 -7
- data/lib/samagotchi/session_manager.rb +115 -25
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
- data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
- data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +134 -247
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +52 -14
- data/lib/samagotchi/tools/task_runtime.rb +19 -0
- data/lib/samagotchi/tools/task_wait.rb +27 -3
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/turn_note.rb +60 -6
- data/lib/samagotchi/update_command.rb +308 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +7 -9
- data/lib/samagotchi/web/app.rb +91 -7
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +13 -1
- data/lib/samagotchi/web/public/annotate_presets.js +26 -0
- data/lib/samagotchi/web/public/annotations.js +13 -0
- data/lib/samagotchi/web/public/app.js +472 -111
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +13 -1
- data/lib/samagotchi/web/public/copy.js +20 -4
- data/lib/samagotchi/web/public/ctx.js +15 -0
- data/lib/samagotchi/web/public/data.js +23 -6
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +60 -3
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +5 -2
- data/lib/samagotchi/web/public/sessions_list.js +7 -0
- data/lib/samagotchi/web/public/timing.js +39 -14
- data/lib/samagotchi/web/public/turn_events.js +75 -5
- data/lib/samagotchi/web/public/turn_view.js +49 -8
- data/lib/samagotchi/web/server.rb +8 -4
- data/lib/samagotchi/web/session_hub.rb +2 -1
- data/lib/samagotchi/web/session_summary.rb +24 -1
- data/lib/samagotchi/worker.rb +16 -4
- metadata +31 -1
|
@@ -66,9 +66,11 @@ export function splitSnapshotCards(cards) {
|
|
|
66
66
|
// goes in its reloaded turn's block: the step of its iteration (the
|
|
67
67
|
// iteration-th .gen, 0-based index here) and the key of the tool row it
|
|
68
68
|
// came before (it came after +calls+ of that step's calls started), or null
|
|
69
|
-
//
|
|
69
|
+
// for a notice not of a turn. One from before the first generation (a
|
|
70
|
+
// before_turn hook's) has no iteration: the first step, as live.
|
|
70
71
|
export function turnNoticePlace(notice) {
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
if (!notice?.in_turn) return null;
|
|
73
|
+
const iteration = notice.iteration == null ? 1 : Number(notice.iteration);
|
|
74
|
+
if (!Number.isInteger(iteration) || iteration < 1) return null;
|
|
73
75
|
return { step: iteration - 1, rowKey: `${iteration}:${(Number(notice.calls) || 0) + 1}` };
|
|
74
76
|
}
|
|
@@ -10,21 +10,26 @@
|
|
|
10
10
|
// sessionId() (image thumbs of a tool row).
|
|
11
11
|
import { newActivity, addStarted, addCompleted, finalizeActivity, activityCount, toolName } from "./activity.js";
|
|
12
12
|
import { copySourceAttr } from "./copy.js";
|
|
13
|
-
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize } from "./format.js";
|
|
13
|
+
import { escapeHtml, leadTrimmed, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
|
|
14
|
+
import { activityDiffHtml } from "./diff_view.js";
|
|
14
15
|
import { thumbsHtml } from "./images.js";
|
|
15
16
|
import { shouldFollowScroll } from "./scroll.js";
|
|
16
17
|
import { tallyText } from "./tally.js";
|
|
17
18
|
import { cancelLineHtml, formatDuration, timedTurnIndexes, turnRecordAt, turnTimingText } from "./timing.js";
|
|
18
19
|
|
|
20
|
+
// A row's status (live, or a saved tool record's on reload): running,
|
|
21
|
+
// error, stopped (a task_wait the user's Stop ended), else ok ("done").
|
|
19
22
|
export function activityStatusClass(status) {
|
|
20
23
|
if (status === "running") return "running";
|
|
21
24
|
if (status === "error") return "error";
|
|
25
|
+
if (status === "stopped") return "stopped";
|
|
22
26
|
return "ok";
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
export function activityStatusLabel(status) {
|
|
26
30
|
if (status === "running") return "running";
|
|
27
31
|
if (status === "error") return "error";
|
|
32
|
+
if (status === "stopped") return "stopped";
|
|
28
33
|
return "done";
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -281,6 +286,10 @@ export function createChatView({ historyEl, appendToHistory, isNearBottom, sawTe
|
|
|
281
286
|
notice() {
|
|
282
287
|
return false;
|
|
283
288
|
},
|
|
289
|
+
// So is a plugin's steer.
|
|
290
|
+
steer() {
|
|
291
|
+
return false;
|
|
292
|
+
},
|
|
284
293
|
// So is a card.
|
|
285
294
|
card() {
|
|
286
295
|
return false;
|
|
@@ -312,6 +321,7 @@ export function chatHistoryHtml(items, timing, { thumbs }) {
|
|
|
312
321
|
return items
|
|
313
322
|
.map((m, i) => {
|
|
314
323
|
if (m.role === "note") return `<div class="bubble note">${noteHtml(m)}</div>`;
|
|
324
|
+
if (m.role === "steer") return steerRowHtml({ source: m.source, text: m.content }, { bubble: true });
|
|
315
325
|
const turnIndex = timedTurns[i];
|
|
316
326
|
const cls = m.role === "user" ? "user" : "output";
|
|
317
327
|
const { body, renderedMarkdown } = messageBodyHtml(m);
|
|
@@ -357,4 +367,6 @@ export function fillActivityRow(el, row, sessionId) {
|
|
|
357
367
|
// The images a tool returned (the chat view's reloaded history has no tool
|
|
358
368
|
// rows; the turn view's has, turn_view.js reloadRowHtml).
|
|
359
369
|
if (row.images?.length && !el.querySelector(".thumbs")) el.insertAdjacentHTML("beforeend", thumbsHtml(sessionId, row.images));
|
|
370
|
+
// An edit/write's change, collapsed: "diff +3 −1".
|
|
371
|
+
if (row.diff && !el.querySelector(".activity-diff")) el.insertAdjacentHTML("beforeend", activityDiffHtml(row.diff));
|
|
360
372
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Copy buttons: one on every prompt and answer bubble, one on every code
|
|
2
2
|
// block of a rendered answer. An answer copies its markdown source (the
|
|
3
|
-
// saved message's `
|
|
4
|
-
//
|
|
5
|
-
// its code.
|
|
3
|
+
// saved message's `display` when a hook presented one, else its `content`,
|
|
4
|
+
// kept on the bubble as data-copy-source when the page shows rendered
|
|
5
|
+
// html), a prompt the text as typed, a code block just its code.
|
|
6
6
|
//
|
|
7
7
|
// The buttons carry no text (an icon; "copied ✓" is CSS content), so a
|
|
8
8
|
// bubble's textContent, the turn output dedupe and an annotation's selected
|
|
@@ -57,12 +57,28 @@ export function codeText(pre) {
|
|
|
57
57
|
return visibleText(code, ".copy-btn").replace(/\n$/, "");
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// What an answer copies: what a hook presented in its place (`display`,
|
|
61
|
+
// AnswerDisplay), else its markdown source.
|
|
62
|
+
export function copySource(message) {
|
|
63
|
+
return String(message?.display ?? message?.content ?? "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Whether +bubble+ shows the answer +message+ (the page's re-read at the
|
|
67
|
+
// end of a turn): its streamed text is the answer's, or it was rendered
|
|
68
|
+
// from it already (its kept source is the answer or its display: an
|
|
69
|
+
// answer_display re-read after the turn_completed one).
|
|
70
|
+
export function showsAnswer(bubble, message, normalize) {
|
|
71
|
+
const source = bubble.getAttribute?.(COPY_SOURCE_ATTR);
|
|
72
|
+
if (source !== null && source !== undefined && (source === message.content || source === message.display)) return true;
|
|
73
|
+
return normalize(bubble.textContent) === normalize(message.content);
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
// The attribute that keeps a message's source on its reloaded bubble:
|
|
61
77
|
// rendered html and a prompt (its `>` lines render as quotes) need it; a
|
|
62
78
|
// plain answer's text is its source.
|
|
63
79
|
export function copySourceAttr(message, renderedMarkdown) {
|
|
64
80
|
if (!renderedMarkdown && message?.role !== "user") return "";
|
|
65
|
-
const text =
|
|
81
|
+
const text = copySource(message)
|
|
66
82
|
.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
67
83
|
return ` ${COPY_SOURCE_ATTR}="${text}"`;
|
|
68
84
|
}
|
|
@@ -33,3 +33,18 @@ export function extractCtxPct(data, windowTokens = null) {
|
|
|
33
33
|
if (!window || window <= 0 || used === null) return null;
|
|
34
34
|
return (used / window) * 100;
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
// The saved context (the session's timing.context: used_tokens after the
|
|
38
|
+
// last counted turn, the window now): the meter's value on load, before a
|
|
39
|
+
// turn streams. null when either count is missing.
|
|
40
|
+
export function savedCtxPct(context) {
|
|
41
|
+
const used = num(context?.used_tokens);
|
|
42
|
+
const window = num(context?.window_tokens);
|
|
43
|
+
if (used === null || !window || window <= 0) return null;
|
|
44
|
+
return (used / window) * 100;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A session card's quiet ctx: "12%", or "" when the list row has none.
|
|
48
|
+
export function cardCtxText(pct) {
|
|
49
|
+
return num(pct) === null ? "" : `${Math.round(pct)}%`;
|
|
50
|
+
}
|
|
@@ -4,6 +4,7 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
|
|
|
4
4
|
"generation_completed",
|
|
5
5
|
"turn_started",
|
|
6
6
|
"turn_completed",
|
|
7
|
+
"answer_display",
|
|
7
8
|
"turn_canceled",
|
|
8
9
|
"turn_failed",
|
|
9
10
|
"turn_enqueued",
|
|
@@ -27,6 +28,9 @@ const DEFAULT_EVENT_TYPES = Object.freeze([
|
|
|
27
28
|
"context_added",
|
|
28
29
|
"guardrail_warning",
|
|
29
30
|
"hook_notice",
|
|
31
|
+
"empty_answer_retry",
|
|
32
|
+
"generation_retrying",
|
|
33
|
+
"plugin_init_wait",
|
|
30
34
|
"card",
|
|
31
35
|
"plugin_init_started",
|
|
32
36
|
"plugin_init_finished",
|
|
@@ -65,10 +69,11 @@ export function createSession(prompt, { dir, model, ...opts } = {}) {
|
|
|
65
69
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
// A session with no first turn:
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
// A session with no first turn: the page's first message is sent into it
|
|
73
|
+
// as a turn (with its images, once they are uploaded). preview: that
|
|
74
|
+
// message, which names the session until its turn is saved.
|
|
75
|
+
export function createIdleSession({ dir, model, preview, ...opts } = {}) {
|
|
76
|
+
const body = { idle: true, ...(dir ? { dir } : {}), ...(model ? { model } : {}), ...(preview ? { preview } : {}) };
|
|
72
77
|
return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -111,10 +116,11 @@ export function sendTurn(id, prompt, { clientId, images, ...opts } = {}) {
|
|
|
111
116
|
|
|
112
117
|
// A session command (/model, /models, !rollback, !cmd, /continue …) for the
|
|
113
118
|
// worker to run; its command_ran reaches every client.
|
|
114
|
-
|
|
119
|
+
// card: the line is a card's action (its echo stays out of the history).
|
|
120
|
+
export function sendCommand(id, line, { clientId, card = false, ...opts } = {}) {
|
|
115
121
|
return api(`/api/sessions/${id}/command`, {
|
|
116
122
|
method: "POST",
|
|
117
|
-
body: JSON.stringify(clientId ? {
|
|
123
|
+
body: JSON.stringify({ line, ...(clientId ? { client_id: clientId } : {}), ...(card ? { card: true } : {}) }),
|
|
118
124
|
...opts,
|
|
119
125
|
});
|
|
120
126
|
}
|
|
@@ -148,6 +154,17 @@ export function stopSession(id, opts = {}) {
|
|
|
148
154
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// Hides the session (and its delegates) from the lists, kept for good; the
|
|
158
|
+
// server stops a live idle worker first and answers 409 while a turn runs.
|
|
159
|
+
export function archiveSession(id, opts = {}) {
|
|
160
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/archive`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Back to the lists, delegates too.
|
|
164
|
+
export function unarchiveSession(id, opts = {}) {
|
|
165
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/unarchive`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
166
|
+
}
|
|
167
|
+
|
|
151
168
|
// Deletes the session for good; the server stops a live worker first.
|
|
152
169
|
export function deleteSession(id, opts = {}) {
|
|
153
170
|
return api(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", ...opts });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What chi's edit/write tools change, as HTML, apart from the DOM. One
|
|
2
|
+
// renderer for both places: the approval card (the dry run, which can also
|
|
3
|
+
// say the edit would fail, or that the file isn't shown) and a tool row's
|
|
4
|
+
// collapsed "diff +3 −1" (what the call really changed). A diff is the
|
|
5
|
+
// server's {text, added, removed, truncated, new_file} (EditPreview).
|
|
6
|
+
|
|
7
|
+
import { escapeHtml } from "./format.js";
|
|
8
|
+
|
|
9
|
+
// More lines than this fold behind a "show all N lines" toggle.
|
|
10
|
+
export const DIFF_FOLD_LINES = 20;
|
|
11
|
+
|
|
12
|
+
const MINUS = "−";
|
|
13
|
+
|
|
14
|
+
// "+3 −1"
|
|
15
|
+
export function diffCounts(diff) {
|
|
16
|
+
return `+${Number(diff?.added) || 0} ${MINUS}${Number(diff?.removed) || 0}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A row's summary line: "diff +3 −1", "new file +12".
|
|
20
|
+
export function diffSummary(diff) {
|
|
21
|
+
return diff?.new_file ? `new file +${Number(diff.added) || 0}` : `diff ${diffCounts(diff)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function diffLineClass(line) {
|
|
25
|
+
if (line.startsWith("@@")) return "hunk";
|
|
26
|
+
if (line.startsWith("+")) return "add";
|
|
27
|
+
if (line.startsWith("-")) return "del";
|
|
28
|
+
if (line.startsWith("\\") || line.startsWith("… ")) return "meta";
|
|
29
|
+
return "ctx";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function linesHtml(lines) {
|
|
33
|
+
return lines.map((line) => `<span class="diff-line diff-${diffLineClass(line)}">${escapeHtml(line) || " "}</span>`).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// @param diff an EditPreview: a diff, or {error} / {skipped}
|
|
37
|
+
// @return {string} HTML; "" for none
|
|
38
|
+
export function diffHtml(diff, { fold = DIFF_FOLD_LINES } = {}) {
|
|
39
|
+
if (!diff || typeof diff !== "object") return "";
|
|
40
|
+
if (diff.error) return `<div class="diff-note warn">this edit would fail: ${escapeHtml(diff.error)}</div>`;
|
|
41
|
+
if (diff.skipped) return `<div class="diff-note">diff not shown: ${escapeHtml(diff.skipped)}</div>`;
|
|
42
|
+
const text = String(diff.text ?? "");
|
|
43
|
+
if (!text) return `<div class="diff-note">no change</div>`;
|
|
44
|
+
const lines = text.split("\n");
|
|
45
|
+
const head = diff.new_file ? `<div class="diff-note">new file</div>` : "";
|
|
46
|
+
const shown = `<pre class="diff"><code>${linesHtml(lines.slice(0, fold))}</code></pre>`;
|
|
47
|
+
if (lines.length <= fold) return head + shown;
|
|
48
|
+
// The rest opens under the first lines; the toggle hides once open (CSS).
|
|
49
|
+
const rest = `<details class="diff-more"><summary>show all ${lines.length} lines</summary>` +
|
|
50
|
+
`<pre class="diff"><code>${linesHtml(lines.slice(fold))}</code></pre></details>`;
|
|
51
|
+
return head + shown + rest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A tool row's collapsed diff (closed by default).
|
|
55
|
+
export function activityDiffHtml(diff) {
|
|
56
|
+
if (!diff || typeof diff !== "object" || diff.error || diff.skipped) return "";
|
|
57
|
+
return `<details class="activity-diff"><summary>${escapeHtml(diffSummary(diff))}</summary>${diffHtml(diff)}</details>`;
|
|
58
|
+
}
|
|
@@ -23,6 +23,15 @@ export function noteHtml(note) {
|
|
|
23
23
|
return `<div class="note-line">${escapeHtml(from)}</div><div class="note-text">${escapeHtml(note?.content ?? "")}</div>`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A plugin's steer (a nudge put into the running turn): one line that
|
|
27
|
+
// names who nudged the model, its text collapsed under it. +bubble+: on its
|
|
28
|
+
// own between bubbles (the chat view), not a row of a step.
|
|
29
|
+
export function steerRowHtml({ source, text } = {}, { bubble = false } = {}) {
|
|
30
|
+
const who = source ? `${source} nudged the model` : "nudged the model";
|
|
31
|
+
return `<details class="${bubble ? "bubble " : ""}steer-row"><summary>${escapeHtml(who)}</summary>` +
|
|
32
|
+
`<div class="steer-text">${escapeHtml(text ?? "")}</div></details>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
26
35
|
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
27
36
|
// stays escaped text. The bubble is pre-wrap, so the blank lines around a
|
|
28
37
|
// quote are dropped (the blockquote's margin spaces it instead).
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
7
|
<style>
|
|
8
8
|
:root{--bg:#0b0d11;--bg-panel:#151821;--bg-inset:#0f1115;--bg-raised:#22262f;--bg-hover:#1b1f27;--border:#1f232b;--border-strong:#2d333f;--text:#e6e8eb;--text-bright:#f0f3f6;--text-2:#c9d1d9;--text-3:#9aa4b2;--text-muted:#8b949e;--text-faint:#6e7681;--accent:#7aa5ff;--accent-strong:#1f6feb;--accent-bg:#1b2336;--accent-ring:rgba(122,165,255,.18);--primary:#2a68d1;--ok:#3fb950;--ok-text:#7ee787;--ok-bg:#1a2e1a;--warn:#d29922;--warn-text:#e3b341;--warn-bright:#f2cc60;--warn-bg:#2b2108;--warn-border:#6b5416;--danger:#f85149;--danger-bg:#da3633;--danger-text:#ff7b72;--danger-bg-dim:#3d1f1f;--think:#8957e5;--think-text:#d2a8ff;--accent-border:#223150;--accent-tint:color-mix(in srgb,var(--accent) 7%,transparent);
|
|
9
|
-
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35)
|
|
9
|
+
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
|
|
10
|
+
--diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc}
|
|
10
11
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
11
12
|
body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
|
|
12
13
|
/* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
|
|
@@ -115,6 +116,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
115
116
|
.activity-status.running{color:var(--warn)}
|
|
116
117
|
.activity-status.ok{color:var(--ok)}
|
|
117
118
|
.activity-status.error{color:var(--danger)}
|
|
119
|
+
.activity-status.stopped{color:var(--warn-text);opacity:.8}
|
|
118
120
|
.activity-tool{color:var(--accent);font-weight:600}
|
|
119
121
|
.activity-params{color:var(--text-muted);word-break:break-all}
|
|
120
122
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
@@ -129,9 +131,15 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
129
131
|
.activity-status,.activity-tool{flex-shrink:0;white-space:nowrap}
|
|
130
132
|
.activity-params{min-width:0}
|
|
131
133
|
.activity-output{flex:1 1 100%;min-width:0}
|
|
134
|
+
/* An edit/write's change: a closed "diff +3 −1" under the output, lined up with it. */
|
|
135
|
+
.activity-diff{flex:1 1 100%;min-width:0;margin:2px 0 2px 60px}
|
|
136
|
+
.activity-diff>summary{cursor:pointer;font-size:12px;color:var(--text-muted);width:max-content;font-variant-numeric:tabular-nums}
|
|
137
|
+
.activity-diff>summary:hover{color:var(--text-2)}
|
|
138
|
+
.activity-diff[open]>summary{margin-bottom:4px}
|
|
132
139
|
@media(max-width:600px){
|
|
133
140
|
.activity-params{flex:1 1 0}
|
|
134
141
|
.activity-output{margin-left:0}
|
|
142
|
+
.activity-diff{margin-left:0}
|
|
135
143
|
}
|
|
136
144
|
.bubble.thinking{border-left:3px solid var(--think);background:color-mix(in srgb,var(--think) 6%,var(--bg-panel));padding:8px 12px}
|
|
137
145
|
.bubble.thinking:not([open]),.bubble.activity:not([open]){padding:3px 10px;background:transparent;border-color:transparent;align-self:flex-start;border-radius:6px;transition:background .12s ease}
|
|
@@ -252,12 +260,34 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
252
260
|
.approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
|
|
253
261
|
.approval-why{color:var(--warn-text)}
|
|
254
262
|
.approval-label{color:var(--text-faint)}
|
|
263
|
+
/* A diff (diff_view.js): the approval card's dry run and a tool row's change. */
|
|
264
|
+
.approval-diff{margin:0 0 6px}
|
|
265
|
+
.diff{margin:0;padding:4px 0;background:var(--bg-inset);border:1px solid var(--border);border-radius:6px;font-size:12px;line-height:1.45;overflow:auto;max-height:420px}
|
|
266
|
+
.diff code{display:block;min-width:max-content}
|
|
267
|
+
.diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
|
|
268
|
+
.diff-add{background:var(--diff-add-bg);color:var(--diff-add-text)}
|
|
269
|
+
.diff-del{background:var(--diff-del-bg);color:var(--diff-del-text)}
|
|
270
|
+
.diff-hunk{color:var(--text-faint)}
|
|
271
|
+
.diff-meta{color:var(--text-faint);font-style:italic}
|
|
272
|
+
.diff-note{font-size:12px;color:var(--text-muted);margin:0 0 4px}
|
|
273
|
+
.diff-note.warn{color:var(--warn-text)}
|
|
274
|
+
.diff-more{margin-top:4px}
|
|
275
|
+
.diff-more>summary{cursor:pointer;font-size:12px;color:var(--accent);width:max-content}
|
|
276
|
+
.diff-more[open]>summary{display:none}
|
|
255
277
|
.bubble.guardrail-warning{border-left:3px solid var(--danger);background:color-mix(in srgb,var(--danger) 8%,var(--bg-panel));color:var(--danger-text);font-size:13px;padding:8px 12px;white-space:pre-wrap}
|
|
256
278
|
.bubble.hook-notice{border-left:3px solid var(--text-faint);background:var(--bg-panel);color:var(--text-3);font-size:13px;padding:8px 12px;white-space:pre-wrap}
|
|
257
279
|
.bubble.hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
258
280
|
.gen .hook-notice{font-size:13px;color:var(--text-3);padding:5px 2px 5px 8px;border-top:1px solid var(--border);border-left:2px solid var(--text-faint);white-space:pre-wrap;word-break:break-word}
|
|
259
281
|
.gen .activity-body>.hook-notice:first-child{border-top:none}
|
|
260
282
|
.gen .hook-notice.warn{border-left-color:var(--warn);color:var(--warn-text)}
|
|
283
|
+
/* A plugin's steer (a nudge into the running turn): a row of its step, or a bubble; its text collapsed. */
|
|
284
|
+
.steer-row{font-size:13px;color:var(--text-3);border-left:2px solid var(--accent-border)}
|
|
285
|
+
.steer-row>summary{cursor:pointer;list-style:none;font-style:italic}
|
|
286
|
+
.steer-row>summary::-webkit-details-marker{display:none}
|
|
287
|
+
.steer-row>summary::before{content:"↪ ";font-style:normal;color:var(--accent)}
|
|
288
|
+
.steer-text{white-space:pre-wrap;word-break:break-word;color:var(--text-muted);margin-top:4px}
|
|
289
|
+
.gen>.steer-row{padding:4px 2px 4px 8px;margin:2px 0 6px}
|
|
290
|
+
.bubble.steer-row{background:transparent;padding:6px 12px;border-left-width:3px}
|
|
261
291
|
/* A plugin's card (card.js): the question card's glass with a cool tint; its actions are commands the session runs. */
|
|
262
292
|
.bubble.plugin-card{border-left:3px solid var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--glass-bg));padding:10px 12px;transition:box-shadow .3s ease}
|
|
263
293
|
.bubble.plugin-card.warn{border-left-color:var(--warn);background:color-mix(in srgb,var(--warn) 7%,var(--glass-bg))}
|
|
@@ -278,8 +308,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
278
308
|
/* In a step: a row, lighter (no float), like the step's notices. */
|
|
279
309
|
.gen .bubble.plugin-card{margin:6px 0 4px;box-shadow:inset 0 1px 0 var(--glass-highlight);padding:8px 10px}
|
|
280
310
|
.hint{color:var(--text-muted);font-size:13px;padding:12px}
|
|
281
|
-
|
|
282
|
-
#
|
|
311
|
+
/* The selection bubble (app.js Annotate): Annotate + the presets (web.annotate_presets), small pills with a gap (they wrap on a narrow page). */
|
|
312
|
+
#annotateBar{position:fixed;z-index:20;display:flex;flex-wrap:wrap;gap:4px;max-width:calc(100vw - 16px)}
|
|
313
|
+
#annotateBar[hidden]{display:none}
|
|
314
|
+
#annotateBar button{font-size:12px;padding:4px 10px;border-radius:999px;box-shadow:0 2px 8px rgba(0,0,0,.5)}
|
|
315
|
+
#annotateBar .annotate-preset{background:var(--bg-raised);color:var(--text-2);border:1px solid var(--border-strong);padding:3px 10px;max-width:32ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
283
316
|
/* Copy buttons (copy.js): top-right in an answer and a code block, left of a prompt (its right edge is the page's); shown on hover or focus, always (dim) on touch. They hold no text: "copied ✓" is CSS content. */
|
|
284
317
|
.bubble.output,.bubble.user{position:relative}
|
|
285
318
|
.copy-btn{position:absolute;top:6px;right:6px;z-index:1;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border-radius:6px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);font-size:11px;line-height:1;white-space:nowrap;user-select:none;-webkit-user-select:none;opacity:0;transition:opacity .12s ease,color .12s ease}
|
|
@@ -376,6 +409,8 @@ body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
|
|
|
376
409
|
#infoBar button{padding:4px 12px;border-radius:8px;font-size:12px;font-weight:600}
|
|
377
410
|
#infoBar #infoStopBtn{background:var(--bg-raised);color:var(--warn-text);border:1px solid var(--border-strong)}
|
|
378
411
|
#infoBar #infoStopBtn:hover{background:var(--warn-bg);border-color:var(--warn-border)}
|
|
412
|
+
#infoBar #infoArchiveBtn{background:transparent;color:var(--text-muted);border:1px solid var(--border-strong)}
|
|
413
|
+
#infoBar #infoArchiveBtn:hover{color:var(--text);background:var(--bg-hover)}
|
|
379
414
|
#infoBar #infoDeleteBtn{background:transparent;color:var(--text-muted);border:1px solid transparent}
|
|
380
415
|
#infoBar #infoDeleteBtn:hover{color:#fff;background:var(--danger-bg);border-color:var(--danger-bg)}
|
|
381
416
|
/* 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. */
|
|
@@ -437,6 +472,16 @@ body.has-session #scope .scope-where{display:none}
|
|
|
437
472
|
}
|
|
438
473
|
/* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
|
|
439
474
|
as the pill text's capitals (phone and desktop alike). */
|
|
475
|
+
/* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
|
|
476
|
+
#notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
|
|
477
|
+
#notifyBtn[hidden]{display:none}
|
|
478
|
+
#notifyBtn:hover{color:var(--text-2);border-color:var(--border)}
|
|
479
|
+
#notifyBtn svg{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linejoin:round;stroke-linecap:round}
|
|
480
|
+
#notifyBtn .slash{display:none}
|
|
481
|
+
#notifyBtn[data-state="on"]{color:var(--accent)}
|
|
482
|
+
#notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
|
|
483
|
+
#notifyBtn[data-state="denied"]{color:var(--text-faint)}
|
|
484
|
+
#notifyBtn[data-state="denied"] .slash{display:inline}
|
|
440
485
|
#stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
|
|
441
486
|
.card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
|
|
442
487
|
.card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
|
|
@@ -465,6 +510,13 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
465
510
|
#allPath[hidden]{display:none}
|
|
466
511
|
#allPath code{font-family:ui-monospace,monospace;background:var(--bg-hover);padding:1px 4px;border-radius:4px}
|
|
467
512
|
#allHead .count{font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
513
|
+
#allHead .include-archived{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text-muted);white-space:nowrap;cursor:pointer;user-select:none}
|
|
514
|
+
.card.archived{opacity:.6}
|
|
515
|
+
.card.archived:hover{opacity:.85}
|
|
516
|
+
.card .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
|
|
517
|
+
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:13px}
|
|
518
|
+
#toast[hidden]{display:none}
|
|
519
|
+
#toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
|
|
468
520
|
#allList{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px;align-content:start;padding:2px}
|
|
469
521
|
.card.skeleton{pointer-events:none;border-style:dashed;opacity:.9;animation:skeleton-pulse 1.2s ease-in-out infinite}
|
|
470
522
|
.card.skeleton .line{height:10px;background:var(--bg-raised);border-radius:6px}
|
|
@@ -484,6 +536,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
|
|
|
484
536
|
.card.active{background:var(--accent-bg);border-color:var(--accent-border)}
|
|
485
537
|
.card .preview{font-size:14px;line-height:1.4;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
486
538
|
.card .time{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
539
|
+
.card .ctx{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text-3)}
|
|
487
540
|
#topStrip .card .preview{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
488
541
|
#allList .card .preview{white-space:normal;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;word-break:break-word;line-height:1.4}
|
|
489
542
|
.card .recap{font-size:12px;line-height:1.4;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:-2px 0 2px}
|
|
@@ -517,6 +570,7 @@ a{color:var(--accent)}
|
|
|
517
570
|
<button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
|
|
518
571
|
<div id="scope" hidden></div>
|
|
519
572
|
<button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
|
|
573
|
+
<button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
|
|
520
574
|
</div>
|
|
521
575
|
<section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
|
|
522
576
|
<section id="center">
|
|
@@ -548,6 +602,7 @@ a{color:var(--accent)}
|
|
|
548
602
|
</div>
|
|
549
603
|
<div id="infoBar">
|
|
550
604
|
<div class="info" id="infoText"></div>
|
|
605
|
+
<button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
|
|
551
606
|
<button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
|
|
552
607
|
<button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
|
|
553
608
|
</div>
|
|
@@ -557,12 +612,14 @@ a{color:var(--accent)}
|
|
|
557
612
|
<div id="allHead">
|
|
558
613
|
<button class="ghost" id="allBack" title="Back to the chat (Esc)">← Back</button>
|
|
559
614
|
<input id="filter" type="search" placeholder="Search… (preview / id / status)" autocomplete="off">
|
|
615
|
+
<label class="include-archived" title="Show archived sessions too"><input id="includeArchived" type="checkbox"> include archived</label>
|
|
560
616
|
<span class="count" id="count"></span>
|
|
561
617
|
</div>
|
|
562
618
|
<div id="allPath" hidden>Stored in <code></code></div>
|
|
563
619
|
<div id="allList"></div>
|
|
564
620
|
</section>
|
|
565
621
|
</main>
|
|
622
|
+
<div id="toast" role="status" aria-live="polite" hidden></div>
|
|
566
623
|
<script type="module" src="/assets/app.js"></script>
|
|
567
624
|
</body>
|
|
568
625
|
</html>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// When a session needs the user: a pure reducer over the hub's session
|
|
2
|
+
// summaries (GET /api/events). The page shows an OS notification and a
|
|
3
|
+
// title badge for what it returns, while the tab is not in front.
|
|
4
|
+
|
|
5
|
+
// A finished turn shorter than this was watched: no "done".
|
|
6
|
+
export const DONE_MIN_SECONDS = 10;
|
|
7
|
+
|
|
8
|
+
// What changed between two summaries of one session that needs the user.
|
|
9
|
+
// @param prev the summary held before (null for a session not seen yet)
|
|
10
|
+
// @param next the new summary
|
|
11
|
+
// @return null | {reason: "question"|"approval"|"card"|"failed"|"done", sessionId, key}
|
|
12
|
+
// key names this one event, the same in every tab (the Notification tag)
|
|
13
|
+
export function attentionFor(prev, next) {
|
|
14
|
+
if (!next || !next.id) return null;
|
|
15
|
+
const q = next.pending_question;
|
|
16
|
+
if (q && q.id && q.id !== prev?.pending_question?.id) {
|
|
17
|
+
return { reason: q.kind === "approval" ? "approval" : "question", sessionId: next.id, key: `${next.id}:${q.id}` };
|
|
18
|
+
}
|
|
19
|
+
// A running turn's card with actions (check-in's Nudge / Keep going /
|
|
20
|
+
// Stop) asks the user as a question does.
|
|
21
|
+
const card = next.pending_card;
|
|
22
|
+
if (card && card.id && card.id !== prev?.pending_card?.id) {
|
|
23
|
+
return { reason: "card", sessionId: next.id, key: cardKey(next.id, card.id) };
|
|
24
|
+
}
|
|
25
|
+
const turn = next.last_turn;
|
|
26
|
+
if (!turn || !turn.ended_at || turn.ended_at === prev?.last_turn?.ended_at) return null;
|
|
27
|
+
// A delegated child's turn is reported by its parent's.
|
|
28
|
+
if (next.parent_id) return null;
|
|
29
|
+
const key = `${next.id}:${turn.ended_at}`;
|
|
30
|
+
if (turn.outcome === "failed") return { reason: "failed", sessionId: next.id, key };
|
|
31
|
+
if (turn.outcome === "completed" && Number(turn.seconds) >= DONE_MIN_SECONDS) {
|
|
32
|
+
return { reason: "done", sessionId: next.id, key };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The tracker's state: the last summary of each session, and whether the
|
|
38
|
+
// first snapshot has been seen (it seeds, so old state never notifies).
|
|
39
|
+
export function initialAttentionState() {
|
|
40
|
+
return { seeded: false, byId: {} };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
44
|
+
|
|
45
|
+
// The keys of what the session no longer has open: its question (answered
|
|
46
|
+
// here or in another client), its card with actions (resolved, or the turn
|
|
47
|
+
// ended), or both when the session is gone.
|
|
48
|
+
// @param prev the summary held before (null for a session not seen yet)
|
|
49
|
+
// @param next the new summary (null when the session is gone)
|
|
50
|
+
// @return [String] keys, maybe none
|
|
51
|
+
export function closedKeys(prev, next) {
|
|
52
|
+
const keys = [];
|
|
53
|
+
const q = prev?.pending_question;
|
|
54
|
+
if (q && q.id && next?.pending_question?.id !== q.id) keys.push(`${prev.id}:${q.id}`);
|
|
55
|
+
const card = prev?.pending_card;
|
|
56
|
+
if (card && card.id && next?.pending_card?.id !== card.id) keys.push(cardKey(prev.id, card.id));
|
|
57
|
+
return keys;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// @param state initialAttentionState() or a previous result's state
|
|
61
|
+
// @param type "snapshot" | "session" | "session_gone"
|
|
62
|
+
// @param data the frame's data
|
|
63
|
+
// @return {state, attentions, closed}; state is a new object, attentions a
|
|
64
|
+
// list, closed the keys of questions and cards no longer open (the badge
|
|
65
|
+
// drops them)
|
|
66
|
+
export function trackAttention(state, type, data = {}) {
|
|
67
|
+
const byId = { ...state.byId };
|
|
68
|
+
const attentions = [];
|
|
69
|
+
const closed = [];
|
|
70
|
+
const close = (prev, next) => closed.push(...closedKeys(prev, next));
|
|
71
|
+
const see = (s) => {
|
|
72
|
+
if (!s || !s.id) return;
|
|
73
|
+
const prev = byId[s.id] || null;
|
|
74
|
+
if (state.seeded) {
|
|
75
|
+
const a = attentionFor(prev, s);
|
|
76
|
+
if (a) attentions.push(a);
|
|
77
|
+
}
|
|
78
|
+
close(prev, s);
|
|
79
|
+
byId[s.id] = s;
|
|
80
|
+
};
|
|
81
|
+
if (type === "snapshot") {
|
|
82
|
+
(Array.isArray(data.sessions) ? data.sessions : []).forEach(see);
|
|
83
|
+
return { state: { seeded: true, byId }, attentions, closed };
|
|
84
|
+
}
|
|
85
|
+
if (type === "session") see(data.session);
|
|
86
|
+
else if (type === "session_gone") {
|
|
87
|
+
close(byId[data.id] || null, null);
|
|
88
|
+
delete byId[data.id];
|
|
89
|
+
}
|
|
90
|
+
return { state: { seeded: state.seeded, byId }, attentions, closed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The notification's words: a short title (the session) and the reason.
|
|
94
|
+
// No question or answer text (it would show on a lock screen).
|
|
95
|
+
export function attentionText(session, attention) {
|
|
96
|
+
const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
97
|
+
const title = name.length > 60 ? `${name.slice(0, 60)}…` : name;
|
|
98
|
+
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
99
|
+
const body = {
|
|
100
|
+
question: "needs an answer",
|
|
101
|
+
approval: "needs approval",
|
|
102
|
+
card: "needs you",
|
|
103
|
+
failed: "turn failed",
|
|
104
|
+
done: `done in ${seconds} s`
|
|
105
|
+
}[attention.reason] || "";
|
|
106
|
+
return { title, body };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Several tabs ──────────────────────────────────────────────────────────
|
|
110
|
+
// Every chi tab gets the same hub frames. A tab in front tells the others
|
|
111
|
+
// (BroadcastChannel "chi-notify") which attention keys it has in front, and
|
|
112
|
+
// a tab behind neither notifies nor badges those: it holds a new attention
|
|
113
|
+
// for +holdMs+ in case the front tab's word is on its way, and drops a key
|
|
114
|
+
// from its badge when the word comes later. Without BroadcastChannel every
|
|
115
|
+
// tab goes on alone, at once.
|
|
116
|
+
export const NOTIFY_CHANNEL = "chi-notify";
|
|
117
|
+
export const NOTIFY_HOLD_MS = 300;
|
|
118
|
+
// How long a key seen in front is remembered (its frame may come late).
|
|
119
|
+
const SEEN_TTL_MS = 10 * 60 * 1000;
|
|
120
|
+
|
|
121
|
+
// @param channel a BroadcastChannel (postMessage, onmessage), or null
|
|
122
|
+
// @param onSeen(keys) another tab has these keys in front (drop them from
|
|
123
|
+
// the badge)
|
|
124
|
+
// @return {seenInFront(keys), offer(attention, deliver), drop(keys)}
|
|
125
|
+
export function createNotifyGate({ channel = null, holdMs = NOTIFY_HOLD_MS, onSeen = () => {},
|
|
126
|
+
schedule = setTimeout, cancel = clearTimeout, now = Date.now } = {}) {
|
|
127
|
+
const seen = new Map(); // key → when
|
|
128
|
+
const pending = new Map(); // key → timer
|
|
129
|
+
const mark = (keys) => {
|
|
130
|
+
const at = now();
|
|
131
|
+
for (const [key, when] of seen) if (at - when > SEEN_TTL_MS) seen.delete(key);
|
|
132
|
+
for (const key of keys) seen.set(key, at);
|
|
133
|
+
};
|
|
134
|
+
const drop = (keys) => {
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
if (!pending.has(key)) continue;
|
|
137
|
+
cancel(pending.get(key));
|
|
138
|
+
pending.delete(key);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
if (channel) {
|
|
142
|
+
channel.onmessage = (e) => {
|
|
143
|
+
const keys = e?.data?.type === "seen" && Array.isArray(e.data.keys) ? e.data.keys.map(String) : [];
|
|
144
|
+
if (keys.length === 0) return;
|
|
145
|
+
mark(keys);
|
|
146
|
+
drop(keys);
|
|
147
|
+
onSeen(keys);
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
// This tab is in front with +keys+.
|
|
152
|
+
seenInFront(keys) {
|
|
153
|
+
if (!channel || keys.length === 0) return;
|
|
154
|
+
try { channel.postMessage({ type: "seen", keys }); } catch (_) {}
|
|
155
|
+
},
|
|
156
|
+
// A new attention in a tab behind: +deliver+ (notify, badge) unless
|
|
157
|
+
// another tab has it in front. @return whether it may still be delivered
|
|
158
|
+
offer(attention, deliver) {
|
|
159
|
+
const key = attention.key;
|
|
160
|
+
if (seen.has(key)) return false;
|
|
161
|
+
if (!channel) {
|
|
162
|
+
deliver();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
drop([key]);
|
|
166
|
+
pending.set(key, schedule(() => {
|
|
167
|
+
pending.delete(key);
|
|
168
|
+
if (!seen.has(key)) deliver();
|
|
169
|
+
}, holdMs));
|
|
170
|
+
return true;
|
|
171
|
+
},
|
|
172
|
+
// Closed before its hold was up (a question answered at once).
|
|
173
|
+
drop,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -8,7 +8,8 @@ export function isApproval(pq) {
|
|
|
8
8
|
return !!pq && pq.kind === "approval";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
// @return {null | {tool, what, isCommand, where, why}}
|
|
11
|
+
// @return {null | {tool, what, isCommand, where, why, preview?}}; preview is
|
|
12
|
+
// an edit/write's dry-run diff (diff_view.js draws it)
|
|
12
13
|
export function approvalView(pq) {
|
|
13
14
|
if (!isApproval(pq)) return null;
|
|
14
15
|
const a = pq.approval || {};
|
|
@@ -26,10 +27,12 @@ export function approvalView(pq) {
|
|
|
26
27
|
return {
|
|
27
28
|
// A plugin tool's label ("chrome: screenshot"), as its row shows it.
|
|
28
29
|
tool: a.label || a.tool || "",
|
|
29
|
-
|
|
30
|
+
// A tool without a command or path (an MCP tool): its compact args.
|
|
31
|
+
what: command || paths.join("\n") || a.args || "",
|
|
30
32
|
isCommand: !!command,
|
|
31
33
|
where,
|
|
32
34
|
why: `${reason} (${who})`,
|
|
35
|
+
...(a.preview && typeof a.preview === "object" ? { preview: a.preview } : {}),
|
|
33
36
|
};
|
|
34
37
|
}
|
|
35
38
|
|