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.
@@ -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
- else if (selected && data.session?.parent_id === selected) updateInfoBar(); // its children chip
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 previewForCard(s).toLowerCase().includes(q) ||
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(previewForCard(s));
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 = !!fam && familyWaits(fam);
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
- const membersAttr = fam ? ` data-members="${escapeHtml(fam.members.map((m) => m.session.id).join(" "))}"` : "";
414
- 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}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
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
- const all = families(listed);
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 focused = document.activeElement?.matches?.("#allList .card-pick, #allList .family-chip")
647
- ? { id: document.activeElement.closest(".card")?.dataset.id, sel: document.activeElement.matches(".card-pick") ? ".card-pick" : ".family-chip" } : null;
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 &quot;${escapeHtml(attachCmd)}&quot; 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
- 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>`;
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
- function showToast(text, action = null, { sticky = false } = {}) {
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
  }