samagotchi 0.46.1 → 0.48.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 +51 -1
- data/docs/cli.md +1 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +26 -6
- data/lib/samagotchi/bridge.rb +3 -1
- data/lib/samagotchi/bundles/coordinator/manifest.yml +4 -4
- data/lib/samagotchi/bundles/coordinator/plugin.rb +15 -2
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +20 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +235 -0
- data/lib/samagotchi/child_reports.rb +32 -8
- data/lib/samagotchi/send_command.rb +64 -9
- data/lib/samagotchi/session.rb +48 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_manager.rb +314 -25
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/tools/delegate_cursor.rb +31 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +50 -0
- data/lib/samagotchi/web/public/app.js +201 -18
- data/lib/samagotchi/web/public/chains.js +247 -0
- data/lib/samagotchi/web/public/data.js +12 -0
- data/lib/samagotchi/web/public/families_view.js +3 -2
- data/lib/samagotchi/web/public/index.html +29 -6
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/sessions_list.js +4 -2
- data/lib/samagotchi/web/public/turn_events.js +7 -3
- data/lib/samagotchi/web/session_summary.rb +3 -0
- data/lib/samagotchi/worker.rb +12 -1
- metadata +4 -1
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -594,6 +594,7 @@ module Samagotchi
|
|
|
594
594
|
if prompt.to_s.strip.empty? && !idle
|
|
595
595
|
return error_response(400, "missing_fields", "prompt is required")
|
|
596
596
|
end
|
|
597
|
+
return handle_continue(body, prompt, idle) if body.key?("continues")
|
|
597
598
|
|
|
598
599
|
# dir: the folder the chat starts in (the page's scope); without it,
|
|
599
600
|
# the server's own cwd.
|
|
@@ -632,6 +633,55 @@ module Samagotchi
|
|
|
632
633
|
json_response(201, session_to_json(session).merge(bridge_port: port))
|
|
633
634
|
end
|
|
634
635
|
|
|
636
|
+
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
637
|
+
# link of that session's chain (SessionManager.continue_session), in its
|
|
638
|
+
# folder, on its model and with its llm_context, so dir, model and
|
|
639
|
+
# llm_context are refused beside it. 409 continued (with next_id, the
|
|
640
|
+
# link that continues it already: the page opens that one),
|
|
641
|
+
# open_children (ids), folder_gone, busy, scratch or owned_by_tui.
|
|
642
|
+
def handle_continue(body, prompt, idle)
|
|
643
|
+
given = body["continues"]
|
|
644
|
+
unless given.is_a?(String) && !given.strip.empty?
|
|
645
|
+
return error_response(400, "invalid_continues", "continues is a session id")
|
|
646
|
+
end
|
|
647
|
+
if (fields = %w[dir model model_name llm_context].select { |key| body.key?(key) }).any?
|
|
648
|
+
return error_response(400, "invalid_continues",
|
|
649
|
+
"continues takes the previous session's folder, model and llm_context; " \
|
|
650
|
+
"leave out #{fields.join(", ")}")
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
preview = idle ? body["preview"].to_s.strip : ""
|
|
654
|
+
session, error = continue_from(given, prompt: idle ? nil : prompt.to_s, title: preview.empty? ? nil : preview)
|
|
655
|
+
return error if error
|
|
656
|
+
|
|
657
|
+
port = await_bridge_port(session.id)
|
|
658
|
+
moved = session.moved_children || []
|
|
659
|
+
# The moved delegates' cards fold under the new link now.
|
|
660
|
+
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
661
|
+
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
662
|
+
json_response(201, session_to_json(session).merge(bridge_port: port, moved: moved))
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# SessionManager.continue_session, its refusals as responses: [session,
|
|
666
|
+
# nil] or [nil, the response]. Only its own errors: what fails after
|
|
667
|
+
# the new link started is not a refusal of it.
|
|
668
|
+
def continue_from(given, prompt:, title:)
|
|
669
|
+
[@manager.continue_session(given, prompt: prompt, title: title, state_dir: @state_dir), nil]
|
|
670
|
+
rescue SessionManager::ContinueRefused => e
|
|
671
|
+
extra = e.reason == :continued ? { next_id: e.ids.first } : { ids: e.ids }
|
|
672
|
+
[nil, json_response(409, { error: e.reason.to_s, detail: e.message, **extra })]
|
|
673
|
+
rescue SessionManager::ArchiveRefused => e
|
|
674
|
+
[nil, error_response(409, e.reason == :scratch ? "scratch" : "busy", e.message)]
|
|
675
|
+
rescue SessionManager::OwnedByTUI
|
|
676
|
+
[nil, error_response(409, "owned_by_tui", "session #{given} is open in a chi REPL; close it there first")]
|
|
677
|
+
rescue ModelProfile::MissingModel => e
|
|
678
|
+
[nil, error_response(400, "invalid_model", e.message)]
|
|
679
|
+
rescue Session::AmbiguousId => e
|
|
680
|
+
[nil, error_response(400, "ambiguous_id", e.message)]
|
|
681
|
+
rescue ArgumentError => e
|
|
682
|
+
[nil, error_response(404, "not_found", e.message)]
|
|
683
|
+
end
|
|
684
|
+
|
|
635
685
|
# The create request's "llm_context" ({strategy:, apply:, budget:},
|
|
636
686
|
# each a word as /llm-context takes it, "default" for unset) as an
|
|
637
687
|
# override: [override, nil] (nil when absent or all default), or
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
openStream,
|
|
13
13
|
openEvents,
|
|
14
14
|
createIdleSession,
|
|
15
|
+
continueSession,
|
|
15
16
|
uploadImage,
|
|
16
17
|
listModels,
|
|
17
18
|
fetchHistory,
|
|
@@ -36,6 +37,7 @@ import { createTurnView, turnHistoryHtml } from "./turn_view.js";
|
|
|
36
37
|
import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
|
|
37
38
|
import { createStageView } from "./stage_view.js";
|
|
38
39
|
import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
|
|
40
|
+
import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, continuedText, foldChains, openChildrenHtml } from "./chains.js";
|
|
39
41
|
import { createTaskStopper } from "./task_stop.js";
|
|
40
42
|
import { appendQuote } from "./annotations.js";
|
|
41
43
|
import { parsePresets } from "./annotate_presets.js";
|
|
@@ -260,7 +262,9 @@ function openSessionEvents() {
|
|
|
260
262
|
allSessions = applySessionEvent(allSessions, "session", data);
|
|
261
263
|
render();
|
|
262
264
|
if (data.session && data.session.id === selected) followSelectedSession(data.session);
|
|
263
|
-
|
|
265
|
+
// Its children chip; its chain's ← → links (a link that continues it, its previous one).
|
|
266
|
+
else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
|
|
267
|
+
|| (data.session?.id && data.session.id === current.continues))) updateInfoBar();
|
|
264
268
|
},
|
|
265
269
|
session_gone: (data) => {
|
|
266
270
|
notifications.noticeSessions("session_gone", data);
|
|
@@ -270,7 +274,7 @@ function openSessionEvents() {
|
|
|
270
274
|
if (next !== allSessions) {
|
|
271
275
|
allSessions = next;
|
|
272
276
|
render();
|
|
273
|
-
if (selected) updateInfoBar(); // a deleted child leaves the children chip
|
|
277
|
+
if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
|
|
274
278
|
}
|
|
275
279
|
leaveGoneSession(data.id, "deleted");
|
|
276
280
|
},
|
|
@@ -363,9 +367,22 @@ function filterQuery() {
|
|
|
363
367
|
return $("#filter").value.trim().toLowerCase();
|
|
364
368
|
}
|
|
365
369
|
|
|
370
|
+
// The page's sessions by id, built again only when the list changed.
|
|
371
|
+
let byIdOf = { list: null, map: new Map() };
|
|
372
|
+
function sessionsById() {
|
|
373
|
+
if (byIdOf.list !== allSessions) byIdOf = { list: allSessions, map: new Map(allSessions.map((s) => [s.id, s])) };
|
|
374
|
+
return byIdOf.map;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// A card's title: its preview, or its chain's first link's for a link
|
|
378
|
+
// titled with the opener (chains.js chainTitle; display only).
|
|
379
|
+
function cardTitle(s) {
|
|
380
|
+
return chainTitle(s, sessionsById());
|
|
381
|
+
}
|
|
382
|
+
|
|
366
383
|
// Whether a session matches the all view's search +q+ (lower case).
|
|
367
384
|
function matchesFilter(s, q) {
|
|
368
|
-
return
|
|
385
|
+
return cardTitle(s).toLowerCase().includes(q) ||
|
|
369
386
|
(s.first_preview || "").toLowerCase().includes(q) ||
|
|
370
387
|
s.short_id.toLowerCase().includes(q) ||
|
|
371
388
|
s.id.toLowerCase().includes(q) ||
|
|
@@ -384,12 +401,13 @@ function matchesFilter(s, q) {
|
|
|
384
401
|
// members, its chip (which opens their list), and the card waits when any
|
|
385
402
|
// member does; with rows (all sessions) an open family lists its members
|
|
386
403
|
// inline, the strip's opens in a popover; matchIds: a search's matching
|
|
387
|
-
// members.
|
|
404
|
+
// members. A family in a session chain (chains.js foldChains) carries its
|
|
405
|
+
// `day N` chip, which opens the other links in a popover.
|
|
388
406
|
function cardHtml(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}) {
|
|
389
407
|
const fam = family && family.members.length ? family : null;
|
|
390
408
|
const ts = s.updated_at || s.created_at || "";
|
|
391
409
|
const tip = `${s.id} · updated ${ts} · ${s.status}`;
|
|
392
|
-
const preview = escapeHtml(
|
|
410
|
+
const preview = escapeHtml(cardTitle(s));
|
|
393
411
|
const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
|
|
394
412
|
const ago = relativeTime(ts);
|
|
395
413
|
const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
|
|
@@ -407,11 +425,15 @@ function cardHtml(s, { deletable = false, archivable = false, selectable = false
|
|
|
407
425
|
const dotWord = waiting ? waiting.title : s.status;
|
|
408
426
|
const pickHtml = selectable ? `<input type="checkbox" class="card-pick" aria-label="Select session ${escapeHtml(s.short_id)}"${picked ? " checked" : ""}>` : "";
|
|
409
427
|
const skipHtml = skipReason ? `<div class="skip-reason">skipped: ${escapeHtml(skipReason)}</div>` : "";
|
|
410
|
-
const familyWaiting = !!
|
|
428
|
+
const familyWaiting = !!family && familyWaits(family);
|
|
429
|
+
const chainHtml = family?.chain ? chainChipHtml(family, { open: chainOpenHead() === s.id }) : "";
|
|
411
430
|
const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
|
|
412
431
|
const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
|
|
413
|
-
|
|
414
|
-
|
|
432
|
+
// Its delegates and its chain's other links: the card is .contains-active while one is open.
|
|
433
|
+
const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
|
|
434
|
+
...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
|
|
435
|
+
const membersAttr = memberIds.length ? ` data-members="${escapeHtml(memberIds.join(" "))}"` : "";
|
|
436
|
+
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>`;
|
|
415
437
|
}
|
|
416
438
|
|
|
417
439
|
function cardToolsHtml(s, { deletable, archivable }) {
|
|
@@ -460,7 +482,7 @@ function listOrder(el, next, idOf = (s) => s.id) {
|
|
|
460
482
|
// The strip's popover holds it too (the pointer leaves the strip for it,
|
|
461
483
|
// and a touch screen has no hover at all).
|
|
462
484
|
const holding = el.getClientRects().length > 0
|
|
463
|
-
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()));
|
|
485
|
+
&& ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
|
|
464
486
|
const held = holding ? drawnOrder.get(el) : null;
|
|
465
487
|
const ordered = held ? heldOrder(held, next, idOf) : next;
|
|
466
488
|
if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
|
|
@@ -473,7 +495,7 @@ function listOrder(el, next, idOf = (s) => s.id) {
|
|
|
473
495
|
// first-listed member's place, so a hub upsert doesn't move it), waiting
|
|
474
496
|
// ones first, as many as fit. A family is one card.
|
|
475
497
|
function stripCards() {
|
|
476
|
-
return listOrder(topStripEl, waitingFirst(families(shownSessions(), { order: "list" })), familyId).slice(0, stripCols);
|
|
498
|
+
return listOrder(topStripEl, waitingFirst(foldChains(families(shownSessions(), { order: "list" }), allSessions)), familyId).slice(0, stripCols);
|
|
477
499
|
}
|
|
478
500
|
|
|
479
501
|
// The strip's family popover (families_view.js): the open family's rows,
|
|
@@ -501,6 +523,52 @@ window.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
|
|
|
501
523
|
document.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
|
|
502
524
|
document.addEventListener("scroll", () => familyPop.reposition(), true);
|
|
503
525
|
|
|
526
|
+
// A chain's popover (chains.js): the other links of the chain a card's
|
|
527
|
+
// `day N` chip stands for, under the chip, in the strip or all sessions
|
|
528
|
+
// (the list it was opened from: chainFrom). A row opens that link.
|
|
529
|
+
let chainFamilies = new Map(); // head id → family, as the lists last drew it
|
|
530
|
+
let chainFrom = null; // the list element the open popover's chip is in
|
|
531
|
+
const chainPop = createFamilyPop({
|
|
532
|
+
id: "chainPop",
|
|
533
|
+
doc: document,
|
|
534
|
+
win: window,
|
|
535
|
+
rowsFor: (id) => {
|
|
536
|
+
const f = chainFamilies.get(id);
|
|
537
|
+
return f?.chain ? chainRowsHtml(f, { activeId: selected }) : null;
|
|
538
|
+
},
|
|
539
|
+
anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
|
|
540
|
+
onPick: (id) => {
|
|
541
|
+
chainPop.close();
|
|
542
|
+
if (allViewOpen()) leaveAllView(sessionHash(id));
|
|
543
|
+
select(id);
|
|
544
|
+
},
|
|
545
|
+
// chainFrom stays (Esc focuses the chip again after the close); only an
|
|
546
|
+
// open popover holds a list (chainPopIn).
|
|
547
|
+
onClose: () => {
|
|
548
|
+
if (chainFrom === topStripEl && heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
function chainOpenHead() {
|
|
552
|
+
return chainPop.openHead();
|
|
553
|
+
}
|
|
554
|
+
function chainPopIn(el) {
|
|
555
|
+
return chainPop.isOpen() && chainFrom === el;
|
|
556
|
+
}
|
|
557
|
+
function toggleChainPop(from, id, e) {
|
|
558
|
+
if (chainPop.openHead() === id && chainFrom === from) {
|
|
559
|
+
chainPop.close();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
chainPop.close();
|
|
563
|
+
chainFrom = from;
|
|
564
|
+
chainPop.open(id);
|
|
565
|
+
// From the keyboard the focus goes into the popover; Esc brings it back.
|
|
566
|
+
if (e?.detail === 0 && chainPop.isOpen()) $("#chainPop .fr-open")?.focus();
|
|
567
|
+
}
|
|
568
|
+
window.addEventListener("keydown", (e) => chainPop.onKeydown(e), true);
|
|
569
|
+
document.addEventListener("pointerdown", (e) => chainPop.onPointerDown(e), true);
|
|
570
|
+
document.addEventListener("scroll", () => chainPop.reposition(), true);
|
|
571
|
+
|
|
504
572
|
function render() {
|
|
505
573
|
fitStripColumns();
|
|
506
574
|
renderTop(stripCards());
|
|
@@ -515,6 +583,7 @@ window.addEventListener("resize", () => {
|
|
|
515
583
|
fitStripColumns();
|
|
516
584
|
if (stripCols !== before && allSessions.length) renderTop(stripCards());
|
|
517
585
|
familyPop.reposition();
|
|
586
|
+
chainPop.reposition();
|
|
518
587
|
}, 150);
|
|
519
588
|
});
|
|
520
589
|
|
|
@@ -524,6 +593,12 @@ window.addEventListener("resize", () => {
|
|
|
524
593
|
function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
|
|
525
594
|
container.querySelectorAll(".card[data-id]").forEach((el) => {
|
|
526
595
|
el.addEventListener("click", (e) => {
|
|
596
|
+
const chainChip = e.target.closest(".chain-chip");
|
|
597
|
+
if (chainChip) {
|
|
598
|
+
e.stopPropagation();
|
|
599
|
+
toggleChainPop(container, el.dataset.id, e);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
527
602
|
const chip = e.target.closest(".family-chip");
|
|
528
603
|
if (chip) {
|
|
529
604
|
e.stopPropagation();
|
|
@@ -575,15 +650,18 @@ function renderTop(cards) {
|
|
|
575
650
|
topStripEl.classList.remove("loading");
|
|
576
651
|
renderStripShow();
|
|
577
652
|
stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
|
|
653
|
+
noteChainFamilies(cards);
|
|
578
654
|
if (scopeError) {
|
|
579
655
|
topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
|
|
580
656
|
familyPop.close();
|
|
657
|
+
chainPop.close();
|
|
581
658
|
return;
|
|
582
659
|
}
|
|
583
660
|
// Only archived sessions: no cards, but the tile stays, the way to them.
|
|
584
661
|
if (!allSessions.length) {
|
|
585
662
|
topStripEl.innerHTML = "";
|
|
586
663
|
familyPop.close();
|
|
664
|
+
chainPop.close();
|
|
587
665
|
return;
|
|
588
666
|
}
|
|
589
667
|
const n = shownSessions().length;
|
|
@@ -603,6 +681,15 @@ function renderTop(cards) {
|
|
|
603
681
|
$("#allTile").addEventListener("click", openAllView);
|
|
604
682
|
$("#stripHide").addEventListener("click", () => setStripHidden(true));
|
|
605
683
|
familyPop.refresh();
|
|
684
|
+
if (chainFrom === topStripEl) chainPop.refresh();
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// The chain families a list drew: the popover's rows come from them.
|
|
688
|
+
function noteChainFamilies(fams) {
|
|
689
|
+
for (const f of fams) {
|
|
690
|
+
if (f.chain) chainFamilies.set(f.head.id, f);
|
|
691
|
+
else chainFamilies.delete(f.head.id);
|
|
692
|
+
}
|
|
606
693
|
}
|
|
607
694
|
|
|
608
695
|
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
|
|
@@ -610,7 +697,7 @@ function renderTop(cards) {
|
|
|
610
697
|
// sessions (a waiting delegate too).
|
|
611
698
|
function renderStripShow() {
|
|
612
699
|
const shown = shownSessions();
|
|
613
|
-
const { count, live, waiting } = stripShowParts(shown, { cards: families(shown).length });
|
|
700
|
+
const { count, live, waiting } = stripShowParts(shown, { cards: foldChains(families(shown), allSessions).length });
|
|
614
701
|
const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
|
|
615
702
|
const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
|
|
616
703
|
$("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
|
|
@@ -629,7 +716,9 @@ const drawnOpen = new Map(); // head id → open as last drawn in all sessions
|
|
|
629
716
|
function renderAll() {
|
|
630
717
|
const listed = allViewSessions();
|
|
631
718
|
const q = filterQuery();
|
|
632
|
-
|
|
719
|
+
// A search and the archived ones see every link as a card (allViewChains).
|
|
720
|
+
const all = allViewChains(families(listed), allSessions, { query: q, includeArchived: $("#includeArchived").checked });
|
|
721
|
+
noteChainFamilies(all);
|
|
633
722
|
const shown = q ? filterFamilies(all, (s) => matchesFilter(s, q)) : all;
|
|
634
723
|
const fams = listOrder(allListEl, waitingFirst(shown), familyId);
|
|
635
724
|
const delegates = all.reduce((n, f) => n + f.members.length, 0);
|
|
@@ -640,11 +729,12 @@ function renderAll() {
|
|
|
640
729
|
if (!fams.length) {
|
|
641
730
|
drawnOpen.clear();
|
|
642
731
|
allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
|
|
732
|
+
if (chainFrom === allListEl) chainPop.refresh();
|
|
643
733
|
return;
|
|
644
734
|
}
|
|
645
735
|
// A hub event redraws the list under a checkbox or a chip the keyboard is on.
|
|
646
|
-
const
|
|
647
|
-
|
|
736
|
+
const focusSel = [".card-pick", ".family-chip", ".chain-chip"].find((sel) => document.activeElement?.matches?.(`#allList ${sel}`));
|
|
737
|
+
const focused = focusSel ? { id: document.activeElement.closest(".card")?.dataset.id, sel: focusSel } : null;
|
|
648
738
|
const opts = (s) => (selecting.on
|
|
649
739
|
? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
|
|
650
740
|
: { deletable: true, archivable: true });
|
|
@@ -676,6 +766,7 @@ function renderAll() {
|
|
|
676
766
|
},
|
|
677
767
|
});
|
|
678
768
|
if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
|
|
769
|
+
if (chainFrom === allListEl) chainPop.refresh();
|
|
679
770
|
}
|
|
680
771
|
|
|
681
772
|
// ── Select mode (all sessions) ─────────────────────────────────────────────
|
|
@@ -908,6 +999,8 @@ function applyView() {
|
|
|
908
999
|
topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
|
|
909
1000
|
}
|
|
910
1001
|
document.body.classList.toggle("all-view", open);
|
|
1002
|
+
// Its chip was in the list just hidden (the strip, or all sessions).
|
|
1003
|
+
chainPop.close();
|
|
911
1004
|
if (open) {
|
|
912
1005
|
familyPop.close(); // the strip is hidden in the all view
|
|
913
1006
|
// The list is live; only its order can lag (upserts keep cards in
|
|
@@ -1178,6 +1271,7 @@ function renderSessionView(data) {
|
|
|
1178
1271
|
current.llmContext = s.llm_context || null;
|
|
1179
1272
|
llmContextControl.refresh();
|
|
1180
1273
|
current.parentId = s.parent_id || null;
|
|
1274
|
+
current.continues = s.continues || null;
|
|
1181
1275
|
current.commands = Array.isArray(data.commands) ? data.commands : [];
|
|
1182
1276
|
if (data.messages && data.messages.length) {
|
|
1183
1277
|
const first = data.messages.find((m) => m.role === "user");
|
|
@@ -1805,6 +1899,16 @@ function updateInfoBar() {
|
|
|
1805
1899
|
const stopBtn = document.getElementById("infoStopBtn");
|
|
1806
1900
|
const deleteBtn = document.getElementById("infoDeleteBtn");
|
|
1807
1901
|
const archiveBtn = document.getElementById("infoArchiveBtn");
|
|
1902
|
+
const continueBtn = document.getElementById("infoContinueBtn");
|
|
1903
|
+
const chain = selected ? chainNeighbours(allSessions, selected, { continues: current.continues }) : { prev: null, next: null };
|
|
1904
|
+
if (continueBtn) {
|
|
1905
|
+
// A link continued already has its → link instead; a terminal's
|
|
1906
|
+
// session is continued there.
|
|
1907
|
+
continueBtn.style.display = selected && !chain.next && current.owner !== "tui" ? "" : "none";
|
|
1908
|
+
const busy = !!selected && continuingIds.has(selected);
|
|
1909
|
+
continueBtn.disabled = busy;
|
|
1910
|
+
continueBtn.textContent = busy ? "continuing…" : "continue →";
|
|
1911
|
+
}
|
|
1808
1912
|
if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
|
|
1809
1913
|
if (archiveBtn) {
|
|
1810
1914
|
archiveBtn.style.display = selected ? "" : "none";
|
|
@@ -1838,6 +1942,12 @@ function updateInfoBar() {
|
|
|
1838
1942
|
// A delegated session names its parent, as a link to it.
|
|
1839
1943
|
const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
|
|
1840
1944
|
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>` : "";
|
|
1945
|
+
// A link of a session chain: ← the previous link, → the next one (each
|
|
1946
|
+
// only while the list holds it).
|
|
1947
|
+
const chainHtml = [
|
|
1948
|
+
chain.prev ? chainLinkHtml(chain.prev, "\u2190", "The previous link of this chain") : "",
|
|
1949
|
+
chain.next ? chainLinkHtml(chain.next, "\u2192", "The next link of this chain") : "",
|
|
1950
|
+
].join("");
|
|
1841
1951
|
// A parent names its delegates; the link opens all sessions.
|
|
1842
1952
|
const kids = childrenSummary({ id: selected }, allSessions);
|
|
1843
1953
|
// The model notes the session's prompt carried.
|
|
@@ -1847,14 +1957,29 @@ function updateInfoBar() {
|
|
|
1847
1957
|
const attachCmd = `chi --attach ${selected}`;
|
|
1848
1958
|
const copied = Date.now() - attachCopiedAt < 2000;
|
|
1849
1959
|
const copyHtml = `<button type="button" class="copy-attach" title="Copy "${escapeHtml(attachCmd)}" to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
|
|
1850
|
-
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}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${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>`;
|
|
1851
|
-
|
|
1960
|
+
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>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${notesHtml}${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>`;
|
|
1961
|
+
// The title line follows the card's: a chain link opened with the opener
|
|
1962
|
+
// reads its chain's first link's title (cardTitle).
|
|
1963
|
+
const shown = sessionsById().get(selected);
|
|
1964
|
+
const titleText = shown && isOpener(current.firstPreview) ? cardTitle(shown) : current.firstPreview;
|
|
1965
|
+
const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(titleText || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
|
|
1852
1966
|
// The session clock ticks every second while a turn runs: only its text
|
|
1853
1967
|
// changes, the bar's other nodes (a tooltip, a button mid-press) stay.
|
|
1854
1968
|
renderStable(infoTextEl, metaHtml + previewHtml, { ".session-time": timingText });
|
|
1855
1969
|
infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
|
|
1856
1970
|
}
|
|
1857
1971
|
|
|
1972
|
+
// The info bar's ← / → link to a chain's link (chains.js chainNeighbours):
|
|
1973
|
+
// its date (else its short id), its preview and id in the tooltip.
|
|
1974
|
+
function chainLinkHtml(link, arrow, what) {
|
|
1975
|
+
const short = String(link.id).slice(0, 8);
|
|
1976
|
+
const date = chainDate(link.session?.created_at);
|
|
1977
|
+
const preview = link.session ? cardTitle(link.session) : "";
|
|
1978
|
+
const title = `${what}: ${short}${preview ? ` · ${preview}` : ""}${link.session?.archived ? " (archived)" : ""}`;
|
|
1979
|
+
const text = arrow === "\u2190" ? `${arrow} ${date || short}` : `${date || short} ${arrow}`;
|
|
1980
|
+
return `<a class="model chain-link" href="${escapeHtml(sessionHash(link.id))}" title="${escapeHtml(title)}">${escapeHtml(text)}</a>`;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1858
1983
|
// `memories: a, b · preloaded: c · muted: d`: the used memories always,
|
|
1859
1984
|
// the session's --memory and --mute lists only when there are any.
|
|
1860
1985
|
function memoriesTooltip(used, preloaded, muted) {
|
|
@@ -2727,12 +2852,67 @@ $("#infoStopBtn").addEventListener("click", async () => {
|
|
|
2727
2852
|
$("#infoDeleteBtn").addEventListener("click", () => {
|
|
2728
2853
|
if (selected) confirmDelete(selected);
|
|
2729
2854
|
});
|
|
2855
|
+
$("#infoContinueBtn").addEventListener("click", () => continueSelected());
|
|
2730
2856
|
$("#infoArchiveBtn").addEventListener("click", () => {
|
|
2731
2857
|
if (!selected) return;
|
|
2732
2858
|
if (selectedArchived()) setArchived(selected, false);
|
|
2733
2859
|
else setArchived(selected, true);
|
|
2734
2860
|
});
|
|
2735
2861
|
|
|
2862
|
+
// ── Continue (session chains) ───────────────────────────────────────────────
|
|
2863
|
+
// Continue → starts the next link of the open session's chain (the server
|
|
2864
|
+
// waits up to 30 s for its recap, archives it, starts the new one with the
|
|
2865
|
+
// recap as a note) and opens it with the opener in the composer, not sent.
|
|
2866
|
+
// A link continued already opens that next link; its open delegates move
|
|
2867
|
+
// to the new link (the toast counts them); a refusal for open delegates
|
|
2868
|
+
// that can't move names them, each a link.
|
|
2869
|
+
// One continue per session at a time (the server locks it too); another
|
|
2870
|
+
// session's continue may run beside it.
|
|
2871
|
+
const continuingIds = new Set();
|
|
2872
|
+
|
|
2873
|
+
async function continueSelected() {
|
|
2874
|
+
if (!selected || continuingIds.has(selected)) return;
|
|
2875
|
+
const id = selected;
|
|
2876
|
+
const short = id.slice(0, 8);
|
|
2877
|
+
continuingIds.add(id);
|
|
2878
|
+
updateInfoBar();
|
|
2879
|
+
try {
|
|
2880
|
+
const created = await continueSession(id);
|
|
2881
|
+
markArchived([id], true);
|
|
2882
|
+
addCreatedSession(created);
|
|
2883
|
+
const next = created.id;
|
|
2884
|
+
if (selected !== id) {
|
|
2885
|
+
showToast(continuedText(id, created, { opened: false }), { label: "Open", run: () => select(next) });
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
await select(next);
|
|
2889
|
+
if (selected === next) fillOpener();
|
|
2890
|
+
showToast(continuedText(id, created));
|
|
2891
|
+
} catch (e) {
|
|
2892
|
+
const refusal = continueRefusal(e, allSessions);
|
|
2893
|
+
if (refusal.kind === "open") {
|
|
2894
|
+
if (selected === id) await select(refusal.id);
|
|
2895
|
+
showToast(`${short} is continued already: ${selected === refusal.id ? "opened" : "see"} ${refusal.id.slice(0, 8)}`);
|
|
2896
|
+
} else if (refusal.kind === "children") {
|
|
2897
|
+
showToast(openChildrenHtml(id, refusal.children, sessionHash), null, { sticky: true, html: true });
|
|
2898
|
+
} else {
|
|
2899
|
+
showToast(`Can't continue ${short}: ${refusal.text}`, null, { sticky: true });
|
|
2900
|
+
}
|
|
2901
|
+
} finally {
|
|
2902
|
+
continuingIds.delete(id);
|
|
2903
|
+
updateInfoBar();
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// The new link's composer: the opener, unless a draft is there (it stays).
|
|
2908
|
+
function fillOpener() {
|
|
2909
|
+
if (promptEl.value.trim() || chips.length) return;
|
|
2910
|
+
promptEl.value = CONTINUE_OPENER;
|
|
2911
|
+
fitPrompt();
|
|
2912
|
+
promptEl.focus();
|
|
2913
|
+
promptEl.setSelectionRange(promptEl.value.length, promptEl.value.length);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2736
2916
|
// ── Archive ────────────────────────────────────────────────────────────────
|
|
2737
2917
|
// Archive hides a session (and its delegates) from the strip and the list,
|
|
2738
2918
|
// kept for good; "include archived" in all sessions finds it. No confirm:
|
|
@@ -2794,9 +2974,10 @@ async function setArchived(id, archive, { undo = true } = {}) {
|
|
|
2794
2974
|
|
|
2795
2975
|
// sticky: stays until its action or its ✕ (else gone after 6 s).
|
|
2796
2976
|
let toastTimer = null;
|
|
2797
|
-
|
|
2977
|
+
// html: +text+ is markup this page built (its parts escaped), not words.
|
|
2978
|
+
function showToast(text, action = null, { sticky = false, html = false } = {}) {
|
|
2798
2979
|
const el = $("#toast");
|
|
2799
|
-
el.innerHTML = `<span>${escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
|
|
2980
|
+
el.innerHTML = `<span>${html ? text : escapeHtml(text)}</span>${action ? `<button type="button" class="toast-action">${escapeHtml(action.label)}</button>` : ""}` +
|
|
2800
2981
|
(sticky ? `<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss">✕</button>` : "");
|
|
2801
2982
|
el.hidden = false;
|
|
2802
2983
|
el.querySelector(".toast-action")?.addEventListener("click", () => {
|
|
@@ -2804,6 +2985,8 @@ function showToast(text, action = null, { sticky = false } = {}) {
|
|
|
2804
2985
|
action.run();
|
|
2805
2986
|
});
|
|
2806
2987
|
el.querySelector(".toast-close")?.addEventListener("click", () => { el.hidden = true; });
|
|
2988
|
+
// A link in it (a delegate a continue waits for) opens that session.
|
|
2989
|
+
el.querySelectorAll("a[href]").forEach((a) => a.addEventListener("click", () => { el.hidden = true; }));
|
|
2807
2990
|
clearTimeout(toastTimer);
|
|
2808
2991
|
if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
|
|
2809
2992
|
}
|