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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/docs/cli.md +1 -1
- data/docs/plugins.md +6 -0
- data/docs/sessions.md +23 -5
- 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/send_command.rb +59 -9
- data/lib/samagotchi/session.rb +13 -4
- data/lib/samagotchi/session_chain.rb +93 -0
- data/lib/samagotchi/session_manager.rb +225 -14
- data/lib/samagotchi/sessions_command.rb +5 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +48 -0
- data/lib/samagotchi/web/public/app.js +200 -18
- data/lib/samagotchi/web/public/chains.js +234 -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
- metadata +3 -1
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
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
|