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
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
cancelTurn,
|
|
7
7
|
stopSession,
|
|
8
8
|
deleteSession,
|
|
9
|
+
archiveSession,
|
|
10
|
+
unarchiveSession,
|
|
9
11
|
sendAnswer,
|
|
10
12
|
dismissQuestion,
|
|
11
13
|
sendCommand,
|
|
@@ -17,13 +19,15 @@ import {
|
|
|
17
19
|
} from "./data.js";
|
|
18
20
|
import { MODEL_KEY, modelNames, pickModel } from "./model_pick.js";
|
|
19
21
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
20
|
-
import { escapeHtml, messageBodyHtml, noteHtml, normalize, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
22
|
+
import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, firstMessageOf, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
|
|
21
23
|
import { chatHistoryHtml, createChatView } from "./chat_view.js";
|
|
22
24
|
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
23
|
-
import { annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
24
|
-
import {
|
|
25
|
+
import { annotateBarLeft, annotationSource, appendQuote, quoteBlock } from "./annotations.js";
|
|
26
|
+
import { parsePresets, presetNote } from "./annotate_presets.js";
|
|
27
|
+
import { COPY_SOURCE_ATTR, copySource, copySourceAttr, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
25
28
|
import { routeChunk } from "./chunk_router.js";
|
|
26
|
-
import { extractCtxPct } from "./ctx.js";
|
|
29
|
+
import { extractCtxPct, savedCtxPct, cardCtxText } from "./ctx.js";
|
|
30
|
+
import { diffHtml } from "./diff_view.js";
|
|
27
31
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
28
32
|
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
29
33
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
@@ -32,12 +36,13 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
32
36
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
33
37
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
34
38
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
35
|
-
import { applySessionEvent, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
39
|
+
import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
40
|
+
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
|
|
36
41
|
import {
|
|
37
42
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
38
43
|
} from "./timing.js";
|
|
39
44
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
40
|
-
import { clientLabel, commandView, continueLine, hookNoticeLabel, isCommandLine, isOwn, newClientId, promptOps, reminderText, restoreAction, snapshotEvents, turnOutput, workerGoneText } from "./turn_events.js";
|
|
45
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, retryStatusLine, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
41
46
|
|
|
42
47
|
const $ = (s) => document.querySelector(s);
|
|
43
48
|
const topStripEl = $("#topStrip");
|
|
@@ -100,6 +105,10 @@ let currentOwner = null;
|
|
|
100
105
|
let currentTiming = normalizeTiming();
|
|
101
106
|
let turnRunning = false;
|
|
102
107
|
let liveTurnTimingEl = null;
|
|
108
|
+
// What the running turn waits on, after its live timing line: a provider
|
|
109
|
+
// retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
|
|
110
|
+
// it streams again.
|
|
111
|
+
let liveTurnNote = "";
|
|
103
112
|
let timingInterval = null;
|
|
104
113
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
105
114
|
// pending_question on session select so a reload mid-question restores it.
|
|
@@ -121,6 +130,9 @@ let unmatchedMerge = false;
|
|
|
121
130
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
122
131
|
// into the composer when their turn fails.
|
|
123
132
|
const sentIds = new Set();
|
|
133
|
+
// Own prompt_restored events that came before their /turn ack, by
|
|
134
|
+
// enqueued_id: the ack puts that prompt back instead of clearing it.
|
|
135
|
+
const earlyRestores = new Map();
|
|
124
136
|
// The last turn_failed's line, shown again after prompt_restored's resync.
|
|
125
137
|
let lastFailedText = null;
|
|
126
138
|
|
|
@@ -158,17 +170,20 @@ async function refresh() {
|
|
|
158
170
|
function openSessionEvents() {
|
|
159
171
|
eventsControl = openEvents({
|
|
160
172
|
snapshot: (data) => {
|
|
173
|
+
noticeSessions("snapshot", data);
|
|
161
174
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
162
175
|
render();
|
|
163
176
|
const sess = allSessions.find((s) => s.id === selected);
|
|
164
177
|
if (sess) followSelectedSession(sess);
|
|
165
178
|
},
|
|
166
179
|
session: (data) => {
|
|
180
|
+
noticeSessions("session", data);
|
|
167
181
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
168
182
|
render();
|
|
169
183
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
170
184
|
},
|
|
171
185
|
session_gone: (data) => {
|
|
186
|
+
noticeSessions("session_gone", data);
|
|
172
187
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
173
188
|
if (next !== allSessions) {
|
|
174
189
|
allSessions = next;
|
|
@@ -217,7 +232,8 @@ function renderScope() {
|
|
|
217
232
|
el.innerHTML = `<span class="scope-name" title="Sessions of every project">all projects</span>${backLink}${where ? `<span class="scope-where" title="New chats start in ${escapeHtml(where)}">new chat in<span class="path"><bdi>${escapeHtml(where)}</bdi></span></span>` : ""}`;
|
|
218
233
|
}
|
|
219
234
|
el.hidden = false;
|
|
220
|
-
|
|
235
|
+
baseTitle = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
|
|
236
|
+
setTitle();
|
|
221
237
|
// The links follow the hash (an open session, the all-sessions view).
|
|
222
238
|
el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
|
|
223
239
|
e.currentTarget.href = allScopeHref(location.hash, scope.dir);
|
|
@@ -227,10 +243,21 @@ function renderScope() {
|
|
|
227
243
|
});
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
|
|
246
|
+
// The strip, its count and the hidden strip's pill leave archived sessions
|
|
247
|
+
// out; the all view shows them with "include archived". allSessions keeps
|
|
248
|
+
// them all (parent chips, the open session).
|
|
249
|
+
function shownSessions() {
|
|
250
|
+
return listedSessions(allSessions, false);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function allViewSessions() {
|
|
254
|
+
return listedSessions(allSessions, $("#includeArchived").checked);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function filteredSessions(list) {
|
|
231
258
|
const q = $("#filter").value.trim().toLowerCase();
|
|
232
|
-
if (!q) return
|
|
233
|
-
return
|
|
259
|
+
if (!q) return list;
|
|
260
|
+
return list.filter(
|
|
234
261
|
(s) =>
|
|
235
262
|
previewForCard(s).toLowerCase().includes(q) ||
|
|
236
263
|
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
@@ -252,7 +279,10 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
252
279
|
const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
|
|
253
280
|
const folder = cardFolder(s.working_directory, scope.projectName);
|
|
254
281
|
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
255
|
-
|
|
282
|
+
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
283
|
+
const ctx = cardCtxText(s.ctx_pct);
|
|
284
|
+
const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
|
|
285
|
+
return `<div class="card${s.archived ? " archived" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}" title="${escapeHtml(s.status)}" aria-label="${escapeHtml(s.status)}"></span>${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
|
|
256
286
|
}
|
|
257
287
|
|
|
258
288
|
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
@@ -277,7 +307,7 @@ function fitStripColumns() {
|
|
|
277
307
|
|
|
278
308
|
function render() {
|
|
279
309
|
fitStripColumns();
|
|
280
|
-
renderTop(
|
|
310
|
+
renderTop(shownSessions().slice(0, stripCols));
|
|
281
311
|
if (allViewOpen()) renderAll();
|
|
282
312
|
}
|
|
283
313
|
|
|
@@ -287,7 +317,7 @@ window.addEventListener("resize", () => {
|
|
|
287
317
|
stripResizeTimer = setTimeout(() => {
|
|
288
318
|
const before = stripCols;
|
|
289
319
|
fitStripColumns();
|
|
290
|
-
if (stripCols !== before && allSessions.length) renderTop(
|
|
320
|
+
if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
|
|
291
321
|
}, 150);
|
|
292
322
|
});
|
|
293
323
|
|
|
@@ -314,11 +344,15 @@ function renderTop(sessions) {
|
|
|
314
344
|
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
|
|
315
345
|
return;
|
|
316
346
|
}
|
|
317
|
-
|
|
347
|
+
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
348
|
+
if (!allSessions.length) {
|
|
318
349
|
topStripEl.innerHTML = "";
|
|
319
350
|
return;
|
|
320
351
|
}
|
|
321
|
-
const
|
|
352
|
+
const n = shownSessions().length;
|
|
353
|
+
const count = n || allSessions.length === n
|
|
354
|
+
? `${n} ${n === 1 ? "session" : "sessions"}`
|
|
355
|
+
: `${allSessions.length - n} archived`;
|
|
322
356
|
const tile = `<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
|
|
323
357
|
topStripEl.innerHTML = sessions.map((s) => cardHtml(s)).join("") + tile;
|
|
324
358
|
bindCards(topStripEl, select);
|
|
@@ -328,7 +362,7 @@ function renderTop(sessions) {
|
|
|
328
362
|
|
|
329
363
|
// The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
|
|
330
364
|
function renderStripShow() {
|
|
331
|
-
const { count, live } = stripShowParts(
|
|
365
|
+
const { count, live } = stripShowParts(shownSessions());
|
|
332
366
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
333
367
|
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
|
|
334
368
|
}
|
|
@@ -336,12 +370,13 @@ function renderStripShow() {
|
|
|
336
370
|
// A delegated session sits right after its parent: applied on every
|
|
337
371
|
// render, since a hub snapshot hands the list back in the server's order.
|
|
338
372
|
function renderAll() {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
373
|
+
const listed = allViewSessions();
|
|
374
|
+
const sessions = withChildrenAfterParents(filteredSessions(listed));
|
|
375
|
+
$("#count").textContent = sessions.length === listed.length
|
|
376
|
+
? `${listed.length}`
|
|
377
|
+
: `${sessions.length} / ${listed.length}`;
|
|
343
378
|
if (!sessions.length) {
|
|
344
|
-
allListEl.innerHTML = `<div class="hint">${
|
|
379
|
+
allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
|
|
345
380
|
return;
|
|
346
381
|
}
|
|
347
382
|
allListEl.innerHTML = sessions.map((s) => cardHtml(s, { deletable: true })).join("");
|
|
@@ -667,6 +702,11 @@ function renderSessionView(data) {
|
|
|
667
702
|
currentDir = s.working_directory;
|
|
668
703
|
currentOwner = s.owner || null;
|
|
669
704
|
currentTiming = normalizeTiming(data.timing);
|
|
705
|
+
// The saved fill, so a reloaded or stopped session shows it before its
|
|
706
|
+
// next turn (a running turn's replay below streams over it).
|
|
707
|
+
const savedPct = savedCtxPct(data.timing?.context);
|
|
708
|
+
if (savedPct !== null) currentCtxPct = savedPct;
|
|
709
|
+
if (data.timing?.context?.window_tokens) currentCtxWindow = data.timing.context.window_tokens;
|
|
670
710
|
currentFirstPreview = s.first_preview || previewForCard(s);
|
|
671
711
|
currentUsedMemories = s.used_memory_names || [];
|
|
672
712
|
currentPreloadedMemories = s.preloaded_memory_names || [];
|
|
@@ -684,6 +724,12 @@ function renderSessionView(data) {
|
|
|
684
724
|
if(txt) currentFirstPreview = txt;
|
|
685
725
|
}
|
|
686
726
|
}
|
|
727
|
+
// A failed turn's prompt went back to the user: shown after the
|
|
728
|
+
// conversation, marked failed, as it was live (prompt_restored).
|
|
729
|
+
if (data.failed_turn) {
|
|
730
|
+
makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
|
|
731
|
+
addEndBubble(failedTurnText(data.failed_turn));
|
|
732
|
+
}
|
|
687
733
|
seedSeenFromDom();
|
|
688
734
|
seedInitTasks(initTasks, data.init_tasks);
|
|
689
735
|
renderInitRow();
|
|
@@ -747,6 +793,36 @@ function setMarkdownWarning(warning) {
|
|
|
747
793
|
markdownWarningEl.hidden = false;
|
|
748
794
|
}
|
|
749
795
|
|
|
796
|
+
// The turn-end re-read (turn_completed): the final answer's rendered
|
|
797
|
+
// markdown, the status and the timing. Kept as turnEndRead, so an
|
|
798
|
+
// answer_display re-read waits for it.
|
|
799
|
+
let turnEndRead = Promise.resolve();
|
|
800
|
+
// Resolves the wait of a turn_completed with display_pending (its
|
|
801
|
+
// answer_display landed).
|
|
802
|
+
let displayLanded = null;
|
|
803
|
+
async function readTurnEnd() {
|
|
804
|
+
if (!selected) return;
|
|
805
|
+
try {
|
|
806
|
+
const d = await getSession(selected, { tail: true });
|
|
807
|
+
if (d.session) {
|
|
808
|
+
if (Array.isArray(d.session.used_memory_names)) currentUsedMemories = d.session.used_memory_names;
|
|
809
|
+
if (d.session.status) currentStatus = d.session.status;
|
|
810
|
+
applyTiming(d.timing);
|
|
811
|
+
finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
|
|
812
|
+
setMarkdownWarning(d.markdown_warning);
|
|
813
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
814
|
+
updateInfoBar();
|
|
815
|
+
const sess = allSessions.find(s=>s.id===selected);
|
|
816
|
+
if (sess) {
|
|
817
|
+
sess.used_memory_names = currentUsedMemories.slice();
|
|
818
|
+
sess.status = currentStatus;
|
|
819
|
+
}
|
|
820
|
+
render();
|
|
821
|
+
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
822
|
+
}
|
|
823
|
+
} catch(_){}
|
|
824
|
+
}
|
|
825
|
+
|
|
750
826
|
function applyFinalMarkdown(messages) {
|
|
751
827
|
const message = [...messages].reverse().find(
|
|
752
828
|
(item) => item?.role === "assistant" && typeof item.html === "string",
|
|
@@ -755,11 +831,11 @@ function applyFinalMarkdown(messages) {
|
|
|
755
831
|
|
|
756
832
|
const bubble = [...historyEl.querySelectorAll(".bubble.output")]
|
|
757
833
|
.reverse()
|
|
758
|
-
.find((item) =>
|
|
834
|
+
.find((item) => showsAnswer(item, message, normalize));
|
|
759
835
|
if (!bubble) return;
|
|
760
836
|
|
|
761
837
|
bubble.classList.add("markdown");
|
|
762
|
-
bubble.setAttribute(COPY_SOURCE_ATTR, message
|
|
838
|
+
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
763
839
|
bubble.innerHTML = message.html;
|
|
764
840
|
}
|
|
765
841
|
|
|
@@ -958,6 +1034,20 @@ function showGuardrailWarning(text, label = "guardrails") {
|
|
|
958
1034
|
// A hook's line to the user (event[:notify]): "<bundle>: text", in the
|
|
959
1035
|
// warning colour for level warn.
|
|
960
1036
|
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
1037
|
+
// A plugin's steers (pending_input_merged's steers): in the turn view a row
|
|
1038
|
+
// of the running turn's step, else a bubble.
|
|
1039
|
+
function showSteers(steers) {
|
|
1040
|
+
for (const steer of steers || []) {
|
|
1041
|
+
if (turnDom.steer(steer)) continue;
|
|
1042
|
+
removeHintIfEmpty();
|
|
1043
|
+
const holder = document.createElement("div");
|
|
1044
|
+
holder.innerHTML = steerRowHtml(steer, { bubble: true });
|
|
1045
|
+
const follow = isNearBottom();
|
|
1046
|
+
appendToHistory(holder.firstChild);
|
|
1047
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
961
1051
|
function showHookNotice(data, { before = null } = {}) {
|
|
962
1052
|
removeHintIfEmpty();
|
|
963
1053
|
const el = document.createElement("div");
|
|
@@ -995,7 +1085,9 @@ function drawCard(el, card) {
|
|
|
995
1085
|
|
|
996
1086
|
// Show +card+: update the one with its id where it is, else add it (a
|
|
997
1087
|
// running turn's card as a row of its step; else before +before+ or at the
|
|
998
|
-
// end of the history).
|
|
1088
|
+
// end of the history). A running turn's card with actions asks the user
|
|
1089
|
+
// something (check-in's Nudge / Keep going / Stop): it goes under the turn's
|
|
1090
|
+
// block, not into a step that collapses when the next one starts.
|
|
999
1091
|
function upsertCard(card, { before = null } = {}) {
|
|
1000
1092
|
if (!card || !card.id) return;
|
|
1001
1093
|
const existing = cardElement(card.id);
|
|
@@ -1008,7 +1100,8 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1008
1100
|
removeHintIfEmpty();
|
|
1009
1101
|
const el = document.createElement("div");
|
|
1010
1102
|
drawCard(el, card);
|
|
1011
|
-
|
|
1103
|
+
const asks = (card.actions || []).some((a) => a && a.command);
|
|
1104
|
+
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
1012
1105
|
const follow = isNearBottom();
|
|
1013
1106
|
if (before) historyEl.insertBefore(el, before);
|
|
1014
1107
|
else appendToHistory(el);
|
|
@@ -1089,7 +1182,7 @@ historyEl.addEventListener("click", (e) => {
|
|
|
1089
1182
|
const err = card.querySelector(".card-error");
|
|
1090
1183
|
err.classList.add("hidden");
|
|
1091
1184
|
button.disabled = true;
|
|
1092
|
-
sendCommand(selected, button.dataset.command, { clientId })
|
|
1185
|
+
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1093
1186
|
.then(() => {
|
|
1094
1187
|
// The command woke the worker: follow its stream if none is open.
|
|
1095
1188
|
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
@@ -1119,7 +1212,14 @@ infoTextEl.addEventListener("click", async (e) => {
|
|
|
1119
1212
|
function updateInfoBar() {
|
|
1120
1213
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1121
1214
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
1215
|
+
const archiveBtn = document.getElementById("infoArchiveBtn");
|
|
1122
1216
|
if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
|
|
1217
|
+
if (archiveBtn) {
|
|
1218
|
+
archiveBtn.style.display = selected ? "" : "none";
|
|
1219
|
+
const archived = selectedArchived();
|
|
1220
|
+
archiveBtn.textContent = archived ? "unarchive" : "archive";
|
|
1221
|
+
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1222
|
+
}
|
|
1123
1223
|
if (!selected) {
|
|
1124
1224
|
// The start page hides the info bar (the storage path is on #/sessions).
|
|
1125
1225
|
infoTextEl.innerHTML = "";
|
|
@@ -1177,6 +1277,7 @@ function startLiveTurnTiming(startedAt = new Date().toISOString()) {
|
|
|
1177
1277
|
currentTiming.activeTurn = { started_at: startedAt };
|
|
1178
1278
|
// The turn records hold the finished turns, so this one comes next.
|
|
1179
1279
|
liveTurnNumber = currentTiming.turnRecords.length + 1;
|
|
1280
|
+
liveTurnNote = "";
|
|
1180
1281
|
liveTurnTimingEl = document.createElement("div");
|
|
1181
1282
|
liveTurnTimingEl.className = "turn-timing live";
|
|
1182
1283
|
historyEl.appendChild(liveTurnTimingEl);
|
|
@@ -1191,16 +1292,24 @@ function appendToHistory(el) {
|
|
|
1191
1292
|
function updateLiveTiming() {
|
|
1192
1293
|
const duration = elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1193
1294
|
if (liveTurnTimingEl && duration !== null) {
|
|
1194
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true });
|
|
1295
|
+
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: true }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1195
1296
|
}
|
|
1196
1297
|
updateInfoBar();
|
|
1197
1298
|
}
|
|
1198
1299
|
|
|
1300
|
+
// Set (or clear, "") what the running turn waits on.
|
|
1301
|
+
function setLiveTurnNote(text) {
|
|
1302
|
+
if (liveTurnNote === text) return;
|
|
1303
|
+
liveTurnNote = text;
|
|
1304
|
+
if (currentTiming.activeTurn) updateLiveTiming();
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1199
1307
|
function finishLiveTurnTiming(record = null) {
|
|
1200
1308
|
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1201
1309
|
? Number(record.duration_ms)
|
|
1202
1310
|
: elapsedSince(currentTiming.activeTurn?.started_at);
|
|
1203
1311
|
if (liveTurnTimingEl && duration !== null) {
|
|
1312
|
+
liveTurnNote = "";
|
|
1204
1313
|
liveTurnTimingEl.classList.remove("live");
|
|
1205
1314
|
const at = record ? currentTiming.turnRecords.indexOf(record) : -1;
|
|
1206
1315
|
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration);
|
|
@@ -1227,15 +1336,12 @@ function updateFirstPeek() {
|
|
|
1227
1336
|
}
|
|
1228
1337
|
}
|
|
1229
1338
|
|
|
1230
|
-
function
|
|
1231
|
-
|
|
1232
|
-
if (!v || currentUsedMemories.includes(v)) return;
|
|
1233
|
-
currentUsedMemories.push(v);
|
|
1339
|
+
function setUsedMemories(names) {
|
|
1340
|
+
currentUsedMemories = names.slice();
|
|
1234
1341
|
updateInfoBar();
|
|
1235
1342
|
const sess = allSessions.find(s=>s.id===selected);
|
|
1236
1343
|
if (sess) {
|
|
1237
|
-
sess.used_memory_names =
|
|
1238
|
-
if (!sess.used_memory_names.includes(v)) sess.used_memory_names.push(v);
|
|
1344
|
+
sess.used_memory_names = currentUsedMemories.slice();
|
|
1239
1345
|
render();
|
|
1240
1346
|
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
1241
1347
|
}
|
|
@@ -1299,6 +1405,7 @@ const streamHandlers = {
|
|
|
1299
1405
|
// block (see chunk_router.js). Raw `content` is only used as a fallback
|
|
1300
1406
|
// for pre-Phase-2 / non-enriched servers.
|
|
1301
1407
|
const { text, thinking } = routeChunk(data);
|
|
1408
|
+
setLiveTurnNote("");
|
|
1302
1409
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1303
1410
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
1304
1411
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
@@ -1309,9 +1416,11 @@ const streamHandlers = {
|
|
|
1309
1416
|
},
|
|
1310
1417
|
generation_started: (data) => {
|
|
1311
1418
|
if (data?.context_window_tokens) currentCtxWindow = data.context_window_tokens;
|
|
1419
|
+
setLiveTurnNote("");
|
|
1312
1420
|
turnDom.generationStarted(data || {});
|
|
1313
1421
|
},
|
|
1314
1422
|
generation_completed: (data) => {
|
|
1423
|
+
setLiveTurnNote("");
|
|
1315
1424
|
if (data?.served_model) {
|
|
1316
1425
|
currentServed = [data.served_model, data.requested_model];
|
|
1317
1426
|
updateInfoBar();
|
|
@@ -1323,7 +1432,10 @@ const streamHandlers = {
|
|
|
1323
1432
|
// pending_input_merged carries the merged text, shown only for prompts
|
|
1324
1433
|
// this tab has no bubble for.
|
|
1325
1434
|
input_merged: (data) => applyPromptOps(data),
|
|
1326
|
-
pending_input_merged: (data) =>
|
|
1435
|
+
pending_input_merged: (data) => {
|
|
1436
|
+
applyPromptOps(data);
|
|
1437
|
+
showSteers(data.steers);
|
|
1438
|
+
},
|
|
1327
1439
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1328
1440
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1329
1441
|
markRecapStale();
|
|
@@ -1339,32 +1451,33 @@ const streamHandlers = {
|
|
|
1339
1451
|
endTurnView("completed");
|
|
1340
1452
|
const out = turnOutput(data);
|
|
1341
1453
|
if (out) appendChunk(out);
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
if (d.session.status) currentStatus = d.session.status;
|
|
1348
|
-
applyTiming(d.timing);
|
|
1349
|
-
finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
|
|
1350
|
-
setMarkdownWarning(d.markdown_warning);
|
|
1351
|
-
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1352
|
-
updateInfoBar();
|
|
1353
|
-
const sess = allSessions.find(s=>s.id===selected);
|
|
1354
|
-
if (sess) {
|
|
1355
|
-
sess.used_memory_names = currentUsedMemories.slice();
|
|
1356
|
-
sess.status = currentStatus;
|
|
1357
|
-
}
|
|
1358
|
-
render();
|
|
1359
|
-
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
1360
|
-
}
|
|
1361
|
-
} catch(_){}
|
|
1362
|
-
}
|
|
1454
|
+
// after_turn hooks are running and may present the answer: its
|
|
1455
|
+
// answer_display (display: null when they didn't) comes next.
|
|
1456
|
+
const display = data.display_pending ? new Promise((resolve) => { displayLanded = resolve; }) : null;
|
|
1457
|
+
turnEndRead = readTurnEnd();
|
|
1458
|
+
await turnEndRead;
|
|
1363
1459
|
// The answer bubble the turn view held at its box's height pops now
|
|
1364
1460
|
// that the rendered markdown is in it (or the re-read failed: then the
|
|
1365
|
-
// plain text pops)
|
|
1461
|
+
// plain text pops), and the display if one was coming, so the links
|
|
1462
|
+
// never swap in after the pop. The hold reveals by itself after
|
|
1463
|
+
// HOLD_MS whatever comes. The chat view has no hold.
|
|
1464
|
+
if (display) await display;
|
|
1366
1465
|
turnDom.answerReady?.();
|
|
1367
1466
|
},
|
|
1467
|
+
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
1468
|
+
// turn_completed, so re-read the answer once that read is done.
|
|
1469
|
+
answer_display: async (data) => {
|
|
1470
|
+
try {
|
|
1471
|
+
await turnEndRead;
|
|
1472
|
+
if (!selected || !data.display) return;
|
|
1473
|
+
const d = await getSession(selected, { tail: true });
|
|
1474
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1475
|
+
} catch (_) {
|
|
1476
|
+
} finally {
|
|
1477
|
+
displayLanded?.();
|
|
1478
|
+
displayLanded = null;
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1368
1481
|
turn_canceled: (data) => {
|
|
1369
1482
|
endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
|
|
1370
1483
|
setLiveStatus("idle");
|
|
@@ -1382,6 +1495,7 @@ const streamHandlers = {
|
|
|
1382
1495
|
// then show the prompt once more, marked failed (with why). This tab's own
|
|
1383
1496
|
// prompt goes back into an empty composer.
|
|
1384
1497
|
prompt_restored: async (data) => {
|
|
1498
|
+
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
1385
1499
|
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
|
|
1386
1500
|
if (refill) {
|
|
1387
1501
|
promptEl.value = refill;
|
|
@@ -1391,6 +1505,13 @@ const streamHandlers = {
|
|
|
1391
1505
|
const failedText = lastFailedText;
|
|
1392
1506
|
lastFailedText = null;
|
|
1393
1507
|
await resync();
|
|
1508
|
+
// The re-render drew it from the session (failed_turn): drawn again
|
|
1509
|
+
// below with this event's label and images.
|
|
1510
|
+
const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
|
|
1511
|
+
if (drawn) {
|
|
1512
|
+
if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
|
|
1513
|
+
drawn.remove();
|
|
1514
|
+
}
|
|
1394
1515
|
// Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
|
|
1395
1516
|
// a resend's echo isn't matched to it.
|
|
1396
1517
|
makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
|
|
@@ -1407,13 +1528,15 @@ const streamHandlers = {
|
|
|
1407
1528
|
}
|
|
1408
1529
|
if (view.resync) await resync();
|
|
1409
1530
|
const queued = view.anytime && commandBubble(view.commandId);
|
|
1410
|
-
if (
|
|
1531
|
+
if (view.hidden) queued?.remove();
|
|
1532
|
+
else if (queued) drawCommandBubble(queued, view);
|
|
1411
1533
|
else addCommandBubble(view);
|
|
1412
1534
|
},
|
|
1413
1535
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
1414
1536
|
// turn: its bubble goes here, so the cards it shows come after it.
|
|
1415
1537
|
command_queued: (data) => {
|
|
1416
|
-
|
|
1538
|
+
// A card's action waits for its command_ran: shown only if it answers.
|
|
1539
|
+
if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble({ ...commandView(data, clientId), text: "" });
|
|
1417
1540
|
},
|
|
1418
1541
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
1419
1542
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
@@ -1421,24 +1544,11 @@ const streamHandlers = {
|
|
|
1421
1544
|
context_added: (data) => addNoteBubble({ label: data.label, content: data.text || "" }),
|
|
1422
1545
|
continue_offered: (data) => renderContinueCard(data),
|
|
1423
1546
|
continue_resolved: (data) => resolveContinueCard(data),
|
|
1424
|
-
tool_call_started: (data) =>
|
|
1425
|
-
turnDom.toolStarted(data);
|
|
1426
|
-
const call = data.call || {};
|
|
1427
|
-
let name = null;
|
|
1428
|
-
if (call.name === "memory_read" && call.content) {
|
|
1429
|
-
name = String(call.content).split(",")[0].trim();
|
|
1430
|
-
if (name) name = name.replace(/\.md$/,"").split("/").pop();
|
|
1431
|
-
} else if (call.name === "read" && call.content && /memories\/.+\.md/.test(call.content)) {
|
|
1432
|
-
name = String(call.content).split("/").pop().replace(/\.md$/,"");
|
|
1433
|
-
}
|
|
1434
|
-
if (name) addUsedMemory(name);
|
|
1435
|
-
},
|
|
1547
|
+
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
1436
1548
|
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
1549
|
+
// The engine's list, after every memory read and at a turn's end.
|
|
1437
1550
|
used_memories_updated: (data) => {
|
|
1438
|
-
if (Array.isArray(data.used_memory_names))
|
|
1439
|
-
currentUsedMemories = data.used_memory_names.slice();
|
|
1440
|
-
updateInfoBar();
|
|
1441
|
-
}
|
|
1551
|
+
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
1442
1552
|
},
|
|
1443
1553
|
context_status: (data) => {
|
|
1444
1554
|
const pct = extractCtxPct(data, currentCtxWindow);
|
|
@@ -1463,6 +1573,13 @@ const streamHandlers = {
|
|
|
1463
1573
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1464
1574
|
// In the turn view, a notice during a turn is a row of its current step.
|
|
1465
1575
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
1576
|
+
// The loop asks again after an empty answer: a row of the empty step (the
|
|
1577
|
+
// chat view shows nothing; after a reload neither does the turn view).
|
|
1578
|
+
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1579
|
+
// The provider is asked again after an error, or the turn waits for
|
|
1580
|
+
// plugins' setup: a note on the live timing line until it streams.
|
|
1581
|
+
generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
|
|
1582
|
+
plugin_init_wait: (data) => setLiveTurnNote(initWaitLine(data)),
|
|
1466
1583
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
1467
1584
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
1468
1585
|
// A plugin's card, or a new version of one (the same id).
|
|
@@ -1623,7 +1740,8 @@ function renderQuestionCard(pq) {
|
|
|
1623
1740
|
if (isNearBottom()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1624
1741
|
}
|
|
1625
1742
|
|
|
1626
|
-
// The tool, the command (or paths) as code, where and why
|
|
1743
|
+
// The tool, the command (or paths, or args) as code, where and why; no
|
|
1744
|
+
// code box when there is nothing to show.
|
|
1627
1745
|
function renderApprovalDetails(view) {
|
|
1628
1746
|
const box = document.createElement("div");
|
|
1629
1747
|
box.className = "approval-details";
|
|
@@ -1631,12 +1749,21 @@ function renderApprovalDetails(view) {
|
|
|
1631
1749
|
tool.className = "approval-tool";
|
|
1632
1750
|
tool.textContent = view.tool;
|
|
1633
1751
|
box.appendChild(tool);
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1752
|
+
if (view.what) {
|
|
1753
|
+
const what = document.createElement("pre");
|
|
1754
|
+
what.className = view.isCommand ? "approval-what command" : "approval-what paths";
|
|
1755
|
+
const code = document.createElement("code");
|
|
1756
|
+
code.textContent = view.what;
|
|
1757
|
+
what.appendChild(code);
|
|
1758
|
+
box.appendChild(what);
|
|
1759
|
+
}
|
|
1760
|
+
// An edit/write: the diff it would make, under the path.
|
|
1761
|
+
if (view.preview) {
|
|
1762
|
+
const diff = document.createElement("div");
|
|
1763
|
+
diff.className = "approval-diff";
|
|
1764
|
+
diff.innerHTML = diffHtml(view.preview);
|
|
1765
|
+
box.appendChild(diff);
|
|
1766
|
+
}
|
|
1640
1767
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
1641
1768
|
if (!text) continue;
|
|
1642
1769
|
const row = document.createElement("div");
|
|
@@ -1728,10 +1855,13 @@ function addCreatedSession(created) {
|
|
|
1728
1855
|
render();
|
|
1729
1856
|
}
|
|
1730
1857
|
|
|
1858
|
+
// A first message: an idle session, then the turn into it as for any
|
|
1859
|
+
// later one (handleSendTurn), so a failed first turn's prompt comes back
|
|
1860
|
+
// into the composer too (sentIds). A first command line starts the session
|
|
1861
|
+
// with it.
|
|
1731
1862
|
async function handleCreate() {
|
|
1732
|
-
// A first message with images: an idle session, then the turn into it.
|
|
1733
|
-
if (chips.length) return handleSendTurn();
|
|
1734
1863
|
const prompt = promptEl.value.trim();
|
|
1864
|
+
if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
|
|
1735
1865
|
if (!prompt) return;
|
|
1736
1866
|
actionBtn.disabled = true;
|
|
1737
1867
|
try {
|
|
@@ -1748,7 +1878,7 @@ async function handleCreate() {
|
|
|
1748
1878
|
}
|
|
1749
1879
|
|
|
1750
1880
|
async function handleSendTurn() {
|
|
1751
|
-
if (!selected && !chips.length) return handleCreate();
|
|
1881
|
+
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1752
1882
|
if (currentOwner === "tui") return;
|
|
1753
1883
|
const prompt = turnText(promptEl.value, chips);
|
|
1754
1884
|
if (!prompt) return;
|
|
@@ -1756,7 +1886,7 @@ async function handleSendTurn() {
|
|
|
1756
1886
|
if (!selected) {
|
|
1757
1887
|
actionBtn.disabled = true;
|
|
1758
1888
|
try {
|
|
1759
|
-
const s = await createIdleSession({ dir: scope.dir, model: chosenModel() });
|
|
1889
|
+
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
|
|
1760
1890
|
addCreatedSession(s);
|
|
1761
1891
|
await select(s.id);
|
|
1762
1892
|
} catch (e) {
|
|
@@ -1782,12 +1912,16 @@ async function handleSendTurn() {
|
|
|
1782
1912
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
1783
1913
|
}
|
|
1784
1914
|
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending) });
|
|
1785
|
-
|
|
1915
|
+
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1916
|
+
// A turn that failed fast was restored before this ack: its prompt
|
|
1917
|
+
// (and images) go back into the composer.
|
|
1918
|
+
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
1919
|
+
promptEl.value = restored?.refill || "";
|
|
1786
1920
|
fitPrompt();
|
|
1787
1921
|
clearChips();
|
|
1922
|
+
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
1788
1923
|
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
1789
1924
|
// worker that wasn't live (no turn_enqueued then).
|
|
1790
|
-
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1791
1925
|
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
1792
1926
|
echo.dataset.enqueuedId = ack.enqueued_id;
|
|
1793
1927
|
}
|
|
@@ -1818,6 +1952,13 @@ async function handleSendTurn() {
|
|
|
1818
1952
|
// /model, !ls, /continue no … run in the session's worker; every client
|
|
1819
1953
|
// shows the command_ran, this one too.
|
|
1820
1954
|
async function handleSendCommand(line) {
|
|
1955
|
+
const local = webLocalReply(line);
|
|
1956
|
+
if (local) {
|
|
1957
|
+
addCommandBubble({ label: null, line, text: local });
|
|
1958
|
+
promptEl.value = "";
|
|
1959
|
+
fitPrompt();
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1821
1962
|
actionBtn.disabled = true;
|
|
1822
1963
|
try {
|
|
1823
1964
|
await sendCommand(selected, line, { clientId });
|
|
@@ -2262,25 +2403,108 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
2262
2403
|
$("#infoDeleteBtn").addEventListener("click", () => {
|
|
2263
2404
|
if (selected) confirmDelete(selected);
|
|
2264
2405
|
});
|
|
2406
|
+
$("#infoArchiveBtn").addEventListener("click", () => {
|
|
2407
|
+
if (!selected) return;
|
|
2408
|
+
if (selectedArchived()) setArchived(selected, false);
|
|
2409
|
+
else setArchived(selected, true);
|
|
2410
|
+
});
|
|
2411
|
+
|
|
2412
|
+
// ── Archive ────────────────────────────────────────────────────────────────
|
|
2413
|
+
// Archive hides a session (and its delegates) from the strip and the list,
|
|
2414
|
+
// kept for good; "include archived" in all sessions finds it. No confirm:
|
|
2415
|
+
// the toast's Undo takes it back. Archiving the open session stays on it
|
|
2416
|
+
// (the button flips to unarchive); its worker was stopped by the server.
|
|
2417
|
+
|
|
2418
|
+
function selectedArchived() {
|
|
2419
|
+
return !!allSessions.find((s) => s.id === selected)?.archived;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// The ids in the server's answer flip here at once; the hub's events
|
|
2423
|
+
// follow with the same.
|
|
2424
|
+
function markArchived(ids, archived) {
|
|
2425
|
+
const set = new Set(ids);
|
|
2426
|
+
allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
async function setArchived(id, archive, { undo = true } = {}) {
|
|
2430
|
+
const short = id.slice(0, 8);
|
|
2431
|
+
let result;
|
|
2432
|
+
// A live worker is stopped by the archive: its stream ending is a stop.
|
|
2433
|
+
if (archive) stoppingId = id;
|
|
2434
|
+
try {
|
|
2435
|
+
result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
2436
|
+
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
2437
|
+
if (archive && id === selected && (result.stopped || []).includes(id)) {
|
|
2438
|
+
// Its worker is gone: don't wait for its stream to notice.
|
|
2439
|
+
streamControl?.close();
|
|
2440
|
+
currentStatus = "stopped";
|
|
2441
|
+
workerGone(id);
|
|
2442
|
+
}
|
|
2443
|
+
} catch (e) {
|
|
2444
|
+
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${e.message}`);
|
|
2445
|
+
return;
|
|
2446
|
+
} finally {
|
|
2447
|
+
if (archive) stoppingId = null;
|
|
2448
|
+
}
|
|
2449
|
+
render();
|
|
2450
|
+
updateInfoBar();
|
|
2451
|
+
if (!eventsControl) refresh();
|
|
2452
|
+
const what = archive ? "Archived" : "Unarchived";
|
|
2453
|
+
showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
let toastTimer = null;
|
|
2457
|
+
function showToast(text, action = null) {
|
|
2458
|
+
const el = $("#toast");
|
|
2459
|
+
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}`;
|
|
2460
|
+
el.hidden = false;
|
|
2461
|
+
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
2462
|
+
el.hidden = true;
|
|
2463
|
+
action.run();
|
|
2464
|
+
});
|
|
2465
|
+
clearTimeout(toastTimer);
|
|
2466
|
+
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
$("#includeArchived").addEventListener("change", () => {
|
|
2470
|
+
if (allViewOpen()) renderAll();
|
|
2471
|
+
});
|
|
2265
2472
|
|
|
2266
2473
|
// ── Annotate ───────────────────────────────────────────────────────────────
|
|
2267
2474
|
// Selecting text in an answer, thinking, a tool row or an own message shows
|
|
2268
|
-
// "Annotate"
|
|
2269
|
-
//
|
|
2270
|
-
//
|
|
2271
|
-
//
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2475
|
+
// "Annotate" and the presets (web.annotate_presets); Annotate appends the
|
|
2476
|
+
// quote (with where it came from) to the composer, a preset the quote with
|
|
2477
|
+
// its text as the note. Nothing is sent. The bar lives outside #history,
|
|
2478
|
+
// which is re-rendered whole. The selection is read when it changes: a live
|
|
2479
|
+
// bubble rewrites its text every frame, which would collapse it by the time
|
|
2480
|
+
// a button is clicked.
|
|
2481
|
+
|
|
2482
|
+
const annotatePresets = parsePresets(document.body.dataset.annotatePresets);
|
|
2483
|
+
const annotateBar = document.createElement("div");
|
|
2484
|
+
annotateBar.id = "annotateBar";
|
|
2485
|
+
annotateBar.hidden = true;
|
|
2486
|
+
annotateBar.setAttribute("role", "toolbar");
|
|
2487
|
+
annotateBar.setAttribute("aria-label", "Annotate");
|
|
2488
|
+
function annotateButton(text, cls) {
|
|
2489
|
+
const btn = document.createElement("button");
|
|
2490
|
+
btn.type = "button";
|
|
2491
|
+
btn.className = cls;
|
|
2492
|
+
btn.textContent = text;
|
|
2493
|
+
return btn;
|
|
2494
|
+
}
|
|
2495
|
+
annotateBar.appendChild(annotateButton("Annotate", "annotate-main"));
|
|
2496
|
+
annotatePresets.forEach((text, i) => {
|
|
2497
|
+
const btn = annotateButton(text, "annotate-preset");
|
|
2498
|
+
btn.dataset.index = String(i);
|
|
2499
|
+
btn.title = text;
|
|
2500
|
+
annotateBar.appendChild(btn);
|
|
2501
|
+
});
|
|
2502
|
+
document.body.appendChild(annotateBar);
|
|
2279
2503
|
let pendingQuote = null;
|
|
2280
2504
|
|
|
2281
2505
|
function hideAnnotate() {
|
|
2282
2506
|
pendingQuote = null;
|
|
2283
|
-
|
|
2507
|
+
annotateBar.hidden = true;
|
|
2284
2508
|
}
|
|
2285
2509
|
|
|
2286
2510
|
function updateAnnotate() {
|
|
@@ -2301,20 +2525,26 @@ function updateAnnotate() {
|
|
|
2301
2525
|
pendingQuote = block;
|
|
2302
2526
|
const rects = clipped.getClientRects();
|
|
2303
2527
|
const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2528
|
+
annotateBar.hidden = false;
|
|
2529
|
+
// Measured at the left edge: a fixed element's shrink-to-fit width is
|
|
2530
|
+
// capped by what's right of it, so at the last (right) spot it would
|
|
2531
|
+
// measure wrapped.
|
|
2532
|
+
annotateBar.style.left = "8px";
|
|
2533
|
+
const w = annotateBar.offsetWidth;
|
|
2534
|
+
const h = annotateBar.offsetHeight;
|
|
2535
|
+
const maxTop = window.innerHeight - h - 8;
|
|
2536
|
+
const top = Math.min(rect.bottom + 6, maxTop);
|
|
2537
|
+
annotateBar.style.top = `${Math.max(top, 8)}px`;
|
|
2538
|
+
annotateBar.style.left = `${annotateBarLeft(rect, w, window.innerWidth)}px`;
|
|
2309
2539
|
// Not over a copy button (this bubble's, one inside it, the next
|
|
2310
2540
|
// bubble's).
|
|
2311
|
-
const box =
|
|
2541
|
+
const box = annotateBar.getBoundingClientRect();
|
|
2312
2542
|
const bubble = source.root.closest(".bubble");
|
|
2313
2543
|
const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
|
|
2314
2544
|
for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
|
|
2315
2545
|
const r = btn.getBoundingClientRect();
|
|
2316
2546
|
if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
|
|
2317
|
-
|
|
2547
|
+
annotateBar.style.top = `${Math.max(Math.min(r.bottom + 4, maxTop), 8)}px`;
|
|
2318
2548
|
break;
|
|
2319
2549
|
}
|
|
2320
2550
|
}
|
|
@@ -2329,11 +2559,13 @@ document.addEventListener("selectionchange", scheduleAnnotate);
|
|
|
2329
2559
|
historyEl.addEventListener("scroll", scheduleAnnotate);
|
|
2330
2560
|
window.addEventListener("resize", scheduleAnnotate);
|
|
2331
2561
|
installCopy(historyEl);
|
|
2332
|
-
// Keep the selection when
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2562
|
+
// Keep the selection when a button is pressed.
|
|
2563
|
+
annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2564
|
+
annotateBar.addEventListener("click", (e) => {
|
|
2565
|
+
const btn = e.target.closest("button");
|
|
2566
|
+
if (!btn || !pendingQuote) return;
|
|
2567
|
+
const preset = btn.dataset.index === undefined ? null : annotatePresets[Number(btn.dataset.index)];
|
|
2568
|
+
promptEl.value = appendQuote(promptEl.value, preset ? presetNote(pendingQuote, preset) : pendingQuote);
|
|
2337
2569
|
fitPrompt();
|
|
2338
2570
|
window.getSelection()?.removeAllRanges();
|
|
2339
2571
|
hideAnnotate();
|
|
@@ -2342,6 +2574,135 @@ annotateBtn.addEventListener("click", () => {
|
|
|
2342
2574
|
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2343
2575
|
});
|
|
2344
2576
|
|
|
2577
|
+
// ── Notifications (notify.js): when a session needs the user (a question,
|
|
2578
|
+
// an approval, a failed turn, a long turn done) and this tab is not in
|
|
2579
|
+
// front, an OS notification (the bell turns them on) and a count in the
|
|
2580
|
+
// title (always). One tag per event, so several tabs show one, and none
|
|
2581
|
+
// while another chi tab is in front with it (notify.js, Several tabs).
|
|
2582
|
+
const NOTIFY_KEY = "chi_notify";
|
|
2583
|
+
const notifyBtn = $("#notifyBtn");
|
|
2584
|
+
let attention = initialAttentionState();
|
|
2585
|
+
const badgeKeys = new Set();
|
|
2586
|
+
let baseTitle = "Chi";
|
|
2587
|
+
|
|
2588
|
+
function setTitle() {
|
|
2589
|
+
document.title = badgeKeys.size ? `(${badgeKeys.size}) ${baseTitle}` : baseTitle;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
// The user is looking at this tab: visible and focused (a window behind
|
|
2593
|
+
// the terminal is visible but not focused).
|
|
2594
|
+
function tabInFront() {
|
|
2595
|
+
return document.visibilityState === "visible" && document.hasFocus();
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function notifySupported() {
|
|
2599
|
+
return typeof globalThis.Notification === "function";
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
function notifyWanted() {
|
|
2603
|
+
try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
// "on" | "off" | "denied" | "unsupported"
|
|
2607
|
+
function notifyState() {
|
|
2608
|
+
if (!notifySupported()) return "unsupported";
|
|
2609
|
+
if (Notification.permission === "denied") return "denied";
|
|
2610
|
+
return notifyWanted() && Notification.permission === "granted" ? "on" : "off";
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
const NOTIFY_TITLES = {
|
|
2614
|
+
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
2615
|
+
off: "Notify me when a session needs me while this tab is in the background",
|
|
2616
|
+
denied: "Notifications are blocked for this site in the browser's settings",
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
function renderNotifyBtn() {
|
|
2620
|
+
const state = notifyState();
|
|
2621
|
+
notifyBtn.hidden = state === "unsupported";
|
|
2622
|
+
notifyBtn.dataset.state = state;
|
|
2623
|
+
notifyBtn.title = NOTIFY_TITLES[state] || "";
|
|
2624
|
+
notifyBtn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
async function toggleNotify() {
|
|
2628
|
+
const state = notifyState();
|
|
2629
|
+
if (state === "unsupported" || state === "denied") return;
|
|
2630
|
+
let wanted = state !== "on";
|
|
2631
|
+
if (wanted && Notification.permission !== "granted") {
|
|
2632
|
+
wanted = (await Notification.requestPermission()) === "granted";
|
|
2633
|
+
}
|
|
2634
|
+
try { localStorage.setItem(NOTIFY_KEY, wanted ? "1" : "0"); } catch (_) {}
|
|
2635
|
+
renderNotifyBtn();
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
function showNotification(session, a) {
|
|
2639
|
+
if (notifyState() !== "on") return;
|
|
2640
|
+
const { title, body } = attentionText(session, a);
|
|
2641
|
+
try {
|
|
2642
|
+
const n = new Notification(title, { body, tag: a.key });
|
|
2643
|
+
n.onclick = () => {
|
|
2644
|
+
window.focus();
|
|
2645
|
+
location.hash = sessionHash(a.sessionId);
|
|
2646
|
+
n.close();
|
|
2647
|
+
};
|
|
2648
|
+
} catch (_) {}
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// Another chi tab in front has these (notify.js, Several tabs).
|
|
2652
|
+
function seenElsewhere(keys) {
|
|
2653
|
+
const size = badgeKeys.size;
|
|
2654
|
+
for (const key of keys) badgeKeys.delete(key);
|
|
2655
|
+
if (badgeKeys.size !== size) setTitle();
|
|
2656
|
+
}
|
|
2657
|
+
let notifyChannel = null;
|
|
2658
|
+
try {
|
|
2659
|
+
if (typeof globalThis.BroadcastChannel === "function") notifyChannel = new BroadcastChannel(NOTIFY_CHANNEL);
|
|
2660
|
+
} catch (_) {}
|
|
2661
|
+
const notifyGate = createNotifyGate({ channel: notifyChannel, onSeen: seenElsewhere });
|
|
2662
|
+
|
|
2663
|
+
// Every hub frame goes through here before the list takes it.
|
|
2664
|
+
function noticeSessions(type, data) {
|
|
2665
|
+
const next = trackAttention(attention, type, data);
|
|
2666
|
+
attention = next.state;
|
|
2667
|
+
const size = badgeKeys.size;
|
|
2668
|
+
for (const key of next.closed) badgeKeys.delete(key);
|
|
2669
|
+
notifyGate.drop(next.closed);
|
|
2670
|
+
if (next.attentions.length > 0 && tabInFront()) {
|
|
2671
|
+
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
2672
|
+
} else {
|
|
2673
|
+
for (const a of next.attentions) {
|
|
2674
|
+
notifyGate.offer(a, () => {
|
|
2675
|
+
if (tabInFront()) return;
|
|
2676
|
+
badgeKeys.add(a.key);
|
|
2677
|
+
setTitle();
|
|
2678
|
+
showNotification(attention.byId[a.sessionId], a);
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
if (badgeKeys.size !== size) setTitle();
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
// The user is here: the badge goes, and the other tabs drop what it had.
|
|
2686
|
+
function clearBadge() {
|
|
2687
|
+
if (badgeKeys.size === 0) return;
|
|
2688
|
+
notifyGate.seenInFront([...badgeKeys]);
|
|
2689
|
+
badgeKeys.clear();
|
|
2690
|
+
setTitle();
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// The user is back: the page turned visible (Safari makes it visible
|
|
2694
|
+
// before it has focus, and a tab switch inside a focused window sends no
|
|
2695
|
+
// focus event), the window got focus, or they touched the page.
|
|
2696
|
+
function backInFront() {
|
|
2697
|
+
if (document.visibilityState === "visible") clearBadge();
|
|
2698
|
+
}
|
|
2699
|
+
document.addEventListener("visibilitychange", backInFront);
|
|
2700
|
+
window.addEventListener("focus", backInFront);
|
|
2701
|
+
document.addEventListener("pointerdown", clearBadge, true);
|
|
2702
|
+
document.addEventListener("keydown", clearBadge, true);
|
|
2703
|
+
notifyBtn.addEventListener("click", toggleNotify);
|
|
2704
|
+
renderNotifyBtn();
|
|
2705
|
+
|
|
2345
2706
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
2346
2707
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|
|
2347
2708
|
function stripHiddenSaved() {
|