samagotchi 0.54.0 → 0.56.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 +50 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +6 -2
- data/docs/cli.md +29 -7
- data/docs/configuration.md +113 -8
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/context-telemetry.md +23 -5
- data/docs/internals/engine.md +54 -18
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/sessions.md +2 -2
- data/docs/testing.md +23 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +9 -3
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/file_ref.rb +59 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +4 -3
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_row_fields.rb +46 -0
- data/lib/samagotchi/tool_runner.rb +7 -11
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -4
- 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 +33 -18
- data/lib/samagotchi/web/public/app.js +256 -1133
- data/lib/samagotchi/web/public/commands.js +269 -0
- data/lib/samagotchi/web/public/composer.js +105 -0
- data/lib/samagotchi/web/public/diff_view.js +30 -4
- data/lib/samagotchi/web/public/index.html +11 -3
- data/lib/samagotchi/web/public/info_bar.js +80 -0
- data/lib/samagotchi/web/public/level_chip.js +174 -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/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_cards.js +2 -1
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- 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 +57 -0
- data/lib/samagotchi/web/public/stream_handlers.js +332 -0
- data/lib/samagotchi/web/public/thinking_chip.js +38 -144
- data/lib/samagotchi/web/public/turn_events.js +17 -258
- data/lib/samagotchi/web/public/turn_html.js +5 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/worker.rb +17 -5
- metadata +18 -2
- data/lib/samagotchi/web/public/stable_html.js +0 -18
|
@@ -23,52 +23,56 @@ 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 { createReasoningHistoryControl } from "./reasoning_history_chip.js";
|
|
34
|
+
import { renderInfoBar } from "./info_bar.js";
|
|
34
35
|
import { createShownTexts } from "./shown_texts.js";
|
|
35
36
|
import { MODEL_KEY } from "./model_pick.js";
|
|
36
|
-
import { emptySessionState } from "./session_state.js";
|
|
37
|
+
import { emptySessionState, sessionStateFrom } from "./session_state.js";
|
|
37
38
|
import { createModelPicker } from "./model_picker.js";
|
|
38
39
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
39
|
-
import { escapeHtml,
|
|
40
|
+
import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
|
|
40
41
|
import { createTurnView, turnHistoryNodes } from "./turn_view.js";
|
|
41
42
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
42
43
|
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";
|
|
44
|
+
import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
|
|
45
45
|
import { createTaskStopper } from "./task_stop.js";
|
|
46
46
|
import { appendQuote } from "./annotations.js";
|
|
47
47
|
import { parsePresets } from "./annotate_presets.js";
|
|
48
48
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
49
49
|
import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
|
|
50
|
-
import {
|
|
51
|
-
import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, ctxBarTip, generationTokens, lastSpeedText, speedText } from "./ctx.js";
|
|
50
|
+
import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
|
|
52
51
|
import { createQuestionCards } from "./question_cards.js";
|
|
53
|
-
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml,
|
|
52
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
54
53
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
55
54
|
import { createCommandList } from "./command_list.js";
|
|
56
55
|
import { createPromptHistory } from "./prompt_history.js";
|
|
57
56
|
import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
58
|
-
import { allScopeHref,
|
|
57
|
+
import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
|
|
59
58
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
60
|
-
import { SHORT_WINDOW_QUERY, stripAutoHidden
|
|
61
|
-
import { applySessionEvent,
|
|
59
|
+
import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
|
|
60
|
+
import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
|
|
62
61
|
import { createNotifications } from "./notifications.js";
|
|
63
62
|
import { tabTitle, topBarTitle } from "./session_title.js";
|
|
64
63
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
65
|
-
import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
|
|
66
64
|
import {
|
|
67
|
-
appendAboveLiveTiming,
|
|
65
|
+
appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
|
|
68
66
|
} from "./timing.js";
|
|
69
67
|
import { createLiveTurn } from "./live_turn.js";
|
|
68
|
+
import { installRefClicks, setRefKinds } from "./refs.js";
|
|
70
69
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
71
|
-
import {
|
|
70
|
+
import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
|
|
71
|
+
import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
|
|
72
|
+
import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
|
|
73
|
+
import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
|
|
74
|
+
import { createStreamHandlers } from "./stream_handlers.js";
|
|
75
|
+
import { createListsView } from "./lists_view.js";
|
|
72
76
|
|
|
73
77
|
const $ = (s) => document.querySelector(s);
|
|
74
78
|
const topStripEl = $("#topStrip");
|
|
@@ -131,6 +135,15 @@ const thinkingControl = createThinkingControl({
|
|
|
131
135
|
running: () => liveTurn.running,
|
|
132
136
|
run: (line) => runSessionCommand(line),
|
|
133
137
|
});
|
|
138
|
+
// The open session's reasoning history chip, after the thinking one (a
|
|
139
|
+
// chat host's only): /reasoning-history runs in the session's worker.
|
|
140
|
+
const reasoningHistoryControl = createReasoningHistoryControl({
|
|
141
|
+
el: infoTextEl,
|
|
142
|
+
summary: () => (selected ? current.reasoningHistory : null),
|
|
143
|
+
session: () => selected,
|
|
144
|
+
running: () => liveTurn.running,
|
|
145
|
+
run: (line) => runSessionCommand(line),
|
|
146
|
+
});
|
|
134
147
|
// The start page's thinking chip: the picked model's level, and a level
|
|
135
148
|
// for the next new chat only (sent with its create, then dropped).
|
|
136
149
|
const thinkingNewEl = $("#thinkingNew");
|
|
@@ -170,7 +183,11 @@ const markdownWarningEl = $("#markdownWarning");
|
|
|
170
183
|
// What the page renders that a notice may stand in for (the server's
|
|
171
184
|
// Web::Capabilities, next to markdown_warning); a notice it names isn't
|
|
172
185
|
// drawn (shownElsewhere). An older server sends none: every notice shows.
|
|
186
|
+
// And what this viewer can do (editor: open a path in this machine's
|
|
187
|
+
// editor; per request), with the editor's URL template (editor_url) next
|
|
188
|
+
// to it: refs.js's actions read both (pageViewer).
|
|
173
189
|
let pageCapabilities = {};
|
|
190
|
+
let pageEditorUrl = null;
|
|
174
191
|
const promptEl = $("#prompt");
|
|
175
192
|
const actionBtn = $("#actionBtn");
|
|
176
193
|
const cancelBtn = $("#cancelBtn");
|
|
@@ -286,7 +303,7 @@ async function refresh() {
|
|
|
286
303
|
allSessions = [];
|
|
287
304
|
renderScope();
|
|
288
305
|
}
|
|
289
|
-
render();
|
|
306
|
+
lists.render();
|
|
290
307
|
}
|
|
291
308
|
|
|
292
309
|
// The list as a projection of the hub's events: a snapshot on every
|
|
@@ -298,14 +315,14 @@ function openSessionEvents() {
|
|
|
298
315
|
noticeVersions(data);
|
|
299
316
|
notifications.noticeSessions("snapshot", data);
|
|
300
317
|
allSessions = applySessionEvent(allSessions, "snapshot", data);
|
|
301
|
-
render();
|
|
318
|
+
lists.render();
|
|
302
319
|
const sess = allSessions.find((s) => s.id === selected);
|
|
303
320
|
if (sess) followSelectedSession(sess);
|
|
304
321
|
},
|
|
305
322
|
session: (data) => {
|
|
306
323
|
notifications.noticeSessions("session", data);
|
|
307
324
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
308
|
-
render();
|
|
325
|
+
lists.render();
|
|
309
326
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
310
327
|
// Its children chip; its chain's ← → links (a link that continues it, its previous one).
|
|
311
328
|
else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
|
|
@@ -313,12 +330,11 @@ function openSessionEvents() {
|
|
|
313
330
|
},
|
|
314
331
|
session_gone: (data) => {
|
|
315
332
|
notifications.noticeSessions("session_gone", data);
|
|
316
|
-
|
|
317
|
-
selecting.skipReasons.delete(data.id);
|
|
333
|
+
lists.forget(data.id);
|
|
318
334
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
319
335
|
if (next !== allSessions) {
|
|
320
336
|
allSessions = next;
|
|
321
|
-
render();
|
|
337
|
+
lists.render();
|
|
322
338
|
if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
|
|
323
339
|
}
|
|
324
340
|
leaveGoneSession(data.id, "deleted");
|
|
@@ -399,582 +415,30 @@ function renderScope() {
|
|
|
399
415
|
});
|
|
400
416
|
}
|
|
401
417
|
|
|
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(ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }))}">${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({
|
|
418
|
+
// The session lists (lists_view.js): the strip, all sessions, their cards,
|
|
419
|
+
// select mode, the family and chain popovers.
|
|
420
|
+
const lists = createListsView({
|
|
552
421
|
doc: document,
|
|
553
422
|
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();
|
|
423
|
+
topStripEl,
|
|
424
|
+
allListEl,
|
|
425
|
+
sessions: () => allSessions,
|
|
426
|
+
selected: () => selected,
|
|
427
|
+
scope,
|
|
428
|
+
scopeError: () => scopeError,
|
|
429
|
+
eventsLive: () => !!eventsControl,
|
|
430
|
+
refresh: () => refresh(),
|
|
431
|
+
select: (id) => select(id),
|
|
432
|
+
confirmDelete: (id) => confirmDelete(id),
|
|
433
|
+
setArchived: (id, archive) => setArchived(id, archive),
|
|
434
|
+
archiveOne: (id, archive) => archiveOne(id, archive),
|
|
435
|
+
updateInfoBar: () => updateInfoBar(),
|
|
436
|
+
updateSessionTitle: () => updateSessionTitle(),
|
|
437
|
+
showToast: (text, action) => showToast(text, action),
|
|
438
|
+
allViewOpen: () => allViewOpen(),
|
|
439
|
+
openAllView: () => openAllView(),
|
|
440
|
+
leaveAllView: (hash) => leaveAllView(hash),
|
|
441
|
+
setStripHidden: (hidden) => setStripHidden(hidden),
|
|
978
442
|
});
|
|
979
443
|
|
|
980
444
|
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
@@ -996,7 +460,7 @@ async function confirmDelete(id) {
|
|
|
996
460
|
}
|
|
997
461
|
allSessions = allSessions.filter((s) => s.id !== id);
|
|
998
462
|
if (selected === id) clearSelection();
|
|
999
|
-
render();
|
|
463
|
+
lists.render();
|
|
1000
464
|
}
|
|
1001
465
|
|
|
1002
466
|
// All sessions: its own view at #/sessions, so the browser's back button
|
|
@@ -1044,19 +508,19 @@ function applyView() {
|
|
|
1044
508
|
if (!open) {
|
|
1045
509
|
allViewPushed = false;
|
|
1046
510
|
// A running batch ends on its own and clears the picks then.
|
|
1047
|
-
if (selecting.on && !selecting.running) leaveSelect();
|
|
511
|
+
if (lists.selecting.on && !lists.selecting.running) lists.leaveSelect();
|
|
1048
512
|
topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
|
|
1049
513
|
}
|
|
1050
514
|
document.body.classList.toggle("all-view", open);
|
|
1051
515
|
updateSessionTitle();
|
|
1052
516
|
// Its chip was in the list just hidden (the strip, or all sessions).
|
|
1053
|
-
chainPop.close();
|
|
517
|
+
lists.chainPop.close();
|
|
1054
518
|
if (open) {
|
|
1055
|
-
familyPop.close(); // the strip is hidden in the all view
|
|
519
|
+
lists.familyPop.close(); // the strip is hidden in the all view
|
|
1056
520
|
// The list is live; only its order can lag (upserts keep cards in
|
|
1057
521
|
// place), so the all view opens in the server's order.
|
|
1058
522
|
allSessions = sortedByUpdated(allSessions);
|
|
1059
|
-
renderAll();
|
|
523
|
+
lists.renderAll();
|
|
1060
524
|
if (!eventsControl) refresh();
|
|
1061
525
|
$("#filter").select();
|
|
1062
526
|
}
|
|
@@ -1151,7 +615,7 @@ function setLiveStatus(status) {
|
|
|
1151
615
|
// snapshot or the all view re-sorts (no card jumping under the pointer;
|
|
1152
616
|
// a session event replaces it in place too, see sessions_list.js).
|
|
1153
617
|
allSessions[i] = withLiveStatus(allSessions[i], status);
|
|
1154
|
-
render();
|
|
618
|
+
lists.render();
|
|
1155
619
|
}
|
|
1156
620
|
updateInfoBar();
|
|
1157
621
|
}
|
|
@@ -1194,14 +658,14 @@ function clearSelection() {
|
|
|
1194
658
|
seedInitTasks(initTasks, []);
|
|
1195
659
|
renderInitRow();
|
|
1196
660
|
setSessionHash(null);
|
|
1197
|
-
paintActive();
|
|
661
|
+
lists.paintActive();
|
|
1198
662
|
resetSessionState();
|
|
1199
663
|
contextChips.clear();
|
|
1200
664
|
liveTurn.forget();
|
|
1201
665
|
setTurnRunning(false);
|
|
1202
666
|
closeStream();
|
|
1203
667
|
setMarkdownWarning(null);
|
|
1204
|
-
setCapabilities(null);
|
|
668
|
+
setCapabilities(null, null);
|
|
1205
669
|
updateComposerMode();
|
|
1206
670
|
updateInfoBar();
|
|
1207
671
|
promptEl.focus();
|
|
@@ -1231,7 +695,7 @@ async function select(id) {
|
|
|
1231
695
|
clearNotice();
|
|
1232
696
|
selected = id;
|
|
1233
697
|
setSessionHash(id);
|
|
1234
|
-
paintActive();
|
|
698
|
+
lists.paintActive();
|
|
1235
699
|
historyEl.innerHTML = `<div class="hint">Loading…</div>`;
|
|
1236
700
|
emptyEl.classList.add("hidden");
|
|
1237
701
|
closeStream();
|
|
@@ -1278,7 +742,7 @@ async function resync({ endText = null } = {}) {
|
|
|
1278
742
|
return;
|
|
1279
743
|
}
|
|
1280
744
|
if (id !== selected) return;
|
|
1281
|
-
const kept = keptCommandBubbles(id);
|
|
745
|
+
const kept = keptCommandBubbles(historyEl, id);
|
|
1282
746
|
resetTurnView();
|
|
1283
747
|
renderSessionView(data, { kept });
|
|
1284
748
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
@@ -1295,88 +759,24 @@ async function resync({ endText = null } = {}) {
|
|
|
1295
759
|
}
|
|
1296
760
|
}
|
|
1297
761
|
|
|
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
762
|
// A session view: its messages, then the turn in progress and the queued
|
|
1334
763
|
// prompts (a live worker's snapshot) replayed through the stream handlers,
|
|
1335
764
|
// then a pending question.
|
|
1336
765
|
function renderSessionView(data, { kept = [] } = {}) {
|
|
1337
|
-
const s = data.session;
|
|
1338
766
|
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);
|
|
767
|
+
setCapabilities(data.capabilities, data.editor_url);
|
|
768
|
+
// The saved fill (when there is one) goes in here too, so a reloaded or
|
|
769
|
+
// stopped session shows it before its next turn (a running turn's replay
|
|
770
|
+
// below streams over it).
|
|
771
|
+
Object.assign(current, sessionStateFrom(data));
|
|
1346
772
|
liveTurn.timing.ended = current.timing.turnRecords.length;
|
|
1347
773
|
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
774
|
llmContextControl.refresh();
|
|
1363
|
-
current.thinking = s.thinking || null;
|
|
1364
775
|
thinkingControl.refresh();
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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);
|
|
776
|
+
reasoningHistoryControl.refresh();
|
|
777
|
+
if (data.messages && data.messages.length) renderHistory(data.messages);
|
|
778
|
+
else renderHistory(data.history || []);
|
|
779
|
+
restoreCommandBubbles(historyEl, kept);
|
|
1380
780
|
// A failed turn's prompt went back to the user: shown after the
|
|
1381
781
|
// conversation, marked failed, as it was live (prompt_restored).
|
|
1382
782
|
if (data.failed_turn) {
|
|
@@ -1453,10 +853,23 @@ function setMarkdownWarning(warning) {
|
|
|
1453
853
|
markdownWarningEl.hidden = false;
|
|
1454
854
|
}
|
|
1455
855
|
|
|
1456
|
-
|
|
856
|
+
// Both from one payload, together; the body's has-editor and refs-<kind>
|
|
857
|
+
// classes follow (the refs' hover styling keys on refs-<kind>).
|
|
858
|
+
function setCapabilities(capabilities, editorUrl) {
|
|
1457
859
|
pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
|
|
860
|
+
pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
|
|
861
|
+
document.body.classList.toggle("has-editor", pageEditorUrl !== null);
|
|
862
|
+
setRefKinds(document.body, pageViewer());
|
|
1458
863
|
}
|
|
1459
864
|
|
|
865
|
+
// What this viewer can do with a ref (refs.js REF_ACTIONS).
|
|
866
|
+
function pageViewer() {
|
|
867
|
+
return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Refs anywhere on the page (a tool row's file, its diff lines): one listener.
|
|
871
|
+
installRefClicks(document.body, pageViewer);
|
|
872
|
+
|
|
1460
873
|
async function readTurnEnd() {
|
|
1461
874
|
if (!selected) return;
|
|
1462
875
|
const id = selected;
|
|
@@ -1474,7 +887,7 @@ async function readTurnEnd() {
|
|
|
1474
887
|
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
1475
888
|
liveTurn.timing.finishLine(line);
|
|
1476
889
|
setMarkdownWarning(d.markdown_warning);
|
|
1477
|
-
setCapabilities(d.capabilities);
|
|
890
|
+
setCapabilities(d.capabilities, d.editor_url);
|
|
1478
891
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1479
892
|
updateInfoBar();
|
|
1480
893
|
const sess = allSessions.find(s=>s.id===selected);
|
|
@@ -1482,8 +895,8 @@ async function readTurnEnd() {
|
|
|
1482
895
|
sess.used_memory_names = current.usedMemories.slice();
|
|
1483
896
|
sess.status = current.status;
|
|
1484
897
|
}
|
|
1485
|
-
render();
|
|
1486
|
-
paintActive();
|
|
898
|
+
lists.render();
|
|
899
|
+
lists.paintActive();
|
|
1487
900
|
}
|
|
1488
901
|
} catch(_){}
|
|
1489
902
|
}
|
|
@@ -1718,11 +1131,6 @@ function showSteers(steers) {
|
|
|
1718
1131
|
}
|
|
1719
1132
|
}
|
|
1720
1133
|
|
|
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
1134
|
function showHookNotice(data, { before = null } = {}) {
|
|
1727
1135
|
removeHintIfEmpty();
|
|
1728
1136
|
const el = document.createElement("div");
|
|
@@ -1957,16 +1365,6 @@ function currentWorkerBadge() {
|
|
|
1957
1365
|
installed: versions.installed, served: versions.served, sessionId: selected });
|
|
1958
1366
|
}
|
|
1959
1367
|
|
|
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
1368
|
// Restart: the session moves to a new worker on the newest chi; the page
|
|
1971
1369
|
// follows it (its bridge_up), the composer's draft stays as it is.
|
|
1972
1370
|
async function restartWorker() {
|
|
@@ -2011,49 +1409,49 @@ function updateInfoBar() {
|
|
|
2011
1409
|
}
|
|
2012
1410
|
if (!selected) {
|
|
2013
1411
|
// The start page hides the info bar (the storage path is on #/sessions).
|
|
2014
|
-
|
|
2015
|
-
infoTextEl.title = "";
|
|
1412
|
+
renderInfoBar(infoTextEl, null);
|
|
2016
1413
|
if (stopBtn) stopBtn.style.display = "none";
|
|
2017
1414
|
updateSessionTitle();
|
|
2018
1415
|
return;
|
|
2019
1416
|
}
|
|
2020
1417
|
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
1418
|
const tokens = current.timing.tokens;
|
|
2024
1419
|
const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
|
|
2025
1420
|
{ tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
|
|
2026
1421
|
budget: current.llmContext?.budget_tokens });
|
|
2027
1422
|
const speedTextNow = lastSpeedText(tokens);
|
|
2028
1423
|
const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
|
|
2029
|
-
const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
|
|
2030
1424
|
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
1425
|
// A delegated session names its parent, as a link to it.
|
|
2035
1426
|
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
|
-
|
|
1427
|
+
const badge = currentWorkerBadge();
|
|
1428
|
+
// The session clock ticks every second while a turn runs: lit writes its
|
|
1429
|
+
// text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1430
|
+
renderInfoBar(infoTextEl, {
|
|
1431
|
+
id: selected,
|
|
1432
|
+
copied: Date.now() - attachCopiedAt < 2000,
|
|
1433
|
+
status: current.status,
|
|
1434
|
+
model: modelLabel(current.model, ...(current.served || [])),
|
|
1435
|
+
// A link of a session chain: ← the previous link, → the next one (each
|
|
1436
|
+
// only while the list holds it).
|
|
1437
|
+
chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
|
|
1438
|
+
parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
|
|
1439
|
+
// A parent names its delegates; the link opens all sessions.
|
|
1440
|
+
kids: childrenSummary({ id: selected }, allSessions),
|
|
1441
|
+
llmContext: current.llmContext,
|
|
1442
|
+
thinking: current.thinking,
|
|
1443
|
+
reasoningHistory: current.reasoningHistory,
|
|
1444
|
+
// The model notes the session's prompt carried.
|
|
1445
|
+
notes: promptNotesChip(current.promptNotes),
|
|
1446
|
+
memories: current.usedMemories,
|
|
1447
|
+
preloaded: current.preloadedMemories,
|
|
1448
|
+
muted: current.mutedMemories,
|
|
1449
|
+
dir: current.dir,
|
|
1450
|
+
ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
|
|
1451
|
+
speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
|
|
1452
|
+
clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
|
|
1453
|
+
worker: badge ? { badge, busy: restartingId === selected } : null,
|
|
1454
|
+
});
|
|
2057
1455
|
updateSessionTitle();
|
|
2058
1456
|
}
|
|
2059
1457
|
|
|
@@ -2062,9 +1460,9 @@ function updateInfoBar() {
|
|
|
2062
1460
|
// browser tab names it too. It follows the card's: a chain link opened with
|
|
2063
1461
|
// the opener reads its chain's first link's title (cardTitle).
|
|
2064
1462
|
function updateSessionTitle() {
|
|
2065
|
-
const shown = selected ? sessionsById().get(selected) : null;
|
|
1463
|
+
const shown = selected ? lists.sessionsById().get(selected) : null;
|
|
2066
1464
|
const first = current.firstPreview || "";
|
|
2067
|
-
const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
|
|
1465
|
+
const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
|
|
2068
1466
|
const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
|
|
2069
1467
|
const text = topBarTitle({
|
|
2070
1468
|
title,
|
|
@@ -2083,24 +1481,12 @@ function updateSessionTitle() {
|
|
|
2083
1481
|
}
|
|
2084
1482
|
}
|
|
2085
1483
|
|
|
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(" · ");
|
|
1484
|
+
// A chain's link (chains.js chainNeighbours) as the info bar's ← / →
|
|
1485
|
+
// shows it (info_bar.js chainLinkTemplate), or null.
|
|
1486
|
+
function chainLink(link) {
|
|
1487
|
+
if (!link) return null;
|
|
1488
|
+
return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
|
|
1489
|
+
archived: !!link.session?.archived };
|
|
2104
1490
|
}
|
|
2105
1491
|
|
|
2106
1492
|
function currentSessionDuration() {
|
|
@@ -2141,8 +1527,8 @@ function setUsedMemories(names) {
|
|
|
2141
1527
|
const sess = allSessions.find(s=>s.id===selected);
|
|
2142
1528
|
if (sess) {
|
|
2143
1529
|
sess.used_memory_names = current.usedMemories.slice();
|
|
2144
|
-
render();
|
|
2145
|
-
paintActive();
|
|
1530
|
+
lists.render();
|
|
1531
|
+
lists.paintActive();
|
|
2146
1532
|
}
|
|
2147
1533
|
}
|
|
2148
1534
|
|
|
@@ -2197,7 +1583,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
2197
1583
|
const i = allSessions.findIndex((s) => s.id === id);
|
|
2198
1584
|
if (i >= 0 && allSessions[i].owner === "worker") {
|
|
2199
1585
|
allSessions[i] = withoutWorker(allSessions[i]);
|
|
2200
|
-
render();
|
|
1586
|
+
lists.render();
|
|
2201
1587
|
}
|
|
2202
1588
|
updateInfoBar();
|
|
2203
1589
|
// Once: a worker still alive with a bridge (the stream dropped, not the
|
|
@@ -2207,274 +1593,71 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
2207
1593
|
else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
|
|
2208
1594
|
}
|
|
2209
1595
|
|
|
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
|
-
|
|
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) {
|
|
1596
|
+
// The stream's event handlers (stream_handlers.js); renderSessionView
|
|
1597
|
+
// replays a snapshot through them too.
|
|
1598
|
+
const streamHandlers = createStreamHandlers({
|
|
1599
|
+
selected: () => selected,
|
|
1600
|
+
state: () => current,
|
|
1601
|
+
clientId,
|
|
1602
|
+
capabilities: () => pageCapabilities,
|
|
1603
|
+
historyEl,
|
|
1604
|
+
liveTurn,
|
|
1605
|
+
turnDom,
|
|
1606
|
+
shownTexts,
|
|
1607
|
+
questionCards,
|
|
1608
|
+
contextChips,
|
|
1609
|
+
llmContextControl,
|
|
1610
|
+
thinkingControl,
|
|
1611
|
+
reasoningHistoryControl,
|
|
1612
|
+
sentIds,
|
|
1613
|
+
earlyRestores,
|
|
1614
|
+
stream: {
|
|
1615
|
+
takeGuess: () => {
|
|
1616
|
+
const guess = streamFromGuess;
|
|
2254
1617
|
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");
|
|
1618
|
+
return guess;
|
|
1619
|
+
},
|
|
1620
|
+
restart: () => {
|
|
1621
|
+
streamControl?.close();
|
|
1622
|
+
streamControl = null;
|
|
1623
|
+
resync();
|
|
1624
|
+
},
|
|
2323
1625
|
},
|
|
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;
|
|
1626
|
+
composer: {
|
|
1627
|
+
empty: () => !promptEl.value.trim() && !chips.length,
|
|
1628
|
+
refill: (text) => {
|
|
1629
|
+
promptEl.value = text;
|
|
2332
1630
|
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
|
-
});
|
|
1631
|
+
},
|
|
1632
|
+
setChips: (list) => setChips(list),
|
|
2476
1633
|
},
|
|
2477
|
-
|
|
1634
|
+
getSession,
|
|
1635
|
+
resync: () => resync(),
|
|
1636
|
+
updateInfoBar: () => updateInfoBar(),
|
|
1637
|
+
applyPromptOps: (data) => applyPromptOps(data),
|
|
1638
|
+
showSteers: (steers) => showSteers(steers),
|
|
1639
|
+
markRecapStale: () => markRecapStale(),
|
|
1640
|
+
setTurnRunning: (running) => setTurnRunning(running),
|
|
1641
|
+
setLiveStatus: (status) => setLiveStatus(status),
|
|
1642
|
+
endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
|
|
1643
|
+
appendChunk: (text) => appendChunk(text),
|
|
1644
|
+
readTurnEnd: () => readTurnEnd(),
|
|
1645
|
+
applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
|
|
1646
|
+
refreshTimingFromSession: () => refreshTimingFromSession(),
|
|
1647
|
+
makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
|
|
1648
|
+
addEndBubble: (text) => addEndBubble(text),
|
|
1649
|
+
commandBubble: (id) => commandBubble(id),
|
|
1650
|
+
addCommandBubble: (view) => addCommandBubble(view),
|
|
1651
|
+
addNoteBubble: (note) => addNoteBubble(note),
|
|
1652
|
+
findInTurnRoots: (selector) => findInTurnRoots(selector),
|
|
1653
|
+
setUsedMemories: (names) => setUsedMemories(names),
|
|
1654
|
+
showRecap: (text) => showRecap(text),
|
|
1655
|
+
showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
|
|
1656
|
+
showHookNotice: (data) => showHookNotice(data),
|
|
1657
|
+
onInitEvent: (type, data) => onInitEvent(type, data),
|
|
1658
|
+
upsertCard: (card) => upsertCard(card),
|
|
1659
|
+
refreshCardBodies: () => refreshCardBodies(),
|
|
1660
|
+
});
|
|
2478
1661
|
|
|
2479
1662
|
async function refreshTimingFromSession() {
|
|
2480
1663
|
if (!selected) return;
|
|
@@ -2508,7 +1691,7 @@ function closeStream() {
|
|
|
2508
1691
|
function addCreatedSession(created) {
|
|
2509
1692
|
const { bridge_port: _port, ...session } = created;
|
|
2510
1693
|
allSessions = applySessionEvent(allSessions, "session", { session });
|
|
2511
|
-
render();
|
|
1694
|
+
lists.render();
|
|
2512
1695
|
}
|
|
2513
1696
|
|
|
2514
1697
|
// A first message: an idle session, then the turn (or the command) into it
|
|
@@ -2524,36 +1707,17 @@ async function handleSendTurn() {
|
|
|
2524
1707
|
if (!prompt) return;
|
|
2525
1708
|
// An image-only send's text is its placeholders: not for ↑.
|
|
2526
1709
|
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
1710
|
// The composer holds the line as typed until the ack (composerAfterAck).
|
|
2539
1711
|
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
|
-
}
|
|
1712
|
+
// Where the line goes (composer.js): the page's own reply, a session to
|
|
1713
|
+
// make first (the start page), a command, or a turn.
|
|
1714
|
+
let route = routeComposerLine(prompt, {
|
|
1715
|
+
session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
|
|
1716
|
+
});
|
|
1717
|
+
if (route.create) {
|
|
2554
1718
|
actionBtn.disabled = true;
|
|
2555
1719
|
try {
|
|
2556
|
-
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview:
|
|
1720
|
+
const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
|
|
2557
1721
|
llm_context: choiceWords(newChatLlmContext),
|
|
2558
1722
|
thinking: newChatThinking || undefined });
|
|
2559
1723
|
// The choice was for this chat only.
|
|
@@ -2569,20 +1733,19 @@ async function handleSendTurn() {
|
|
|
2569
1733
|
} finally {
|
|
2570
1734
|
actionBtn.disabled = false;
|
|
2571
1735
|
}
|
|
1736
|
+
// In the new session, by its command list (select read it).
|
|
1737
|
+
route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
|
|
1738
|
+
}
|
|
1739
|
+
if (route.reply) {
|
|
1740
|
+
// The start page's reply is a notice: no session is made for it (2.29).
|
|
1741
|
+
if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
|
|
1742
|
+
else showNotice(route.reply);
|
|
1743
|
+
clearComposer();
|
|
1744
|
+
return;
|
|
2572
1745
|
}
|
|
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
|
-
}
|
|
1746
|
+
if (route.command) return composerCommands.send(route.command);
|
|
1747
|
+
prompt = route.send;
|
|
1748
|
+
const delivery = route.delivery;
|
|
2586
1749
|
const hint = historyEl.querySelector(".hint");
|
|
2587
1750
|
if (hint) hint.remove();
|
|
2588
1751
|
// Sending moves a finished turn out of the stage at once (else the echo
|
|
@@ -2605,8 +1768,7 @@ async function handleSendTurn() {
|
|
|
2605
1768
|
// The worker knew it as a command (its list was newer than ours):
|
|
2606
1769
|
// its command_ran shows it, not a prompt bubble.
|
|
2607
1770
|
echo.remove();
|
|
2608
|
-
|
|
2609
|
-
fitPrompt();
|
|
1771
|
+
clearComposer();
|
|
2610
1772
|
followWokenWorker();
|
|
2611
1773
|
return;
|
|
2612
1774
|
}
|
|
@@ -2650,47 +1812,36 @@ async function handleSendTurn() {
|
|
|
2650
1812
|
}
|
|
2651
1813
|
}
|
|
2652
1814
|
|
|
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
|
-
}
|
|
1815
|
+
// /model, !ls … from the composer, and a control's command (the llm ctx
|
|
1816
|
+
// and thinking chips): composer.js.
|
|
1817
|
+
const composerCommands = createComposerCommands({
|
|
1818
|
+
session: () => selected,
|
|
1819
|
+
clientId,
|
|
1820
|
+
sendCommand,
|
|
1821
|
+
followWokenWorker: () => followWokenWorker(),
|
|
1822
|
+
refreshHistory: () => refreshHistory(),
|
|
1823
|
+
failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
|
|
1824
|
+
ownedByTui: () => {
|
|
1825
|
+
current.owner = "tui";
|
|
1826
|
+
updateComposerMode();
|
|
1827
|
+
},
|
|
1828
|
+
composer: {
|
|
1829
|
+
busy: () => { actionBtn.disabled = true; },
|
|
1830
|
+
clear: () => clearComposer(),
|
|
1831
|
+
done: () => {
|
|
1832
|
+
actionBtn.disabled = current.owner === "tui";
|
|
1833
|
+
promptEl.focus();
|
|
1834
|
+
},
|
|
1835
|
+
},
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
function runSessionCommand(line) {
|
|
1839
|
+
return composerCommands.run(line);
|
|
2682
1840
|
}
|
|
2683
1841
|
|
|
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
|
-
}
|
|
1842
|
+
function clearComposer() {
|
|
1843
|
+
promptEl.value = "";
|
|
1844
|
+
fitPrompt();
|
|
2694
1845
|
}
|
|
2695
1846
|
|
|
2696
1847
|
function addCommandBubble(view) {
|
|
@@ -2704,24 +1855,6 @@ function addCommandBubble(view) {
|
|
|
2704
1855
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
2705
1856
|
}
|
|
2706
1857
|
|
|
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
1858
|
function commandBubble(commandId) {
|
|
2726
1859
|
if (!commandId) return null;
|
|
2727
1860
|
return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
|
|
@@ -3104,7 +2237,7 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
3104
2237
|
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
|
|
3105
2238
|
return;
|
|
3106
2239
|
}
|
|
3107
|
-
render();
|
|
2240
|
+
lists.render();
|
|
3108
2241
|
updateInfoBar();
|
|
3109
2242
|
if (!eventsControl) refresh();
|
|
3110
2243
|
const what = archive ? "Archived" : "Unarchived";
|
|
@@ -3148,10 +2281,6 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
|
|
|
3148
2281
|
if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
3149
2282
|
}
|
|
3150
2283
|
|
|
3151
|
-
$("#includeArchived").addEventListener("change", () => {
|
|
3152
|
-
if (allViewOpen()) renderAll();
|
|
3153
|
-
});
|
|
3154
|
-
|
|
3155
2284
|
// Annotate (annotate_bar.js): a selection in the conversation offers
|
|
3156
2285
|
// "Annotate" and the presets, which append a quote to the composer.
|
|
3157
2286
|
const annotateBar = createAnnotateBar({
|
|
@@ -3168,6 +2297,12 @@ const annotateBar = createAnnotateBar({
|
|
|
3168
2297
|
},
|
|
3169
2298
|
});
|
|
3170
2299
|
historyEl.addEventListener("scroll", annotateBar.schedule);
|
|
2300
|
+
// A command bubble's Apply (CacheConfirm): sends the change's --yes line
|
|
2301
|
+
// once; its own bubble then says what it did.
|
|
2302
|
+
historyEl.addEventListener("click", (e) => {
|
|
2303
|
+
const button = e.target.closest?.("[data-confirm-line]");
|
|
2304
|
+
if (button) applyConfirm(button, runSessionCommand);
|
|
2305
|
+
});
|
|
3171
2306
|
turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
|
|
3172
2307
|
turnRoots().forEach((root) => installCopy(root));
|
|
3173
2308
|
|
|
@@ -3198,7 +2333,7 @@ function applyStripHidden() {
|
|
|
3198
2333
|
updateSessionTitle();
|
|
3199
2334
|
}
|
|
3200
2335
|
function setStripHidden(hidden) {
|
|
3201
|
-
if (hidden) familyPop.close();
|
|
2336
|
+
if (hidden) lists.familyPop.close();
|
|
3202
2337
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
3203
2338
|
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
3204
2339
|
applyStripHidden();
|
|
@@ -3211,23 +2346,11 @@ shortWindow.addEventListener("change", () => {
|
|
|
3211
2346
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
3212
2347
|
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
3213
2348
|
|
|
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
2349
|
// Esc leaves select mode first, then the all-sessions view.
|
|
3227
2350
|
document.addEventListener("keydown", (e) => {
|
|
3228
2351
|
if (e.key !== "Escape" || !allViewOpen()) return;
|
|
3229
|
-
if (selecting.on) {
|
|
3230
|
-
if (!selecting.running) leaveSelect();
|
|
2352
|
+
if (lists.selecting.on) {
|
|
2353
|
+
if (!lists.selecting.running) lists.leaveSelect();
|
|
3231
2354
|
return;
|
|
3232
2355
|
}
|
|
3233
2356
|
closeAllView();
|