samagotchi 0.2.0 → 0.3.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 +116 -1
- data/README.md +40 -4
- data/bin/chi +86 -19
- data/docs/cli.md +108 -5
- data/docs/configuration.md +229 -45
- data/docs/desktop.md +6 -0
- data/docs/hooks.md +126 -5
- data/docs/plugins.md +68 -2
- data/docs/releasing.md +18 -8
- data/docs/sessions.md +30 -4
- 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 +14 -3
- data/lib/samagotchi/bridge.rb +9 -0
- 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 +358 -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/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +8 -2
- data/lib/samagotchi/client.rb +72 -13
- data/lib/samagotchi/config.rb +196 -36
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +13 -7
- data/lib/samagotchi/desktop/macos/Panel.swift +71 -19
- data/lib/samagotchi/empty_answer_retry.rb +43 -0
- data/lib/samagotchi/engine.rb +233 -36
- data/lib/samagotchi/guardrails/approval.rb +9 -0
- 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 +4 -3
- data/lib/samagotchi/idle_recap.rb +5 -1
- data/lib/samagotchi/kernel_loop.rb +47 -15
- data/lib/samagotchi/llm/chat_loop.rb +59 -20
- data/lib/samagotchi/llm/errors.rb +21 -3
- data/lib/samagotchi/llm/http.rb +42 -13
- data/lib/samagotchi/llm/openai_chat.rb +12 -4
- data/lib/samagotchi/log_subscriber.rb +18 -3
- data/lib/samagotchi/model_profile.rb +1 -1
- data/lib/samagotchi/plugin/context.rb +22 -1
- data/lib/samagotchi/plugin/sessions.rb +3 -1
- data/lib/samagotchi/reply_wait.rb +126 -0
- data/lib/samagotchi/sampling_settings.rb +58 -0
- data/lib/samagotchi/self_report.rb +1 -0
- data/lib/samagotchi/send_command.rb +153 -7
- data/lib/samagotchi/session.rb +52 -11
- data/lib/samagotchi/session_archive_command.rb +107 -0
- data/lib/samagotchi/session_commands.rb +11 -2
- data/lib/samagotchi/session_manager.rb +114 -9
- data/lib/samagotchi/session_metrics.rb +222 -106
- data/lib/samagotchi/steer.rb +72 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
- data/lib/samagotchi/terminal_ui/event_renderer.rb +13 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +31 -8
- data/lib/samagotchi/terminal_ui/input_support.rb +3 -0
- data/lib/samagotchi/terminal_ui.rb +77 -4
- data/lib/samagotchi/tool_activity.rb +3 -1
- data/lib/samagotchi/tools/builtins.rb +15 -4
- data/lib/samagotchi/tools/delegate_wait.rb +26 -69
- 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/turn_note.rb +60 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +2 -6
- data/lib/samagotchi/web/app.rb +88 -4
- data/lib/samagotchi/web/public/activity.js +10 -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 +437 -88
- data/lib/samagotchi/web/public/card.js +5 -3
- data/lib/samagotchi/web/public/chat_view.js +10 -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 +21 -6
- data/lib/samagotchi/web/public/format.js +9 -0
- data/lib/samagotchi/web/public/index.html +38 -2
- data/lib/samagotchi/web/public/notify.js +175 -0
- data/lib/samagotchi/web/public/question_card.js +2 -1
- 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 +46 -0
- data/lib/samagotchi/web/public/turn_view.js +47 -7
- 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 +11 -0
- metadata +20 -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,14 @@ 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";
|
|
27
30
|
import { approvalAllowed, approvalView, isApproval, nextCardAction, resultText, summaryText } from "./question_card.js";
|
|
28
31
|
import { cardClass, cardInnerHtml, cardPlace, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
29
32
|
import { gripFloor, promptHeight } from "./composer_size.js";
|
|
@@ -32,12 +35,13 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
32
35
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
33
36
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
34
37
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
35
|
-
import { applySessionEvent, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
38
|
+
import { applySessionEvent, listedSessions, sortedByUpdated, withChildrenAfterParents } from "./sessions_list.js";
|
|
39
|
+
import { NOTIFY_CHANNEL, attentionText, createNotifyGate, initialAttentionState, trackAttention } from "./notify.js";
|
|
36
40
|
import {
|
|
37
41
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, normalizeTiming, turnTimingText,
|
|
38
42
|
} from "./timing.js";
|
|
39
43
|
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";
|
|
44
|
+
import { clientLabel, commandView, continueLine, emptyRetryLine, hookNoticeLabel, isCommandLine, isOwn, keepEarlyRestore, newClientId, promptOps, reminderText, restoreAction, restoreOnAck, snapshotEvents, turnOutput, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
41
45
|
|
|
42
46
|
const $ = (s) => document.querySelector(s);
|
|
43
47
|
const topStripEl = $("#topStrip");
|
|
@@ -121,6 +125,9 @@ let unmatchedMerge = false;
|
|
|
121
125
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
122
126
|
// into the composer when their turn fails.
|
|
123
127
|
const sentIds = new Set();
|
|
128
|
+
// Own prompt_restored events that came before their /turn ack, by
|
|
129
|
+
// enqueued_id: the ack puts that prompt back instead of clearing it.
|
|
130
|
+
const earlyRestores = new Map();
|
|
124
131
|
// The last turn_failed's line, shown again after prompt_restored's resync.
|
|
125
132
|
let lastFailedText = null;
|
|
126
133
|
|
|
@@ -158,17 +165,20 @@ async function refresh() {
|
|
|
158
165
|
function openSessionEvents() {
|
|
159
166
|
eventsControl = openEvents({
|
|
160
167
|
snapshot: (data) => {
|
|
168
|
+
noticeSessions("snapshot", data);
|
|
161
169
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
162
170
|
render();
|
|
163
171
|
const sess = allSessions.find((s) => s.id === selected);
|
|
164
172
|
if (sess) followSelectedSession(sess);
|
|
165
173
|
},
|
|
166
174
|
session: (data) => {
|
|
175
|
+
noticeSessions("session", data);
|
|
167
176
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
168
177
|
render();
|
|
169
178
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
170
179
|
},
|
|
171
180
|
session_gone: (data) => {
|
|
181
|
+
noticeSessions("session_gone", data);
|
|
172
182
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
173
183
|
if (next !== allSessions) {
|
|
174
184
|
allSessions = next;
|
|
@@ -217,7 +227,8 @@ function renderScope() {
|
|
|
217
227
|
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
228
|
}
|
|
219
229
|
el.hidden = false;
|
|
220
|
-
|
|
230
|
+
baseTitle = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
|
|
231
|
+
setTitle();
|
|
221
232
|
// The links follow the hash (an open session, the all-sessions view).
|
|
222
233
|
el.querySelector(".scope-all:not(.scope-back)")?.addEventListener("click", (e) => {
|
|
223
234
|
e.currentTarget.href = allScopeHref(location.hash, scope.dir);
|
|
@@ -227,10 +238,21 @@ function renderScope() {
|
|
|
227
238
|
});
|
|
228
239
|
}
|
|
229
240
|
|
|
230
|
-
|
|
241
|
+
// The strip, its count and the hidden strip's pill leave archived sessions
|
|
242
|
+
// out; the all view shows them with "include archived". allSessions keeps
|
|
243
|
+
// them all (parent chips, the open session).
|
|
244
|
+
function shownSessions() {
|
|
245
|
+
return listedSessions(allSessions, false);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function allViewSessions() {
|
|
249
|
+
return listedSessions(allSessions, $("#includeArchived").checked);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function filteredSessions(list) {
|
|
231
253
|
const q = $("#filter").value.trim().toLowerCase();
|
|
232
|
-
if (!q) return
|
|
233
|
-
return
|
|
254
|
+
if (!q) return list;
|
|
255
|
+
return list.filter(
|
|
234
256
|
(s) =>
|
|
235
257
|
previewForCard(s).toLowerCase().includes(q) ||
|
|
236
258
|
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
@@ -252,7 +274,10 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
252
274
|
const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
|
|
253
275
|
const folder = cardFolder(s.working_directory, scope.projectName);
|
|
254
276
|
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
255
|
-
|
|
277
|
+
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
278
|
+
const ctx = cardCtxText(s.ctx_pct);
|
|
279
|
+
const ctxHtml = ctx ? `<span class="ctx" title="Context used after the last turn">${escapeHtml(ctx)}</span>` : "";
|
|
280
|
+
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
281
|
}
|
|
257
282
|
|
|
258
283
|
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
@@ -277,7 +302,7 @@ function fitStripColumns() {
|
|
|
277
302
|
|
|
278
303
|
function render() {
|
|
279
304
|
fitStripColumns();
|
|
280
|
-
renderTop(
|
|
305
|
+
renderTop(shownSessions().slice(0, stripCols));
|
|
281
306
|
if (allViewOpen()) renderAll();
|
|
282
307
|
}
|
|
283
308
|
|
|
@@ -287,7 +312,7 @@ window.addEventListener("resize", () => {
|
|
|
287
312
|
stripResizeTimer = setTimeout(() => {
|
|
288
313
|
const before = stripCols;
|
|
289
314
|
fitStripColumns();
|
|
290
|
-
if (stripCols !== before && allSessions.length) renderTop(
|
|
315
|
+
if (stripCols !== before && allSessions.length) renderTop(shownSessions().slice(0, stripCols));
|
|
291
316
|
}, 150);
|
|
292
317
|
});
|
|
293
318
|
|
|
@@ -314,11 +339,15 @@ function renderTop(sessions) {
|
|
|
314
339
|
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref(""))}">show every session</a></div>`;
|
|
315
340
|
return;
|
|
316
341
|
}
|
|
317
|
-
|
|
342
|
+
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
343
|
+
if (!allSessions.length) {
|
|
318
344
|
topStripEl.innerHTML = "";
|
|
319
345
|
return;
|
|
320
346
|
}
|
|
321
|
-
const
|
|
347
|
+
const n = shownSessions().length;
|
|
348
|
+
const count = n || allSessions.length === n
|
|
349
|
+
? `${n} ${n === 1 ? "session" : "sessions"}`
|
|
350
|
+
: `${allSessions.length - n} archived`;
|
|
322
351
|
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
352
|
topStripEl.innerHTML = sessions.map((s) => cardHtml(s)).join("") + tile;
|
|
324
353
|
bindCards(topStripEl, select);
|
|
@@ -328,7 +357,7 @@ function renderTop(sessions) {
|
|
|
328
357
|
|
|
329
358
|
// The hidden strip's pill: "▾ 5 sessions · ● 1 live", following the list.
|
|
330
359
|
function renderStripShow() {
|
|
331
|
-
const { count, live } = stripShowParts(
|
|
360
|
+
const { count, live } = stripShowParts(shownSessions());
|
|
332
361
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
333
362
|
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}`;
|
|
334
363
|
}
|
|
@@ -336,12 +365,13 @@ function renderStripShow() {
|
|
|
336
365
|
// A delegated session sits right after its parent: applied on every
|
|
337
366
|
// render, since a hub snapshot hands the list back in the server's order.
|
|
338
367
|
function renderAll() {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
368
|
+
const listed = allViewSessions();
|
|
369
|
+
const sessions = withChildrenAfterParents(filteredSessions(listed));
|
|
370
|
+
$("#count").textContent = sessions.length === listed.length
|
|
371
|
+
? `${listed.length}`
|
|
372
|
+
: `${sessions.length} / ${listed.length}`;
|
|
343
373
|
if (!sessions.length) {
|
|
344
|
-
allListEl.innerHTML = `<div class="hint">${
|
|
374
|
+
allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
|
|
345
375
|
return;
|
|
346
376
|
}
|
|
347
377
|
allListEl.innerHTML = sessions.map((s) => cardHtml(s, { deletable: true })).join("");
|
|
@@ -667,6 +697,11 @@ function renderSessionView(data) {
|
|
|
667
697
|
currentDir = s.working_directory;
|
|
668
698
|
currentOwner = s.owner || null;
|
|
669
699
|
currentTiming = normalizeTiming(data.timing);
|
|
700
|
+
// The saved fill, so a reloaded or stopped session shows it before its
|
|
701
|
+
// next turn (a running turn's replay below streams over it).
|
|
702
|
+
const savedPct = savedCtxPct(data.timing?.context);
|
|
703
|
+
if (savedPct !== null) currentCtxPct = savedPct;
|
|
704
|
+
if (data.timing?.context?.window_tokens) currentCtxWindow = data.timing.context.window_tokens;
|
|
670
705
|
currentFirstPreview = s.first_preview || previewForCard(s);
|
|
671
706
|
currentUsedMemories = s.used_memory_names || [];
|
|
672
707
|
currentPreloadedMemories = s.preloaded_memory_names || [];
|
|
@@ -684,6 +719,12 @@ function renderSessionView(data) {
|
|
|
684
719
|
if(txt) currentFirstPreview = txt;
|
|
685
720
|
}
|
|
686
721
|
}
|
|
722
|
+
// A failed turn's prompt went back to the user: shown after the
|
|
723
|
+
// conversation, marked failed, as it was live (prompt_restored).
|
|
724
|
+
if (data.failed_turn) {
|
|
725
|
+
makeUserBubble(data.failed_turn.prompt, { state: "failed" }).dataset.failedTurn = "1";
|
|
726
|
+
addEndBubble(failedTurnText(data.failed_turn));
|
|
727
|
+
}
|
|
687
728
|
seedSeenFromDom();
|
|
688
729
|
seedInitTasks(initTasks, data.init_tasks);
|
|
689
730
|
renderInitRow();
|
|
@@ -747,6 +788,36 @@ function setMarkdownWarning(warning) {
|
|
|
747
788
|
markdownWarningEl.hidden = false;
|
|
748
789
|
}
|
|
749
790
|
|
|
791
|
+
// The turn-end re-read (turn_completed): the final answer's rendered
|
|
792
|
+
// markdown, the status and the timing. Kept as turnEndRead, so an
|
|
793
|
+
// answer_display re-read waits for it.
|
|
794
|
+
let turnEndRead = Promise.resolve();
|
|
795
|
+
// Resolves the wait of a turn_completed with display_pending (its
|
|
796
|
+
// answer_display landed).
|
|
797
|
+
let displayLanded = null;
|
|
798
|
+
async function readTurnEnd() {
|
|
799
|
+
if (!selected) return;
|
|
800
|
+
try {
|
|
801
|
+
const d = await getSession(selected, { tail: true });
|
|
802
|
+
if (d.session) {
|
|
803
|
+
if (Array.isArray(d.session.used_memory_names)) currentUsedMemories = d.session.used_memory_names;
|
|
804
|
+
if (d.session.status) currentStatus = d.session.status;
|
|
805
|
+
applyTiming(d.timing);
|
|
806
|
+
finishLiveTurnTiming(currentTiming.turnRecords[currentTiming.turnRecords.length - 1]);
|
|
807
|
+
setMarkdownWarning(d.markdown_warning);
|
|
808
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
809
|
+
updateInfoBar();
|
|
810
|
+
const sess = allSessions.find(s=>s.id===selected);
|
|
811
|
+
if (sess) {
|
|
812
|
+
sess.used_memory_names = currentUsedMemories.slice();
|
|
813
|
+
sess.status = currentStatus;
|
|
814
|
+
}
|
|
815
|
+
render();
|
|
816
|
+
document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
|
|
817
|
+
}
|
|
818
|
+
} catch(_){}
|
|
819
|
+
}
|
|
820
|
+
|
|
750
821
|
function applyFinalMarkdown(messages) {
|
|
751
822
|
const message = [...messages].reverse().find(
|
|
752
823
|
(item) => item?.role === "assistant" && typeof item.html === "string",
|
|
@@ -755,11 +826,11 @@ function applyFinalMarkdown(messages) {
|
|
|
755
826
|
|
|
756
827
|
const bubble = [...historyEl.querySelectorAll(".bubble.output")]
|
|
757
828
|
.reverse()
|
|
758
|
-
.find((item) =>
|
|
829
|
+
.find((item) => showsAnswer(item, message, normalize));
|
|
759
830
|
if (!bubble) return;
|
|
760
831
|
|
|
761
832
|
bubble.classList.add("markdown");
|
|
762
|
-
bubble.setAttribute(COPY_SOURCE_ATTR, message
|
|
833
|
+
bubble.setAttribute(COPY_SOURCE_ATTR, copySource(message));
|
|
763
834
|
bubble.innerHTML = message.html;
|
|
764
835
|
}
|
|
765
836
|
|
|
@@ -958,6 +1029,20 @@ function showGuardrailWarning(text, label = "guardrails") {
|
|
|
958
1029
|
// A hook's line to the user (event[:notify]): "<bundle>: text", in the
|
|
959
1030
|
// warning colour for level warn.
|
|
960
1031
|
// +before+: the element it goes above (a snapshot's notice), else the end.
|
|
1032
|
+
// A plugin's steers (pending_input_merged's steers): in the turn view a row
|
|
1033
|
+
// of the running turn's step, else a bubble.
|
|
1034
|
+
function showSteers(steers) {
|
|
1035
|
+
for (const steer of steers || []) {
|
|
1036
|
+
if (turnDom.steer(steer)) continue;
|
|
1037
|
+
removeHintIfEmpty();
|
|
1038
|
+
const holder = document.createElement("div");
|
|
1039
|
+
holder.innerHTML = steerRowHtml(steer, { bubble: true });
|
|
1040
|
+
const follow = isNearBottom();
|
|
1041
|
+
appendToHistory(holder.firstChild);
|
|
1042
|
+
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
961
1046
|
function showHookNotice(data, { before = null } = {}) {
|
|
962
1047
|
removeHintIfEmpty();
|
|
963
1048
|
const el = document.createElement("div");
|
|
@@ -995,7 +1080,9 @@ function drawCard(el, card) {
|
|
|
995
1080
|
|
|
996
1081
|
// Show +card+: update the one with its id where it is, else add it (a
|
|
997
1082
|
// running turn's card as a row of its step; else before +before+ or at the
|
|
998
|
-
// end of the history).
|
|
1083
|
+
// end of the history). A running turn's card with actions asks the user
|
|
1084
|
+
// something (check-in's Nudge / Keep going / Stop): it goes under the turn's
|
|
1085
|
+
// block, not into a step that collapses when the next one starts.
|
|
999
1086
|
function upsertCard(card, { before = null } = {}) {
|
|
1000
1087
|
if (!card || !card.id) return;
|
|
1001
1088
|
const existing = cardElement(card.id);
|
|
@@ -1008,7 +1095,8 @@ function upsertCard(card, { before = null } = {}) {
|
|
|
1008
1095
|
removeHintIfEmpty();
|
|
1009
1096
|
const el = document.createElement("div");
|
|
1010
1097
|
drawCard(el, card);
|
|
1011
|
-
|
|
1098
|
+
const asks = (card.actions || []).some((a) => a && a.command);
|
|
1099
|
+
if (card.in_turn && !before && !asks && turnDom.card(el)) return;
|
|
1012
1100
|
const follow = isNearBottom();
|
|
1013
1101
|
if (before) historyEl.insertBefore(el, before);
|
|
1014
1102
|
else appendToHistory(el);
|
|
@@ -1089,7 +1177,7 @@ historyEl.addEventListener("click", (e) => {
|
|
|
1089
1177
|
const err = card.querySelector(".card-error");
|
|
1090
1178
|
err.classList.add("hidden");
|
|
1091
1179
|
button.disabled = true;
|
|
1092
|
-
sendCommand(selected, button.dataset.command, { clientId })
|
|
1180
|
+
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1093
1181
|
.then(() => {
|
|
1094
1182
|
// The command woke the worker: follow its stream if none is open.
|
|
1095
1183
|
if (!streamControl?.live) startStream(selected, selectSeq ?? 0);
|
|
@@ -1119,7 +1207,14 @@ infoTextEl.addEventListener("click", async (e) => {
|
|
|
1119
1207
|
function updateInfoBar() {
|
|
1120
1208
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1121
1209
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
1210
|
+
const archiveBtn = document.getElementById("infoArchiveBtn");
|
|
1122
1211
|
if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
|
|
1212
|
+
if (archiveBtn) {
|
|
1213
|
+
archiveBtn.style.display = selected ? "" : "none";
|
|
1214
|
+
const archived = selectedArchived();
|
|
1215
|
+
archiveBtn.textContent = archived ? "unarchive" : "archive";
|
|
1216
|
+
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1217
|
+
}
|
|
1123
1218
|
if (!selected) {
|
|
1124
1219
|
// The start page hides the info bar (the storage path is on #/sessions).
|
|
1125
1220
|
infoTextEl.innerHTML = "";
|
|
@@ -1323,7 +1418,10 @@ const streamHandlers = {
|
|
|
1323
1418
|
// pending_input_merged carries the merged text, shown only for prompts
|
|
1324
1419
|
// this tab has no bubble for.
|
|
1325
1420
|
input_merged: (data) => applyPromptOps(data),
|
|
1326
|
-
pending_input_merged: (data) =>
|
|
1421
|
+
pending_input_merged: (data) => {
|
|
1422
|
+
applyPromptOps(data);
|
|
1423
|
+
showSteers(data.steers);
|
|
1424
|
+
},
|
|
1327
1425
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1328
1426
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1329
1427
|
markRecapStale();
|
|
@@ -1339,32 +1437,33 @@ const streamHandlers = {
|
|
|
1339
1437
|
endTurnView("completed");
|
|
1340
1438
|
const out = turnOutput(data);
|
|
1341
1439
|
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
|
-
}
|
|
1440
|
+
// after_turn hooks are running and may present the answer: its
|
|
1441
|
+
// answer_display (display: null when they didn't) comes next.
|
|
1442
|
+
const display = data.display_pending ? new Promise((resolve) => { displayLanded = resolve; }) : null;
|
|
1443
|
+
turnEndRead = readTurnEnd();
|
|
1444
|
+
await turnEndRead;
|
|
1363
1445
|
// The answer bubble the turn view held at its box's height pops now
|
|
1364
1446
|
// that the rendered markdown is in it (or the re-read failed: then the
|
|
1365
|
-
// plain text pops)
|
|
1447
|
+
// plain text pops), and the display if one was coming, so the links
|
|
1448
|
+
// never swap in after the pop. The hold reveals by itself after
|
|
1449
|
+
// HOLD_MS whatever comes. The chat view has no hold.
|
|
1450
|
+
if (display) await display;
|
|
1366
1451
|
turnDom.answerReady?.();
|
|
1367
1452
|
},
|
|
1453
|
+
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
1454
|
+
// turn_completed, so re-read the answer once that read is done.
|
|
1455
|
+
answer_display: async (data) => {
|
|
1456
|
+
try {
|
|
1457
|
+
await turnEndRead;
|
|
1458
|
+
if (!selected || !data.display) return;
|
|
1459
|
+
const d = await getSession(selected, { tail: true });
|
|
1460
|
+
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1461
|
+
} catch (_) {
|
|
1462
|
+
} finally {
|
|
1463
|
+
displayLanded?.();
|
|
1464
|
+
displayLanded = null;
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1368
1467
|
turn_canceled: (data) => {
|
|
1369
1468
|
endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
|
|
1370
1469
|
setLiveStatus("idle");
|
|
@@ -1382,6 +1481,7 @@ const streamHandlers = {
|
|
|
1382
1481
|
// then show the prompt once more, marked failed (with why). This tab's own
|
|
1383
1482
|
// prompt goes back into an empty composer.
|
|
1384
1483
|
prompt_restored: async (data) => {
|
|
1484
|
+
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
1385
1485
|
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
|
|
1386
1486
|
if (refill) {
|
|
1387
1487
|
promptEl.value = refill;
|
|
@@ -1391,6 +1491,13 @@ const streamHandlers = {
|
|
|
1391
1491
|
const failedText = lastFailedText;
|
|
1392
1492
|
lastFailedText = null;
|
|
1393
1493
|
await resync();
|
|
1494
|
+
// The re-render drew it from the session (failed_turn): drawn again
|
|
1495
|
+
// below with this event's label and images.
|
|
1496
|
+
const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
|
|
1497
|
+
if (drawn) {
|
|
1498
|
+
if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
|
|
1499
|
+
drawn.remove();
|
|
1500
|
+
}
|
|
1394
1501
|
// Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
|
|
1395
1502
|
// a resend's echo isn't matched to it.
|
|
1396
1503
|
makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
|
|
@@ -1407,13 +1514,15 @@ const streamHandlers = {
|
|
|
1407
1514
|
}
|
|
1408
1515
|
if (view.resync) await resync();
|
|
1409
1516
|
const queued = view.anytime && commandBubble(view.commandId);
|
|
1410
|
-
if (
|
|
1517
|
+
if (view.hidden) queued?.remove();
|
|
1518
|
+
else if (queued) drawCommandBubble(queued, view);
|
|
1411
1519
|
else addCommandBubble(view);
|
|
1412
1520
|
},
|
|
1413
1521
|
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
1414
1522
|
// turn: its bubble goes here, so the cards it shows come after it.
|
|
1415
1523
|
command_queued: (data) => {
|
|
1416
|
-
|
|
1524
|
+
// A card's action waits for its command_ran: shown only if it answers.
|
|
1525
|
+
if (data.anytime && !data.card && !commandBubble(data.command_id)) addCommandBubble({ ...commandView(data, clientId), text: "" });
|
|
1417
1526
|
},
|
|
1418
1527
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
1419
1528
|
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
@@ -1463,6 +1572,9 @@ const streamHandlers = {
|
|
|
1463
1572
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1464
1573
|
// In the turn view, a notice during a turn is a row of its current step.
|
|
1465
1574
|
hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
|
|
1575
|
+
// The loop asks again after an empty answer: a row of the empty step (the
|
|
1576
|
+
// chat view shows nothing; after a reload neither does the turn view).
|
|
1577
|
+
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1466
1578
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
1467
1579
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
1468
1580
|
// A plugin's card, or a new version of one (the same id).
|
|
@@ -1623,7 +1735,8 @@ function renderQuestionCard(pq) {
|
|
|
1623
1735
|
if (isNearBottom()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
1624
1736
|
}
|
|
1625
1737
|
|
|
1626
|
-
// The tool, the command (or paths) as code, where and why
|
|
1738
|
+
// The tool, the command (or paths, or args) as code, where and why; no
|
|
1739
|
+
// code box when there is nothing to show.
|
|
1627
1740
|
function renderApprovalDetails(view) {
|
|
1628
1741
|
const box = document.createElement("div");
|
|
1629
1742
|
box.className = "approval-details";
|
|
@@ -1631,12 +1744,14 @@ function renderApprovalDetails(view) {
|
|
|
1631
1744
|
tool.className = "approval-tool";
|
|
1632
1745
|
tool.textContent = view.tool;
|
|
1633
1746
|
box.appendChild(tool);
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1747
|
+
if (view.what) {
|
|
1748
|
+
const what = document.createElement("pre");
|
|
1749
|
+
what.className = view.isCommand ? "approval-what command" : "approval-what paths";
|
|
1750
|
+
const code = document.createElement("code");
|
|
1751
|
+
code.textContent = view.what;
|
|
1752
|
+
what.appendChild(code);
|
|
1753
|
+
box.appendChild(what);
|
|
1754
|
+
}
|
|
1640
1755
|
for (const [cls, label, text] of [["approval-where", "in", view.where], ["approval-why", "why", view.why]]) {
|
|
1641
1756
|
if (!text) continue;
|
|
1642
1757
|
const row = document.createElement("div");
|
|
@@ -1728,10 +1843,13 @@ function addCreatedSession(created) {
|
|
|
1728
1843
|
render();
|
|
1729
1844
|
}
|
|
1730
1845
|
|
|
1846
|
+
// A first message: an idle session, then the turn into it as for any
|
|
1847
|
+
// later one (handleSendTurn), so a failed first turn's prompt comes back
|
|
1848
|
+
// into the composer too (sentIds). A first command line starts the session
|
|
1849
|
+
// with it.
|
|
1731
1850
|
async function handleCreate() {
|
|
1732
|
-
// A first message with images: an idle session, then the turn into it.
|
|
1733
|
-
if (chips.length) return handleSendTurn();
|
|
1734
1851
|
const prompt = promptEl.value.trim();
|
|
1852
|
+
if (chips.length || (prompt && !isCommandLine(prompt))) return handleSendTurn();
|
|
1735
1853
|
if (!prompt) return;
|
|
1736
1854
|
actionBtn.disabled = true;
|
|
1737
1855
|
try {
|
|
@@ -1748,7 +1866,7 @@ async function handleCreate() {
|
|
|
1748
1866
|
}
|
|
1749
1867
|
|
|
1750
1868
|
async function handleSendTurn() {
|
|
1751
|
-
if (!selected && !chips.length) return handleCreate();
|
|
1869
|
+
if (!selected && !chips.length && isCommandLine(promptEl.value.trim())) return handleCreate();
|
|
1752
1870
|
if (currentOwner === "tui") return;
|
|
1753
1871
|
const prompt = turnText(promptEl.value, chips);
|
|
1754
1872
|
if (!prompt) return;
|
|
@@ -1756,7 +1874,7 @@ async function handleSendTurn() {
|
|
|
1756
1874
|
if (!selected) {
|
|
1757
1875
|
actionBtn.disabled = true;
|
|
1758
1876
|
try {
|
|
1759
|
-
const s = await createIdleSession({ dir: scope.dir, model: chosenModel() });
|
|
1877
|
+
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt });
|
|
1760
1878
|
addCreatedSession(s);
|
|
1761
1879
|
await select(s.id);
|
|
1762
1880
|
} catch (e) {
|
|
@@ -1782,12 +1900,16 @@ async function handleSendTurn() {
|
|
|
1782
1900
|
if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
|
|
1783
1901
|
}
|
|
1784
1902
|
const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending) });
|
|
1785
|
-
|
|
1903
|
+
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1904
|
+
// A turn that failed fast was restored before this ack: its prompt
|
|
1905
|
+
// (and images) go back into the composer.
|
|
1906
|
+
const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
|
|
1907
|
+
promptEl.value = restored?.refill || "";
|
|
1786
1908
|
fitPrompt();
|
|
1787
1909
|
clearChips();
|
|
1910
|
+
if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
|
|
1788
1911
|
// Its turn_enqueued usually tagged the echo already; the ack covers a
|
|
1789
1912
|
// worker that wasn't live (no turn_enqueued then).
|
|
1790
|
-
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
1791
1913
|
if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
|
|
1792
1914
|
echo.dataset.enqueuedId = ack.enqueued_id;
|
|
1793
1915
|
}
|
|
@@ -1818,6 +1940,13 @@ async function handleSendTurn() {
|
|
|
1818
1940
|
// /model, !ls, /continue no … run in the session's worker; every client
|
|
1819
1941
|
// shows the command_ran, this one too.
|
|
1820
1942
|
async function handleSendCommand(line) {
|
|
1943
|
+
const local = webLocalReply(line);
|
|
1944
|
+
if (local) {
|
|
1945
|
+
addCommandBubble({ label: null, line, text: local });
|
|
1946
|
+
promptEl.value = "";
|
|
1947
|
+
fitPrompt();
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1821
1950
|
actionBtn.disabled = true;
|
|
1822
1951
|
try {
|
|
1823
1952
|
await sendCommand(selected, line, { clientId });
|
|
@@ -2262,25 +2391,108 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
2262
2391
|
$("#infoDeleteBtn").addEventListener("click", () => {
|
|
2263
2392
|
if (selected) confirmDelete(selected);
|
|
2264
2393
|
});
|
|
2394
|
+
$("#infoArchiveBtn").addEventListener("click", () => {
|
|
2395
|
+
if (!selected) return;
|
|
2396
|
+
if (selectedArchived()) setArchived(selected, false);
|
|
2397
|
+
else setArchived(selected, true);
|
|
2398
|
+
});
|
|
2399
|
+
|
|
2400
|
+
// ── Archive ────────────────────────────────────────────────────────────────
|
|
2401
|
+
// Archive hides a session (and its delegates) from the strip and the list,
|
|
2402
|
+
// kept for good; "include archived" in all sessions finds it. No confirm:
|
|
2403
|
+
// the toast's Undo takes it back. Archiving the open session stays on it
|
|
2404
|
+
// (the button flips to unarchive); its worker was stopped by the server.
|
|
2405
|
+
|
|
2406
|
+
function selectedArchived() {
|
|
2407
|
+
return !!allSessions.find((s) => s.id === selected)?.archived;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// The ids in the server's answer flip here at once; the hub's events
|
|
2411
|
+
// follow with the same.
|
|
2412
|
+
function markArchived(ids, archived) {
|
|
2413
|
+
const set = new Set(ids);
|
|
2414
|
+
allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
async function setArchived(id, archive, { undo = true } = {}) {
|
|
2418
|
+
const short = id.slice(0, 8);
|
|
2419
|
+
let result;
|
|
2420
|
+
// A live worker is stopped by the archive: its stream ending is a stop.
|
|
2421
|
+
if (archive) stoppingId = id;
|
|
2422
|
+
try {
|
|
2423
|
+
result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
2424
|
+
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
2425
|
+
if (archive && id === selected && (result.stopped || []).includes(id)) {
|
|
2426
|
+
// Its worker is gone: don't wait for its stream to notice.
|
|
2427
|
+
streamControl?.close();
|
|
2428
|
+
currentStatus = "stopped";
|
|
2429
|
+
workerGone(id);
|
|
2430
|
+
}
|
|
2431
|
+
} catch (e) {
|
|
2432
|
+
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${e.message}`);
|
|
2433
|
+
return;
|
|
2434
|
+
} finally {
|
|
2435
|
+
if (archive) stoppingId = null;
|
|
2436
|
+
}
|
|
2437
|
+
render();
|
|
2438
|
+
updateInfoBar();
|
|
2439
|
+
if (!eventsControl) refresh();
|
|
2440
|
+
const what = archive ? "Archived" : "Unarchived";
|
|
2441
|
+
showToast(`${what} ${short}`, undo ? { label: "Undo", run: () => setArchived(id, !archive, { undo: false }) } : null);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
let toastTimer = null;
|
|
2445
|
+
function showToast(text, action = null) {
|
|
2446
|
+
const el = $("#toast");
|
|
2447
|
+
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}`;
|
|
2448
|
+
el.hidden = false;
|
|
2449
|
+
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
2450
|
+
el.hidden = true;
|
|
2451
|
+
action.run();
|
|
2452
|
+
});
|
|
2453
|
+
clearTimeout(toastTimer);
|
|
2454
|
+
toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
$("#includeArchived").addEventListener("change", () => {
|
|
2458
|
+
if (allViewOpen()) renderAll();
|
|
2459
|
+
});
|
|
2265
2460
|
|
|
2266
2461
|
// ── Annotate ───────────────────────────────────────────────────────────────
|
|
2267
2462
|
// 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
|
-
|
|
2463
|
+
// "Annotate" and the presets (web.annotate_presets); Annotate appends the
|
|
2464
|
+
// quote (with where it came from) to the composer, a preset the quote with
|
|
2465
|
+
// its text as the note. Nothing is sent. The bar lives outside #history,
|
|
2466
|
+
// which is re-rendered whole. The selection is read when it changes: a live
|
|
2467
|
+
// bubble rewrites its text every frame, which would collapse it by the time
|
|
2468
|
+
// a button is clicked.
|
|
2469
|
+
|
|
2470
|
+
const annotatePresets = parsePresets(document.body.dataset.annotatePresets);
|
|
2471
|
+
const annotateBar = document.createElement("div");
|
|
2472
|
+
annotateBar.id = "annotateBar";
|
|
2473
|
+
annotateBar.hidden = true;
|
|
2474
|
+
annotateBar.setAttribute("role", "toolbar");
|
|
2475
|
+
annotateBar.setAttribute("aria-label", "Annotate");
|
|
2476
|
+
function annotateButton(text, cls) {
|
|
2477
|
+
const btn = document.createElement("button");
|
|
2478
|
+
btn.type = "button";
|
|
2479
|
+
btn.className = cls;
|
|
2480
|
+
btn.textContent = text;
|
|
2481
|
+
return btn;
|
|
2482
|
+
}
|
|
2483
|
+
annotateBar.appendChild(annotateButton("Annotate", "annotate-main"));
|
|
2484
|
+
annotatePresets.forEach((text, i) => {
|
|
2485
|
+
const btn = annotateButton(text, "annotate-preset");
|
|
2486
|
+
btn.dataset.index = String(i);
|
|
2487
|
+
btn.title = text;
|
|
2488
|
+
annotateBar.appendChild(btn);
|
|
2489
|
+
});
|
|
2490
|
+
document.body.appendChild(annotateBar);
|
|
2279
2491
|
let pendingQuote = null;
|
|
2280
2492
|
|
|
2281
2493
|
function hideAnnotate() {
|
|
2282
2494
|
pendingQuote = null;
|
|
2283
|
-
|
|
2495
|
+
annotateBar.hidden = true;
|
|
2284
2496
|
}
|
|
2285
2497
|
|
|
2286
2498
|
function updateAnnotate() {
|
|
@@ -2301,20 +2513,26 @@ function updateAnnotate() {
|
|
|
2301
2513
|
pendingQuote = block;
|
|
2302
2514
|
const rects = clipped.getClientRects();
|
|
2303
2515
|
const rect = rects.length ? rects[rects.length - 1] : clipped.getBoundingClientRect();
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2516
|
+
annotateBar.hidden = false;
|
|
2517
|
+
// Measured at the left edge: a fixed element's shrink-to-fit width is
|
|
2518
|
+
// capped by what's right of it, so at the last (right) spot it would
|
|
2519
|
+
// measure wrapped.
|
|
2520
|
+
annotateBar.style.left = "8px";
|
|
2521
|
+
const w = annotateBar.offsetWidth;
|
|
2522
|
+
const h = annotateBar.offsetHeight;
|
|
2523
|
+
const maxTop = window.innerHeight - h - 8;
|
|
2524
|
+
const top = Math.min(rect.bottom + 6, maxTop);
|
|
2525
|
+
annotateBar.style.top = `${Math.max(top, 8)}px`;
|
|
2526
|
+
annotateBar.style.left = `${annotateBarLeft(rect, w, window.innerWidth)}px`;
|
|
2309
2527
|
// Not over a copy button (this bubble's, one inside it, the next
|
|
2310
2528
|
// bubble's).
|
|
2311
|
-
const box =
|
|
2529
|
+
const box = annotateBar.getBoundingClientRect();
|
|
2312
2530
|
const bubble = source.root.closest(".bubble");
|
|
2313
2531
|
const near = [bubble, bubble?.nextElementSibling].filter(Boolean);
|
|
2314
2532
|
for (const btn of near.flatMap((el) => [...el.querySelectorAll(".copy-btn")])) {
|
|
2315
2533
|
const r = btn.getBoundingClientRect();
|
|
2316
2534
|
if (r.bottom < box.top || r.top > box.bottom || r.right < box.left || r.left > box.right) continue;
|
|
2317
|
-
|
|
2535
|
+
annotateBar.style.top = `${Math.max(Math.min(r.bottom + 4, maxTop), 8)}px`;
|
|
2318
2536
|
break;
|
|
2319
2537
|
}
|
|
2320
2538
|
}
|
|
@@ -2329,11 +2547,13 @@ document.addEventListener("selectionchange", scheduleAnnotate);
|
|
|
2329
2547
|
historyEl.addEventListener("scroll", scheduleAnnotate);
|
|
2330
2548
|
window.addEventListener("resize", scheduleAnnotate);
|
|
2331
2549
|
installCopy(historyEl);
|
|
2332
|
-
// Keep the selection when
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2550
|
+
// Keep the selection when a button is pressed.
|
|
2551
|
+
annotateBar.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2552
|
+
annotateBar.addEventListener("click", (e) => {
|
|
2553
|
+
const btn = e.target.closest("button");
|
|
2554
|
+
if (!btn || !pendingQuote) return;
|
|
2555
|
+
const preset = btn.dataset.index === undefined ? null : annotatePresets[Number(btn.dataset.index)];
|
|
2556
|
+
promptEl.value = appendQuote(promptEl.value, preset ? presetNote(pendingQuote, preset) : pendingQuote);
|
|
2337
2557
|
fitPrompt();
|
|
2338
2558
|
window.getSelection()?.removeAllRanges();
|
|
2339
2559
|
hideAnnotate();
|
|
@@ -2342,6 +2562,135 @@ annotateBtn.addEventListener("click", () => {
|
|
|
2342
2562
|
promptEl.scrollTop = promptEl.scrollHeight;
|
|
2343
2563
|
});
|
|
2344
2564
|
|
|
2565
|
+
// ── Notifications (notify.js): when a session needs the user (a question,
|
|
2566
|
+
// an approval, a failed turn, a long turn done) and this tab is not in
|
|
2567
|
+
// front, an OS notification (the bell turns them on) and a count in the
|
|
2568
|
+
// title (always). One tag per event, so several tabs show one, and none
|
|
2569
|
+
// while another chi tab is in front with it (notify.js, Several tabs).
|
|
2570
|
+
const NOTIFY_KEY = "chi_notify";
|
|
2571
|
+
const notifyBtn = $("#notifyBtn");
|
|
2572
|
+
let attention = initialAttentionState();
|
|
2573
|
+
const badgeKeys = new Set();
|
|
2574
|
+
let baseTitle = "Chi";
|
|
2575
|
+
|
|
2576
|
+
function setTitle() {
|
|
2577
|
+
document.title = badgeKeys.size ? `(${badgeKeys.size}) ${baseTitle}` : baseTitle;
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
// The user is looking at this tab: visible and focused (a window behind
|
|
2581
|
+
// the terminal is visible but not focused).
|
|
2582
|
+
function tabInFront() {
|
|
2583
|
+
return document.visibilityState === "visible" && document.hasFocus();
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
function notifySupported() {
|
|
2587
|
+
return typeof globalThis.Notification === "function";
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function notifyWanted() {
|
|
2591
|
+
try { return localStorage.getItem(NOTIFY_KEY) === "1"; } catch (_) { return false; }
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// "on" | "off" | "denied" | "unsupported"
|
|
2595
|
+
function notifyState() {
|
|
2596
|
+
if (!notifySupported()) return "unsupported";
|
|
2597
|
+
if (Notification.permission === "denied") return "denied";
|
|
2598
|
+
return notifyWanted() && Notification.permission === "granted" ? "on" : "off";
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
const NOTIFY_TITLES = {
|
|
2602
|
+
on: "Notifications on: a session that needs you while this tab is in the background. Click to turn off",
|
|
2603
|
+
off: "Notify me when a session needs me while this tab is in the background",
|
|
2604
|
+
denied: "Notifications are blocked for this site in the browser's settings",
|
|
2605
|
+
};
|
|
2606
|
+
|
|
2607
|
+
function renderNotifyBtn() {
|
|
2608
|
+
const state = notifyState();
|
|
2609
|
+
notifyBtn.hidden = state === "unsupported";
|
|
2610
|
+
notifyBtn.dataset.state = state;
|
|
2611
|
+
notifyBtn.title = NOTIFY_TITLES[state] || "";
|
|
2612
|
+
notifyBtn.setAttribute("aria-pressed", state === "on" ? "true" : "false");
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
async function toggleNotify() {
|
|
2616
|
+
const state = notifyState();
|
|
2617
|
+
if (state === "unsupported" || state === "denied") return;
|
|
2618
|
+
let wanted = state !== "on";
|
|
2619
|
+
if (wanted && Notification.permission !== "granted") {
|
|
2620
|
+
wanted = (await Notification.requestPermission()) === "granted";
|
|
2621
|
+
}
|
|
2622
|
+
try { localStorage.setItem(NOTIFY_KEY, wanted ? "1" : "0"); } catch (_) {}
|
|
2623
|
+
renderNotifyBtn();
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
function showNotification(session, a) {
|
|
2627
|
+
if (notifyState() !== "on") return;
|
|
2628
|
+
const { title, body } = attentionText(session, a);
|
|
2629
|
+
try {
|
|
2630
|
+
const n = new Notification(title, { body, tag: a.key });
|
|
2631
|
+
n.onclick = () => {
|
|
2632
|
+
window.focus();
|
|
2633
|
+
location.hash = sessionHash(a.sessionId);
|
|
2634
|
+
n.close();
|
|
2635
|
+
};
|
|
2636
|
+
} catch (_) {}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// Another chi tab in front has these (notify.js, Several tabs).
|
|
2640
|
+
function seenElsewhere(keys) {
|
|
2641
|
+
const size = badgeKeys.size;
|
|
2642
|
+
for (const key of keys) badgeKeys.delete(key);
|
|
2643
|
+
if (badgeKeys.size !== size) setTitle();
|
|
2644
|
+
}
|
|
2645
|
+
let notifyChannel = null;
|
|
2646
|
+
try {
|
|
2647
|
+
if (typeof globalThis.BroadcastChannel === "function") notifyChannel = new BroadcastChannel(NOTIFY_CHANNEL);
|
|
2648
|
+
} catch (_) {}
|
|
2649
|
+
const notifyGate = createNotifyGate({ channel: notifyChannel, onSeen: seenElsewhere });
|
|
2650
|
+
|
|
2651
|
+
// Every hub frame goes through here before the list takes it.
|
|
2652
|
+
function noticeSessions(type, data) {
|
|
2653
|
+
const next = trackAttention(attention, type, data);
|
|
2654
|
+
attention = next.state;
|
|
2655
|
+
const size = badgeKeys.size;
|
|
2656
|
+
for (const key of next.closed) badgeKeys.delete(key);
|
|
2657
|
+
notifyGate.drop(next.closed);
|
|
2658
|
+
if (next.attentions.length > 0 && tabInFront()) {
|
|
2659
|
+
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
2660
|
+
} else {
|
|
2661
|
+
for (const a of next.attentions) {
|
|
2662
|
+
notifyGate.offer(a, () => {
|
|
2663
|
+
if (tabInFront()) return;
|
|
2664
|
+
badgeKeys.add(a.key);
|
|
2665
|
+
setTitle();
|
|
2666
|
+
showNotification(attention.byId[a.sessionId], a);
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
if (badgeKeys.size !== size) setTitle();
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// The user is here: the badge goes, and the other tabs drop what it had.
|
|
2674
|
+
function clearBadge() {
|
|
2675
|
+
if (badgeKeys.size === 0) return;
|
|
2676
|
+
notifyGate.seenInFront([...badgeKeys]);
|
|
2677
|
+
badgeKeys.clear();
|
|
2678
|
+
setTitle();
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// The user is back: the page turned visible (Safari makes it visible
|
|
2682
|
+
// before it has focus, and a tab switch inside a focused window sends no
|
|
2683
|
+
// focus event), the window got focus, or they touched the page.
|
|
2684
|
+
function backInFront() {
|
|
2685
|
+
if (document.visibilityState === "visible") clearBadge();
|
|
2686
|
+
}
|
|
2687
|
+
document.addEventListener("visibilitychange", backInFront);
|
|
2688
|
+
window.addEventListener("focus", backInFront);
|
|
2689
|
+
document.addEventListener("pointerdown", clearBadge, true);
|
|
2690
|
+
document.addEventListener("keydown", clearBadge, true);
|
|
2691
|
+
notifyBtn.addEventListener("click", toggleNotify);
|
|
2692
|
+
renderNotifyBtn();
|
|
2693
|
+
|
|
2345
2694
|
// The latest-sessions strip can be hidden for more chat room; the choice
|
|
2346
2695
|
// stays per browser (it replaced zen mode, so a zen user starts hidden).
|
|
2347
2696
|
function stripHiddenSaved() {
|