samagotchi 0.34.0 → 0.36.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 +77 -1
- data/README.md +2 -0
- data/bin/chi +3 -0
- data/docs/broadcast.md +129 -0
- data/docs/cli.md +8 -0
- data/docs/configuration.md +2 -0
- data/docs/context.md +37 -0
- data/docs/guardrails.md +14 -4
- data/docs/hooks.md +25 -4
- data/docs/memory.md +3 -0
- data/docs/plugins.md +29 -3
- data/docs/sessions.md +6 -6
- data/lib/samagotchi/bridge/card_store.rb +1 -1
- data/lib/samagotchi/bridge/turn_notice.rb +1 -1
- data/lib/samagotchi/broadcast/recipients.rb +63 -0
- data/lib/samagotchi/broadcast/scope_card.rb +94 -0
- data/lib/samagotchi/broadcast/tags.rb +154 -0
- data/lib/samagotchi/broadcast_command.rb +228 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
- data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
- data/lib/samagotchi/bundles/system/self_map.md +4 -0
- data/lib/samagotchi/children_status.rb +2 -16
- data/lib/samagotchi/config.rb +6 -0
- data/lib/samagotchi/engine.rb +29 -5
- data/lib/samagotchi/git_head.rb +24 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
- data/lib/samagotchi/guardrails/rules.rb +15 -4
- data/lib/samagotchi/guardrails/targets.rb +9 -2
- data/lib/samagotchi/guardrails/verdict.rb +4 -3
- data/lib/samagotchi/hooks/registry.rb +11 -5
- data/lib/samagotchi/log_subscriber.rb +1 -1
- data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
- data/lib/samagotchi/note_command.rb +13 -13
- data/lib/samagotchi/note_delivery.rb +42 -0
- data/lib/samagotchi/plugin/context.rb +4 -4
- data/lib/samagotchi/session_commands.rb +1 -0
- data/lib/samagotchi/session_manager.rb +4 -3
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
- data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
- data/lib/samagotchi/terminal_ui.rb +14 -0
- data/lib/samagotchi/tool_activity.rb +14 -3
- data/lib/samagotchi/tool_declarations.rb +8 -4
- data/lib/samagotchi/tools/builtins.rb +1 -1
- data/lib/samagotchi/tools/memory.rb +103 -2
- data/lib/samagotchi/tools/send_note.rb +5 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/capabilities.rb +19 -0
- data/lib/samagotchi/web/public/activity.js +26 -6
- data/lib/samagotchi/web/public/app.js +515 -288
- data/lib/samagotchi/web/public/card.js +12 -3
- data/lib/samagotchi/web/public/command_view.js +2 -3
- data/lib/samagotchi/web/public/families_view.js +171 -0
- data/lib/samagotchi/web/public/format.js +52 -0
- data/lib/samagotchi/web/public/index.html +94 -21
- data/lib/samagotchi/web/public/live_timing.js +154 -0
- data/lib/samagotchi/web/public/live_turn.js +44 -0
- data/lib/samagotchi/web/public/sessions_list.js +228 -32
- data/lib/samagotchi/web/public/strip.js +5 -2
- data/lib/samagotchi/web/public/turn_html.js +107 -0
- data/lib/samagotchi/web/public/turn_model.js +5 -4
- data/lib/samagotchi/web/public/turn_view.js +43 -129
- data/lib/samagotchi/web/session_summary.rb +4 -0
- data/lib/samagotchi/worker.rb +166 -307
- data/lib/samagotchi/worker_inbound.rb +251 -0
- data/lib/samagotchi/worker_wakes.rb +120 -0
- metadata +15 -1
|
@@ -27,9 +27,11 @@ import { MODEL_KEY } from "./model_pick.js";
|
|
|
27
27
|
import { emptySessionState } from "./session_state.js";
|
|
28
28
|
import { createModelPicker } from "./model_picker.js";
|
|
29
29
|
import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
|
|
30
|
-
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml,
|
|
31
|
-
import {
|
|
30
|
+
import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary } from "./format.js";
|
|
31
|
+
import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
32
|
+
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
32
33
|
import { createStageView } from "./stage_view.js";
|
|
34
|
+
import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
|
|
33
35
|
import { createTaskStopper } from "./task_stop.js";
|
|
34
36
|
import { appendQuote } from "./annotations.js";
|
|
35
37
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -38,7 +40,7 @@ import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from
|
|
|
38
40
|
import { routeChunk } from "./chunk_router.js";
|
|
39
41
|
import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
|
|
40
42
|
import { createQuestionCards } from "./question_cards.js";
|
|
41
|
-
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
43
|
+
import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
|
|
42
44
|
import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
|
|
43
45
|
import { createCommandList } from "./command_list.js";
|
|
44
46
|
import { createPromptHistory } from "./prompt_history.js";
|
|
@@ -46,14 +48,15 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
|
|
|
46
48
|
import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
|
|
47
49
|
import { shouldFollowScroll, keepFollowing } from "./scroll.js";
|
|
48
50
|
import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
|
|
49
|
-
import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText
|
|
51
|
+
import { applySessionEvent, batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, flattenFamilies, heldOrder, inTree, rangeIds, runBatch, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText } from "./sessions_list.js";
|
|
50
52
|
import { createNotifications } from "./notifications.js";
|
|
51
53
|
import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
|
|
52
54
|
import {
|
|
53
|
-
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
|
|
55
|
+
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
|
|
54
56
|
} from "./timing.js";
|
|
57
|
+
import { createLiveTurn } from "./live_turn.js";
|
|
55
58
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
56
|
-
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores,
|
|
59
|
+
import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
57
60
|
|
|
58
61
|
const $ = (s) => document.querySelector(s);
|
|
59
62
|
const topStripEl = $("#topStrip");
|
|
@@ -73,6 +76,10 @@ const contextChips = createContextChips({
|
|
|
73
76
|
toast: (text) => showToast(text),
|
|
74
77
|
});
|
|
75
78
|
const markdownWarningEl = $("#markdownWarning");
|
|
79
|
+
// What the page renders that a notice may stand in for (the server's
|
|
80
|
+
// Web::Capabilities, next to markdown_warning); a notice it names isn't
|
|
81
|
+
// drawn (shownElsewhere). An older server sends none: every notice shows.
|
|
82
|
+
let pageCapabilities = {};
|
|
76
83
|
const promptEl = $("#prompt");
|
|
77
84
|
const actionBtn = $("#actionBtn");
|
|
78
85
|
const cancelBtn = $("#cancelBtn");
|
|
@@ -121,22 +128,20 @@ function findInTurnRoots(selector) {
|
|
|
121
128
|
// The open session's fields (session_state.js); resetSessionState() starts
|
|
122
129
|
// them over.
|
|
123
130
|
let current = emptySessionState();
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
let liveTurnNote = "";
|
|
139
|
-
let timingInterval = null;
|
|
131
|
+
// The running turn as the page follows it (live_turn.js): whether one
|
|
132
|
+
// runs, its timing line and the turns this page saw end (live_timing.js),
|
|
133
|
+
// its merges' bubble flags, its turn-end reads.
|
|
134
|
+
const liveTurn = createLiveTurn({
|
|
135
|
+
timing: () => current.timing,
|
|
136
|
+
// The stage's status row while it holds the turn (the line moves into
|
|
137
|
+
// the history with it), else the history.
|
|
138
|
+
place: (el) => {
|
|
139
|
+
const stageHost = turnDom.timingHost?.() || null;
|
|
140
|
+
(stageHost || historyEl).appendChild(el);
|
|
141
|
+
return !!stageHost;
|
|
142
|
+
},
|
|
143
|
+
onTick: () => updateInfoBar(),
|
|
144
|
+
});
|
|
140
145
|
|
|
141
146
|
// This tab's id in the session's events: tells its own prompts from the
|
|
142
147
|
// ones other clients (an attached TUI, another tab, reminders) sent.
|
|
@@ -157,20 +162,12 @@ let selectSeq = null;
|
|
|
157
162
|
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
158
163
|
// new turn has started on it yet: see followWokenWorker.
|
|
159
164
|
let streamFromGuess = false;
|
|
160
|
-
// The last input_merged had an origin with no bubble: its merged text
|
|
161
|
-
// (pending_input_merged) gets one ("report": a delegate report's).
|
|
162
|
-
let unmatchedMerge = false;
|
|
163
|
-
// The running turn is one chi ran for delegate reports and its report
|
|
164
|
-
// bubble isn't drawn yet: that bubble starts the turn.
|
|
165
|
-
let wakeStart = false;
|
|
166
165
|
// The enqueued_ids this page sent (from the /turn acks): only these come back
|
|
167
166
|
// into the composer when their turn fails.
|
|
168
167
|
const sentIds = new Set();
|
|
169
168
|
// Own prompt_restored events that came before their /turn ack, by
|
|
170
169
|
// enqueued_id: the ack puts that prompt back instead of clearing it.
|
|
171
170
|
const earlyRestores = new Map();
|
|
172
|
-
// The last turn_failed's line, shown again after prompt_restored's resync.
|
|
173
|
-
let lastFailedText = null;
|
|
174
171
|
|
|
175
172
|
// The page's scope (scope.js): ?dir= is the folder a project view came
|
|
176
173
|
// from (new chats start there); the server names its project, if any.
|
|
@@ -219,13 +216,17 @@ function openSessionEvents() {
|
|
|
219
216
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
220
217
|
render();
|
|
221
218
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
219
|
+
else if (selected && data.session?.parent_id === selected) updateInfoBar(); // its children chip
|
|
222
220
|
},
|
|
223
221
|
session_gone: (data) => {
|
|
224
222
|
notifications.noticeSessions("session_gone", data);
|
|
223
|
+
selecting.picked.delete(data.id);
|
|
224
|
+
selecting.skipReasons.delete(data.id);
|
|
225
225
|
const next = applySessionEvent(allSessions, "session_gone", data);
|
|
226
226
|
if (next !== allSessions) {
|
|
227
227
|
allSessions = next;
|
|
228
228
|
render();
|
|
229
|
+
if (selected) updateInfoBar(); // a deleted child leaves the children chip
|
|
229
230
|
}
|
|
230
231
|
leaveGoneSession(data.id, "deleted");
|
|
231
232
|
},
|
|
@@ -314,26 +315,34 @@ function allViewSessions() {
|
|
|
314
315
|
return listedSessions(allSessions, $("#includeArchived").checked);
|
|
315
316
|
}
|
|
316
317
|
|
|
317
|
-
function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
318
|
+
function filterQuery() {
|
|
319
|
+
return $("#filter").value.trim().toLowerCase();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Whether a session matches the all view's search +q+ (lower case).
|
|
323
|
+
function matchesFilter(s, q) {
|
|
324
|
+
return previewForCard(s).toLowerCase().includes(q) ||
|
|
325
|
+
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
326
|
+
s.short_id.toLowerCase().includes(q) ||
|
|
327
|
+
s.id.toLowerCase().includes(q) ||
|
|
328
|
+
s.status.toLowerCase().includes(q) ||
|
|
329
|
+
waitingSearchText(s).includes(q);
|
|
329
330
|
}
|
|
330
331
|
|
|
331
332
|
// A card: the first message as its title, then status, how long ago, owner
|
|
332
333
|
// and memories. The id is in the tooltip (and the search) only. A session
|
|
333
334
|
// that waits on the user (a question, an approval, a card with actions)
|
|
334
335
|
// wears the warning colour and says what for.
|
|
335
|
-
// deletable: the all-sessions view's cards get a delete
|
|
336
|
-
|
|
336
|
+
// deletable / archivable: the all-sessions view's cards get a delete and an
|
|
337
|
+
// archive (unarchive, on an archived card) button. selectable: select mode's
|
|
338
|
+
// checkbox instead (picked: checked), and skipReason: why a batch skipped it.
|
|
339
|
+
// family: the family the session heads (sessions_list.js families): with
|
|
340
|
+
// members, its chip (which opens their list), and the card waits when any
|
|
341
|
+
// member does; with rows (all sessions) an open family lists its members
|
|
342
|
+
// inline, the strip's opens in a popover; matchIds: a search's matching
|
|
343
|
+
// members.
|
|
344
|
+
function cardHtml(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}) {
|
|
345
|
+
const fam = family && family.members.length ? family : null;
|
|
337
346
|
const ts = s.updated_at || s.created_at || "";
|
|
338
347
|
const tip = `${s.id} · updated ${ts} · ${s.status}`;
|
|
339
348
|
const preview = escapeHtml(previewForCard(s));
|
|
@@ -352,7 +361,23 @@ function cardHtml(s, { deletable = false } = {}) {
|
|
|
352
361
|
const stopped = waiting ? null : stoppedBadge(s);
|
|
353
362
|
const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
|
|
354
363
|
const dotWord = waiting ? waiting.title : s.status;
|
|
355
|
-
|
|
364
|
+
const pickHtml = selectable ? `<input type="checkbox" class="card-pick" aria-label="Select session ${escapeHtml(s.short_id)}"${picked ? " checked" : ""}>` : "";
|
|
365
|
+
const skipHtml = skipReason ? `<div class="skip-reason">skipped: ${escapeHtml(skipReason)}</div>` : "";
|
|
366
|
+
const familyWaiting = !!fam && familyWaits(fam);
|
|
367
|
+
const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
|
|
368
|
+
const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
|
|
369
|
+
const membersAttr = fam ? ` data-members="${escapeHtml(fam.members.map((m) => m.session.id).join(" "))}"` : "";
|
|
370
|
+
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)}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function cardToolsHtml(s, { deletable, archivable }) {
|
|
374
|
+
if (!deletable && !archivable) return "";
|
|
375
|
+
const short = escapeHtml(s.short_id);
|
|
376
|
+
const arch = !archivable ? "" : s.archived
|
|
377
|
+
? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
|
|
378
|
+
: `<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>`;
|
|
379
|
+
const del = deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${short}">\u2715</button>` : "";
|
|
380
|
+
return `<div class="card-tools">${arch}${del}</div>`;
|
|
356
381
|
}
|
|
357
382
|
|
|
358
383
|
// A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
|
|
@@ -375,30 +400,62 @@ function fitStripColumns() {
|
|
|
375
400
|
topStripEl.style.setProperty("--strip-cols", stripCols);
|
|
376
401
|
}
|
|
377
402
|
|
|
378
|
-
// The order a list draws its cards in:
|
|
379
|
-
// first (waitingFirst), back to
|
|
403
|
+
// The order a list draws its cards in: +next+, what waits on the user
|
|
404
|
+
// first (waitingFirst), back to its place once answered. Never under the
|
|
380
405
|
// pointer, though: while it is over the list the order last drawn holds,
|
|
381
|
-
// and its leaving draws the list again.
|
|
382
|
-
|
|
406
|
+
// and its leaving draws the list again. +idOf+ names a card: a session's
|
|
407
|
+
// id, or familyId (a card per family: its head's id). drawnOrder is the
|
|
408
|
+
// cards drawn, which select mode picks from.
|
|
409
|
+
const drawnOrder = new Map(); // list element → card ids in the order last drawn
|
|
383
410
|
const heldLists = new Set(); // lists drawn in a held order
|
|
384
411
|
const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
|
|
385
|
-
function listOrder(el,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
412
|
+
function listOrder(el, next, idOf = (s) => s.id) {
|
|
413
|
+
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
414
|
+
// and a touch screen has no hover at all).
|
|
415
|
+
const holding = (canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen());
|
|
416
|
+
const held = holding ? drawnOrder.get(el) : null;
|
|
417
|
+
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
418
|
+
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
390
419
|
else heldLists.delete(el);
|
|
391
|
-
drawnOrder.set(el, ordered.map(
|
|
420
|
+
drawnOrder.set(el, ordered.map(idOf));
|
|
392
421
|
return ordered;
|
|
393
422
|
}
|
|
394
423
|
|
|
395
|
-
|
|
396
|
-
|
|
424
|
+
// The strip's cards: families in the list's order (a family in its
|
|
425
|
+
// first-listed member's place, so a hub upsert doesn't move it), waiting
|
|
426
|
+
// ones first, as many as fit. A family is one card.
|
|
427
|
+
function stripCards() {
|
|
428
|
+
return listOrder(topStripEl, waitingFirst(families(shownSessions(), { order: "list" })), familyId).slice(0, stripCols);
|
|
397
429
|
}
|
|
398
430
|
|
|
431
|
+
// The strip's family popover (families_view.js): the open family's rows,
|
|
432
|
+
// under its chip.
|
|
433
|
+
let stripFamilies = new Map(); // head id → family, as the strip last drew it
|
|
434
|
+
const familyPop = createFamilyPop({
|
|
435
|
+
doc: document,
|
|
436
|
+
win: window,
|
|
437
|
+
rowsFor: (id) => {
|
|
438
|
+
const f = stripFamilies.get(id);
|
|
439
|
+
return f && f.members.length ? familyRowsHtml(f, { activeId: selected }) : null;
|
|
440
|
+
},
|
|
441
|
+
anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
|
|
442
|
+
onPick: (id) => {
|
|
443
|
+
familyPop.close();
|
|
444
|
+
select(id);
|
|
445
|
+
},
|
|
446
|
+
// A strip order held while it was open catches up (unless the pointer is
|
|
447
|
+
// back over the strip: its leaving does it then).
|
|
448
|
+
onClose: () => {
|
|
449
|
+
if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
window.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
|
|
453
|
+
document.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
|
|
454
|
+
document.addEventListener("scroll", () => familyPop.reposition(), true);
|
|
455
|
+
|
|
399
456
|
function render() {
|
|
400
457
|
fitStripColumns();
|
|
401
|
-
renderTop(
|
|
458
|
+
renderTop(stripCards());
|
|
402
459
|
if (allViewOpen()) renderAll();
|
|
403
460
|
}
|
|
404
461
|
|
|
@@ -408,36 +465,77 @@ window.addEventListener("resize", () => {
|
|
|
408
465
|
stripResizeTimer = setTimeout(() => {
|
|
409
466
|
const before = stripCols;
|
|
410
467
|
fitStripColumns();
|
|
411
|
-
if (stripCols !== before && allSessions.length) renderTop(
|
|
468
|
+
if (stripCols !== before && allSessions.length) renderTop(stripCards());
|
|
469
|
+
familyPop.reposition();
|
|
412
470
|
}, 150);
|
|
413
471
|
});
|
|
414
472
|
|
|
415
|
-
|
|
473
|
+
// onPick: a card's click (open it, or pick it in select mode); onFamily:
|
|
474
|
+
// a family chip's (toggle its list); onRow: a family row's (open that
|
|
475
|
+
// delegate).
|
|
476
|
+
function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
|
|
416
477
|
container.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
417
478
|
el.addEventListener("click", (e) => {
|
|
479
|
+
const chip = e.target.closest(".family-chip");
|
|
480
|
+
if (chip) {
|
|
481
|
+
e.stopPropagation();
|
|
482
|
+
onFamily(el.dataset.id, chip, e);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
418
485
|
const del = e.target.closest(".card-del");
|
|
419
486
|
if (del) {
|
|
420
487
|
e.stopPropagation();
|
|
421
488
|
confirmDelete(del.dataset.del);
|
|
422
489
|
return;
|
|
423
490
|
}
|
|
424
|
-
|
|
491
|
+
// Archive from the list: the view stays open, the toast has Undo.
|
|
492
|
+
const arch = e.target.closest(".card-arch");
|
|
493
|
+
if (arch) {
|
|
494
|
+
e.stopPropagation();
|
|
495
|
+
const id = arch.dataset.arch;
|
|
496
|
+
setArchived(id, !allSessions.find((x) => x.id === id)?.archived);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const row = e.target.closest(".family-row[data-id]");
|
|
500
|
+
if (row) {
|
|
501
|
+
e.stopPropagation();
|
|
502
|
+
onRow(row.dataset.id, e);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
onPick(el.dataset.id, e);
|
|
425
506
|
});
|
|
426
|
-
|
|
507
|
+
});
|
|
508
|
+
paintActive();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The open session's marks: its card (.active), its row in a family's list
|
|
512
|
+
// (in a card or the strip's popover), and a quieter .contains-active on the
|
|
513
|
+
// card of the family it is folded into.
|
|
514
|
+
function paintActive() {
|
|
515
|
+
document.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
516
|
+
el.classList.toggle("active", !!selected && el.dataset.id === selected);
|
|
517
|
+
el.classList.toggle("contains-active", !!selected && (el.dataset.members || "").split(" ").includes(selected));
|
|
518
|
+
});
|
|
519
|
+
document.querySelectorAll(".family-row[data-id]").forEach((el) => {
|
|
520
|
+
el.classList.toggle("active", !!selected && el.dataset.id === selected);
|
|
427
521
|
});
|
|
428
522
|
}
|
|
429
523
|
|
|
430
|
-
// The latest sessions (as many as fit), then the way
|
|
431
|
-
|
|
524
|
+
// The latest sessions (as many as fit, a family one card), then the way
|
|
525
|
+
// to all of them.
|
|
526
|
+
function renderTop(cards) {
|
|
432
527
|
topStripEl.classList.remove("loading");
|
|
433
528
|
renderStripShow();
|
|
529
|
+
stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
|
|
434
530
|
if (scopeError) {
|
|
435
531
|
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
|
|
532
|
+
familyPop.close();
|
|
436
533
|
return;
|
|
437
534
|
}
|
|
438
535
|
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
439
536
|
if (!allSessions.length) {
|
|
440
537
|
topStripEl.innerHTML = "";
|
|
538
|
+
familyPop.close();
|
|
441
539
|
return;
|
|
442
540
|
}
|
|
443
541
|
const n = shownSessions().length;
|
|
@@ -445,37 +543,252 @@ function renderTop(sessions) {
|
|
|
445
543
|
? `${n} ${n === 1 ? "session" : "sessions"}`
|
|
446
544
|
: `${allSessions.length - n} archived`;
|
|
447
545
|
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>`;
|
|
448
|
-
topStripEl.innerHTML =
|
|
449
|
-
bindCards(topStripEl, select
|
|
546
|
+
topStripEl.innerHTML = cards.map((f) => cardHtml(f.head, { family: f, open: familyPop.openHead() === f.head.id })).join("") + tile;
|
|
547
|
+
bindCards(topStripEl, select, {
|
|
548
|
+
onFamily: (id, _chip, e) => {
|
|
549
|
+
familyPop.toggle(id);
|
|
550
|
+
// From the keyboard (Enter / Space: no pointer, detail 0) the focus
|
|
551
|
+
// goes into the popover, which sits at the end of body; Esc brings it back.
|
|
552
|
+
if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
|
|
553
|
+
},
|
|
554
|
+
});
|
|
450
555
|
$("#allTile").addEventListener("click", openAllView);
|
|
451
556
|
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
557
|
+
familyPop.refresh();
|
|
452
558
|
}
|
|
453
559
|
|
|
454
560
|
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
|
|
455
|
-
// following the list
|
|
561
|
+
// following the list: its cards (a family is one), the live and waiting
|
|
562
|
+
// sessions (a waiting delegate too).
|
|
456
563
|
function renderStripShow() {
|
|
457
|
-
const
|
|
564
|
+
const shown = shownSessions();
|
|
565
|
+
const { count, live, waiting } = stripShowParts(shown, { cards: families(shown).length });
|
|
458
566
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
459
567
|
const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
|
|
460
568
|
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
|
|
461
569
|
}
|
|
462
570
|
|
|
463
|
-
// A
|
|
464
|
-
//
|
|
571
|
+
// A parent's card holds its delegates (families, by the newest member: a
|
|
572
|
+
// hub snapshot hands the list back in the server's order), folded unless
|
|
573
|
+
// one waits on the user, a search matched one or the user opened it. The
|
|
574
|
+
// search runs over the families, so a matching delegate brings its parent's
|
|
575
|
+
// card along. Select mode picks cards (a family's rows have no checkbox:
|
|
576
|
+
// archiving the parent takes its delegates).
|
|
577
|
+
const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
|
|
578
|
+
const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
|
|
579
|
+
const drawnOpen = new Map(); // head id → open as last drawn in all sessions
|
|
580
|
+
|
|
465
581
|
function renderAll() {
|
|
466
582
|
const listed = allViewSessions();
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
583
|
+
const q = filterQuery();
|
|
584
|
+
const all = families(listed);
|
|
585
|
+
const shown = q ? filterFamilies(all, (s) => matchesFilter(s, q)) : all;
|
|
586
|
+
const fams = listOrder(allListEl, waitingFirst(shown), familyId);
|
|
587
|
+
const delegates = all.reduce((n, f) => n + f.members.length, 0);
|
|
588
|
+
$("#count").textContent = fams.length === all.length
|
|
589
|
+
? `${all.length}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
|
|
590
|
+
: `${fams.length} / ${all.length}`;
|
|
591
|
+
updateSelectBar();
|
|
592
|
+
if (!fams.length) {
|
|
593
|
+
drawnOpen.clear();
|
|
472
594
|
allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
|
|
473
595
|
return;
|
|
474
596
|
}
|
|
475
|
-
|
|
476
|
-
|
|
597
|
+
// A hub event redraws the list under a checkbox or a chip the keyboard is on.
|
|
598
|
+
const focused = document.activeElement?.matches?.("#allList .card-pick, #allList .family-chip")
|
|
599
|
+
? { id: document.activeElement.closest(".card")?.dataset.id, sel: document.activeElement.matches(".card-pick") ? ".card-pick" : ".family-chip" } : null;
|
|
600
|
+
const opts = (s) => (selecting.on
|
|
601
|
+
? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
|
|
602
|
+
: { deletable: true, archivable: true });
|
|
603
|
+
drawnOpen.clear();
|
|
604
|
+
allListEl.innerHTML = fams.map((f) => {
|
|
605
|
+
const matched = !!f.matchIds?.size;
|
|
606
|
+
const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
|
|
607
|
+
drawnOpen.set(f.head.id, open);
|
|
608
|
+
return cardHtml(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
|
|
609
|
+
}).join("");
|
|
610
|
+
bindCards(allListEl, (id, e) => {
|
|
611
|
+
if (selecting.on) {
|
|
612
|
+
togglePick(id, e);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
leaveAllView(sessionHash(id));
|
|
616
|
+
select(id);
|
|
617
|
+
}, {
|
|
618
|
+
onFamily: (headId) => {
|
|
619
|
+
const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
|
|
620
|
+
(matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
|
|
621
|
+
renderAll();
|
|
622
|
+
},
|
|
623
|
+
// In select mode a row does nothing: rows aren't picked.
|
|
624
|
+
onRow: (id) => {
|
|
625
|
+
if (selecting.on) return;
|
|
626
|
+
leaveAllView(sessionHash(id));
|
|
627
|
+
select(id);
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── Select mode (all sessions) ─────────────────────────────────────────────
|
|
634
|
+
// "Select" turns the cards into picks: a click (or Shift-click, a range in
|
|
635
|
+
// the drawn order) toggles one instead of opening it, and the bar acts on
|
|
636
|
+
// the picked ones that are shown. The picks live here, not in the DOM:
|
|
637
|
+
// renderAll draws the list again on every hub event.
|
|
638
|
+
const selecting = {
|
|
639
|
+
on: false,
|
|
640
|
+
picked: new Set(),
|
|
641
|
+
last: null, // the last toggled id: a Shift-click's range starts there
|
|
642
|
+
skipReasons: new Map(), // id → why the last batch skipped it
|
|
643
|
+
running: null, // {done, total, archive} while a batch runs
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
function enterSelect() {
|
|
647
|
+
selecting.on = true;
|
|
648
|
+
renderAll();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function leaveSelect() {
|
|
652
|
+
selecting.on = false;
|
|
653
|
+
selecting.picked.clear();
|
|
654
|
+
selecting.last = null;
|
|
655
|
+
selecting.skipReasons.clear();
|
|
656
|
+
if (allViewOpen()) renderAll();
|
|
657
|
+
else updateSelectBar();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function togglePick(id, e) {
|
|
661
|
+
if (selecting.running) return; // the grid reflows while a batch runs
|
|
662
|
+
const order = drawnOrder.get(allListEl) || [];
|
|
663
|
+
const on = !selecting.picked.has(id);
|
|
664
|
+
const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
|
|
665
|
+
for (const x of ids) {
|
|
666
|
+
if (on) selecting.picked.add(x);
|
|
667
|
+
else selecting.picked.delete(x);
|
|
668
|
+
paintPick(x);
|
|
669
|
+
}
|
|
670
|
+
selecting.last = id;
|
|
671
|
+
updateSelectBar();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// A card's pick state in place (no redraw: the focus stays put).
|
|
675
|
+
function paintPick(id) {
|
|
676
|
+
const el = allListEl.querySelector(`.card[data-id="${id}"]`);
|
|
677
|
+
if (!el) return;
|
|
678
|
+
const on = selecting.picked.has(id);
|
|
679
|
+
el.classList.toggle("picked", on);
|
|
680
|
+
const box = el.querySelector(".card-pick");
|
|
681
|
+
if (box) box.checked = on;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// The bar's numbers: picks of sessions still in the list (a snapshot can
|
|
685
|
+
// drop one with no session_gone), and the shown ones a batch would act on.
|
|
686
|
+
function selectCounts() {
|
|
687
|
+
const byId = new Map(allSessions.map((s) => [s.id, s]));
|
|
688
|
+
const shown = drawnOrder.get(allListEl) || [];
|
|
689
|
+
const shownSet = new Set(shown);
|
|
690
|
+
const live = [...selecting.picked].filter((id) => byId.has(id));
|
|
691
|
+
return {
|
|
692
|
+
picked: live.length,
|
|
693
|
+
hidden: live.filter((id) => !shownSet.has(id)).length,
|
|
694
|
+
archive: batchTargets(selecting.picked, shown, true, byId),
|
|
695
|
+
unarchive: batchTargets(selecting.picked, shown, false, byId),
|
|
696
|
+
};
|
|
477
697
|
}
|
|
478
698
|
|
|
699
|
+
function updateSelectBar() {
|
|
700
|
+
$("#allView").classList.toggle("selecting", selecting.on);
|
|
701
|
+
$("#selectBtn").hidden = selecting.on;
|
|
702
|
+
const bar = $("#selectBar");
|
|
703
|
+
bar.hidden = !selecting.on;
|
|
704
|
+
if (!selecting.on) return;
|
|
705
|
+
const c = selectCounts();
|
|
706
|
+
const run = selecting.running;
|
|
707
|
+
$("#selectCount").textContent = run
|
|
708
|
+
? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}\u2026`
|
|
709
|
+
: `${c.picked} selected${c.hidden ? ` \u00b7 ${c.hidden} not shown` : ""}`;
|
|
710
|
+
$("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
|
|
711
|
+
$("#batchArchiveBtn").disabled = !!run || !c.archive.length;
|
|
712
|
+
$("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
|
|
713
|
+
$("#batchUnarchiveBtn").hidden = !c.unarchive.length;
|
|
714
|
+
$("#batchUnarchiveBtn").disabled = !!run;
|
|
715
|
+
$("#selectAllBtn").disabled = !!run;
|
|
716
|
+
$("#selectDoneBtn").disabled = !!run;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// The batch: the picked shown ones, one request at a time in the drawn
|
|
720
|
+
// order (parents before their delegates), through the info bar's
|
|
721
|
+
// archiveOne. A refused one stays picked with the server's reason on its
|
|
722
|
+
// card; the end toast's Undo runs the opposite batch on the ones that went.
|
|
723
|
+
async function runSelectBatch(archive) {
|
|
724
|
+
if (selecting.running) return;
|
|
725
|
+
const byId = new Map(allSessions.map((s) => [s.id, s]));
|
|
726
|
+
const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
|
|
727
|
+
if (!ids.length) return;
|
|
728
|
+
selecting.skipReasons.clear();
|
|
729
|
+
const outcome = await archiveBatch(ids, archive);
|
|
730
|
+
if (!outcome) return;
|
|
731
|
+
if (!allViewOpen()) leaveSelect(); // left the view while it ran
|
|
732
|
+
else renderAll();
|
|
733
|
+
showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
|
|
734
|
+
label: "Undo",
|
|
735
|
+
run: async () => {
|
|
736
|
+
const undone = await archiveBatch(outcome.done, !archive);
|
|
737
|
+
if (!undone) return;
|
|
738
|
+
if (allViewOpen()) renderAll();
|
|
739
|
+
showToast(batchToastText(batchCounts(undone, !archive)));
|
|
740
|
+
},
|
|
741
|
+
} : null);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// runBatch over archiveOne, with the bar's progress; the picks and skip
|
|
745
|
+
// reasons follow the outcome. null when a batch already runs.
|
|
746
|
+
async function archiveBatch(ids, archive) {
|
|
747
|
+
if (selecting.running) return null;
|
|
748
|
+
selecting.running = { done: 0, total: ids.length, archive };
|
|
749
|
+
updateSelectBar();
|
|
750
|
+
try {
|
|
751
|
+
const outcome = await runBatch(ids, async (id) => {
|
|
752
|
+
const answer = await archiveOne(id, archive);
|
|
753
|
+
if (answer.ok) {
|
|
754
|
+
render();
|
|
755
|
+
updateInfoBar();
|
|
756
|
+
}
|
|
757
|
+
return answer;
|
|
758
|
+
}, (done) => {
|
|
759
|
+
selecting.running.done = done;
|
|
760
|
+
updateSelectBar();
|
|
761
|
+
});
|
|
762
|
+
for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
|
|
763
|
+
selecting.picked.delete(id);
|
|
764
|
+
selecting.skipReasons.delete(id);
|
|
765
|
+
}
|
|
766
|
+
for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
|
|
767
|
+
return outcome;
|
|
768
|
+
} finally {
|
|
769
|
+
selecting.running = null;
|
|
770
|
+
updateSelectBar();
|
|
771
|
+
if (!eventsControl) refresh();
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
$("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
|
|
776
|
+
$("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
|
|
777
|
+
$("#selectBtn").addEventListener("click", enterSelect);
|
|
778
|
+
$("#selectDoneBtn").addEventListener("click", leaveSelect);
|
|
779
|
+
$("#selectAllBtn").addEventListener("click", () => {
|
|
780
|
+
for (const id of drawnOrder.get(allListEl) || []) {
|
|
781
|
+
selecting.picked.add(id);
|
|
782
|
+
paintPick(id);
|
|
783
|
+
}
|
|
784
|
+
updateSelectBar();
|
|
785
|
+
});
|
|
786
|
+
// A Shift-click picks a range: keep the browser from selecting the text
|
|
787
|
+
// between the two cards.
|
|
788
|
+
allListEl.addEventListener("mousedown", (e) => {
|
|
789
|
+
if (selecting.on && e.shiftKey) e.preventDefault();
|
|
790
|
+
});
|
|
791
|
+
|
|
479
792
|
// Delete a session (the info bar's button, an all-sessions card's ✕) after
|
|
480
793
|
// a confirm; the list drops it without a reload. The server stops a live
|
|
481
794
|
// worker first and refuses a session a terminal chi has open.
|
|
@@ -542,10 +855,13 @@ function applyView() {
|
|
|
542
855
|
const open = allViewOpen();
|
|
543
856
|
if (!open) {
|
|
544
857
|
allViewPushed = false;
|
|
858
|
+
// A running batch ends on its own and clears the picks then.
|
|
859
|
+
if (selecting.on && !selecting.running) leaveSelect();
|
|
545
860
|
topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
|
|
546
861
|
}
|
|
547
862
|
document.body.classList.toggle("all-view", open);
|
|
548
863
|
if (open) {
|
|
864
|
+
familyPop.close(); // the strip is hidden in the all view
|
|
549
865
|
// The list is live; only its order can lag (upserts keep cards in
|
|
550
866
|
// place), so the all view opens in the server's order.
|
|
551
867
|
allSessions = sortedByUpdated(allSessions);
|
|
@@ -582,7 +898,7 @@ function updateComposerMode() {
|
|
|
582
898
|
fitPrompt();
|
|
583
899
|
}
|
|
584
900
|
if (selected) modelPicker.close();
|
|
585
|
-
const running =
|
|
901
|
+
const running = liveTurn.running && !!selected;
|
|
586
902
|
cancelBtn.style.display = running ? "" : "none";
|
|
587
903
|
if (running) {
|
|
588
904
|
setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
|
|
@@ -615,7 +931,7 @@ function updateComposerMode() {
|
|
|
615
931
|
}
|
|
616
932
|
|
|
617
933
|
function setTurnRunning(running) {
|
|
618
|
-
|
|
934
|
+
liveTurn.running = !!running;
|
|
619
935
|
updateComposerMode();
|
|
620
936
|
updateInfoBar();
|
|
621
937
|
}
|
|
@@ -673,15 +989,14 @@ function clearSelection() {
|
|
|
673
989
|
seedInitTasks(initTasks, []);
|
|
674
990
|
renderInitRow();
|
|
675
991
|
setSessionHash(null);
|
|
676
|
-
|
|
992
|
+
paintActive();
|
|
677
993
|
resetSessionState();
|
|
678
994
|
contextChips.clear();
|
|
679
|
-
|
|
680
|
-
liveTurnTimingEl = null;
|
|
681
|
-
liveTurnId = null;
|
|
995
|
+
liveTurn.forget();
|
|
682
996
|
setTurnRunning(false);
|
|
683
997
|
closeStream();
|
|
684
998
|
setMarkdownWarning(null);
|
|
999
|
+
setCapabilities(null);
|
|
685
1000
|
updateComposerMode();
|
|
686
1001
|
updateInfoBar();
|
|
687
1002
|
updateFirstPeek();
|
|
@@ -712,9 +1027,7 @@ async function select(id) {
|
|
|
712
1027
|
clearNotice();
|
|
713
1028
|
selected = id;
|
|
714
1029
|
setSessionHash(id);
|
|
715
|
-
|
|
716
|
-
el.classList.toggle("active", el.dataset.id === id),
|
|
717
|
-
);
|
|
1030
|
+
paintActive();
|
|
718
1031
|
historyEl.innerHTML = `<div class="hint">Loading…</div>`;
|
|
719
1032
|
emptyEl.classList.add("hidden");
|
|
720
1033
|
closeStream();
|
|
@@ -731,15 +1044,12 @@ async function select(id) {
|
|
|
731
1044
|
// Clear everything that belongs to one turn's rendering.
|
|
732
1045
|
function resetTurnView() {
|
|
733
1046
|
closeStream();
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1047
|
+
// workerGone and a re-render come through here: the answer_display wait
|
|
1048
|
+
// goes too.
|
|
1049
|
+
liveTurn.forget();
|
|
737
1050
|
questionCards.removeQuestion();
|
|
738
1051
|
questionCards.forgetContinue();
|
|
739
|
-
unmatchedMerge = false;
|
|
740
|
-
wakeStart = false;
|
|
741
1052
|
setTurnRunning(false);
|
|
742
|
-
releaseDisplayWait();
|
|
743
1053
|
}
|
|
744
1054
|
|
|
745
1055
|
// (Re)render the selected session from the server and stream on from the
|
|
@@ -765,7 +1075,6 @@ async function resync({ endText = null } = {}) {
|
|
|
765
1075
|
return;
|
|
766
1076
|
}
|
|
767
1077
|
if (id !== selected) return;
|
|
768
|
-
releaseDisplayWait();
|
|
769
1078
|
resetTurnView();
|
|
770
1079
|
renderSessionView(data);
|
|
771
1080
|
// Preview is read-only: a worker is woken only by POST /turn, so attach
|
|
@@ -788,13 +1097,14 @@ async function resync({ endText = null } = {}) {
|
|
|
788
1097
|
function renderSessionView(data) {
|
|
789
1098
|
const s = data.session;
|
|
790
1099
|
setMarkdownWarning(data.markdown_warning);
|
|
1100
|
+
setCapabilities(data.capabilities);
|
|
791
1101
|
current.status = s.status;
|
|
792
1102
|
current.model = s.model_name;
|
|
793
1103
|
current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
|
|
794
1104
|
current.dir = s.working_directory;
|
|
795
1105
|
current.owner = s.owner || null;
|
|
796
1106
|
current.timing = normalizeTiming(data.timing);
|
|
797
|
-
|
|
1107
|
+
liveTurn.timing.ended = current.timing.turnRecords.length;
|
|
798
1108
|
const activeTurnId = current.timing.activeTurn?.id || null;
|
|
799
1109
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
800
1110
|
// next turn (a running turn's replay below streams over it).
|
|
@@ -838,20 +1148,21 @@ function renderSessionView(data) {
|
|
|
838
1148
|
else showRecap(data.recap);
|
|
839
1149
|
// A live worker's last cards and between-turns notices, where they
|
|
840
1150
|
// arrived; the running turn's go into its step once it is drawn.
|
|
841
|
-
const snapshotCards = splitSnapshotCards(data.cards);
|
|
1151
|
+
const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
|
|
842
1152
|
placeSnapshotCards(snapshotCards.placed);
|
|
843
1153
|
const replay = snapshotEvents({
|
|
844
1154
|
current_turn: data.current_turn,
|
|
845
1155
|
queued: data.queued,
|
|
846
1156
|
started_at: current.timing.activeTurn?.started_at,
|
|
847
1157
|
});
|
|
848
|
-
replay
|
|
1158
|
+
// Marked replayed: a call's duration isn't timed from the replay's clock.
|
|
1159
|
+
replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
|
|
849
1160
|
if (!data.current_turn) {
|
|
850
1161
|
setTurnRunning(current.status === "running");
|
|
851
|
-
if (
|
|
852
|
-
} else if (
|
|
1162
|
+
if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
|
|
1163
|
+
} else if (liveTurn.running) {
|
|
853
1164
|
// The replayed turn_started carries no id: the running turn's record's.
|
|
854
|
-
|
|
1165
|
+
liveTurn.timing.turnId = activeTurnId;
|
|
855
1166
|
}
|
|
856
1167
|
turnDom.flush();
|
|
857
1168
|
snapshotCards.current.forEach((card) => upsertCard(card));
|
|
@@ -893,19 +1204,8 @@ function setMarkdownWarning(warning) {
|
|
|
893
1204
|
markdownWarningEl.hidden = false;
|
|
894
1205
|
}
|
|
895
1206
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
// answer_display re-read waits for it.
|
|
899
|
-
let turnEndRead = Promise.resolve();
|
|
900
|
-
// The wait of a turn_completed with display_pending for its answer_display
|
|
901
|
-
// (turn_events.js displayWait). The worker may die in between and never send
|
|
902
|
-
// one, so workerGone (through resetTurnView) and a re-render release it as
|
|
903
|
-
// well as its own timeout: it must never hold the turn's answer forever.
|
|
904
|
-
let displayPending = null;
|
|
905
|
-
|
|
906
|
-
function releaseDisplayWait() {
|
|
907
|
-
displayPending?.finish();
|
|
908
|
-
displayPending = null;
|
|
1207
|
+
function setCapabilities(capabilities) {
|
|
1208
|
+
pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
|
|
909
1209
|
}
|
|
910
1210
|
|
|
911
1211
|
async function readTurnEnd() {
|
|
@@ -913,7 +1213,7 @@ async function readTurnEnd() {
|
|
|
913
1213
|
const id = selected;
|
|
914
1214
|
// The line and the turn this read is for, before the await: a turn
|
|
915
1215
|
// queued behind this one may start (a new live line) before it lands.
|
|
916
|
-
const line =
|
|
1216
|
+
const line = liveTurn.timing.capture();
|
|
917
1217
|
try {
|
|
918
1218
|
const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
919
1219
|
if (id !== selected) return;
|
|
@@ -921,8 +1221,9 @@ async function readTurnEnd() {
|
|
|
921
1221
|
if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
|
|
922
1222
|
if (d.session.status) current.status = d.session.status;
|
|
923
1223
|
if (!(await mergeTurnTiming(id, d.timing))) return;
|
|
924
|
-
|
|
1224
|
+
liveTurn.timing.finishLine(line);
|
|
925
1225
|
setMarkdownWarning(d.markdown_warning);
|
|
1226
|
+
setCapabilities(d.capabilities);
|
|
926
1227
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
927
1228
|
updateInfoBar();
|
|
928
1229
|
const sess = allSessions.find(s=>s.id===selected);
|
|
@@ -931,7 +1232,7 @@ async function readTurnEnd() {
|
|
|
931
1232
|
sess.status = current.status;
|
|
932
1233
|
}
|
|
933
1234
|
render();
|
|
934
|
-
|
|
1235
|
+
paintActive();
|
|
935
1236
|
}
|
|
936
1237
|
} catch(_){}
|
|
937
1238
|
}
|
|
@@ -994,15 +1295,6 @@ function addEndBubble(text, { stopped = false } = {}) {
|
|
|
994
1295
|
if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
|
|
995
1296
|
}
|
|
996
1297
|
|
|
997
|
-
// The notice of a turn that ended with no answer (turn_summary.empty_answer),
|
|
998
|
-
// as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
|
|
999
|
-
function emptyAnswerEl(emptyAnswer) {
|
|
1000
|
-
const el = document.createElement("div");
|
|
1001
|
-
el.className = EMPTY_ANSWER_CLASS;
|
|
1002
|
-
el.textContent = emptyAnswerLine(emptyAnswer);
|
|
1003
|
-
return el;
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
1298
|
// Everything a turn's end (completed, canceled, failed) closes: then its
|
|
1007
1299
|
// end line (+endText+, the cancel or failed line), then the step cards that
|
|
1008
1300
|
// left the collapsed block (card.js leavesBlock), where a reload puts them.
|
|
@@ -1010,13 +1302,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1010
1302
|
setTurnRunning(false);
|
|
1011
1303
|
// Every ended turn has a record (completed, canceled, failed); a worker
|
|
1012
1304
|
// gone mid-turn is re-rendered (resync), which counts again.
|
|
1013
|
-
if (kind !== "gone")
|
|
1305
|
+
if (kind !== "gone") liveTurn.timing.ended += 1;
|
|
1014
1306
|
// A completed turn with no answer: its notice where the answer goes
|
|
1015
1307
|
// (the turn view places it), else after the turn; before the timing.
|
|
1016
|
-
const endNote = emptyAnswer ?
|
|
1308
|
+
const endNote = emptyAnswer ? htmlElement(emptyAnswerHtml(emptyAnswer)) : null;
|
|
1017
1309
|
const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
|
|
1018
1310
|
if (endNote && !endNote.isConnected) appendToHistory(endNote);
|
|
1019
|
-
|
|
1311
|
+
liveTurn.timing.finish({ canceled: kind === "canceled" });
|
|
1020
1312
|
if (endText) addEndBubble(endText, { stopped });
|
|
1021
1313
|
if (kept.length) {
|
|
1022
1314
|
kept.forEach((el) => appendToHistory(el));
|
|
@@ -1031,28 +1323,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
|
|
|
1031
1323
|
// turn starts, or steered when merged into a running turn. This tab's own
|
|
1032
1324
|
// prompts are echoed at send (data-own) and tagged by their turn_enqueued.
|
|
1033
1325
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1326
|
+
// The bubble a reload draws (turn_html.js), with this page's own fields.
|
|
1036
1327
|
function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
|
|
1037
1328
|
const hint = historyEl.querySelector(".hint");
|
|
1038
1329
|
if (hint) hint.remove();
|
|
1039
|
-
const div =
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
div.setAttribute(COPY_SOURCE_ATTR, prompt);
|
|
1043
|
-
if (own) div.dataset.own = "1";
|
|
1044
|
-
if (enqueuedId) div.dataset.enqueuedId = enqueuedId;
|
|
1045
|
-
if (label) {
|
|
1046
|
-
const from = document.createElement("span");
|
|
1047
|
-
from.className = "origin-label";
|
|
1048
|
-
from.textContent = label;
|
|
1049
|
-
div.appendChild(from);
|
|
1050
|
-
}
|
|
1051
|
-
const message = document.createElement("div");
|
|
1052
|
-
message.className = "user-message";
|
|
1053
|
-
message.innerHTML = userBodyHtml(prompt) + thumbsHtml(selected, images);
|
|
1054
|
-
div.appendChild(message);
|
|
1055
|
-
setBubbleState(div, state);
|
|
1330
|
+
const div = htmlElement(userBubbleHtml({ content: prompt }, {
|
|
1331
|
+
label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsHtml(selected, images),
|
|
1332
|
+
}));
|
|
1056
1333
|
const follow = isNearBottom();
|
|
1057
1334
|
appendToHistory(div);
|
|
1058
1335
|
if (follow) historyEl.scrollTop = historyEl.scrollHeight;
|
|
@@ -1068,10 +1345,7 @@ function setBubbleState(bubble, state) {
|
|
|
1068
1345
|
}
|
|
1069
1346
|
bubble.classList.add(state);
|
|
1070
1347
|
bubble.dataset.userState = state;
|
|
1071
|
-
|
|
1072
|
-
badge.className = "state-badge";
|
|
1073
|
-
badge.textContent = BADGES[state];
|
|
1074
|
-
bubble.appendChild(badge);
|
|
1348
|
+
bubble.insertAdjacentHTML("beforeend", stateBadgeHtml(state));
|
|
1075
1349
|
}
|
|
1076
1350
|
|
|
1077
1351
|
function bubbleById(enqueuedId) {
|
|
@@ -1091,18 +1365,18 @@ function untaggedOwnBubble(prompt) {
|
|
|
1091
1365
|
// Returns the bubble of the turn a turn_started starts (the stage takes it
|
|
1092
1366
|
// into its prompt line), else null.
|
|
1093
1367
|
function applyPromptOps(event) {
|
|
1094
|
-
if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
1095
|
-
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
|
|
1096
|
-
if (event.type === "input_merged") unmatchedMerge = false;
|
|
1097
|
-
if (event.type === "pending_input_merged") unmatchedMerge = false;
|
|
1368
|
+
if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
|
|
1369
|
+
const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
|
|
1370
|
+
if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
|
|
1371
|
+
if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
|
|
1098
1372
|
let started = null;
|
|
1099
1373
|
for (const op of ops) {
|
|
1100
1374
|
if (op.op === "report") {
|
|
1101
|
-
unmatchedMerge = "report";
|
|
1375
|
+
liveTurn.unmatchedMerge = "report";
|
|
1102
1376
|
} else if (op.op === "add") {
|
|
1103
1377
|
const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
|
|
1104
1378
|
if (op.state === "started") started = bubble;
|
|
1105
|
-
if (event.type === "pending_input_merged") wakeStart = false;
|
|
1379
|
+
if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
|
|
1106
1380
|
} else if (op.op === "tag") {
|
|
1107
1381
|
if (bubbleById(op.enqueuedId)) continue;
|
|
1108
1382
|
const echo = untaggedOwnBubble(op.prompt);
|
|
@@ -1122,7 +1396,7 @@ function applyPromptOps(event) {
|
|
|
1122
1396
|
} else if (op.op === "steer") {
|
|
1123
1397
|
const bubble = bubbleById(op.enqueuedId);
|
|
1124
1398
|
if (bubble) setBubbleState(bubble, "steered");
|
|
1125
|
-
else unmatchedMerge = true;
|
|
1399
|
+
else liveTurn.unmatchedMerge = true;
|
|
1126
1400
|
}
|
|
1127
1401
|
}
|
|
1128
1402
|
return started;
|
|
@@ -1150,7 +1424,7 @@ function showRecap(text, turnsSince = 0) {
|
|
|
1150
1424
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
1151
1425
|
// Never in the stage: a recap is between turns.
|
|
1152
1426
|
const before = recapPlace(turnPromptBubbles(), turnsSince);
|
|
1153
|
-
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details,
|
|
1427
|
+
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
1154
1428
|
}
|
|
1155
1429
|
|
|
1156
1430
|
// A turn started: the recap describes the chat before it.
|
|
@@ -1402,6 +1676,11 @@ function removeRecap() {
|
|
|
1402
1676
|
let attachCopiedAt = 0;
|
|
1403
1677
|
infoTextEl.addEventListener("click", (e) => {
|
|
1404
1678
|
if (e.target.closest(".worker-restart")) restartWorker();
|
|
1679
|
+
// Pushed as openAllView does, so the view's back returns here.
|
|
1680
|
+
if (e.target.closest(".children-chip")) {
|
|
1681
|
+
e.preventDefault();
|
|
1682
|
+
openAllView();
|
|
1683
|
+
}
|
|
1405
1684
|
});
|
|
1406
1685
|
infoTextEl.addEventListener("click", async (e) => {
|
|
1407
1686
|
if (!e.target.closest(".copy-attach") || !selected) return;
|
|
@@ -1484,10 +1763,13 @@ function updateInfoBar() {
|
|
|
1484
1763
|
// A delegated session names its parent, as a link to it.
|
|
1485
1764
|
const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
|
|
1486
1765
|
const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
|
|
1766
|
+
// A parent names its delegates; the link opens all sessions.
|
|
1767
|
+
const kids = childrenSummary({ id: selected }, allSessions);
|
|
1768
|
+
const kidsHtml = kids ? `<a class="model children-chip${kids.waitingText ? " waiting" : ""}" href="${escapeHtml(ALL_SESSIONS_HASH)}" title="${escapeHtml(`${kids.title} (all sessions)`)}">${escapeHtml(kids.text)}${kids.waitingText ? ` · <span class="kids-waiting">${escapeHtml(kids.waitingText)}</span>` : ""}</a>` : "";
|
|
1487
1769
|
const attachCmd = `chi --attach ${selected}`;
|
|
1488
1770
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1489
1771
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1490
|
-
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1772
|
+
const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
|
|
1491
1773
|
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1492
1774
|
// The session clock ticks every second while a turn runs: only its text
|
|
1493
1775
|
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
@@ -1505,80 +1787,16 @@ function memoriesTooltip(used, preloaded, muted) {
|
|
|
1505
1787
|
}
|
|
1506
1788
|
|
|
1507
1789
|
function currentSessionDuration() {
|
|
1508
|
-
if (
|
|
1790
|
+
if (liveTurn.running && current.timing.startedAt) {
|
|
1509
1791
|
return elapsedSince(current.timing.startedAt);
|
|
1510
1792
|
}
|
|
1511
1793
|
return current.timing.sessionDurationMs;
|
|
1512
1794
|
}
|
|
1513
1795
|
|
|
1514
|
-
function clearTimingInterval() {
|
|
1515
|
-
if (timingInterval !== null) {
|
|
1516
|
-
clearInterval(timingInterval);
|
|
1517
|
-
timingInterval = null;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
let liveTurnNumber = null;
|
|
1522
|
-
// The live line sits in the stage's status row, which already says the turn
|
|
1523
|
-
// runs ("running a tool"): it drops its own "running" there. It never moves
|
|
1524
|
-
// mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
|
|
1525
|
-
let liveTurnTimingInStage = false;
|
|
1526
|
-
|
|
1527
|
-
function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
|
|
1528
|
-
clearTimingInterval();
|
|
1529
|
-
current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
|
|
1530
|
-
liveTurnId = turnId || null;
|
|
1531
|
-
// The turns ended so far, so this one comes next.
|
|
1532
|
-
liveTurnNumber = turnsEnded + 1;
|
|
1533
|
-
liveTurnNote = "";
|
|
1534
|
-
liveTurnTimingEl = document.createElement("div");
|
|
1535
|
-
liveTurnTimingEl.className = "turn-timing live";
|
|
1536
|
-
// The stage's status row while it holds the turn: the line moves into the
|
|
1537
|
-
// history with it.
|
|
1538
|
-
const stageHost = turnDom.timingHost?.() || null;
|
|
1539
|
-
liveTurnTimingInStage = !!stageHost;
|
|
1540
|
-
(stageHost || historyEl).appendChild(liveTurnTimingEl);
|
|
1541
|
-
updateLiveTiming();
|
|
1542
|
-
timingInterval = setInterval(updateLiveTiming, 1000);
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
1796
|
// The stage takes a running turn's rows (its extras); else the history.
|
|
1546
1797
|
function appendToHistory(el) {
|
|
1547
1798
|
if (turnDom.place?.(el)) return;
|
|
1548
|
-
appendAboveLiveTiming(historyEl, el,
|
|
1549
|
-
}
|
|
1550
|
-
|
|
1551
|
-
function updateLiveTiming() {
|
|
1552
|
-
const duration = elapsedSince(current.timing.activeTurn?.started_at);
|
|
1553
|
-
if (liveTurnTimingEl && duration !== null) {
|
|
1554
|
-
liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
|
|
1555
|
-
}
|
|
1556
|
-
updateInfoBar();
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
// Set (or clear, "") what the running turn waits on.
|
|
1560
|
-
function setLiveTurnNote(text) {
|
|
1561
|
-
if (liveTurnNote === text) return;
|
|
1562
|
-
liveTurnNote = text;
|
|
1563
|
-
if (current.timing.activeTurn) updateLiveTiming();
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
// A canceled turn's line says so (+canceled+, or its record's status), as
|
|
1567
|
-
// a reload's does.
|
|
1568
|
-
function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
1569
|
-
const duration = Number.isFinite(Number(record?.duration_ms))
|
|
1570
|
-
? Number(record.duration_ms)
|
|
1571
|
-
: elapsedSince(current.timing.activeTurn?.started_at);
|
|
1572
|
-
if (liveTurnTimingEl && duration !== null) {
|
|
1573
|
-
liveTurnNote = "";
|
|
1574
|
-
liveTurnTimingEl.classList.remove("live");
|
|
1575
|
-
const at = record ? current.timing.turnRecords.indexOf(record) : -1;
|
|
1576
|
-
liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
|
|
1577
|
-
{ canceled: canceled || record?.status === "canceled" });
|
|
1578
|
-
}
|
|
1579
|
-
current.timing.activeTurn = null;
|
|
1580
|
-
clearTimingInterval();
|
|
1581
|
-
updateInfoBar();
|
|
1799
|
+
appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
|
|
1582
1800
|
}
|
|
1583
1801
|
|
|
1584
1802
|
// A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
|
|
@@ -1587,7 +1805,7 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
|
|
|
1587
1805
|
// turn that started meanwhile keeps its line and its own active turn.
|
|
1588
1806
|
// @return false when the session changed meanwhile
|
|
1589
1807
|
async function mergeTurnTiming(id, data) {
|
|
1590
|
-
const keepActive = () => (
|
|
1808
|
+
const keepActive = () => (liveTurn.running ? current.timing.activeTurn : null);
|
|
1591
1809
|
const merged = mergeTiming(current.timing, data);
|
|
1592
1810
|
current.timing = { ...merged.timing, activeTurn: keepActive() };
|
|
1593
1811
|
if (merged.short) {
|
|
@@ -1599,30 +1817,10 @@ async function mergeTurnTiming(id, data) {
|
|
|
1599
1817
|
if (id !== selected) return false;
|
|
1600
1818
|
}
|
|
1601
1819
|
}
|
|
1602
|
-
|
|
1820
|
+
liveTurn.timing.ended = Math.max(liveTurn.timing.ended, current.timing.turnRecords.length);
|
|
1603
1821
|
return true;
|
|
1604
1822
|
}
|
|
1605
1823
|
|
|
1606
|
-
// Finish the timing line a turn-end read captured (+line+: {el, turnId})
|
|
1607
|
-
// with that turn's record: its place in the records and its duration (a
|
|
1608
|
-
// canceled one says so). No id (an older worker's events): the last
|
|
1609
|
-
// record. No record: the line stays as the turn's end left it. The ticker
|
|
1610
|
-
// stops only if that line is still the live one.
|
|
1611
|
-
function finishTurnLine(line) {
|
|
1612
|
-
const records = current.timing.turnRecords;
|
|
1613
|
-
const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
|
|
1614
|
-
if (line.el === liveTurnTimingEl) {
|
|
1615
|
-
current.timing.activeTurn = null;
|
|
1616
|
-
clearTimingInterval();
|
|
1617
|
-
}
|
|
1618
|
-
const duration = Number(record?.duration_ms);
|
|
1619
|
-
if (line.el && record && Number.isFinite(duration)) {
|
|
1620
|
-
line.el.classList.remove("live");
|
|
1621
|
-
line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
|
|
1622
|
-
}
|
|
1623
|
-
updateInfoBar();
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
1824
|
function updateFirstPeek() {
|
|
1627
1825
|
if (current.ctxPct !== null && current.ctxPct > 20 && current.firstPreview) {
|
|
1628
1826
|
firstPeekEl.textContent = current.firstPreview;
|
|
@@ -1640,7 +1838,7 @@ function setUsedMemories(names) {
|
|
|
1640
1838
|
if (sess) {
|
|
1641
1839
|
sess.used_memory_names = current.usedMemories.slice();
|
|
1642
1840
|
render();
|
|
1643
|
-
|
|
1841
|
+
paintActive();
|
|
1644
1842
|
}
|
|
1645
1843
|
}
|
|
1646
1844
|
|
|
@@ -1683,7 +1881,7 @@ function workerGone(id, { opened = true } = {}) {
|
|
|
1683
1881
|
if (id !== selected) return;
|
|
1684
1882
|
// A turn that was running is over: its worker can't finish it.
|
|
1685
1883
|
const stopped = stoppingId === id;
|
|
1686
|
-
const endText = workerGoneText({ turnRunning, stopped });
|
|
1884
|
+
const endText = workerGoneText({ turnRunning: liveTurn.running, stopped });
|
|
1687
1885
|
if (endText) {
|
|
1688
1886
|
endTurnView("gone", endText, { stopped });
|
|
1689
1887
|
if (!stopped) setLiveStatus("idle");
|
|
@@ -1712,7 +1910,7 @@ const streamHandlers = {
|
|
|
1712
1910
|
// Route the chunk's bytes to the text bubble and the thinking block (see
|
|
1713
1911
|
// chunk_router.js). Raw `content` is only a fallback for an older server.
|
|
1714
1912
|
const { text, thinking } = routeChunk(data);
|
|
1715
|
-
|
|
1913
|
+
liveTurn.timing.setNote("");
|
|
1716
1914
|
turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
|
|
1717
1915
|
const pct = extractCtxPct(data, current.ctxWindow);
|
|
1718
1916
|
if (pct !== null && !Number.isNaN(pct)) {
|
|
@@ -1723,11 +1921,11 @@ const streamHandlers = {
|
|
|
1723
1921
|
},
|
|
1724
1922
|
generation_started: (data) => {
|
|
1725
1923
|
if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
|
|
1726
|
-
|
|
1924
|
+
liveTurn.timing.setNote("");
|
|
1727
1925
|
turnDom.generationStarted(data || {});
|
|
1728
1926
|
},
|
|
1729
1927
|
generation_completed: (data) => {
|
|
1730
|
-
|
|
1928
|
+
liveTurn.timing.setNote("");
|
|
1731
1929
|
if (data?.served_model) current.served = [data.served_model, data.requested_model];
|
|
1732
1930
|
// The generation's speed and the session's token totals, from the server.
|
|
1733
1931
|
current.timing.tokens = generationTokens(current.timing.tokens, data);
|
|
@@ -1764,10 +1962,9 @@ const streamHandlers = {
|
|
|
1764
1962
|
const promptBubble = applyPromptOps(data);
|
|
1765
1963
|
setTurnRunning(true);
|
|
1766
1964
|
setLiveStatus("running");
|
|
1767
|
-
|
|
1768
|
-
liveTurnTimingEl = null;
|
|
1965
|
+
liveTurn.timing.detach();
|
|
1769
1966
|
turnDom.turnStarted({ ...data, promptBubble });
|
|
1770
|
-
|
|
1967
|
+
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
1771
1968
|
}),
|
|
1772
1969
|
turn_completed: async (data) => {
|
|
1773
1970
|
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
@@ -1778,9 +1975,9 @@ const streamHandlers = {
|
|
|
1778
1975
|
// that dies before it never sends one: workerGone releases the wait, and
|
|
1779
1976
|
// displayWait's timeout ends it whatever happens (4.14).
|
|
1780
1977
|
const display = data.display_pending ? displayWait() : null;
|
|
1781
|
-
if (display) displayPending = display;
|
|
1782
|
-
|
|
1783
|
-
await
|
|
1978
|
+
if (display) liveTurn.displayPending = display;
|
|
1979
|
+
liveTurn.endRead = readTurnEnd();
|
|
1980
|
+
await liveTurn.endRead;
|
|
1784
1981
|
// The answer bubble the turn view held at its box's height pops now
|
|
1785
1982
|
// that the rendered markdown is in it (or the re-read failed: then the
|
|
1786
1983
|
// plain text pops), and the display if one was coming, so the links
|
|
@@ -1793,13 +1990,13 @@ const streamHandlers = {
|
|
|
1793
1990
|
// turn_completed, so re-read the answer once that read is done.
|
|
1794
1991
|
answer_display: async (data) => {
|
|
1795
1992
|
try {
|
|
1796
|
-
await
|
|
1993
|
+
await liveTurn.endRead;
|
|
1797
1994
|
if (!selected || !data.display) return;
|
|
1798
1995
|
const d = await getSession(selected, { tail: true, recent: true });
|
|
1799
1996
|
if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
|
|
1800
1997
|
} catch (_) {
|
|
1801
1998
|
} finally {
|
|
1802
|
-
|
|
1999
|
+
liveTurn.releaseDisplay();
|
|
1803
2000
|
}
|
|
1804
2001
|
},
|
|
1805
2002
|
turn_canceled: (data) => {
|
|
@@ -1811,8 +2008,8 @@ const streamHandlers = {
|
|
|
1811
2008
|
// stays up: it rolls the turn back and hands the prompt back
|
|
1812
2009
|
// (prompt_restored), and the prompts queued behind it run as usual.
|
|
1813
2010
|
turn_failed: (data) => {
|
|
1814
|
-
|
|
1815
|
-
endTurnView("failed",
|
|
2011
|
+
liveTurn.failedText = failedTurnText(data);
|
|
2012
|
+
endTurnView("failed", liveTurn.failedText);
|
|
1816
2013
|
setLiveStatus("idle");
|
|
1817
2014
|
},
|
|
1818
2015
|
// The rolled-back turn left the session: render it from the server again,
|
|
@@ -1826,8 +2023,8 @@ const streamHandlers = {
|
|
|
1826
2023
|
fitPrompt();
|
|
1827
2024
|
}
|
|
1828
2025
|
if (restoredImages?.length) setChips(restoredChips(selected, restoredImages));
|
|
1829
|
-
const failedText =
|
|
1830
|
-
|
|
2026
|
+
const failedText = liveTurn.failedText;
|
|
2027
|
+
liveTurn.failedText = null;
|
|
1831
2028
|
await resync();
|
|
1832
2029
|
// The re-render drew it from the session (failed_turn): drawn again
|
|
1833
2030
|
// below with this event's label and images.
|
|
@@ -1904,19 +2101,24 @@ const streamHandlers = {
|
|
|
1904
2101
|
recap_ready: (data) => {
|
|
1905
2102
|
// After the turn it covers, never above it.
|
|
1906
2103
|
turnDom.handOff?.({ immediate: true });
|
|
1907
|
-
if (!
|
|
2104
|
+
if (!liveTurn.running) showRecap(data.recap);
|
|
1908
2105
|
},
|
|
1909
2106
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
1910
|
-
// A notice during a turn is a row of its current step.
|
|
1911
|
-
|
|
2107
|
+
// A notice during a turn is a row of its current step. One the page
|
|
2108
|
+
// shows elsewhere (a hook's links in the rendered answer) isn't drawn,
|
|
2109
|
+
// not even in the stage trail; a joined turn's notices come here too.
|
|
2110
|
+
hook_notice: (data) => {
|
|
2111
|
+
if (shownElsewhere(data, pageCapabilities)) return;
|
|
2112
|
+
if (!turnDom.notice(data)) showHookNotice(data);
|
|
2113
|
+
},
|
|
1912
2114
|
// The loop asks again after an empty answer: a row of the empty step
|
|
1913
2115
|
// (a reload puts it back from the snapshot's cards).
|
|
1914
2116
|
empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
|
|
1915
2117
|
steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
|
|
1916
2118
|
// The provider is asked again after an error, or the turn waits for
|
|
1917
2119
|
// plugins' setup: a note on the live timing line until it streams.
|
|
1918
|
-
generation_retrying: (data) =>
|
|
1919
|
-
plugin_init_wait: (data) =>
|
|
2120
|
+
generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
|
|
2121
|
+
plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
|
|
1920
2122
|
plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
|
|
1921
2123
|
plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
|
|
1922
2124
|
// A plugin's card, or a new version of one (the same id).
|
|
@@ -1939,12 +2141,12 @@ const streamHandlers = {
|
|
|
1939
2141
|
async function refreshTimingFromSession() {
|
|
1940
2142
|
if (!selected) return;
|
|
1941
2143
|
const id = selected;
|
|
1942
|
-
const line =
|
|
2144
|
+
const line = liveTurn.timing.capture();
|
|
1943
2145
|
try {
|
|
1944
2146
|
const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
|
|
1945
2147
|
if (id !== selected) return;
|
|
1946
2148
|
if (data.session?.status) current.status = data.session.status;
|
|
1947
|
-
if (await mergeTurnTiming(id, data.timing))
|
|
2149
|
+
if (await mergeTurnTiming(id, data.timing)) liveTurn.timing.finishLine(line);
|
|
1948
2150
|
} catch (_) {}
|
|
1949
2151
|
}
|
|
1950
2152
|
|
|
@@ -2429,25 +2631,40 @@ function markArchived(ids, archived) {
|
|
|
2429
2631
|
allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
|
|
2430
2632
|
}
|
|
2431
2633
|
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2634
|
+
// One archive / unarchive request, the core the info bar, a card's button
|
|
2635
|
+
// and a batch share. The ids in the answer flip in the list at once. When
|
|
2636
|
+
// the open session's worker was stopped (it, or a delegate of the archived
|
|
2637
|
+
// one), its stream ending is a stop, not a lost worker.
|
|
2638
|
+
// @return {ok: true, result} | {ok: false, error}
|
|
2639
|
+
async function archiveOne(id, archive) {
|
|
2640
|
+
// A live worker in the tree is stopped by the archive: the open
|
|
2641
|
+
// session's stream ending then is a stop.
|
|
2642
|
+
const mine = archive && selected && inTree(allSessions, id, selected) ? selected : null;
|
|
2643
|
+
if (mine) stoppingId = mine;
|
|
2437
2644
|
try {
|
|
2438
|
-
result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
2645
|
+
const result = archive ? await archiveSession(id) : await unarchiveSession(id);
|
|
2439
2646
|
markArchived(archive ? result.archived : result.unarchived, archive);
|
|
2440
|
-
if (
|
|
2647
|
+
if (mine && mine === selected && (result.stopped || []).includes(mine)) {
|
|
2441
2648
|
// Its worker is gone: don't wait for its stream to notice.
|
|
2442
2649
|
streamControl?.close();
|
|
2443
2650
|
current.status = "stopped";
|
|
2444
|
-
workerGone(
|
|
2651
|
+
workerGone(mine);
|
|
2445
2652
|
}
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
return;
|
|
2653
|
+
return { ok: true, result };
|
|
2654
|
+
} catch (error) {
|
|
2655
|
+
return { ok: false, error };
|
|
2449
2656
|
} finally {
|
|
2450
|
-
|
|
2657
|
+
// Another call may have taken it meanwhile: clear only our own.
|
|
2658
|
+
if (mine && stoppingId === mine) stoppingId = null;
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
async function setArchived(id, archive, { undo = true } = {}) {
|
|
2663
|
+
const short = id.slice(0, 8);
|
|
2664
|
+
const answer = await archiveOne(id, archive);
|
|
2665
|
+
if (!answer.ok) {
|
|
2666
|
+
alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
|
|
2667
|
+
return;
|
|
2451
2668
|
}
|
|
2452
2669
|
render();
|
|
2453
2670
|
updateInfoBar();
|
|
@@ -2482,7 +2699,7 @@ const annotateBar = createAnnotateBar({
|
|
|
2482
2699
|
presets: parsePresets(document.body.dataset.annotatePresets),
|
|
2483
2700
|
canAnnotate: () => !!selected && current.owner !== "tui",
|
|
2484
2701
|
roots: turnRoots,
|
|
2485
|
-
isLive: (root) =>
|
|
2702
|
+
isLive: (root) => liveTurn.running && turnDom.containsLive(root),
|
|
2486
2703
|
onQuote: (text) => {
|
|
2487
2704
|
promptEl.value = appendQuote(promptEl.value, text);
|
|
2488
2705
|
fitPrompt();
|
|
@@ -2521,6 +2738,7 @@ function applyStripHidden() {
|
|
|
2521
2738
|
}));
|
|
2522
2739
|
}
|
|
2523
2740
|
function setStripHidden(hidden) {
|
|
2741
|
+
if (hidden) familyPop.close();
|
|
2524
2742
|
try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
|
|
2525
2743
|
stripShownWhileShort = !hidden && shortWindow.matches;
|
|
2526
2744
|
applyStripHidden();
|
|
@@ -2533,17 +2751,26 @@ shortWindow.addEventListener("change", () => {
|
|
|
2533
2751
|
$("#stripShow").addEventListener("click", () => setStripHidden(false));
|
|
2534
2752
|
$("#stripHideTop").addEventListener("click", () => setStripHidden(true));
|
|
2535
2753
|
|
|
2536
|
-
$("#filter").addEventListener("input",
|
|
2754
|
+
$("#filter").addEventListener("input", () => {
|
|
2755
|
+
searchToggles.clear();
|
|
2756
|
+
renderAll();
|
|
2757
|
+
});
|
|
2537
2758
|
// A list whose order was held under the pointer (listOrder) catches up.
|
|
2538
2759
|
topStripEl.addEventListener("pointerleave", () => {
|
|
2539
|
-
|
|
2760
|
+
// Not while its family popover is open: the pointer went there (close() catches up).
|
|
2761
|
+
if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
|
|
2540
2762
|
});
|
|
2541
2763
|
allListEl.addEventListener("pointerleave", () => {
|
|
2542
2764
|
if (heldLists.has(allListEl) && allViewOpen()) renderAll();
|
|
2543
2765
|
});
|
|
2544
|
-
// Esc leaves the all-sessions view.
|
|
2766
|
+
// Esc leaves select mode first, then the all-sessions view.
|
|
2545
2767
|
document.addEventListener("keydown", (e) => {
|
|
2546
|
-
if (e.key
|
|
2768
|
+
if (e.key !== "Escape" || !allViewOpen()) return;
|
|
2769
|
+
if (selecting.on) {
|
|
2770
|
+
if (!selecting.running) leaveSelect();
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
closeAllView();
|
|
2547
2774
|
});
|
|
2548
2775
|
applyView();
|
|
2549
2776
|
|