samagotchi 0.53.0 → 0.55.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 +52 -1
- data/README.md +1 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +94 -54
- data/docs/configuration.md +30 -2
- data/docs/context.md +104 -9
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +73 -0
- data/docs/internals/context-telemetry.md +27 -6
- data/docs/internals/engine.md +55 -24
- data/docs/internals/tool-guardrails.md +8 -6
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +3 -3
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
- data/lib/samagotchi/bundle_command.rb +31 -36
- data/lib/samagotchi/bundle_needs.rb +4 -5
- data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
- data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/child_move.rb +6 -4
- data/lib/samagotchi/config.rb +2 -1
- data/lib/samagotchi/context_command.rb +1 -1
- data/lib/samagotchi/context_providers.rb +11 -14
- data/lib/samagotchi/context_status.rb +10 -18
- data/lib/samagotchi/engine.rb +117 -134
- data/lib/samagotchi/extension_load.rb +102 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/guardrail_wiring.rb +16 -16
- data/lib/samagotchi/kernel_loop.rb +5 -3
- data/lib/samagotchi/llm/chat_loop.rb +5 -4
- data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
- data/lib/samagotchi/memory_bundle/builder.rb +170 -178
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
- data/lib/samagotchi/memory_bundle/installer.rb +252 -488
- data/lib/samagotchi/memory_bundle/listing.rb +6 -6
- data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
- data/lib/samagotchi/memory_bundle/profile.rb +21 -21
- data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
- data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
- data/lib/samagotchi/memory_bundle/status.rb +22 -24
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
- data/lib/samagotchi/plugin/loader.rb +8 -8
- data/lib/samagotchi/self_report.rb +2 -2
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session_continue.rb +275 -0
- data/lib/samagotchi/session_manager.rb +9 -253
- data/lib/samagotchi/session_metrics.rb +3 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
- data/lib/samagotchi/terminal_ui.rb +12 -2
- data/lib/samagotchi/tool_activity.rb +84 -14
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +26 -3
- data/lib/samagotchi/web/capabilities.rb +13 -9
- data/lib/samagotchi/web/editor.rb +38 -0
- data/lib/samagotchi/web/message_parts.rb +5 -10
- data/lib/samagotchi/web/public/activity.js +35 -18
- data/lib/samagotchi/web/public/app.js +240 -1136
- data/lib/samagotchi/web/public/commands.js +236 -0
- data/lib/samagotchi/web/public/composer.js +101 -0
- data/lib/samagotchi/web/public/ctx.js +18 -4
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +9 -2
- data/lib/samagotchi/web/public/info_bar.js +78 -0
- data/lib/samagotchi/web/public/lists_view.js +725 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
- data/lib/samagotchi/web/public/question_card.js +9 -1
- data/lib/samagotchi/web/public/question_cards.js +7 -4
- data/lib/samagotchi/web/public/refs.js +134 -0
- data/lib/samagotchi/web/public/restore.js +81 -0
- data/lib/samagotchi/web/public/session_state.js +52 -0
- data/lib/samagotchi/web/public/stream_handlers.js +324 -0
- data/lib/samagotchi/web/public/thinking_chip.js +6 -7
- data/lib/samagotchi/web/public/timing.js +8 -2
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +15 -7
- data/lib/samagotchi/web/server.rb +4 -4
- metadata +17 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -23,52 +23,55 @@ import {
|
|
|
23
23
|
} from "./data.js";
|
|
24
24
|
import { createContextChips } from "./context_chips.js";
|
|
25
25
|
import {
|
|
26
|
-
|
|
26
|
+
chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
|
|
27
27
|
withChoice,
|
|
28
28
|
} from "./llm_context_chip.js";
|
|
29
29
|
import {
|
|
30
|
-
|
|
30
|
+
chipModel as thinkingChipModel, createThinkingControl,
|
|
31
31
|
withChoice as withThinkingChoice,
|
|
32
32
|
} from "./thinking_chip.js";
|
|
33
|
-
import {
|
|
33
|
+
import { renderInfoBar } from "./info_bar.js";
|
|
34
34
|
import { createShownTexts } from "./shown_texts.js";
|
|
35
35
|
import { MODEL_KEY } from "./model_pick.js";
|
|
36
|
-
import { emptySessionState } from "./session_state.js";
|
|
36
|
+
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
37
37
|
import { createModelPicker } from "./model_picker.js";
|
|
38
38
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
39
|
-
import { escapeHtml,
|
|
39
|
+
import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
40
40
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
41
41
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
42
42
|
import { createStageView } from "./stage_view.js";
|
|
43
|
-
import {
|
|
44
|
-
import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, continuedText, foldChains, openChildrenHtml } from "./chains.js";
|
|
43
|
+
import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
|
|
45
44
|
import { createTaskStopper } from "./task_stop.js";
|
|
46
45
|
import { appendQuote } from "./annotations.js";
|
|
47
46
|
import { parsePresets } from "./annotate_presets.js";
|
|
48
47
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
48
|
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
50
|
-
import {
|
|
51
|
-
import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
49
|
+
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
52
50
|
import { createQuestionCards } from "./question_cards.js";
|
|
53
|
-
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml,
|
|
51
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
54
52
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
55
53
|
import { createCommandList } from "./command_list.js";
|
|
56
54
|
import { createPromptHistory } from "./prompt_history.js";
|
|
57
55
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
58
|
-
import { allScopeHref,
|
|
56
|
+
import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
|
|
59
57
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
60
|
-
import { SHORT_WINDOW_QUERY, stripAutoHidden
|
|
61
|
-
import { applySessionEvent,
|
|
58
|
+
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
59
|
+
import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
62
60
|
import { createNotifications } from "./notifications.js";
|
|
63
61
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
64
62
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
65
|
-
import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
|
|
66
63
|
import {
|
|
67
|
-
appendAboveLiveTiming,
|
|
64
|
+
appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
68
65
|
} from "./timing.js";
|
|
69
66
|
import { createLiveTurn } from "./live_turn.js";
|
|
67
|
+
import { installRefClicks, setRefKinds } from "./refs.js";
|
|
70
68
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
71
|
-
import {
|
|
69
|
+
import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
70
|
+
import { drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
71
|
+
import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
|
|
72
|
+
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
73
|
+
import { createStreamHandlers } from "./stream_handlers.js";
|
|
74
|
+
import { createListsView } from "./lists_view.js";
|
|
72
75
|
|
|
73
76
|
const $ = (s) => document.querySelector(s);
|
|
74
77
|
const topStripEl = $("#topStrip");
|
|
@@ -170,7 +173,11 @@ const markdownWarningEl = $("#markdownWarning");
|
|
|
170
173
|
// What the page renders that a notice may stand in for (the server's
|
|
171
174
|
// Web::Capabilities, next to markdown_warning); a notice it names isn't
|
|
172
175
|
// drawn (shownElsewhere). An older server sends none: every notice shows.
|
|
176
|
+
// And what this viewer can do (editor: open a path in this machine's
|
|
177
|
+
// editor; per request), with the editor's URL template (editor_url) next
|
|
178
|
+
// to it: refs.js's actions read both (pageViewer).
|
|
173
179
|
let pageCapabilities = {};
|
|
180
|
+
let pageEditorUrl = null;
|
|
174
181
|
const promptEl = $("#prompt");
|
|
175
182
|
const actionBtn = $("#actionBtn");
|
|
176
183
|
const cancelBtn = $("#cancelBtn");
|
|
@@ -286,7 +293,7 @@ async function refresh() {
|
|
|
286
293
|
allSessions = [];
|
|
287
294
|
renderScope();
|
|
288
295
|
}
|
|
289
|
-
render();
|
|
296
|
+
lists.render();
|
|
290
297
|
}
|
|
291
298
|
|
|
292
299
|
// The list as a projection of the hub's events: a snapshot on every
|
|
@@ -298,14 +305,14 @@ function openSessionEvents() {
|
|
|
298
305
|
noticeVersions(data);
|
|
299
306
|
notifications.noticeSessions("snapshot", data);
|
|
300
307
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
301
|
-
render();
|
|
308
|
+
lists.render();
|
|
302
309
|
const sess = allSessions.find((s) => s.id === selected);
|
|
303
310
|
if (sess) followSelectedSession(sess);
|
|
304
311
|
},
|
|
305
312
|
session: (data) => {
|
|
306
313
|
notifications.noticeSessions("session", data);
|
|
307
314
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
308
|
-
render();
|
|
315
|
+
lists.render();
|
|
309
316
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
310
317
|
// Its children chip; its chain's ← → links (a link that continues it, its previous one).
|
|
311
318
|
else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
|
|
@@ -313,12 +320,11 @@ function openSessionEvents() {
|
|
|
313
320
|
},
|
|
314
321
|
session_gone: (data) => {
|
|
315
322
|
notifications.noticeSessions("session_gone", data);
|
|
316
|
-
|
|
317
|
-
selecting.skipReasons.delete(data.id);
|
|
323
|
+
lists.forget(data.id);
|
|
318
324
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
319
325
|
if (next !== allSessions) {
|
|
320
326
|
allSessions = next;
|
|
321
|
-
render();
|
|
327
|
+
lists.render();
|
|
322
328
|
if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
|
|
323
329
|
}
|
|
324
330
|
leaveGoneSession(data.id, "deleted");
|
|
@@ -399,582 +405,30 @@ function renderScope() {
|
|
|
399
405
|
});
|
|
400
406
|
}
|
|
401
407
|
|
|
402
|
-
// The
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
function shownSessions() {
|
|
406
|
-
return listedSessions(allSessions, false);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function allViewSessions() {
|
|
410
|
-
return listedSessions(allSessions, $("#includeArchived").checked);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function filterQuery() {
|
|
414
|
-
return $("#filter").value.trim().toLowerCase();
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// The page's sessions by id, built again only when the list changed.
|
|
418
|
-
let byIdOf = { list: null, map: new Map() };
|
|
419
|
-
function sessionsById() {
|
|
420
|
-
if (byIdOf.list !== allSessions) byIdOf = { list: allSessions, map: new Map(allSessions.map((s) => [s.id, s])) };
|
|
421
|
-
return byIdOf.map;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
// A card's title: its preview, or its chain's first link's for a link
|
|
425
|
-
// titled with the opener (chains.js chainTitle; display only).
|
|
426
|
-
function cardTitle(s) {
|
|
427
|
-
return chainTitle(s, sessionsById());
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Whether a session matches the all view's search +q+ (lower case).
|
|
431
|
-
function matchesFilter(s, q) {
|
|
432
|
-
return cardTitle(s).toLowerCase().includes(q) ||
|
|
433
|
-
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
434
|
-
s.short_id.toLowerCase().includes(q) ||
|
|
435
|
-
s.id.toLowerCase().includes(q) ||
|
|
436
|
-
s.status.toLowerCase().includes(q) ||
|
|
437
|
-
waitingSearchText(s).includes(q);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// A card: the first message as its title, then status, how long ago, owner
|
|
441
|
-
// and memories. The id is in the tooltip (and the search) only. A session
|
|
442
|
-
// that waits on the user (a question, an approval, a card with actions)
|
|
443
|
-
// wears the warning colour and says what for.
|
|
444
|
-
// deletable / archivable: the all-sessions view's cards get a delete and an
|
|
445
|
-
// archive (unarchive, on an archived card) button. selectable: select mode's
|
|
446
|
-
// checkbox instead (picked: checked), and skipReason: why a batch skipped it.
|
|
447
|
-
// family: the family the session heads (sessions_list.js families): with
|
|
448
|
-
// members, its chip (which opens their list), and the card waits when any
|
|
449
|
-
// member does; with rows (all sessions) an open family lists its members
|
|
450
|
-
// inline, the strip's opens in a popover; matchIds: a search's matching
|
|
451
|
-
// members. A family in a session chain (chains.js foldChains) carries its
|
|
452
|
-
// `day N` chip, which opens the other links in a popover.
|
|
453
|
-
function cardHtml(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}) {
|
|
454
|
-
const fam = family && family.members.length ? family : null;
|
|
455
|
-
const ts = s.updated_at || s.created_at || "";
|
|
456
|
-
const tip = `${s.id} · updated ${ts} · ${s.status}`;
|
|
457
|
-
const preview = escapeHtml(cardTitle(s));
|
|
458
|
-
const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
|
|
459
|
-
const ago = relativeTime(ts);
|
|
460
|
-
const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
|
|
461
|
-
const folder = cardFolder(s.working_directory, scope.projectName);
|
|
462
|
-
const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
|
|
463
|
-
const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
|
|
464
|
-
const ctx = cardCtxText(s.ctx_pct);
|
|
465
|
-
const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(tokensTipText(s.tokens, undefined, s.memory_index))}">${escapeHtml(ctx)}</span>` : "";
|
|
466
|
-
const waiting = waitingBadge(s);
|
|
467
|
-
const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
|
|
468
|
-
// A turn a hook stopped wears its own badge, unless the session waits on
|
|
469
|
-
// the user (the waiting badge wins: the stop is behind the need).
|
|
470
|
-
const stopped = waiting ? null : stoppedBadge(s);
|
|
471
|
-
const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
|
|
472
|
-
const dotWord = waiting ? waiting.title : s.status;
|
|
473
|
-
const pickHtml = selectable ? `<input type="checkbox" class="card-pick" aria-label="Select session ${escapeHtml(s.short_id)}"${picked ? " checked" : ""}>` : "";
|
|
474
|
-
const skipHtml = skipReason ? `<div class="skip-reason">skipped: ${escapeHtml(skipReason)}</div>` : "";
|
|
475
|
-
const familyWaiting = !!family && familyWaits(family);
|
|
476
|
-
const chainHtml = family?.chain ? chainChipHtml(family, { open: chainOpenHead() === s.id }) : "";
|
|
477
|
-
const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
|
|
478
|
-
const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
|
|
479
|
-
// Its delegates and its chain's other links: the card is .contains-active while one is open.
|
|
480
|
-
const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
|
|
481
|
-
...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
|
|
482
|
-
const membersAttr = memberIds.length ? ` data-members="${escapeHtml(memberIds.join(" "))}"` : "";
|
|
483
|
-
return `<div class="card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}" data-id="${s.id}"${membersAttr} 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)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${chainHtml}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
function cardToolsHtml(s, { deletable, archivable }) {
|
|
487
|
-
if (!deletable && !archivable) return "";
|
|
488
|
-
const short = escapeHtml(s.short_id);
|
|
489
|
-
const arch = !archivable ? "" : s.archived
|
|
490
|
-
? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
|
|
491
|
-
: `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Archive: hide from the lists, keep for good" aria-label="Archive session ${short}"></button>`;
|
|
492
|
-
const del = deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${short}">\u2715</button>` : "";
|
|
493
|
-
return `<div class="card-tools">${arch}${del}</div>`;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
497
|
-
function parentChipHtml(s) {
|
|
498
|
-
const chip = delegatedBy(s.parent_id, allSessions.find((x) => x.id === s.parent_id) || null);
|
|
499
|
-
return chip ? `<span class="parent" title="${escapeHtml(chip.title)}">${escapeHtml(chip.text)}</span>` : "";
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
function ownerBadgeHtml(s) {
|
|
503
|
-
const badge = ownerBadge(s.owner, s.id);
|
|
504
|
-
return badge ? `<span class="owner ${badge.kind}" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}</span>` : "";
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// The strip's card count follows the window: more cards on wide screens
|
|
508
|
-
// (each capped at 440 px) instead of 3 stretched ones. main's 16 px padding
|
|
509
|
-
// sits on both sides; measuring main works while the strip is hidden too.
|
|
510
|
-
let stripCols = 3;
|
|
511
|
-
function fitStripColumns() {
|
|
512
|
-
stripCols = stripColumns(topStripEl.parentElement.clientWidth - 32);
|
|
513
|
-
topStripEl.style.setProperty("--strip-cols", stripCols);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// The order a list draws its cards in: +next+, what waits on the user
|
|
517
|
-
// first (waitingFirst), back to its place once answered. Never under the
|
|
518
|
-
// pointer, though: while it is over the list the order last drawn holds,
|
|
519
|
-
// and its leaving draws the list again. A hidden list holds nothing: the
|
|
520
|
-
// strip keeps :hover for a moment after a click on its All sessions tile
|
|
521
|
-
// hid it, and an update held then brought it back from the all view in an
|
|
522
|
-
// order nobody saw (a waiting family behind). +idOf+ names a card: a
|
|
523
|
-
// session's id, or familyId (a card per family: its head's id). drawnOrder
|
|
524
|
-
// is the cards drawn, which select mode picks from.
|
|
525
|
-
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
526
|
-
const heldLists = new Set(); // lists drawn in a held order
|
|
527
|
-
const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
|
|
528
|
-
function listOrder(el, next, idOf = (s) => s.id) {
|
|
529
|
-
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
530
|
-
// and a touch screen has no hover at all).
|
|
531
|
-
const holding = el.getClientRects().length > 0
|
|
532
|
-
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
|
|
533
|
-
const held = holding ? drawnOrder.get(el) : null;
|
|
534
|
-
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
535
|
-
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
536
|
-
else heldLists.delete(el);
|
|
537
|
-
drawnOrder.set(el, ordered.map(idOf));
|
|
538
|
-
return ordered;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// The strip's cards: families in the list's order (a family in its
|
|
542
|
-
// first-listed member's place, so a hub upsert doesn't move it), waiting
|
|
543
|
-
// ones first, as many as fit. A family is one card.
|
|
544
|
-
function stripCards() {
|
|
545
|
-
return listOrder(topStripEl, waitingFirst(foldChains(families(shownSessions(), { order: "list" }), allSessions)), familyId).slice(0, stripCols);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// The strip's family popover (families_view.js): the open family's rows,
|
|
549
|
-
// under its chip.
|
|
550
|
-
let stripFamilies = new Map(); // head id → family, as the strip last drew it
|
|
551
|
-
const familyPop = createFamilyPop({
|
|
408
|
+
// The session lists (lists_view.js): the strip, all sessions, their cards,
|
|
409
|
+
// select mode, the family and chain popovers.
|
|
410
|
+
const lists = createListsView({
|
|
552
411
|
doc: document,
|
|
553
412
|
win: window,
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
// A chain's popover (chains.js): the other links of the chain a card's
|
|
574
|
-
// `day N` chip stands for, under the chip, in the strip or all sessions
|
|
575
|
-
// (the list it was opened from: chainFrom). A row opens that link.
|
|
576
|
-
let chainFamilies = new Map(); // head id → family, as the lists last drew it
|
|
577
|
-
let chainFrom = null; // the list element the open popover's chip is in
|
|
578
|
-
const chainPop = createFamilyPop({
|
|
579
|
-
id: "chainPop",
|
|
580
|
-
doc: document,
|
|
581
|
-
win: window,
|
|
582
|
-
rowsFor: (id) => {
|
|
583
|
-
const f = chainFamilies.get(id);
|
|
584
|
-
return f?.chain ? chainRowsHtml(f, { activeId: selected }) : null;
|
|
585
|
-
},
|
|
586
|
-
anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
|
|
587
|
-
onPick: (id) => {
|
|
588
|
-
chainPop.close();
|
|
589
|
-
if (allViewOpen()) leaveAllView(sessionHash(id));
|
|
590
|
-
select(id);
|
|
591
|
-
},
|
|
592
|
-
// chainFrom stays (Esc focuses the chip again after the close); only an
|
|
593
|
-
// open popover holds a list (chainPopIn).
|
|
594
|
-
onClose: () => {
|
|
595
|
-
if (chainFrom === topStripEl && heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
|
|
596
|
-
},
|
|
597
|
-
});
|
|
598
|
-
function chainOpenHead() {
|
|
599
|
-
return chainPop.openHead();
|
|
600
|
-
}
|
|
601
|
-
function chainPopIn(el) {
|
|
602
|
-
return chainPop.isOpen() && chainFrom === el;
|
|
603
|
-
}
|
|
604
|
-
function toggleChainPop(from, id, e) {
|
|
605
|
-
if (chainPop.openHead() === id && chainFrom === from) {
|
|
606
|
-
chainPop.close();
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
chainPop.close();
|
|
610
|
-
chainFrom = from;
|
|
611
|
-
chainPop.open(id);
|
|
612
|
-
// From the keyboard the focus goes into the popover; Esc brings it back.
|
|
613
|
-
if (e?.detail === 0 && chainPop.isOpen()) $("#chainPop .fr-open")?.focus();
|
|
614
|
-
}
|
|
615
|
-
window.addEventListener("keydown", (e) => chainPop.onKeydown(e), true);
|
|
616
|
-
document.addEventListener("pointerdown", (e) => chainPop.onPointerDown(e), true);
|
|
617
|
-
document.addEventListener("scroll", () => chainPop.reposition(), true);
|
|
618
|
-
|
|
619
|
-
function render() {
|
|
620
|
-
fitStripColumns();
|
|
621
|
-
renderTop(stripCards());
|
|
622
|
-
if (allViewOpen()) renderAll();
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
let stripResizeTimer = null;
|
|
626
|
-
window.addEventListener("resize", () => {
|
|
627
|
-
clearTimeout(stripResizeTimer);
|
|
628
|
-
stripResizeTimer = setTimeout(() => {
|
|
629
|
-
const before = stripCols;
|
|
630
|
-
fitStripColumns();
|
|
631
|
-
if (stripCols !== before && allSessions.length) renderTop(stripCards());
|
|
632
|
-
familyPop.reposition();
|
|
633
|
-
chainPop.reposition();
|
|
634
|
-
}, 150);
|
|
635
|
-
});
|
|
636
|
-
|
|
637
|
-
// onPick: a card's click (open it, or pick it in select mode); onFamily:
|
|
638
|
-
// a family chip's (toggle its list); onRow: a family row's (open that
|
|
639
|
-
// delegate).
|
|
640
|
-
function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
|
|
641
|
-
container.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
642
|
-
el.addEventListener("click", (e) => {
|
|
643
|
-
const chainChip = e.target.closest(".chain-chip");
|
|
644
|
-
if (chainChip) {
|
|
645
|
-
e.stopPropagation();
|
|
646
|
-
toggleChainPop(container, el.dataset.id, e);
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
const chip = e.target.closest(".family-chip");
|
|
650
|
-
if (chip) {
|
|
651
|
-
e.stopPropagation();
|
|
652
|
-
onFamily(el.dataset.id, chip, e);
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
const del = e.target.closest(".card-del");
|
|
656
|
-
if (del) {
|
|
657
|
-
e.stopPropagation();
|
|
658
|
-
confirmDelete(del.dataset.del);
|
|
659
|
-
return;
|
|
660
|
-
}
|
|
661
|
-
// Archive from the list: the view stays open, the toast has Undo.
|
|
662
|
-
const arch = e.target.closest(".card-arch");
|
|
663
|
-
if (arch) {
|
|
664
|
-
e.stopPropagation();
|
|
665
|
-
const id = arch.dataset.arch;
|
|
666
|
-
setArchived(id, !allSessions.find((x) => x.id === id)?.archived);
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
669
|
-
const row = e.target.closest(".family-row[data-id]");
|
|
670
|
-
if (row) {
|
|
671
|
-
e.stopPropagation();
|
|
672
|
-
onRow(row.dataset.id, e);
|
|
673
|
-
return;
|
|
674
|
-
}
|
|
675
|
-
onPick(el.dataset.id, e);
|
|
676
|
-
});
|
|
677
|
-
});
|
|
678
|
-
paintActive();
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// The open session's marks: its card (.active), its row in a family's list
|
|
682
|
-
// (in a card or the strip's popover), and a quieter .contains-active on the
|
|
683
|
-
// card of the family it is folded into.
|
|
684
|
-
function paintActive() {
|
|
685
|
-
document.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
686
|
-
el.classList.toggle("active", !!selected && el.dataset.id === selected);
|
|
687
|
-
el.classList.toggle("contains-active", !!selected && (el.dataset.members || "").split(" ").includes(selected));
|
|
688
|
-
});
|
|
689
|
-
document.querySelectorAll(".family-row[data-id]").forEach((el) => {
|
|
690
|
-
el.classList.toggle("active", !!selected && el.dataset.id === selected);
|
|
691
|
-
});
|
|
692
|
-
// The open session's card may have come into the strip or left it.
|
|
693
|
-
updateSessionTitle();
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// The latest sessions (as many as fit, a family one card), then the way
|
|
697
|
-
// to all of them.
|
|
698
|
-
function renderTop(cards) {
|
|
699
|
-
topStripEl.classList.remove("loading");
|
|
700
|
-
renderStripShow();
|
|
701
|
-
stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
|
|
702
|
-
noteChainFamilies(cards);
|
|
703
|
-
if (scopeError) {
|
|
704
|
-
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
|
|
705
|
-
familyPop.close();
|
|
706
|
-
chainPop.close();
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
710
|
-
if (!allSessions.length) {
|
|
711
|
-
topStripEl.innerHTML = "";
|
|
712
|
-
familyPop.close();
|
|
713
|
-
chainPop.close();
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
const n = shownSessions().length;
|
|
717
|
-
const count = n || allSessions.length === n
|
|
718
|
-
? `${n} ${n === 1 ? "session" : "sessions"}`
|
|
719
|
-
: `${allSessions.length - n} archived`;
|
|
720
|
-
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>`;
|
|
721
|
-
topStripEl.innerHTML = cards.map((f) => cardHtml(f.head, { family: f, open: familyPop.openHead() === f.head.id })).join("") + tile;
|
|
722
|
-
bindCards(topStripEl, select, {
|
|
723
|
-
onFamily: (id, _chip, e) => {
|
|
724
|
-
familyPop.toggle(id);
|
|
725
|
-
// From the keyboard (Enter / Space: no pointer, detail 0) the focus
|
|
726
|
-
// goes into the popover, which sits at the end of body; Esc brings it back.
|
|
727
|
-
if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
|
|
728
|
-
},
|
|
729
|
-
});
|
|
730
|
-
$("#allTile").addEventListener("click", openAllView);
|
|
731
|
-
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
732
|
-
familyPop.refresh();
|
|
733
|
-
if (chainFrom === topStripEl) chainPop.refresh();
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// The chain families a list drew: the popover's rows come from them.
|
|
737
|
-
function noteChainFamilies(fams) {
|
|
738
|
-
for (const f of fams) {
|
|
739
|
-
if (f.chain) chainFamilies.set(f.head.id, f);
|
|
740
|
-
else chainFamilies.delete(f.head.id);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
|
|
745
|
-
// following the list: its sessions, the number the All sessions tile
|
|
746
|
-
// shows, and the live and waiting ones (a waiting delegate too).
|
|
747
|
-
function renderStripShow() {
|
|
748
|
-
const shown = shownSessions();
|
|
749
|
-
const { count, live, waiting } = stripShowParts(shown);
|
|
750
|
-
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
751
|
-
const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
|
|
752
|
-
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// A parent's card holds its delegates (families, by the newest member: a
|
|
756
|
-
// hub snapshot hands the list back in the server's order), folded unless
|
|
757
|
-
// one waits on the user, a search matched one or the user opened it. The
|
|
758
|
-
// search runs over the families, so a matching delegate brings its parent's
|
|
759
|
-
// card along. Select mode picks cards (a family's rows have no checkbox:
|
|
760
|
-
// archiving the parent takes its delegates).
|
|
761
|
-
const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
|
|
762
|
-
const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
|
|
763
|
-
const drawnOpen = new Map(); // head id → open as last drawn in all sessions
|
|
764
|
-
|
|
765
|
-
function renderAll() {
|
|
766
|
-
const listed = allViewSessions();
|
|
767
|
-
const q = filterQuery();
|
|
768
|
-
// A search and the archived ones see every link as a card (allViewChains).
|
|
769
|
-
const all = allViewChains(families(listed), allSessions, { query: q, includeArchived: $("#includeArchived").checked });
|
|
770
|
-
noteChainFamilies(all);
|
|
771
|
-
const shown = q ? filterFamilies(all, (s) => matchesFilter(s, q)) : all;
|
|
772
|
-
const fams = listOrder(allListEl, waitingFirst(shown), familyId);
|
|
773
|
-
const delegates = all.reduce((n, f) => n + f.members.length, 0);
|
|
774
|
-
$("#count").textContent = fams.length === all.length
|
|
775
|
-
? `${all.length}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
|
|
776
|
-
: `${fams.length} / ${all.length}`;
|
|
777
|
-
updateSelectBar();
|
|
778
|
-
if (!fams.length) {
|
|
779
|
-
drawnOpen.clear();
|
|
780
|
-
allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
|
|
781
|
-
if (chainFrom === allListEl) chainPop.refresh();
|
|
782
|
-
return;
|
|
783
|
-
}
|
|
784
|
-
// A hub event redraws the list under a checkbox or a chip the keyboard is on.
|
|
785
|
-
const focusSel = [".card-pick", ".family-chip", ".chain-chip"].find((sel) => document.activeElement?.matches?.(`#allList ${sel}`));
|
|
786
|
-
const focused = focusSel ? { id: document.activeElement.closest(".card")?.dataset.id, sel: focusSel } : null;
|
|
787
|
-
const opts = (s) => (selecting.on
|
|
788
|
-
? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
|
|
789
|
-
: { deletable: true, archivable: true });
|
|
790
|
-
drawnOpen.clear();
|
|
791
|
-
allListEl.innerHTML = fams.map((f) => {
|
|
792
|
-
const matched = !!f.matchIds?.size;
|
|
793
|
-
const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
|
|
794
|
-
drawnOpen.set(f.head.id, open);
|
|
795
|
-
return cardHtml(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
|
|
796
|
-
}).join("");
|
|
797
|
-
bindCards(allListEl, (id, e) => {
|
|
798
|
-
if (selecting.on) {
|
|
799
|
-
togglePick(id, e);
|
|
800
|
-
return;
|
|
801
|
-
}
|
|
802
|
-
leaveAllView(sessionHash(id));
|
|
803
|
-
select(id);
|
|
804
|
-
}, {
|
|
805
|
-
onFamily: (headId) => {
|
|
806
|
-
const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
|
|
807
|
-
(matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
|
|
808
|
-
renderAll();
|
|
809
|
-
},
|
|
810
|
-
// In select mode a row does nothing: rows aren't picked.
|
|
811
|
-
onRow: (id) => {
|
|
812
|
-
if (selecting.on) return;
|
|
813
|
-
leaveAllView(sessionHash(id));
|
|
814
|
-
select(id);
|
|
815
|
-
},
|
|
816
|
-
});
|
|
817
|
-
if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
|
|
818
|
-
if (chainFrom === allListEl) chainPop.refresh();
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// ── Select mode (all sessions) ─────────────────────────────────────────────
|
|
822
|
-
// "Select" turns the cards into picks: a click (or Shift-click, a range in
|
|
823
|
-
// the drawn order) toggles one instead of opening it, and the bar acts on
|
|
824
|
-
// the picked ones that are shown. The picks live here, not in the DOM:
|
|
825
|
-
// renderAll draws the list again on every hub event.
|
|
826
|
-
const selecting = {
|
|
827
|
-
on: false,
|
|
828
|
-
picked: new Set(),
|
|
829
|
-
last: null, // the last toggled id: a Shift-click's range starts there
|
|
830
|
-
skipReasons: new Map(), // id → why the last batch skipped it
|
|
831
|
-
running: null, // {done, total, archive} while a batch runs
|
|
832
|
-
};
|
|
833
|
-
|
|
834
|
-
function enterSelect() {
|
|
835
|
-
selecting.on = true;
|
|
836
|
-
renderAll();
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
function leaveSelect() {
|
|
840
|
-
selecting.on = false;
|
|
841
|
-
selecting.picked.clear();
|
|
842
|
-
selecting.last = null;
|
|
843
|
-
selecting.skipReasons.clear();
|
|
844
|
-
if (allViewOpen()) renderAll();
|
|
845
|
-
else updateSelectBar();
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function togglePick(id, e) {
|
|
849
|
-
if (selecting.running) return; // the grid reflows while a batch runs
|
|
850
|
-
const order = drawnOrder.get(allListEl) || [];
|
|
851
|
-
const on = !selecting.picked.has(id);
|
|
852
|
-
const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
|
|
853
|
-
for (const x of ids) {
|
|
854
|
-
if (on) selecting.picked.add(x);
|
|
855
|
-
else selecting.picked.delete(x);
|
|
856
|
-
paintPick(x);
|
|
857
|
-
}
|
|
858
|
-
selecting.last = id;
|
|
859
|
-
updateSelectBar();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
// A card's pick state in place (no redraw: the focus stays put).
|
|
863
|
-
function paintPick(id) {
|
|
864
|
-
const el = allListEl.querySelector(`.card[data-id="${id}"]`);
|
|
865
|
-
if (!el) return;
|
|
866
|
-
const on = selecting.picked.has(id);
|
|
867
|
-
el.classList.toggle("picked", on);
|
|
868
|
-
const box = el.querySelector(".card-pick");
|
|
869
|
-
if (box) box.checked = on;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// The bar's numbers: picks of sessions still in the list (a snapshot can
|
|
873
|
-
// drop one with no session_gone), and the shown ones a batch would act on.
|
|
874
|
-
function selectCounts() {
|
|
875
|
-
const byId = new Map(allSessions.map((s) => [s.id, s]));
|
|
876
|
-
const shown = drawnOrder.get(allListEl) || [];
|
|
877
|
-
const shownSet = new Set(shown);
|
|
878
|
-
const live = [...selecting.picked].filter((id) => byId.has(id));
|
|
879
|
-
return {
|
|
880
|
-
picked: live.length,
|
|
881
|
-
hidden: live.filter((id) => !shownSet.has(id)).length,
|
|
882
|
-
archive: batchTargets(selecting.picked, shown, true, byId),
|
|
883
|
-
unarchive: batchTargets(selecting.picked, shown, false, byId),
|
|
884
|
-
};
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
function updateSelectBar() {
|
|
888
|
-
$("#allView").classList.toggle("selecting", selecting.on);
|
|
889
|
-
$("#selectBtn").hidden = selecting.on;
|
|
890
|
-
const bar = $("#selectBar");
|
|
891
|
-
bar.hidden = !selecting.on;
|
|
892
|
-
if (!selecting.on) return;
|
|
893
|
-
const c = selectCounts();
|
|
894
|
-
const run = selecting.running;
|
|
895
|
-
$("#selectCount").textContent = run
|
|
896
|
-
? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}\u2026`
|
|
897
|
-
: `${c.picked} selected${c.hidden ? ` \u00b7 ${c.hidden} not shown` : ""}`;
|
|
898
|
-
$("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
|
|
899
|
-
$("#batchArchiveBtn").disabled = !!run || !c.archive.length;
|
|
900
|
-
$("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
|
|
901
|
-
$("#batchUnarchiveBtn").hidden = !c.unarchive.length;
|
|
902
|
-
$("#batchUnarchiveBtn").disabled = !!run;
|
|
903
|
-
$("#selectAllBtn").disabled = !!run;
|
|
904
|
-
$("#selectDoneBtn").disabled = !!run;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// The batch: the picked shown ones, one request at a time in the drawn
|
|
908
|
-
// order (parents before their delegates), through the info bar's
|
|
909
|
-
// archiveOne. A refused one stays picked with the server's reason on its
|
|
910
|
-
// card; the end toast's Undo runs the opposite batch on the ones that went.
|
|
911
|
-
async function runSelectBatch(archive) {
|
|
912
|
-
if (selecting.running) return;
|
|
913
|
-
const byId = new Map(allSessions.map((s) => [s.id, s]));
|
|
914
|
-
const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
|
|
915
|
-
if (!ids.length) return;
|
|
916
|
-
selecting.skipReasons.clear();
|
|
917
|
-
const outcome = await archiveBatch(ids, archive);
|
|
918
|
-
if (!outcome) return;
|
|
919
|
-
if (!allViewOpen()) leaveSelect(); // left the view while it ran
|
|
920
|
-
else renderAll();
|
|
921
|
-
showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
|
|
922
|
-
label: "Undo",
|
|
923
|
-
run: async () => {
|
|
924
|
-
const undone = await archiveBatch(outcome.done, !archive);
|
|
925
|
-
if (!undone) return;
|
|
926
|
-
if (allViewOpen()) renderAll();
|
|
927
|
-
showToast(batchToastText(batchCounts(undone, !archive)));
|
|
928
|
-
},
|
|
929
|
-
} : null);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// runBatch over archiveOne, with the bar's progress; the picks and skip
|
|
933
|
-
// reasons follow the outcome. null when a batch already runs.
|
|
934
|
-
async function archiveBatch(ids, archive) {
|
|
935
|
-
if (selecting.running) return null;
|
|
936
|
-
selecting.running = { done: 0, total: ids.length, archive };
|
|
937
|
-
updateSelectBar();
|
|
938
|
-
try {
|
|
939
|
-
const outcome = await runBatch(ids, async (id) => {
|
|
940
|
-
const answer = await archiveOne(id, archive);
|
|
941
|
-
if (answer.ok) {
|
|
942
|
-
render();
|
|
943
|
-
updateInfoBar();
|
|
944
|
-
}
|
|
945
|
-
return answer;
|
|
946
|
-
}, (done) => {
|
|
947
|
-
selecting.running.done = done;
|
|
948
|
-
updateSelectBar();
|
|
949
|
-
});
|
|
950
|
-
for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
|
|
951
|
-
selecting.picked.delete(id);
|
|
952
|
-
selecting.skipReasons.delete(id);
|
|
953
|
-
}
|
|
954
|
-
for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
|
|
955
|
-
return outcome;
|
|
956
|
-
} finally {
|
|
957
|
-
selecting.running = null;
|
|
958
|
-
updateSelectBar();
|
|
959
|
-
if (!eventsControl) refresh();
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
$("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
|
|
964
|
-
$("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
|
|
965
|
-
$("#selectBtn").addEventListener("click", enterSelect);
|
|
966
|
-
$("#selectDoneBtn").addEventListener("click", leaveSelect);
|
|
967
|
-
$("#selectAllBtn").addEventListener("click", () => {
|
|
968
|
-
for (const id of drawnOrder.get(allListEl) || []) {
|
|
969
|
-
selecting.picked.add(id);
|
|
970
|
-
paintPick(id);
|
|
971
|
-
}
|
|
972
|
-
updateSelectBar();
|
|
973
|
-
});
|
|
974
|
-
// A Shift-click picks a range: keep the browser from selecting the text
|
|
975
|
-
// between the two cards.
|
|
976
|
-
allListEl.addEventListener("mousedown", (e) => {
|
|
977
|
-
if (selecting.on && e.shiftKey) e.preventDefault();
|
|
413
|
+
topStripEl,
|
|
414
|
+
allListEl,
|
|
415
|
+
sessions: () => allSessions,
|
|
416
|
+
selected: () => selected,
|
|
417
|
+
scope,
|
|
418
|
+
scopeError: () => scopeError,
|
|
419
|
+
eventsLive: () => !!eventsControl,
|
|
420
|
+
refresh: () => refresh(),
|
|
421
|
+
select: (id) => select(id),
|
|
422
|
+
confirmDelete: (id) => confirmDelete(id),
|
|
423
|
+
setArchived: (id, archive) => setArchived(id, archive),
|
|
424
|
+
archiveOne: (id, archive) => archiveOne(id, archive),
|
|
425
|
+
updateInfoBar: () => updateInfoBar(),
|
|
426
|
+
updateSessionTitle: () => updateSessionTitle(),
|
|
427
|
+
showToast: (text, action) => showToast(text, action),
|
|
428
|
+
allViewOpen: () => allViewOpen(),
|
|
429
|
+
openAllView: () => openAllView(),
|
|
430
|
+
leaveAllView: (hash) => leaveAllView(hash),
|
|
431
|
+
setStripHidden: (hidden) => setStripHidden(hidden),
|
|
978
432
|
});
|
|
979
433
|
|
|
980
434
|
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
@@ -996,7 +450,7 @@ async function confirmDelete(id) {
|
|
|
996
450
|
}
|
|
997
451
|
allSessions = allSessions.filter((s) => s.id !== id);
|
|
998
452
|
if (selected === id) clearSelection();
|
|
999
|
-
render();
|
|
453
|
+
lists.render();
|
|
1000
454
|
}
|
|
1001
455
|
|
|
1002
456
|
// All sessions: its own view at #/sessions, so the browser's back button
|
|
@@ -1044,19 +498,19 @@ function applyView() {
|
|
|
1044
498
|
if (!open) {
|
|
1045
499
|
allViewPushed = false;
|
|
1046
500
|
// A running batch ends on its own and clears the picks then.
|
|
1047
|
-
if (selecting.on && !selecting.running) leaveSelect();
|
|
501
|
+
if (lists.selecting.on && !lists.selecting.running) lists.leaveSelect();
|
|
1048
502
|
topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
|
|
1049
503
|
}
|
|
1050
504
|
document.body.classList.toggle("all-view", open);
|
|
1051
505
|
updateSessionTitle();
|
|
1052
506
|
// Its chip was in the list just hidden (the strip, or all sessions).
|
|
1053
|
-
chainPop.close();
|
|
507
|
+
lists.chainPop.close();
|
|
1054
508
|
if (open) {
|
|
1055
|
-
familyPop.close(); // the strip is hidden in the all view
|
|
509
|
+
lists.familyPop.close(); // the strip is hidden in the all view
|
|
1056
510
|
// The list is live; only its order can lag (upserts keep cards in
|
|
1057
511
|
// place), so the all view opens in the server's order.
|
|
1058
512
|
allSessions = sortedByUpdated(allSessions);
|
|
1059
|
-
renderAll();
|
|
513
|
+
lists.renderAll();
|
|
1060
514
|
if (!eventsControl) refresh();
|
|
1061
515
|
$("#filter").select();
|
|
1062
516
|
}
|
|
@@ -1151,7 +605,7 @@ function setLiveStatus(status) {
|
|
|
1151
605
|
// snapshot or the all view re-sorts (no card jumping under the pointer;
|
|
1152
606
|
// a session event replaces it in place too, see sessions_list.js).
|
|
1153
607
|
allSessions[i] = withLiveStatus(allSessions[i], status);
|
|
1154
|
-
render();
|
|
608
|
+
lists.render();
|
|
1155
609
|
}
|
|
1156
610
|
updateInfoBar();
|
|
1157
611
|
}
|
|
@@ -1194,14 +648,14 @@ function clearSelection() {
|
|
|
1194
648
|
seedInitTasks(initTasks, []);
|
|
1195
649
|
renderInitRow();
|
|
1196
650
|
setSessionHash(null);
|
|
1197
|
-
paintActive();
|
|
651
|
+
lists.paintActive();
|
|
1198
652
|
resetSessionState();
|
|
1199
653
|
contextChips.clear();
|
|
1200
654
|
liveTurn.forget();
|
|
1201
655
|
setTurnRunning(false);
|
|
1202
656
|
closeStream();
|
|
1203
657
|
setMarkdownWarning(null);
|
|
1204
|
-
setCapabilities(null);
|
|
658
|
+
setCapabilities(null, null);
|
|
1205
659
|
updateComposerMode();
|
|
1206
660
|
updateInfoBar();
|
|
1207
661
|
promptEl.focus();
|
|
@@ -1231,7 +685,7 @@ async function select(id) {
|
|
|
1231
685
|
clearNotice();
|
|
1232
686
|
selected = id;
|
|
1233
687
|
setSessionHash(id);
|
|
1234
|
-
paintActive();
|
|
688
|
+
lists.paintActive();
|
|
1235
689
|
historyEl.innerHTML = `<div class="hint">Loading…</div>`;
|
|
1236
690
|
emptyEl.classList.add("hidden");
|
|
1237
691
|
closeStream();
|
|
@@ -1278,7 +732,7 @@ async function resync({ endText = null } = {}) {
|
|
|
1278
732
|
return;
|
|
1279
733
|
}
|
|
1280
734
|
if (id !== selected) return;
|
|
1281
|
-
const kept = keptCommandBubbles(id);
|
|
735
|
+
const kept = keptCommandBubbles(historyEl, id);
|
|
1282
736
|
resetTurnView();
|
|
1283
737
|
renderSessionView(data, { kept });
|
|
1284
738
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
@@ -1295,88 +749,23 @@ async function resync({ endText = null } = {}) {
|
|
|
1295
749
|
}
|
|
1296
750
|
}
|
|
1297
751
|
|
|
1298
|
-
// The prompt and command bubbles in the history, in order, by kind
|
|
1299
|
-
// (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
|
|
1300
|
-
function historyAnchors() {
|
|
1301
|
-
const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
|
|
1302
|
-
const kinds = els.map((el) => {
|
|
1303
|
-
if (el.classList.contains("user")) return "user";
|
|
1304
|
-
const line = el.querySelector(":scope > .command-line")?.textContent;
|
|
1305
|
-
return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
|
|
1306
|
-
});
|
|
1307
|
-
return { els, kinds };
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
// This session's own command bubbles (/help, /model …: drawn by this page,
|
|
1311
|
-
// never saved) with their anchors, before a resync redraws the history.
|
|
1312
|
-
// In-flight ones (busy, queued) are left out: the snapshot draws them again.
|
|
1313
|
-
function keptCommandBubbles(id) {
|
|
1314
|
-
const { els, kinds } = historyAnchors();
|
|
1315
|
-
return els
|
|
1316
|
-
.map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
|
|
1317
|
-
.filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
|
|
1318
|
-
.filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// The kept bubbles back into the redrawn history, each after its turn.
|
|
1322
|
-
function restoreCommandBubbles(kept) {
|
|
1323
|
-
if (!kept.length) return;
|
|
1324
|
-
historyEl.querySelector(".hint")?.remove();
|
|
1325
|
-
kept.forEach(({ el, anchor }) => {
|
|
1326
|
-
const { els, kinds } = historyAnchors();
|
|
1327
|
-
const at = anchorInsertIndex(kinds, anchor);
|
|
1328
|
-
if (at < els.length) els[at].before(el);
|
|
1329
|
-
else historyEl.appendChild(el);
|
|
1330
|
-
});
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
752
|
// A session view: its messages, then the turn in progress and the queued
|
|
1334
753
|
// prompts (a live worker's snapshot) replayed through the stream handlers,
|
|
1335
754
|
// then a pending question.
|
|
1336
755
|
function renderSessionView(data, { kept = [] } = {}) {
|
|
1337
|
-
const s = data.session;
|
|
1338
756
|
setMarkdownWarning(data.markdown_warning);
|
|
1339
|
-
setCapabilities(data.capabilities);
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
current
|
|
1344
|
-
current.owner = s.owner || null;
|
|
1345
|
-
current.timing = normalizeTiming(data.timing);
|
|
757
|
+
setCapabilities(data.capabilities, data.editor_url);
|
|
758
|
+
// The saved fill (when there is one) goes in here too, so a reloaded or
|
|
759
|
+
// stopped session shows it before its next turn (a running turn's replay
|
|
760
|
+
// below streams over it).
|
|
761
|
+
Object.assign(current, sessionStateFrom(data));
|
|
1346
762
|
liveTurn.timing.ended = current.timing.turnRecords.length;
|
|
1347
763
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
1348
|
-
// The saved fill, so a reloaded or stopped session shows it before its
|
|
1349
|
-
// next turn (a running turn's replay below streams over it).
|
|
1350
|
-
const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
|
|
1351
|
-
if (savedPct !== null) current.ctxPct = savedPct;
|
|
1352
|
-
if (data.timing?.context?.window_tokens) {
|
|
1353
|
-
current.ctxWindow = data.timing.context.window_tokens;
|
|
1354
|
-
current.ctxWindowSource = data.timing.context.window_source || null;
|
|
1355
|
-
}
|
|
1356
|
-
current.firstPreview = s.first_preview || previewForCard(s);
|
|
1357
|
-
current.usedMemories = s.used_memory_names || [];
|
|
1358
|
-
current.preloadedMemories = s.preloaded_memory_names || [];
|
|
1359
|
-
current.mutedMemories = s.muted_memory_names || [];
|
|
1360
|
-
current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
|
|
1361
|
-
current.llmContext = s.llm_context || null;
|
|
1362
764
|
llmContextControl.refresh();
|
|
1363
|
-
current.thinking = s.thinking || null;
|
|
1364
765
|
thinkingControl.refresh();
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
if (data.messages && data.messages.length) {
|
|
1369
|
-
const first = data.messages.find((m) => m.role === "user");
|
|
1370
|
-
if (first && first.content) current.firstPreview = firstPreview(first.content);
|
|
1371
|
-
renderHistory(data.messages);
|
|
1372
|
-
} else {
|
|
1373
|
-
renderHistory(data.history || []);
|
|
1374
|
-
if (!current.firstPreview && data.history && data.history.length) {
|
|
1375
|
-
const txt = firstPreview(data.history[0]);
|
|
1376
|
-
if (txt) current.firstPreview = txt;
|
|
1377
|
-
}
|
|
1378
|
-
}
|
|
1379
|
-
restoreCommandBubbles(kept);
|
|
766
|
+
if (data.messages && data.messages.length) renderHistory(data.messages);
|
|
767
|
+
else renderHistory(data.history || []);
|
|
768
|
+
restoreCommandBubbles(historyEl, kept);
|
|
1380
769
|
// A failed turn's prompt went back to the user: shown after the
|
|
1381
770
|
// conversation, marked failed, as it was live (prompt_restored).
|
|
1382
771
|
if (data.failed_turn) {
|
|
@@ -1453,10 +842,23 @@ function setMarkdownWarning(warning) {
|
|
|
1453
842
|
markdownWarningEl.hidden = false;
|
|
1454
843
|
}
|
|
1455
844
|
|
|
1456
|
-
|
|
845
|
+
// Both from one payload, together; the body's has-editor and refs-<kind>
|
|
846
|
+
// classes follow (the refs' hover styling keys on refs-<kind>).
|
|
847
|
+
function setCapabilities(capabilities, editorUrl) {
|
|
1457
848
|
pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
|
|
849
|
+
pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
|
|
850
|
+
document.body.classList.toggle("has-editor", pageEditorUrl !== null);
|
|
851
|
+
setRefKinds(document.body, pageViewer());
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// What this viewer can do with a ref (refs.js REF_ACTIONS).
|
|
855
|
+
function pageViewer() {
|
|
856
|
+
return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
|
|
1458
857
|
}
|
|
1459
858
|
|
|
859
|
+
// Refs anywhere on the page (a tool row's file, its diff lines): one listener.
|
|
860
|
+
installRefClicks(document.body, pageViewer);
|
|
861
|
+
|
|
1460
862
|
async function readTurnEnd() {
|
|
1461
863
|
if (!selected) return;
|
|
1462
864
|
const id = selected;
|
|
@@ -1474,7 +876,7 @@ async function readTurnEnd() {
|
|
|
1474
876
|
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
1475
877
|
liveTurn.timing.finishLine(line);
|
|
1476
878
|
setMarkdownWarning(d.markdown_warning);
|
|
1477
|
-
setCapabilities(d.capabilities);
|
|
879
|
+
setCapabilities(d.capabilities, d.editor_url);
|
|
1478
880
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1479
881
|
updateInfoBar();
|
|
1480
882
|
const sess = allSessions.find(s=>s.id===selected);
|
|
@@ -1482,8 +884,8 @@ async function readTurnEnd() {
|
|
|
1482
884
|
sess.used_memory_names = current.usedMemories.slice();
|
|
1483
885
|
sess.status = current.status;
|
|
1484
886
|
}
|
|
1485
|
-
render();
|
|
1486
|
-
paintActive();
|
|
887
|
+
lists.render();
|
|
888
|
+
lists.paintActive();
|
|
1487
889
|
}
|
|
1488
890
|
} catch(_){}
|
|
1489
891
|
}
|
|
@@ -1718,11 +1120,6 @@ function showSteers(steers) {
|
|
|
1718
1120
|
}
|
|
1719
1121
|
}
|
|
1720
1122
|
|
|
1721
|
-
// A ✂ row's notice data (turnDom.notice, placeTurnNotice).
|
|
1722
|
-
function llmContextNotice(data) {
|
|
1723
|
-
return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
|
|
1724
|
-
}
|
|
1725
|
-
|
|
1726
1123
|
function showHookNotice(data, { before = null } = {}) {
|
|
1727
1124
|
removeHintIfEmpty();
|
|
1728
1125
|
const el = document.createElement("div");
|
|
@@ -1957,16 +1354,6 @@ function currentWorkerBadge() {
|
|
|
1957
1354
|
installed: versions.installed, served: versions.served, sessionId: selected });
|
|
1958
1355
|
}
|
|
1959
1356
|
|
|
1960
|
-
function workerBadgeHtml() {
|
|
1961
|
-
const badge = currentWorkerBadge();
|
|
1962
|
-
if (!badge) return "";
|
|
1963
|
-
const busy = restartingId === selected;
|
|
1964
|
-
const button = badge.restart
|
|
1965
|
-
? `<button type="button" class="worker-restart"${busy ? " disabled" : ""}>${busy ? "restarting…" : "Restart"}</button>`
|
|
1966
|
-
: "";
|
|
1967
|
-
return `<span class="worker-badge" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}${button}</span>`;
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
1357
|
// Restart: the session moves to a new worker on the newest chi; the page
|
|
1971
1358
|
// follows it (its bridge_up), the composer's draft stays as it is.
|
|
1972
1359
|
async function restartWorker() {
|
|
@@ -2011,49 +1398,48 @@ function updateInfoBar() {
|
|
|
2011
1398
|
}
|
|
2012
1399
|
if (!selected) {
|
|
2013
1400
|
// The start page hides the info bar (the storage path is on #/sessions).
|
|
2014
|
-
|
|
2015
|
-
infoTextEl.title = "";
|
|
1401
|
+
renderInfoBar(infoTextEl, null);
|
|
2016
1402
|
if (stopBtn) stopBtn.style.display = "none";
|
|
2017
1403
|
updateSessionTitle();
|
|
2018
1404
|
return;
|
|
2019
1405
|
}
|
|
2020
1406
|
if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
|
|
2021
|
-
const idShort = selected.slice(0,8);
|
|
2022
|
-
const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
|
|
2023
1407
|
const tokens = current.timing.tokens;
|
|
2024
|
-
const ctxTip =
|
|
2025
|
-
|
|
2026
|
-
|
|
1408
|
+
const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
|
|
1409
|
+
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
1410
|
+
budget: current.llmContext?.budget_tokens });
|
|
2027
1411
|
const speedTextNow = lastSpeedText(tokens);
|
|
2028
1412
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
2029
|
-
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
2030
1413
|
const sessionDuration = currentSessionDuration();
|
|
2031
|
-
const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
|
|
2032
|
-
const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
|
|
2033
|
-
const label = modelLabel(current.model, ...(current.served || []));
|
|
2034
1414
|
// A delegated session names its parent, as a link to it.
|
|
2035
1415
|
const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
|
|
2036
|
-
const
|
|
2037
|
-
//
|
|
2038
|
-
//
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
1416
|
+
const badge = currentWorkerBadge();
|
|
1417
|
+
// The session clock ticks every second while a turn runs: lit writes its
|
|
1418
|
+
// text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1419
|
+
renderInfoBar(infoTextEl, {
|
|
1420
|
+
id: selected,
|
|
1421
|
+
copied: Date.now() - attachCopiedAt < 2000,
|
|
1422
|
+
status: current.status,
|
|
1423
|
+
model: modelLabel(current.model, ...(current.served || [])),
|
|
1424
|
+
// A link of a session chain: ← the previous link, → the next one (each
|
|
1425
|
+
// only while the list holds it).
|
|
1426
|
+
chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
|
|
1427
|
+
parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
|
|
1428
|
+
// A parent names its delegates; the link opens all sessions.
|
|
1429
|
+
kids: childrenSummary({ id: selected }, allSessions),
|
|
1430
|
+
llmContext: current.llmContext,
|
|
1431
|
+
thinking: current.thinking,
|
|
1432
|
+
// The model notes the session's prompt carried.
|
|
1433
|
+
notes: promptNotesChip(current.promptNotes),
|
|
1434
|
+
memories: current.usedMemories,
|
|
1435
|
+
preloaded: current.preloadedMemories,
|
|
1436
|
+
muted: current.mutedMemories,
|
|
1437
|
+
dir: current.dir,
|
|
1438
|
+
ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
|
|
1439
|
+
speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
|
|
1440
|
+
clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
|
|
1441
|
+
worker: badge ? { badge, busy: restartingId === selected } : null,
|
|
1442
|
+
});
|
|
2057
1443
|
updateSessionTitle();
|
|
2058
1444
|
}
|
|
2059
1445
|
|
|
@@ -2062,9 +1448,9 @@ function updateInfoBar() {
|
|
|
2062
1448
|
// browser tab names it too. It follows the card's: a chain link opened with
|
|
2063
1449
|
// the opener reads its chain's first link's title (cardTitle).
|
|
2064
1450
|
function updateSessionTitle() {
|
|
2065
|
-
const shown = selected ? sessionsById().get(selected) : null;
|
|
1451
|
+
const shown = selected ? lists.sessionsById().get(selected) : null;
|
|
2066
1452
|
const first = current.firstPreview || "";
|
|
2067
|
-
const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
|
|
1453
|
+
const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
|
|
2068
1454
|
const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
|
|
2069
1455
|
const text = topBarTitle({
|
|
2070
1456
|
title,
|
|
@@ -2083,24 +1469,12 @@ function updateSessionTitle() {
|
|
|
2083
1469
|
}
|
|
2084
1470
|
}
|
|
2085
1471
|
|
|
2086
|
-
//
|
|
2087
|
-
//
|
|
2088
|
-
function
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
const title = `${what}: ${short}${preview ? ` · ${preview}` : ""}${link.session?.archived ? " (archived)" : ""}`;
|
|
2093
|
-
const text = arrow === "\u2190" ? `${arrow} ${date || short}` : `${date || short} ${arrow}`;
|
|
2094
|
-
return `<a class="model chain-link" href="${escapeHtml(sessionHash(link.id))}" title="${escapeHtml(title)}">${escapeHtml(text)}</a>`;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
// `memories: a, b · preloaded: c · muted: d`: the used memories always,
|
|
2098
|
-
// the session's --memory and --mute lists only when there are any.
|
|
2099
|
-
function memoriesTooltip(used, preloaded, muted) {
|
|
2100
|
-
const parts = [`memories: ${(used || []).join(", ") || "—"}`];
|
|
2101
|
-
if (preloaded && preloaded.length) parts.push(`preloaded: ${preloaded.join(", ")}`);
|
|
2102
|
-
if (muted && muted.length) parts.push(`muted: ${muted.join(", ")}`);
|
|
2103
|
-
return parts.join(" · ");
|
|
1472
|
+
// A chain's link (chains.js chainNeighbours) as the info bar's ← / →
|
|
1473
|
+
// shows it (info_bar.js chainLinkTemplate), or null.
|
|
1474
|
+
function chainLink(link) {
|
|
1475
|
+
if (!link) return null;
|
|
1476
|
+
return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
|
|
1477
|
+
archived: !!link.session?.archived };
|
|
2104
1478
|
}
|
|
2105
1479
|
|
|
2106
1480
|
function currentSessionDuration() {
|
|
@@ -2141,8 +1515,8 @@ function setUsedMemories(names) {
|
|
|
2141
1515
|
const sess = allSessions.find(s=>s.id===selected);
|
|
2142
1516
|
if (sess) {
|
|
2143
1517
|
sess.used_memory_names = current.usedMemories.slice();
|
|
2144
|
-
render();
|
|
2145
|
-
paintActive();
|
|
1518
|
+
lists.render();
|
|
1519
|
+
lists.paintActive();
|
|
2146
1520
|
}
|
|
2147
1521
|
}
|
|
2148
1522
|
|
|
@@ -2197,7 +1571,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
2197
1571
|
const i = allSessions.findIndex((s) => s.id === id);
|
|
2198
1572
|
if (i >= 0 && allSessions[i].owner === "worker") {
|
|
2199
1573
|
allSessions[i] = withoutWorker(allSessions[i]);
|
|
2200
|
-
render();
|
|
1574
|
+
lists.render();
|
|
2201
1575
|
}
|
|
2202
1576
|
updateInfoBar();
|
|
2203
1577
|
// Once: a worker still alive with a bridge (the stream dropped, not the
|
|
@@ -2207,274 +1581,70 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
2207
1581
|
else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
|
|
2208
1582
|
}
|
|
2209
1583
|
|
|
2210
|
-
// The stream's event handlers; renderSessionView
|
|
2211
|
-
// them too.
|
|
2212
|
-
const streamHandlers = {
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
liveTurn.timing.setNote("");
|
|
2231
|
-
turnDom.generationStarted(data || {});
|
|
2232
|
-
},
|
|
2233
|
-
generation_completed: (data) => {
|
|
2234
|
-
liveTurn.timing.setNote("");
|
|
2235
|
-
if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
|
|
2236
|
-
// The generation's speed and the session's token totals, from the server.
|
|
2237
|
-
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
2238
|
-
if (data?.served_model || data?.tokens) updateInfoBar();
|
|
2239
|
-
turnDom.generationCompleted();
|
|
2240
|
-
},
|
|
2241
|
-
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
2242
|
-
// input_merged names them (their bubbles turn "steered"), then
|
|
2243
|
-
// pending_input_merged carries the merged text, shown only for prompts
|
|
2244
|
-
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
2245
|
-
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
2246
|
-
input_merged: (data) => applyPromptOps(data),
|
|
2247
|
-
pending_input_merged: (data) => {
|
|
2248
|
-
applyPromptOps(data);
|
|
2249
|
-
showSteers(data.steers);
|
|
2250
|
-
},
|
|
2251
|
-
turn_enqueued: (data) => applyPromptOps(data),
|
|
2252
|
-
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
2253
|
-
if (streamFromGuess) {
|
|
1584
|
+
// The stream's event handlers (stream_handlers.js); renderSessionView
|
|
1585
|
+
// replays a snapshot through them too.
|
|
1586
|
+
const streamHandlers = createStreamHandlers({
|
|
1587
|
+
selected: () => selected,
|
|
1588
|
+
state: () => current,
|
|
1589
|
+
clientId,
|
|
1590
|
+
capabilities: () => pageCapabilities,
|
|
1591
|
+
historyEl,
|
|
1592
|
+
liveTurn,
|
|
1593
|
+
turnDom,
|
|
1594
|
+
shownTexts,
|
|
1595
|
+
questionCards,
|
|
1596
|
+
contextChips,
|
|
1597
|
+
llmContextControl,
|
|
1598
|
+
thinkingControl,
|
|
1599
|
+
sentIds,
|
|
1600
|
+
earlyRestores,
|
|
1601
|
+
stream: {
|
|
1602
|
+
takeGuess: () => {
|
|
1603
|
+
const guess = streamFromGuess;
|
|
2254
1604
|
streamFromGuess = false;
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
}
|
|
2263
|
-
// A finished turn still in the stage goes first: the start op looks
|
|
2264
|
-
// for a queued prompt among its rows.
|
|
2265
|
-
turnDom.handOff?.({ immediate: true });
|
|
2266
|
-
markRecapStale();
|
|
2267
|
-
shownTexts.turnStarted();
|
|
2268
|
-
const promptBubble = applyPromptOps(data);
|
|
2269
|
-
setTurnRunning(true);
|
|
2270
|
-
setLiveStatus("running");
|
|
2271
|
-
liveTurn.timing.detach();
|
|
2272
|
-
turnDom.turnStarted({ ...data, promptBubble });
|
|
2273
|
-
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
2274
|
-
}),
|
|
2275
|
-
turn_completed: async (data) => {
|
|
2276
|
-
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
2277
|
-
const out = turnOutput(data);
|
|
2278
|
-
if (out) appendChunk(out);
|
|
2279
|
-
// after_turn hooks are running and may present the answer: its
|
|
2280
|
-
// answer_display (display: null when they didn't) comes next. A worker
|
|
2281
|
-
// that dies before it never sends one: workerGone releases the wait, and
|
|
2282
|
-
// displayWait's timeout ends it whatever happens (4.14).
|
|
2283
|
-
const display = data.display_pending ? displayWait() : null;
|
|
2284
|
-
if (display) liveTurn.displayPending = display;
|
|
2285
|
-
liveTurn.endRead = readTurnEnd();
|
|
2286
|
-
await liveTurn.endRead;
|
|
2287
|
-
// The answer bubble the turn view held at its box's height pops now
|
|
2288
|
-
// that the rendered markdown is in it (or the re-read failed: then the
|
|
2289
|
-
// plain text pops), and the display if one was coming, so the links
|
|
2290
|
-
// never swap in after the pop. The hold reveals by itself after
|
|
2291
|
-
// HOLD_MS whatever comes. The stage has no hold.
|
|
2292
|
-
if (display) await display.promise;
|
|
2293
|
-
turnDom.answerReady?.();
|
|
2294
|
-
},
|
|
2295
|
-
// An after_turn hook presented the answer (AnswerDisplay): it comes after
|
|
2296
|
-
// turn_completed, so re-read the answer once that read is done.
|
|
2297
|
-
answer_display: async (data) => {
|
|
2298
|
-
try {
|
|
2299
|
-
await liveTurn.endRead;
|
|
2300
|
-
if (!selected || !data.display) return;
|
|
2301
|
-
const d = await getSession(selected, { tail: true, recent: true });
|
|
2302
|
-
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
2303
|
-
} catch (_) {
|
|
2304
|
-
} finally {
|
|
2305
|
-
liveTurn.releaseDisplay();
|
|
2306
|
-
}
|
|
2307
|
-
},
|
|
2308
|
-
turn_canceled: (data) => {
|
|
2309
|
-
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
2310
|
-
setLiveStatus("idle");
|
|
2311
|
-
refreshTimingFromSession();
|
|
2312
|
-
},
|
|
2313
|
-
// The turn raised (e.g. the model server stayed unreachable). The worker
|
|
2314
|
-
// stays up: it rolls the turn back and hands the prompt back
|
|
2315
|
-
// (prompt_restored), and the prompts queued behind it run as usual. A turn
|
|
2316
|
-
// that got to tool steps stays as it is (kept_steps; no prompt_restored).
|
|
2317
|
-
turn_failed: (data) => {
|
|
2318
|
-
const text = failedTurnText(data);
|
|
2319
|
-
// Only a rolled-back turn's line waits for its prompt_restored.
|
|
2320
|
-
liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
|
|
2321
|
-
endTurnView("failed", text);
|
|
2322
|
-
setLiveStatus("idle");
|
|
1605
|
+
return guess;
|
|
1606
|
+
},
|
|
1607
|
+
restart: () => {
|
|
1608
|
+
streamControl?.close();
|
|
1609
|
+
streamControl = null;
|
|
1610
|
+
resync();
|
|
1611
|
+
},
|
|
2323
1612
|
},
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
|
|
2329
|
-
const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
|
|
2330
|
-
if (refill) {
|
|
2331
|
-
promptEl.value = refill;
|
|
1613
|
+
composer: {
|
|
1614
|
+
empty: () => !promptEl.value.trim() && !chips.length,
|
|
1615
|
+
refill: (text) => {
|
|
1616
|
+
promptEl.value = text;
|
|
2332
1617
|
fitPrompt();
|
|
2333
|
-
}
|
|
2334
|
-
|
|
2335
|
-
const failedText = liveTurn.failedText;
|
|
2336
|
-
liveTurn.failedText = null;
|
|
2337
|
-
await resync();
|
|
2338
|
-
// The re-render drew it from the session (failed_turn): drawn again
|
|
2339
|
-
// below with this event's label and images.
|
|
2340
|
-
const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
|
|
2341
|
-
if (drawn) {
|
|
2342
|
-
if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
|
|
2343
|
-
drawn.remove();
|
|
2344
|
-
}
|
|
2345
|
-
// Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
|
|
2346
|
-
// a resend's echo isn't matched to it.
|
|
2347
|
-
makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
|
|
2348
|
-
if (failedText) addEndBubble(failedText);
|
|
2349
|
-
},
|
|
2350
|
-
// A session command ran in the worker (any client's). One that changed
|
|
2351
|
-
// the conversation (!rollback, !cmd, a continue answered no) re-reads it.
|
|
2352
|
-
command_ran: async (data) => {
|
|
2353
|
-
const view = commandView(data, clientId);
|
|
2354
|
-
if (view.modelName && view.modelName !== current.model) {
|
|
2355
|
-
current.model = view.modelName;
|
|
2356
|
-
current.served = null;
|
|
2357
|
-
updateInfoBar();
|
|
2358
|
-
}
|
|
2359
|
-
// /llm-context changed what the next turn runs under.
|
|
2360
|
-
if (data.llm_context) {
|
|
2361
|
-
current.llmContext = data.llm_context;
|
|
2362
|
-
updateInfoBar();
|
|
2363
|
-
llmContextControl.refresh();
|
|
2364
|
-
}
|
|
2365
|
-
// /thinking (or /model) changed the level the next turn runs at.
|
|
2366
|
-
if (data.thinking) {
|
|
2367
|
-
current.thinking = data.thinking;
|
|
2368
|
-
updateInfoBar();
|
|
2369
|
-
thinkingControl.refresh();
|
|
2370
|
-
}
|
|
2371
|
-
if (view.resync) await resync();
|
|
2372
|
-
// A card's action that isn't anytime (the step-limit question's
|
|
2373
|
-
// /continue) has no bubble: nothing to remove. An anytime or queued
|
|
2374
|
-
// one's bubble was drawn at its command_queued: filled in here. One
|
|
2375
|
-
// queued for after the turn ran after it: its bubble moves from among
|
|
2376
|
-
// the turn's rows to after its answer and timing line.
|
|
2377
|
-
const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
|
|
2378
|
-
if (view.hidden) queued?.remove();
|
|
2379
|
-
else if (queued && view.queued && !view.anytime) {
|
|
2380
|
-
queued.remove();
|
|
2381
|
-
addCommandBubble(view);
|
|
2382
|
-
} else if (queued) drawCommandBubble(queued, view);
|
|
2383
|
-
// A command that changed the conversation (!cmd): the redrawn history
|
|
2384
|
-
// ends with its command bubble already (the saved "!(…)\n<output>"
|
|
2385
|
-
// message renders as one) — no second bubble, or the output shows twice.
|
|
2386
|
-
else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
|
|
2387
|
-
else addCommandBubble(view);
|
|
2388
|
-
},
|
|
2389
|
-
// An anytime command (/help, a plugin's /btw) runs at once, beside a
|
|
2390
|
-
// turn: its bubble goes here, so the cards it shows come after it. One
|
|
2391
|
-
// queued for after the turn (/model X mid-turn) shows waiting here.
|
|
2392
|
-
command_queued: (data) => {
|
|
2393
|
-
// A card's action waits for its command_ran: shown only if it answers.
|
|
2394
|
-
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
2395
|
-
},
|
|
2396
|
-
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
2397
|
-
reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
|
|
2398
|
-
// A context note joined the conversation (between turns; no turn runs).
|
|
2399
|
-
context_added: (data) => {
|
|
2400
|
-
addNoteBubble({ label: data.label, content: data.text || "" });
|
|
2401
|
-
// An attached context source's note: its chip changed.
|
|
2402
|
-
if (data.context_source) contextChips.refresh();
|
|
2403
|
-
},
|
|
2404
|
-
continue_offered: (data) => questionCards.renderContinue(data),
|
|
2405
|
-
continue_resolved: (data) => questionCards.resolveContinue(data),
|
|
2406
|
-
tool_call_started: (data) => turnDom.toolStarted(data),
|
|
2407
|
-
tool_call_completed: (data) => turnDom.toolCompleted(data),
|
|
2408
|
-
// The engine's list, after every memory read and at a turn's end.
|
|
2409
|
-
used_memories_updated: (data) => {
|
|
2410
|
-
if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
|
|
2411
|
-
},
|
|
2412
|
-
context_status: (data) => {
|
|
2413
|
-
const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
|
|
2414
|
-
if (pct !== null) {
|
|
2415
|
-
current.ctxPct = pct;
|
|
2416
|
-
updateInfoBar();
|
|
2417
|
-
}
|
|
2418
|
-
},
|
|
2419
|
-
question_requested: (data) => {
|
|
2420
|
-
const pq = data.pending_question;
|
|
2421
|
-
if (pq && pq.id) questionCards.renderQuestion(pq);
|
|
2422
|
-
},
|
|
2423
|
-
question_answered: (data) => {
|
|
2424
|
-
questionCards.resolveQuestion(data.id, { answer: data.answer });
|
|
2425
|
-
},
|
|
2426
|
-
// A delegate's question relayed to its parent's card, or no longer.
|
|
2427
|
-
question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
|
|
2428
|
-
question_cancelled: (data) => {
|
|
2429
|
-
questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
|
|
2430
|
-
},
|
|
2431
|
-
// One collected just as a turn started describes the chat before it.
|
|
2432
|
-
recap_ready: (data) => {
|
|
2433
|
-
// After the turn it covers, never above it.
|
|
2434
|
-
turnDom.handOff?.({ immediate: true });
|
|
2435
|
-
if (!liveTurn.running) showRecap(data.recap);
|
|
2436
|
-
},
|
|
2437
|
-
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
2438
|
-
// A notice during a turn is a row of its current step. One the page
|
|
2439
|
-
// shows elsewhere (a hook's links in the rendered answer) isn't drawn,
|
|
2440
|
-
// not even in the stage trail; a joined turn's notices come here too.
|
|
2441
|
-
hook_notice: (data) => {
|
|
2442
|
-
if (shownElsewhere(data, pageCapabilities)) return;
|
|
2443
|
-
if (!turnDom.notice(data)) showHookNotice(data);
|
|
2444
|
-
},
|
|
2445
|
-
// The loop asks again after an empty answer: a row of the empty step
|
|
2446
|
-
// (a reload puts it back from the snapshot's cards).
|
|
2447
|
-
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
2448
|
-
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
|
|
2449
|
-
// A batch of LLM context edits the server applied: a ✂ row of the
|
|
2450
|
-
// current step, its hover listing the outputs.
|
|
2451
|
-
llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
|
|
2452
|
-
// The provider is asked again after an error, or the turn waits for
|
|
2453
|
-
// plugins' setup: a note on the live timing line until it streams.
|
|
2454
|
-
generation_retrying: (data) => {
|
|
2455
|
-
// A dropped stream's step asked again: what it streamed is void.
|
|
2456
|
-
if (data?.restarted) turnDom.generationRestarted(data);
|
|
2457
|
-
liveTurn.timing.setNote(retryStatusLine(data));
|
|
2458
|
-
},
|
|
2459
|
-
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
2460
|
-
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
2461
|
-
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
2462
|
-
// A plugin's card, or a new version of one (the same id).
|
|
2463
|
-
card: (data) => {
|
|
2464
|
-
upsertCard(data);
|
|
2465
|
-
if (data.body) refreshCardBodies();
|
|
2466
|
-
},
|
|
2467
|
-
// The stream's cursor could not be replayed: render from scratch.
|
|
2468
|
-
reset: () => resync(),
|
|
2469
|
-
// Synthetic (snapshotEvents): a prompt merged into the turn in progress.
|
|
2470
|
-
merged_input: (data) => {
|
|
2471
|
-
const origin = (data.origins || [])[0] || {};
|
|
2472
|
-
const own = isOwn(origin.client_id, clientId);
|
|
2473
|
-
makeUserBubble(data.content, {
|
|
2474
|
-
state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
|
|
2475
|
-
});
|
|
1618
|
+
},
|
|
1619
|
+
setChips: (list) => setChips(list),
|
|
2476
1620
|
},
|
|
2477
|
-
|
|
1621
|
+
getSession,
|
|
1622
|
+
resync: () => resync(),
|
|
1623
|
+
updateInfoBar: () => updateInfoBar(),
|
|
1624
|
+
applyPromptOps: (data) => applyPromptOps(data),
|
|
1625
|
+
showSteers: (steers) => showSteers(steers),
|
|
1626
|
+
markRecapStale: () => markRecapStale(),
|
|
1627
|
+
setTurnRunning: (running) => setTurnRunning(running),
|
|
1628
|
+
setLiveStatus: (status) => setLiveStatus(status),
|
|
1629
|
+
endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
|
|
1630
|
+
appendChunk: (text) => appendChunk(text),
|
|
1631
|
+
readTurnEnd: () => readTurnEnd(),
|
|
1632
|
+
applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
|
|
1633
|
+
refreshTimingFromSession: () => refreshTimingFromSession(),
|
|
1634
|
+
makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
|
|
1635
|
+
addEndBubble: (text) => addEndBubble(text),
|
|
1636
|
+
commandBubble: (id) => commandBubble(id),
|
|
1637
|
+
addCommandBubble: (view) => addCommandBubble(view),
|
|
1638
|
+
addNoteBubble: (note) => addNoteBubble(note),
|
|
1639
|
+
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
1640
|
+
setUsedMemories: (names) => setUsedMemories(names),
|
|
1641
|
+
showRecap: (text) => showRecap(text),
|
|
1642
|
+
showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
|
|
1643
|
+
showHookNotice: (data) => showHookNotice(data),
|
|
1644
|
+
onInitEvent: (type, data) => onInitEvent(type, data),
|
|
1645
|
+
upsertCard: (card) => upsertCard(card),
|
|
1646
|
+
refreshCardBodies: () => refreshCardBodies(),
|
|
1647
|
+
});
|
|
2478
1648
|
|
|
2479
1649
|
async function refreshTimingFromSession() {
|
|
2480
1650
|
if (!selected) return;
|
|
@@ -2508,7 +1678,7 @@ function closeStream() {
|
|
|
2508
1678
|
function addCreatedSession(created) {
|
|
2509
1679
|
const { bridge_port: _port, ...session } = created;
|
|
2510
1680
|
allSessions = applySessionEvent(allSessions, "session", { session });
|
|
2511
|
-
render();
|
|
1681
|
+
lists.render();
|
|
2512
1682
|
}
|
|
2513
1683
|
|
|
2514
1684
|
// A first message: an idle session, then the turn (or the command) into it
|
|
@@ -2524,36 +1694,17 @@ async function handleSendTurn() {
|
|
|
2524
1694
|
if (!prompt) return;
|
|
2525
1695
|
// An image-only send's text is its placeholders: not for ↑.
|
|
2526
1696
|
const typed = promptEl.value.trim() !== "";
|
|
2527
|
-
// /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
|
|
2528
|
-
// text as a cut, or the page's own answer (/queue, the bare word).
|
|
2529
|
-
let delivery = null;
|
|
2530
|
-
const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
|
|
2531
|
-
if (choice?.reply) {
|
|
2532
|
-
if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
|
|
2533
|
-
else showNotice(choice.reply);
|
|
2534
|
-
promptEl.value = "";
|
|
2535
|
-
fitPrompt();
|
|
2536
|
-
return;
|
|
2537
|
-
}
|
|
2538
1697
|
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
2539
1698
|
const line = prompt;
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
// typo: neither needs a session, so none is made for it (2.29).
|
|
2547
|
-
const local = startPageReplyOrHint(prompt, { commands: current.commands, images: chips.length });
|
|
2548
|
-
if (local) {
|
|
2549
|
-
promptEl.value = "";
|
|
2550
|
-
fitPrompt();
|
|
2551
|
-
showNotice(local);
|
|
2552
|
-
return;
|
|
2553
|
-
}
|
|
1699
|
+
// Where the line goes (composer.js): the page's own reply, a session to
|
|
1700
|
+
// make first (the start page), a command, or a turn.
|
|
1701
|
+
let route = routeComposerLine(prompt, {
|
|
1702
|
+
session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
|
|
1703
|
+
});
|
|
1704
|
+
if (route.create) {
|
|
2554
1705
|
actionBtn.disabled = true;
|
|
2555
1706
|
try {
|
|
2556
|
-
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview:
|
|
1707
|
+
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
|
|
2557
1708
|
llm_context: choiceWords(newChatLlmContext),
|
|
2558
1709
|
thinking: newChatThinking || undefined });
|
|
2559
1710
|
// The choice was for this chat only.
|
|
@@ -2569,20 +1720,19 @@ async function handleSendTurn() {
|
|
|
2569
1720
|
} finally {
|
|
2570
1721
|
actionBtn.disabled = false;
|
|
2571
1722
|
}
|
|
1723
|
+
// In the new session, by its command list (select read it).
|
|
1724
|
+
route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
|
|
1725
|
+
}
|
|
1726
|
+
if (route.reply) {
|
|
1727
|
+
// The start page's reply is a notice: no session is made for it (2.29).
|
|
1728
|
+
if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
|
|
1729
|
+
else showNotice(route.reply);
|
|
1730
|
+
clearComposer();
|
|
1731
|
+
return;
|
|
2572
1732
|
}
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
|
|
2577
|
-
if (!chips.length) {
|
|
2578
|
-
const typo = unknownCommandHint(prompt, current.commands);
|
|
2579
|
-
if (typo) {
|
|
2580
|
-
addCommandBubble({ label: null, line: prompt, text: typo });
|
|
2581
|
-
promptEl.value = "";
|
|
2582
|
-
fitPrompt();
|
|
2583
|
-
return;
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
1733
|
+
if (route.command) return composerCommands.send(route.command);
|
|
1734
|
+
prompt = route.send;
|
|
1735
|
+
const delivery = route.delivery;
|
|
2586
1736
|
const hint = historyEl.querySelector(".hint");
|
|
2587
1737
|
if (hint) hint.remove();
|
|
2588
1738
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -2605,8 +1755,7 @@ async function handleSendTurn() {
|
|
|
2605
1755
|
// The worker knew it as a command (its list was newer than ours):
|
|
2606
1756
|
// its command_ran shows it, not a prompt bubble.
|
|
2607
1757
|
echo.remove();
|
|
2608
|
-
|
|
2609
|
-
fitPrompt();
|
|
1758
|
+
clearComposer();
|
|
2610
1759
|
followWokenWorker();
|
|
2611
1760
|
return;
|
|
2612
1761
|
}
|
|
@@ -2650,47 +1799,36 @@ async function handleSendTurn() {
|
|
|
2650
1799
|
}
|
|
2651
1800
|
}
|
|
2652
1801
|
|
|
2653
|
-
// /model, !ls
|
|
2654
|
-
//
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
} finally {
|
|
2679
|
-
actionBtn.disabled = current.owner === "tui";
|
|
2680
|
-
promptEl.focus();
|
|
2681
|
-
}
|
|
1802
|
+
// /model, !ls … from the composer, and a control's command (the llm ctx
|
|
1803
|
+
// and thinking chips): composer.js.
|
|
1804
|
+
const composerCommands = createComposerCommands({
|
|
1805
|
+
session: () => selected,
|
|
1806
|
+
clientId,
|
|
1807
|
+
sendCommand,
|
|
1808
|
+
followWokenWorker: () => followWokenWorker(),
|
|
1809
|
+
refreshHistory: () => refreshHistory(),
|
|
1810
|
+
failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
|
|
1811
|
+
ownedByTui: () => {
|
|
1812
|
+
current.owner = "tui";
|
|
1813
|
+
updateComposerMode();
|
|
1814
|
+
},
|
|
1815
|
+
composer: {
|
|
1816
|
+
busy: () => { actionBtn.disabled = true; },
|
|
1817
|
+
clear: () => clearComposer(),
|
|
1818
|
+
done: () => {
|
|
1819
|
+
actionBtn.disabled = current.owner === "tui";
|
|
1820
|
+
promptEl.focus();
|
|
1821
|
+
},
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
function runSessionCommand(line) {
|
|
1826
|
+
return composerCommands.run(line);
|
|
2682
1827
|
}
|
|
2683
1828
|
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
if (!selected) return;
|
|
2688
|
-
try {
|
|
2689
|
-
await sendCommand(selected, line, { clientId });
|
|
2690
|
-
followWokenWorker();
|
|
2691
|
-
} catch (e) {
|
|
2692
|
-
addCommandBubble({ label: null, line, text: e.message, failed: true });
|
|
2693
|
-
}
|
|
1829
|
+
function clearComposer() {
|
|
1830
|
+
promptEl.value = "";
|
|
1831
|
+
fitPrompt();
|
|
2694
1832
|
}
|
|
2695
1833
|
|
|
2696
1834
|
function addCommandBubble(view) {
|
|
@@ -2704,24 +1842,6 @@ function addCommandBubble(view) {
|
|
|
2704
1842
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2705
1843
|
}
|
|
2706
1844
|
|
|
2707
|
-
// A command bubble's line and output (again, when its command_ran fills in
|
|
2708
|
-
// an anytime command's bubble in place).
|
|
2709
|
-
function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
|
|
2710
|
-
div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
|
|
2711
|
-
if (commandId) div.dataset.commandId = commandId;
|
|
2712
|
-
div.replaceChildren();
|
|
2713
|
-
const head = document.createElement("div");
|
|
2714
|
-
head.className = "command-line";
|
|
2715
|
-
head.textContent = label ? `${label}> ${line}` : line;
|
|
2716
|
-
div.appendChild(head);
|
|
2717
|
-
if (text) {
|
|
2718
|
-
const body = document.createElement("pre");
|
|
2719
|
-
body.className = "command-output";
|
|
2720
|
-
body.textContent = text;
|
|
2721
|
-
div.appendChild(body);
|
|
2722
|
-
}
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
1845
|
function commandBubble(commandId) {
|
|
2726
1846
|
if (!commandId) return null;
|
|
2727
1847
|
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
@@ -3104,7 +2224,7 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
3104
2224
|
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
|
|
3105
2225
|
return;
|
|
3106
2226
|
}
|
|
3107
|
-
render();
|
|
2227
|
+
lists.render();
|
|
3108
2228
|
updateInfoBar();
|
|
3109
2229
|
if (!eventsControl) refresh();
|
|
3110
2230
|
const what = archive ? "Archived" : "Unarchived";
|
|
@@ -3148,10 +2268,6 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
|
|
|
3148
2268
|
if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
3149
2269
|
}
|
|
3150
2270
|
|
|
3151
|
-
$("#includeArchived").addEventListener("change", () => {
|
|
3152
|
-
if (allViewOpen()) renderAll();
|
|
3153
|
-
});
|
|
3154
|
-
|
|
3155
2271
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
3156
2272
|
// "Annotate" and the presets, which append a quote to the composer.
|
|
3157
2273
|
const annotateBar = createAnnotateBar({
|
|
@@ -3198,7 +2314,7 @@ function applyStripHidden() {
|
|
|
3198
2314
|
updateSessionTitle();
|
|
3199
2315
|
}
|
|
3200
2316
|
function setStripHidden(hidden) {
|
|
3201
|
-
if (hidden) familyPop.close();
|
|
2317
|
+
if (hidden) lists.familyPop.close();
|
|
3202
2318
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
3203
2319
|
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
3204
2320
|
applyStripHidden();
|
|
@@ -3211,23 +2327,11 @@ shortWindow.addEventListener("change", () => {
|
|
|
3211
2327
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
3212
2328
|
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
3213
2329
|
|
|
3214
|
-
$("#filter").addEventListener("input", () => {
|
|
3215
|
-
searchToggles.clear();
|
|
3216
|
-
renderAll();
|
|
3217
|
-
});
|
|
3218
|
-
// A list whose order was held under the pointer (listOrder) catches up.
|
|
3219
|
-
topStripEl.addEventListener("pointerleave", () => {
|
|
3220
|
-
// Not while its family popover is open: the pointer went there (close() catches up).
|
|
3221
|
-
if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
|
|
3222
|
-
});
|
|
3223
|
-
allListEl.addEventListener("pointerleave", () => {
|
|
3224
|
-
if (heldLists.has(allListEl) && allViewOpen()) renderAll();
|
|
3225
|
-
});
|
|
3226
2330
|
// Esc leaves select mode first, then the all-sessions view.
|
|
3227
2331
|
document.addEventListener("keydown", (e) => {
|
|
3228
2332
|
if (e.key !== "Escape" || !allViewOpen()) return;
|
|
3229
|
-
if (selecting.on) {
|
|
3230
|
-
if (!selecting.running) leaveSelect();
|
|
2333
|
+
if (lists.selecting.on) {
|
|
2334
|
+
if (!lists.selecting.running) lists.leaveSelect();
|
|
3231
2335
|
return;
|
|
3232
2336
|
}
|
|
3233
2337
|
closeAllView();
|