samagotchi 0.46.1 → 0.47.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.
@@ -0,0 +1,234 @@
1
+ // Session chains on the page (docs/sessions.md, Session chains): a session
2
+ // that continues an earlier one (its summary's `continues`), "the next day of
3
+ // the same routine". The chain is derived from the list, never stored: back
4
+ // through `continues`, forward through the session whose `continues` names
5
+ // this one. The lists fold a chain into its latest listed link's card (a
6
+ // `day N` chip, its earlier links in a popover); the session page links the
7
+ // previous and the next link. Pure: app.js places the markup and binds it.
8
+ import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
9
+ import { stoppedBadge, waitingBadge } from "./sessions_list.js";
10
+ import { waitingOn } from "./notify.js";
11
+
12
+ // The opener a new link's composer is filled with (never sent: the user
13
+ // edits or sends it). The same for every chain.
14
+ export const CONTINUE_OPENER = "Continue where we left off.";
15
+
16
+ // Whether +text+ (a title or a first message) is the opener and nothing
17
+ // else, trimmed: "Continue where we left off. Then fix X" is a title of its own.
18
+ export function isOpener(text) {
19
+ return normalize(text) === CONTINUE_OPENER;
20
+ }
21
+
22
+ // A link's card title (format.js previewForCard), display only: a link
23
+ // whose title is the opener, as every link's first message is once sent,
24
+ // reads its chain's first link's title instead, so the days of a chain
25
+ // keep its name. Its own title when the first link isn't in +byId+ (id →
26
+ // session; deleted, or a walk a missing link cut short) or is the opener too.
27
+ export function chainTitle(s, byId) {
28
+ const own = previewForCard(s);
29
+ if (!s?.continues || !isOpener(s.first_preview || s.last_prompt)) return own;
30
+ const seen = new Set([s.id]);
31
+ let at = s;
32
+ while (at.continues) {
33
+ const prev = byId.get(at.continues);
34
+ if (!prev || seen.has(prev.id)) return own;
35
+ seen.add(prev.id);
36
+ at = prev;
37
+ }
38
+ return isOpener(at.first_preview || at.last_prompt) ? own : previewForCard(at);
39
+ }
40
+
41
+ const createdMs = (s) => {
42
+ const ms = Date.parse(s?.created_at || "");
43
+ return Number.isNaN(ms) ? 0 : ms;
44
+ };
45
+
46
+ // session id → the session that continues it: the first created, as the
47
+ // server's next_of picks (a link is continued once; a hand-edited second
48
+ // one doesn't fork the chain).
49
+ function nextsOf(list) {
50
+ const nexts = new Map();
51
+ for (const s of list.slice().sort((a, b) => createdMs(a) - createdMs(b))) {
52
+ if (s.continues && s.continues !== s.id && !nexts.has(s.continues)) nexts.set(s.continues, s);
53
+ }
54
+ return nexts;
55
+ }
56
+
57
+ // The chain +id+ is in, earliest link first, as far as +list+ holds it (a
58
+ // link that isn't listed ends the walk there). [] for an id not listed; a
59
+ // session in no chain is a chain of one. A loop a hand-edited file could
60
+ // make ends where it started. +nexts+ and +byId+ (id → session): a caller
61
+ // walking many chains of one list builds them once (foldChains).
62
+ export function chainLinks(list, id, nexts = nextsOf(list), byId = new Map(list.map((s) => [s.id, s]))) {
63
+ const self = byId.get(id);
64
+ if (!self) return [];
65
+ const links = [self];
66
+ const seen = new Set([id]);
67
+ for (let at = byId.get(self.continues); at && !seen.has(at.id); at = byId.get(at.continues)) {
68
+ seen.add(at.id);
69
+ links.unshift(at);
70
+ }
71
+ for (let at = nexts.get(id); at && !seen.has(at.id); at = nexts.get(at.id)) {
72
+ seen.add(at.id);
73
+ links.push(at);
74
+ }
75
+ return links;
76
+ }
77
+
78
+ // The session page's neighbours of +id+: {prev, next}, each {id, session}
79
+ // or null. +continues+: the open session's own `continues`, when the list
80
+ // may not have caught up with it. A previous link the list doesn't hold
81
+ // (deleted) is null: its link would open a not-found page.
82
+ export function chainNeighbours(list, id, { continues = null } = {}) {
83
+ const self = list.find((s) => s.id === id);
84
+ const prevId = self?.continues || continues || null;
85
+ const prevSession = prevId && prevId !== id ? list.find((s) => s.id === prevId) : null;
86
+ const prev = prevSession ? { id: prevId, session: prevSession } : null;
87
+ const nextSession = nextsOf(list).get(id) || null;
88
+ const next = nextSession ? { id: nextSession.id, session: nextSession } : null;
89
+ return { prev, next };
90
+ }
91
+
92
+ // A link's day as the carry note writes it: the local YYYY-MM-DD of its
93
+ // start; "" when it has none.
94
+ export function chainDate(iso) {
95
+ const d = new Date(iso || "");
96
+ if (Number.isNaN(d.getTime())) return "";
97
+ const pad = (n) => String(n).padStart(2, "0");
98
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
99
+ }
100
+
101
+ // The families (sessions_list.js families) with the chains folded in: a
102
+ // family whose head is an earlier link of a chain whose later link heads a
103
+ // listed family is left out (it is in that card's chain list), whatever
104
+ // its archived state, its delegates with it; a family whose head is in a
105
+ // chain of two or more carries chain: {links, at, folded} (at: the head's
106
+ // index). +all+: every session the page holds, archived ones too (the links
107
+ // the list leaves out). The same array when no chain is in it.
108
+ // @param fold false: every family stays (each link's card carries its
109
+ // chip, "day 1 of 3"; folded false), for a search or the archived ones
110
+ export function foldChains(fams, all, { fold = true } = {}) {
111
+ if (!all.some((s) => s.continues)) return fams;
112
+ const nexts = nextsOf(all);
113
+ const byId = new Map(all.map((s) => [s.id, s]));
114
+ const heads = new Set(fams.map((f) => f.head.id));
115
+ const out = [];
116
+ let changed = false;
117
+ for (const f of fams) {
118
+ const links = chainLinks(all, f.head.id, nexts, byId);
119
+ if (links.length < 2) {
120
+ out.push(f);
121
+ continue;
122
+ }
123
+ changed = true;
124
+ const at = links.findIndex((s) => s.id === f.head.id);
125
+ if (fold && links.slice(at + 1).some((s) => heads.has(s.id))) continue;
126
+ out.push({ ...f, chain: { links, at, folded: fold } });
127
+ }
128
+ return changed ? out : fams;
129
+ }
130
+
131
+ // The all view's families: folded (foldChains) unless a search is typed or
132
+ // the archived ones are included. Then every link is a card of its own
133
+ // (with its chip), so a search finds an earlier link, and an earlier
134
+ // link's delegates fold into its own card as anywhere else.
135
+ export function allViewChains(fams, all, { query = "", includeArchived = false } = {}) {
136
+ return foldChains(fams, all, { fold: !query && !includeArchived });
137
+ }
138
+
139
+ // The links a family's chain list shows: the chain but its head, newest
140
+ // first, each {session, day} (day: its 1-based place in the chain).
141
+ export function chainOthers(f) {
142
+ const links = f?.chain?.links || [];
143
+ return links.map((session, i) => ({ session, day: i + 1 }))
144
+ .filter(({ session }) => session.id !== f.head.id)
145
+ .reverse();
146
+ }
147
+
148
+ // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
149
+ // later links aren't listed), with a live dot / "waiting" when one of the
150
+ // other links has a worker / waits on the user (typing in an archived link
151
+ // brings it back, but it stays folded here). "" for a family in no chain.
152
+ // @param open whether the list is open (aria-expanded)
153
+ export function chainChipHtml(f, { open = false } = {}) {
154
+ if (!f?.chain) return "";
155
+ const { links, at } = f.chain;
156
+ const others = chainOthers(f);
157
+ const live = others.filter(({ session: s }) => s.owner === "worker").length;
158
+ const waiting = others.filter(({ session: s }) => waitingOn(s)).length;
159
+ const day = at + 1;
160
+ const text = day === links.length ? `day ${day}` : `day ${day} of ${links.length}`;
161
+ const earlier = at;
162
+ const later = links.length - 1 - at;
163
+ const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
164
+ const title = `Session chain: day ${day} of ${links.length} (${parts.join(", ")}); open the list`;
165
+ const liveHtml = live ? `<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : "";
166
+ const waitingHtml = waiting ? ` · <span class="cc-waiting">${waiting} waiting</span>` : "";
167
+ return `<button type="button" class="chain-chip${waiting ? " waiting" : ""}" aria-expanded="${open ? "true" : "false"}" title="${escapeHtml(title)}"><span class="cc-arrow" aria-hidden="true">↩</span>${escapeHtml(text)}${liveHtml}${waitingHtml}</button>`;
168
+ }
169
+
170
+ // The chain's list (the chip's popover): one row per other link, newest
171
+ // first: status dot, "day N", its date, its recap's first sentence (else
172
+ // its preview), the waiting / stopped badge, archived. A row's click opens
173
+ // that link (families_view.js's popover binds .family-row[data-id]).
174
+ // @param activeId the open session's id (its row is .active)
175
+ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
176
+ const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
177
+ const rows = chainOthers(f).map(({ session: s, day }) => {
178
+ const cls = ["family-row", "chain-row"];
179
+ if (activeId && s.id === activeId) cls.push("active");
180
+ if (s.archived) cls.push("archived");
181
+ const waiting = waitingBadge(s, { inFamily: true });
182
+ const stopped = waiting ? null : stoppedBadge(s);
183
+ const dotWord = waiting ? waiting.title : s.status;
184
+ const date = chainDate(s.created_at);
185
+ const ago = relativeTime(s.updated_at || s.created_at || "", now);
186
+ const parts = [
187
+ `<span class="status dot ${escapeHtml(s.status || "")}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord || "")}" aria-label="${escapeHtml(dotWord || "")}"></span>`,
188
+ `<span class="cr-day">day ${day}</span>`,
189
+ date ? `<span class="cr-date">${escapeHtml(date)}</span>` : "",
190
+ `<span class="fr-preview">${escapeHtml(s.recap || chainTitle(s, byId))}</span>`,
191
+ waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "",
192
+ stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "",
193
+ s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "",
194
+ ].join("");
195
+ const tip = `${s.id} · ${s.status || ""}${ago ? ` · updated ${ago}` : ""}`;
196
+ return `<div class="${cls.join(" ")}" data-id="${escapeHtml(s.id)}" style="--depth:1" role="listitem"><button type="button" class="fr-open" title="${escapeHtml(tip)}">${parts}</button></div>`;
197
+ });
198
+ return `<div class="family-rows chain-rows" role="list">${rows.join("")}</div>`;
199
+ }
200
+
201
+ // What the page does with a refused continue (POST /api/sessions
202
+ // continues, api()'s error with the answer's body): "open" the link that
203
+ // continues it already (409 continued, next_id), "children" naming the
204
+ // delegates still open (409 open_children, ids: {id, short, text} each,
205
+ // the text its preview and why it is open: the server's word from its
206
+ // detail, "<short> (running)", else its state here), or "error" with the
207
+ // server's words.
208
+ export function continueRefusal(error, sessions = []) {
209
+ const body = error?.body || {};
210
+ if (error?.code === "continued" && body.next_id) return { kind: "open", id: body.next_id };
211
+ const text = String(error?.message || "refused").replace(/\s*\(\d{3}\)$/, "");
212
+ if (error?.code === "open_children" && Array.isArray(body.ids) && body.ids.length) {
213
+ const byId = new Map(sessions.map((s) => [s.id, s]));
214
+ const children = body.ids.map((id) => {
215
+ const s = byId.get(id);
216
+ const short = String(id).slice(0, 8);
217
+ const said = text.split(short + " (")[1]?.split(")")[0];
218
+ const why = said || (s ? (waitingOn(s) ? "waiting" : s.status || "") : "");
219
+ const preview = s ? previewForCard(s) : "";
220
+ return { id, short, text: [preview, why ? `(${why})` : ""].filter(Boolean).join(" ") };
221
+ });
222
+ return { kind: "children", children, text };
223
+ }
224
+ return { kind: "error", text };
225
+ }
226
+
227
+ // The open-children refusal as the toast shows it: the delegates named,
228
+ // each a link to its session.
229
+ // @param hrefFor (id) → its page's href
230
+ export function openChildrenHtml(prevId, children, hrefFor) {
231
+ const n = children.length;
232
+ const items = children.map((c) => `<li><a href="${escapeHtml(hrefFor(c.id))}" class="cr-child">${escapeHtml(c.short)}</a>${c.text ? ` ${escapeHtml(c.text)}` : ""}</li>`).join("");
233
+ return `<div class="continue-refused"><div>Can't continue ${escapeHtml(String(prevId).slice(0, 8))}: ${n} ${n === 1 ? "delegate is" : "delegates are"} still open. Wait for ${n === 1 ? "it" : "them"}, stop or archive ${n === 1 ? "it" : "them"}, then continue.</div><ul>${items}</ul></div>`;
234
+ }
@@ -11,6 +11,9 @@ export function api(path, opts = {}) {
11
11
  // The server's error code ({error: "owned_by_tui", detail}): what
12
12
  // the page matches on, never the message's words.
13
13
  error.code = j.error || null;
14
+ // The whole answer, for what an error carries besides its words (a
15
+ // refused continue's next_id or ids).
16
+ error.body = j;
14
17
  throw error;
15
18
  }
16
19
  return j;
@@ -39,6 +42,15 @@ export function createIdleSession({ dir, model, preview, llm_context: llmContext
39
42
  return api("/api/sessions", { method: "POST", body: JSON.stringify(body), ...opts });
40
43
  }
41
44
 
45
+ // The next link of +id+'s chain (docs/sessions.md, Session chains): a new
46
+ // session in its folder, on its model, starting with its recap, idle (the
47
+ // page fills the composer, the user sends); +id+ is archived by it. A
48
+ // refusal is api()'s error: code "continued" with body.next_id (the link
49
+ // that continues it already), "open_children" with body.ids, …
50
+ export function continueSession(id, opts = {}) {
51
+ return api("/api/sessions", { method: "POST", body: JSON.stringify({ continues: id, idle: true }), ...opts });
52
+ }
53
+
42
54
  // The models a new session can start on: {default, models: [{name, host, id}],
43
55
  // warning?} (a host that failed is in the warning, the list has the rest).
44
56
  export function listModels(opts = {}) {
@@ -69,7 +69,8 @@ export const PHONE_MAX_PX = 600;
69
69
  // @param anchorFor (headId) → its chip in the strip, or null
70
70
  // @param onPick (id) a row's click
71
71
  // @param onClose () after it closed (the strip's held order catches up)
72
- export function createFamilyPop({ doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
72
+ // @param id the element's id (a chain's popover, chains.js, is another one)
73
+ export function createFamilyPop({ id = "familyPop", doc, win, rowsFor, anchorFor, onPick, onClose = () => {} }) {
73
74
  let el = null;
74
75
  let headId = null;
75
76
  let html = "";
@@ -77,7 +78,7 @@ export function createFamilyPop({ doc, win, rowsFor, anchorFor, onPick, onClose
77
78
  const element = () => {
78
79
  if (el) return el;
79
80
  el = doc.createElement("div");
80
- el.id = "familyPop";
81
+ el.id = id;
81
82
  el.className = "family-pop";
82
83
  el.hidden = true;
83
84
  el.addEventListener("click", (e) => {
@@ -761,6 +761,8 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
761
761
  #infoBar .meta .mem-chip{display:inline;order:-1}
762
762
  /* A parent's children chip too: its waiting part is what to see. */
763
763
  #infoBar .meta .children-chip{order:-1}
764
+ /* A chain's ← → links too: the way to the other days. */
765
+ #infoBar .meta .chain-link{order:-1}
764
766
  /* The chips get a second line of their own (only when there are some), scrolling sideways. */
765
767
  #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
766
768
  #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
@@ -838,12 +840,32 @@ body.has-session #scope .scope-where{display:none}
838
840
  #infoBar .meta a.parent{text-decoration:none}
839
841
  #infoBar .meta a.parent:hover{color:var(--accent)}
840
842
  /* The strip's family popover (in body, position: fixed: the strip's sideways scroll on a phone would clip it), glass as the cards. */
841
- #familyPop{position:fixed;z-index:40;max-height:50vh;overflow:auto;padding:6px;border-radius:10px;background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
842
- #familyPop[hidden]{display:none}
843
- #familyPop .family-rows{border-top:0;margin:0;padding-top:0}
844
- @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#familyPop{background:var(--bg-panel)}}
845
- @media (prefers-reduced-transparency:reduce){#familyPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
846
- body.no-glass #familyPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
843
+ #familyPop,#chainPop{position:fixed;z-index:40;max-height:50vh;overflow:auto;padding:6px;border-radius:10px;background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
844
+ #familyPop[hidden],#chainPop[hidden]{display:none}
845
+ :is(#familyPop,#chainPop) .family-rows{border-top:0;margin:0;padding-top:0}
846
+ @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#familyPop,#chainPop{background:var(--bg-panel)}}
847
+ @media (prefers-reduced-transparency:reduce){#familyPop,#chainPop{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}}
848
+ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
849
+ /* A chain's chip (chains.js): the card is the latest link of a session chain; a click lists the other links in a popover. */
850
+ .card .meta:has(.chain-chip){flex-wrap:wrap;row-gap:4px}
851
+ .card .chain-chip{font-family:ui-monospace,monospace;font-size:11px;line-height:1.4;white-space:nowrap;flex-shrink:0;padding:1px 7px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-tint);color:var(--accent);cursor:pointer}
852
+ .card .chain-chip:hover:not(:disabled){filter:none;border-color:var(--accent);background:var(--accent-bg)}
853
+ .card .chain-chip[aria-expanded="true"]{background:var(--accent-bg);border-color:var(--accent)}
854
+ .card .chain-chip.waiting{color:var(--warn-text);background:var(--warn-bg);border-color:var(--warn-border)}
855
+ .chain-chip .cc-arrow{display:inline-block;margin-right:4px}
856
+ .chain-chip .live-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ok);margin-right:3px;vertical-align:1px}
857
+ .chain-chip .cc-waiting{font-family:system-ui,sans-serif;font-weight:600}
858
+ .chain-row .cr-day{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);white-space:nowrap}
859
+ .chain-row .cr-date{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);white-space:nowrap;font-variant-numeric:tabular-nums}
860
+ /* The session page's links to the previous and the next link of its chain. */
861
+ #infoBar .meta a.chain-link{text-decoration:none;font-family:ui-monospace,monospace;white-space:nowrap;color:var(--accent)}
862
+ #infoBar .meta a.chain-link:hover{background:var(--accent-bg)}
863
+ #infoBar #infoContinueBtn{color:var(--accent);border:1px solid var(--accent-border)}
864
+ #infoBar #infoContinueBtn:hover:not(:disabled){background:var(--accent-bg)}
865
+ #infoBar #infoContinueBtn:disabled{opacity:.7;cursor:progress}
866
+ /* A continue refused for open delegates: the toast names them, each a link. */
867
+ #toast .continue-refused ul{margin:4px 0 0;padding-left:18px}
868
+ #toast .continue-refused a{color:var(--accent);font-family:ui-monospace,monospace}
847
869
  /* A parent's family chip: its delegates folded into its card; a click opens or closes their list. */
848
870
  /* A card with a family chip lets its meta row wrap: the chip goes to a line of its own when the card is narrow. */
849
871
  .card .meta:has(.family-chip){flex-wrap:wrap;row-gap:4px}
@@ -1032,6 +1054,7 @@ a{color:var(--accent)}
1032
1054
  <div id="infoBar">
1033
1055
  <div class="info" id="infoText"></div>
1034
1056
  <div id="contextChips" aria-label="Attached context" hidden></div>
1057
+ <button class="ghost" id="infoContinueBtn" title="Continue: start the next link of this session's chain from its recap (this one is archived)" style="display:none">continue →</button>
1035
1058
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1036
1059
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop</button>
1037
1060
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
@@ -19,6 +19,8 @@ export function emptySessionState() {
19
19
  promptNotes: [],
20
20
  // A delegated session's parent (the info bar's "delegated by" chip).
21
21
  parentId: null,
22
+ // The previous link of its chain (its `continues`), or null.
23
+ continues: null,
22
24
  ctxPct: null,
23
25
  // Window the kernel resolved for the running generation (:generation_started).
24
26
  ctxWindow: null,
@@ -125,9 +125,11 @@ export function flattenFamilies(fams) {
125
125
  return fams.flatMap((f) => [f.head, ...f.members.map((m) => m.session)]);
126
126
  }
127
127
 
128
- // Whether any of a family (its head or a member) waits on the user.
128
+ // Whether any of a family (its head or a member) waits on the user, or
129
+ // another link of its chain folded into its card (chains.js foldChains).
129
130
  export function familyWaits(f) {
130
- return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session));
131
+ return !!waitingOn(f.head) || f.members.some((m) => waitingOn(m.session))
132
+ || (!!f.chain?.folded && f.chain.links.some((s) => s.id !== f.head.id && waitingOn(s)));
131
133
  }
132
134
 
133
135
  const WAITING_BADGES = {
@@ -433,12 +433,16 @@ export function restoreInto(refill, current) {
433
433
  // What the composer holds once a /turn ack lands (4.13): the composer still
434
434
  // carries the text that was just sent, so that text goes (it is the history's
435
435
  // now) — but text typed since the send stays, and a prompt restored before
436
- // this ack (restoreOnAck's refill) is put back without replacing it.
437
- // @param sent the composer's text when the turn was sent
436
+ // this ack (restoreOnAck's refill) is put back without replacing it. The
437
+ // sent text is trimmed (turnText), so the composer's is too before they are
438
+ // compared: a trailing newline or space must not keep the message; and an
439
+ // image-only send (placeholders sent) leaves nothing worth keeping.
440
+ // @param sent the text the turn was sent with
438
441
  // @param current the composer's text as the ack lands
439
442
  // @param refill the prompt an early restore handed back, if any
440
443
  export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
441
- const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
444
+ const typed = String(current ?? "").trim();
445
+ const kept = !typed || typed === String(sent ?? "").trim() ? "" : current;
442
446
  return restoreInto(refill, kept);
443
447
  }
444
448
 
@@ -63,6 +63,9 @@ module Samagotchi
63
63
  # parent_id too but isn't one. The parent's children chip counts
64
64
  # delegates only, as the TUI's segment and /children do.
65
65
  delegate: session.delegate?,
66
+ # The session this one continues (the previous link of its
67
+ # chain), else nil: the lists draw chains from it.
68
+ continues: session.continues,
66
69
  first_preview: first_preview_for(session),
67
70
  owner: owner&.kind,
68
71
  # The saved recap's first sentence, for the session card.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.46.1
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -395,6 +395,7 @@ files:
395
395
  - lib/samagotchi/served_model.rb
396
396
  - lib/samagotchi/session.rb
397
397
  - lib/samagotchi/session_archive_command.rb
398
+ - lib/samagotchi/session_chain.rb
398
399
  - lib/samagotchi/session_commands.rb
399
400
  - lib/samagotchi/session_delete_command.rb
400
401
  - lib/samagotchi/session_inbox.rb
@@ -499,6 +500,7 @@ files:
499
500
  - lib/samagotchi/web/public/annotations.js
500
501
  - lib/samagotchi/web/public/app.js
501
502
  - lib/samagotchi/web/public/card.js
503
+ - lib/samagotchi/web/public/chains.js
502
504
  - lib/samagotchi/web/public/chunk_router.js
503
505
  - lib/samagotchi/web/public/command_complete.js
504
506
  - lib/samagotchi/web/public/command_list.js