samagotchi 0.109.0 → 0.110.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/bin/chi +5 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -1
- data/docs/configuration.md +97 -3
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +6 -3
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +15 -6
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +87 -31
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- metadata +6 -1
|
@@ -253,13 +253,79 @@ export function relativeTime(iso, now = Date.now()) {
|
|
|
253
253
|
return d.getFullYear() === new Date(now).getFullYear() ? date : `${date} ${d.getFullYear()}`;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
256
|
+
// ── Wall times (MessageTime's twin, lib/samagotchi/message_time.rb) ──
|
|
257
|
+
// A history entry's `at` (local ISO), a turn record's started_at, an
|
|
258
|
+
// event's at / created_at: the bubbles' hover and the gap dividers. Every
|
|
259
|
+
// helper takes its times (ISO text or epoch ms) and +now+ explicitly.
|
|
260
|
+
|
|
261
|
+
// The quiet time after which the page marks a gap (MessageTime::GAP_SECONDS).
|
|
262
|
+
export const GAP_SECONDS = 600;
|
|
263
|
+
|
|
264
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
265
|
+
|
|
266
|
+
// +time+ (ISO text, epoch ms or a Date) as a Date, or null.
|
|
267
|
+
export function timeOf(time) {
|
|
268
|
+
const t = time instanceof Date ? time.getTime() : typeof time === "number" ? time : typeof time === "string" ? Date.parse(time) : NaN;
|
|
269
|
+
return Number.isFinite(t) ? new Date(t) : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
273
|
+
const hhmm = (d) => `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
274
|
+
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
275
|
+
// "Sat 24 Oct 09:10" (MessageTime.day_clock).
|
|
276
|
+
const dayClock = (d) => `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${hhmm(d)}`;
|
|
277
|
+
|
|
278
|
+
// "14:32", local time; on another local day than +now+ (when given) the
|
|
279
|
+
// day form "Sat 24 Oct 09:10". "" for no or bad input.
|
|
280
|
+
export function wallClock(time, now = null) {
|
|
281
|
+
const d = timeOf(time);
|
|
282
|
+
if (!d) return "";
|
|
283
|
+
const today = now === null ? null : timeOf(now);
|
|
284
|
+
return today && !sameDay(d, today) ? dayClock(d) : hhmm(d);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A bubble's hover: "Sat 24 Oct 2026 14:32:05", local time; "" for none.
|
|
288
|
+
export function fullTime(time) {
|
|
289
|
+
const d = timeOf(time);
|
|
290
|
+
if (!d) return "";
|
|
291
|
+
return `${DAYS[d.getDay()]} ${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()} ${hhmm(d)}:${pad2(d.getSeconds())}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// "47 min", "3 h", "2 d": whole units, rounded down (MessageTime.ago; the
|
|
295
|
+
// scale of relativeTime).
|
|
296
|
+
export function agoText(seconds) {
|
|
297
|
+
const s = Number(seconds) || 0;
|
|
298
|
+
if (s < 3600) return `${Math.max(Math.floor(s / 60), 0)} min`;
|
|
299
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h`;
|
|
300
|
+
return `${Math.floor(s / 86400)} d`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The divider between activity that ended at +prevEnd+ and activity that
|
|
304
|
+
// starts at +nextStart+: null below GAP_SECONDS (or when either is
|
|
305
|
+
// unknown), else "── 14:32 · 47 min later ──" (MessageTime.gap_line). The
|
|
306
|
+
// date is added when the local day changed, and (the page reads old
|
|
307
|
+
// history too) when the next start is on another day than +now+.
|
|
308
|
+
export function gapText(prevEnd, nextStart, now = Date.now()) {
|
|
309
|
+
const prev = timeOf(prevEnd);
|
|
310
|
+
const next = timeOf(nextStart);
|
|
311
|
+
if (!prev || !next) return null;
|
|
312
|
+
const seconds = (next - prev) / 1000;
|
|
313
|
+
if (seconds < GAP_SECONDS) return null;
|
|
314
|
+
const today = timeOf(now);
|
|
315
|
+
const time = sameDay(prev, next) && (!today || sameDay(next, today)) ? hhmm(next) : dayClock(next);
|
|
316
|
+
return `\u2500\u2500 ${time} \u00b7 ${agoText(seconds)} later \u2500\u2500`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The recap bubble's label: when it was written (+createdAt+, wallClock
|
|
320
|
+
// against +now+), how many turns came after it (stale: a turn started while
|
|
321
|
+
// the page showed it, count unknown).
|
|
322
|
+
export function recapLabel(turnsSince, stale = false, createdAt = null, now = Date.now()) {
|
|
259
323
|
const n = Number(turnsSince) || 0;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
324
|
+
const time = wallClock(createdAt, now);
|
|
325
|
+
const head = time ? `recap · ${time}` : "recap";
|
|
326
|
+
if (n === 1) return `${head} · before the last turn`;
|
|
327
|
+
if (n > 1) return `${head} · before the last ${n} turns`;
|
|
328
|
+
return stale ? `${head} · earlier` : head;
|
|
263
329
|
}
|
|
264
330
|
|
|
265
331
|
// The info bar's memory chip on a phone (where the "mem: …" line goes),
|
|
@@ -51,6 +51,8 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
51
51
|
.bubble.output.boxed{overflow:hidden;transition:max-height 360ms ease}
|
|
52
52
|
.bubble.output.revealing>:not(.copy-btn){animation:fadeIn 240ms ease}
|
|
53
53
|
@media (prefers-reduced-motion:reduce){.bubble.output.boxed{transition:none}.bubble.output.revealing>:not(.copy-btn){animation:none}}
|
|
54
|
+
/* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
|
|
55
|
+
.time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
|
|
54
56
|
.turn-timing{margin-top:8px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
|
|
55
57
|
.turn-timing.live{color:var(--accent)}
|
|
56
58
|
.bubble.output.markdown{white-space:normal}
|
|
@@ -67,6 +67,8 @@ body{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica
|
|
|
67
67
|
main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
68
68
|
.p-thread{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:12px}
|
|
69
69
|
.p-empty{color:var(--text-muted);text-align:center;padding:40px 0}
|
|
70
|
+
/* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
|
|
71
|
+
.time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
|
|
70
72
|
.p-msg{display:flex;flex-direction:column;gap:4px;min-width:0}
|
|
71
73
|
.p-who{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px}
|
|
72
74
|
.p-who b{font-weight:600;color:var(--author-color,var(--author-owner))}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
// POST …/turn (text only); their queued ones come back with Dequeue.
|
|
8
8
|
import {
|
|
9
9
|
authorColor, participantAuthor, authorLabel, toolLine, toolsSummary, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
10
|
-
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries,
|
|
10
|
+
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps,
|
|
11
11
|
} from "./participant_model.js";
|
|
12
|
+
import { fullTime } from "./format.js";
|
|
12
13
|
import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
13
14
|
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
14
15
|
|
|
@@ -241,6 +242,9 @@ function setView(view) {
|
|
|
241
242
|
function messageNode(message) {
|
|
242
243
|
const user = message.role === "user";
|
|
243
244
|
const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
|
|
245
|
+
// When it happened, on hover (the owner's page's bubbles do the same).
|
|
246
|
+
const when = fullTime(message.at);
|
|
247
|
+
if (when) node.title = when;
|
|
244
248
|
if (user) {
|
|
245
249
|
node.style.setProperty("--author-color", authorColor(message.author));
|
|
246
250
|
const who = el("div", "p-who");
|
|
@@ -283,16 +287,35 @@ function toolsNode(rows, open = null) {
|
|
|
283
287
|
return details;
|
|
284
288
|
}
|
|
285
289
|
|
|
290
|
+
// A gap divider (the owner's page's .time-gap line), kept while its text is.
|
|
291
|
+
function gapNode(text, old) {
|
|
292
|
+
if (!text) return null;
|
|
293
|
+
if (old?.textContent === text) return old;
|
|
294
|
+
const node = el("div", "time-gap", text);
|
|
295
|
+
node.setAttribute("role", "separator");
|
|
296
|
+
return node;
|
|
297
|
+
}
|
|
298
|
+
|
|
286
299
|
function renderThread() {
|
|
287
300
|
const thread = $("thread");
|
|
288
|
-
|
|
301
|
+
const messages = withToolSteps(data.messages || []);
|
|
302
|
+
const gaps = threadGaps(messages, { currentAt: data.current_turn?.at || null });
|
|
303
|
+
drawnMessages = messages.map((message, at) => {
|
|
289
304
|
const sig = JSON.stringify(message);
|
|
290
305
|
const old = drawnMessages[at];
|
|
291
|
-
|
|
292
|
-
|
|
306
|
+
const gap = gapNode(gaps.before[at], old?.gap);
|
|
307
|
+
if (old?.sig === sig) return { ...old, gap };
|
|
308
|
+
return { sig, gap, node: keepToolsOpen(messageNode(message), old?.node) };
|
|
293
309
|
});
|
|
294
|
-
const nodes = drawnMessages.
|
|
295
|
-
if (data.current_turn)
|
|
310
|
+
const nodes = drawnMessages.flatMap((entry) => (entry.gap ? [entry.gap, entry.node] : [entry.node]));
|
|
311
|
+
if (data.current_turn) {
|
|
312
|
+
const gap = gapNode(gaps.current, thread.querySelector(":scope > .time-gap.current"));
|
|
313
|
+
if (gap) {
|
|
314
|
+
gap.classList.add("current");
|
|
315
|
+
nodes.push(gap);
|
|
316
|
+
}
|
|
317
|
+
nodes.push(...currentTurnNodes());
|
|
318
|
+
}
|
|
296
319
|
else if (data.waiting_for_owner) nodes.push(el("div", "p-waiting", "Waiting for the session's owner"));
|
|
297
320
|
if (nodes.length === 0) nodes.push(el("div", "p-empty", "Nothing here yet. Comments and replies show here."));
|
|
298
321
|
const main = $("main");
|
|
@@ -326,7 +349,7 @@ function currentTurnNodes() {
|
|
|
326
349
|
const nodes = [];
|
|
327
350
|
if (turn.prompt !== undefined || turn.automatic) {
|
|
328
351
|
nodes.push(messageNode({ role: "user", content: turn.automatic ? "An automatic turn" : turn.prompt, author: turn.author,
|
|
329
|
-
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic }));
|
|
352
|
+
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at }));
|
|
330
353
|
}
|
|
331
354
|
const reply = el("div", "p-msg reply live");
|
|
332
355
|
fillLive(reply, $("liveReply"));
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// refusal in words, the stream's URL, and the live turn as filtered events
|
|
4
4
|
// build it (ParticipantView on the server decides what arrives at all).
|
|
5
5
|
|
|
6
|
+
import { gapText } from "./format.js";
|
|
7
|
+
|
|
6
8
|
// A participant's author: "p:" and its grant id, as the server writes it
|
|
7
9
|
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
8
10
|
export const PARTICIPANT_PREFIX = "p:";
|
|
@@ -47,30 +49,51 @@ export function toolsSummary(rows) {
|
|
|
47
49
|
// The conversation with each run of text-less tool steps (a reply that
|
|
48
50
|
// only called tools) folded into the next reply's rows, or one row group
|
|
49
51
|
// when no reply follows: one "3 tools: …" line instead of three.
|
|
52
|
+
// A folded run with no reply after it keeps its last step's time (at).
|
|
50
53
|
export function withToolSteps(messages) {
|
|
51
54
|
const out = [];
|
|
52
55
|
let pending = [];
|
|
56
|
+
let pendingAt = null;
|
|
57
|
+
const flush = () => out.push({ role: "assistant", content: "", tools: pending, ...(pendingAt ? { at: pendingAt } : {}) });
|
|
53
58
|
for (const message of messages) {
|
|
54
59
|
const toolOnly = message.role === "assistant" && !String(message.content || "").trim() && message.tools?.length;
|
|
55
60
|
if (toolOnly) {
|
|
56
61
|
pending = pending.concat(message.tools);
|
|
62
|
+
pendingAt = message.at || pendingAt;
|
|
57
63
|
continue;
|
|
58
64
|
}
|
|
59
65
|
if (pending.length) {
|
|
60
66
|
if (message.role === "assistant") {
|
|
61
67
|
out.push({ ...message, tools: pending.concat(message.tools || []) });
|
|
62
68
|
pending = [];
|
|
69
|
+
pendingAt = null;
|
|
63
70
|
continue;
|
|
64
71
|
}
|
|
65
|
-
|
|
72
|
+
flush();
|
|
66
73
|
pending = [];
|
|
74
|
+
pendingAt = null;
|
|
67
75
|
}
|
|
68
76
|
out.push(message);
|
|
69
77
|
}
|
|
70
|
-
if (pending.length)
|
|
78
|
+
if (pending.length) flush();
|
|
71
79
|
return out;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
// The thread's gap dividers (format.js gapText, the owner's page's words):
|
|
83
|
+
// before each message, and before the turn in progress (+currentAt+, its
|
|
84
|
+
// turn_started's at), the divider's text or null. A gap is marked only
|
|
85
|
+
// where a person's message or a turn starts, measured from the last
|
|
86
|
+
// message's time before it: never inside a turn's work.
|
|
87
|
+
export function threadGaps(messages, { currentAt = null, now = Date.now() } = {}) {
|
|
88
|
+
let last = null;
|
|
89
|
+
const before = (messages || []).map((message) => {
|
|
90
|
+
const text = message?.role === "user" && last && message.at ? gapText(last, message.at, now) : null;
|
|
91
|
+
if (message?.at) last = message.at;
|
|
92
|
+
return text;
|
|
93
|
+
});
|
|
94
|
+
return { before, current: last && currentAt ? gapText(last, currentAt, now) : null };
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
// The server's words (ParticipantMessages, a relay's detail) as the page
|
|
75
98
|
// shows them: a sentence, never reworded; null with none.
|
|
76
99
|
export function sentence(detail) {
|
|
@@ -36,10 +36,13 @@
|
|
|
36
36
|
// setUsedMemories(names), showRecap(text), showGuardrailWarning(text, label),
|
|
37
37
|
// showHookNotice(data), onInitEvent(type, data), upsertCard(card),
|
|
38
38
|
// refreshCardBodies(), syncModelPick() the page's hooks
|
|
39
|
+
// gapClock the page's last activity (time_gaps.js
|
|
40
|
+
// createGapClock; renderHistory seeds it)
|
|
41
|
+
// addTimeGap(text, {before}) a gap's divider (bubbles.js)
|
|
39
42
|
|
|
40
43
|
import { routeChunk } from "./chunk_router.js";
|
|
41
44
|
import { extractCtxPct, generationTokens } from "./ctx.js";
|
|
42
|
-
import { failedTurnText } from "./format.js";
|
|
45
|
+
import { failedTurnText, fullTime } from "./format.js";
|
|
43
46
|
import { cancelLineText } from "./timing.js";
|
|
44
47
|
import { keepFollowing } from "./scroll.js";
|
|
45
48
|
import { shownElsewhere } from "./card.js";
|
|
@@ -60,7 +63,17 @@ export function createStreamHandlers(deps) {
|
|
|
60
63
|
appendChunk, readTurnEnd, applyFinalMarkdown, refreshTimingFromSession, makeUserBubble, addEndBubble,
|
|
61
64
|
commandBubble, addCommandBubble, addNoteBubble, findInTurnRoots, setUsedMemories, showRecap,
|
|
62
65
|
showGuardrailWarning, showHookNotice, onInitEvent, upsertCard, refreshCardBodies, syncModelPick,
|
|
66
|
+
gapClock = null, addTimeGap = () => null,
|
|
63
67
|
} = deps;
|
|
68
|
+
// Activity starts (a turn, a reminder, a note) at +time+ (the event's;
|
|
69
|
+
// none: now): a gap's divider above +before+ (else at the end) when a
|
|
70
|
+
// quiet stretch passed since the last activity.
|
|
71
|
+
function gapBefore(time, before = null) {
|
|
72
|
+
const text = gapClock?.start(time);
|
|
73
|
+
if (text) addTimeGap(text, { before });
|
|
74
|
+
}
|
|
75
|
+
// Activity ended (a turn's end): the next gap counts from now.
|
|
76
|
+
const activityEnded = () => gapClock?.end(null);
|
|
64
77
|
|
|
65
78
|
return {
|
|
66
79
|
generation_chunk: (data) => {
|
|
@@ -137,6 +150,12 @@ export function createStreamHandlers(deps) {
|
|
|
137
150
|
markRecapStale();
|
|
138
151
|
shownTexts.turnStarted();
|
|
139
152
|
const promptBubble = applyPromptOps(data);
|
|
153
|
+
// The turn's wall time (a replay's: its start): the divider above its
|
|
154
|
+
// prompt, or where the turn goes when it has none; its bubble's hover.
|
|
155
|
+
const startedAt = data.at || data.started_at || null;
|
|
156
|
+
gapBefore(startedAt, promptBubble);
|
|
157
|
+
const when = fullTime(startedAt);
|
|
158
|
+
if (when && promptBubble?.setAttribute && !promptBubble.title) promptBubble.title = when;
|
|
140
159
|
liveTurn.cutAfter = data.cut_after ?? null;
|
|
141
160
|
liveTurn.serverGen = null;
|
|
142
161
|
// A participant's review turn: its id and author, for Apply at its end
|
|
@@ -152,6 +171,7 @@ export function createStreamHandlers(deps) {
|
|
|
152
171
|
liveTurn.timing.start(data.started_at, data.turn_id || null);
|
|
153
172
|
}),
|
|
154
173
|
turn_completed: async (data) => {
|
|
174
|
+
activityEnded();
|
|
155
175
|
// A participant's turn: their spend and turns in the panel moved.
|
|
156
176
|
if (data.origin?.author) participantsControl?.reload();
|
|
157
177
|
endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
|
|
@@ -196,6 +216,7 @@ export function createStreamHandlers(deps) {
|
|
|
196
216
|
}
|
|
197
217
|
},
|
|
198
218
|
turn_canceled: (data) => {
|
|
219
|
+
activityEnded();
|
|
199
220
|
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
|
|
200
221
|
setLiveStatus("idle");
|
|
201
222
|
refreshTimingFromSession();
|
|
@@ -205,6 +226,7 @@ export function createStreamHandlers(deps) {
|
|
|
205
226
|
// (prompt_restored), and the prompts queued behind it run as usual. A turn
|
|
206
227
|
// that got to tool steps stays as it is (kept_steps; no prompt_restored).
|
|
207
228
|
turn_failed: (data) => {
|
|
229
|
+
activityEnded();
|
|
208
230
|
const text = failedTurnText(data);
|
|
209
231
|
// Only a rolled-back turn's line waits for its prompt_restored.
|
|
210
232
|
liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
|
|
@@ -314,10 +336,16 @@ export function createStreamHandlers(deps) {
|
|
|
314
336
|
if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
|
|
315
337
|
},
|
|
316
338
|
// Due reminders went into the turn (a reminder turn has no prompt bubble).
|
|
317
|
-
reminder_injected: (data) =>
|
|
339
|
+
reminder_injected: (data) => {
|
|
340
|
+
gapBefore(data.at);
|
|
341
|
+
addCommandBubble({ label: null, line: reminderText(data), text: "", at: data.at });
|
|
342
|
+
},
|
|
318
343
|
// A context note joined the conversation (between turns; no turn runs).
|
|
344
|
+
// A note is activity of its own: it starts nothing, so it ends there too.
|
|
319
345
|
context_added: (data) => {
|
|
320
|
-
|
|
346
|
+
const at = data.at || data.created_at || null;
|
|
347
|
+
gapBefore(at);
|
|
348
|
+
addNoteBubble({ label: data.label, content: data.text || "", at });
|
|
321
349
|
// An attached context source's note: its chip changed.
|
|
322
350
|
if (data.context_source) contextChips.refresh();
|
|
323
351
|
},
|
|
@@ -385,7 +413,7 @@ export function createStreamHandlers(deps) {
|
|
|
385
413
|
recap_ready: (data) => {
|
|
386
414
|
// After the turn it covers, never above it.
|
|
387
415
|
turnDom.handOff?.({ immediate: true });
|
|
388
|
-
if (!liveTurn.running) showRecap(data.recap);
|
|
416
|
+
if (!liveTurn.running) showRecap(data.recap, 0, data.created_at || null);
|
|
389
417
|
},
|
|
390
418
|
guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
|
|
391
419
|
// A notice during a turn is a row of its current step. One the page
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The history's gap dividers: one dim line ("── 14:32 · 47 min later ──",
|
|
2
|
+
// format.js gapText) where a quiet stretch of GAP_SECONDS or more passed
|
|
3
|
+
// between the end of one activity and the start of the next (a turn, a
|
|
4
|
+
// note, a reminder). Nothing during active work: a long turn followed at
|
|
5
|
+
// once by another has none (measured end to start).
|
|
6
|
+
//
|
|
7
|
+
// The times: a history entry's `at` (MessageTime, the server forwards it),
|
|
8
|
+
// else for a turn its record's started_at and started_at + duration_ms
|
|
9
|
+
// (sessions saved before stamps). Live: the events' at / created_at, else
|
|
10
|
+
// the page's clock (createGapClock).
|
|
11
|
+
//
|
|
12
|
+
// The divider is its own element (.time-gap), never a .bubble or a
|
|
13
|
+
// .turn-work: code that walks those (turn prompts, recap placement, the
|
|
14
|
+
// stage's hand-off) never sees it.
|
|
15
|
+
import { html } from "./vendor/lit-html.js";
|
|
16
|
+
import { elementOf } from "./turn_html.js";
|
|
17
|
+
import { gapText, timeOf } from "./format.js";
|
|
18
|
+
|
|
19
|
+
export const TIME_GAP_CLASS = "time-gap";
|
|
20
|
+
|
|
21
|
+
export function timeGapTemplate(text) {
|
|
22
|
+
return html`<div class=${TIME_GAP_CLASS} role="separator">${text}</div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function timeGapEl(text) {
|
|
26
|
+
return elementOf(timeGapTemplate(text));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The history items a group (timing.js turnGroups) holds, by index.
|
|
30
|
+
function groupIndexes(group) {
|
|
31
|
+
if (group.kind === "note" || group.kind === "steer") return [group.i];
|
|
32
|
+
if (group.kind === "answer") return [group.answer];
|
|
33
|
+
const steps = group.steps || [];
|
|
34
|
+
return [
|
|
35
|
+
group.user, ...steps.map((step) => step.i), ...steps.flatMap((step) => step.steers || []),
|
|
36
|
+
...(group.steers || []), ...(group.merged || []), group.answer, group.emptyAnswer,
|
|
37
|
+
].filter((i) => Number.isInteger(i));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A group's start and end (epoch ms, null when unknown): its first stamped
|
|
41
|
+
// entry's time, else its turn record's start; its last activity: the later
|
|
42
|
+
// of its last stamped entry and its record's end (a turn's tool calls and
|
|
43
|
+
// hooks run after its last message).
|
|
44
|
+
export function groupTimes(group, items) {
|
|
45
|
+
const stamps = groupIndexes(group).map((i) => timeOf(items[i]?.at)?.getTime()).filter(Number.isFinite);
|
|
46
|
+
const record = group.record || null;
|
|
47
|
+
const recordStart = timeOf(record?.started_at)?.getTime();
|
|
48
|
+
const duration = Number(record?.duration_ms);
|
|
49
|
+
const recordEnd = Number.isFinite(recordStart) && Number.isFinite(duration) ? recordStart + duration : NaN;
|
|
50
|
+
const start = stamps.length ? Math.min(...stamps) : Number.isFinite(recordStart) ? recordStart : null;
|
|
51
|
+
const ends = [...stamps, recordEnd].filter(Number.isFinite);
|
|
52
|
+
return { start, end: ends.length ? Math.max(...ends) : start };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// For each group, the divider text before it, or null. A group with no
|
|
56
|
+
// known time draws none and hides the gap across it (no divider measured
|
|
57
|
+
// over a stretch whose times are unknown).
|
|
58
|
+
export function groupGaps(groups, items, now = Date.now()) {
|
|
59
|
+
let prevEnd = null;
|
|
60
|
+
return groups.map((group) => {
|
|
61
|
+
const { start, end } = groupTimes(group, items);
|
|
62
|
+
const text = prevEnd !== null && start !== null ? gapText(prevEnd, start, now) : null;
|
|
63
|
+
prevEnd = end;
|
|
64
|
+
return text;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The last activity the history shows (epoch ms), or null: the live
|
|
69
|
+
// dividers' starting point.
|
|
70
|
+
export function lastActivity(groups, items) {
|
|
71
|
+
for (let k = groups.length - 1; k >= 0; k -= 1) {
|
|
72
|
+
const { end } = groupTimes(groups[k], items);
|
|
73
|
+
if (end !== null) return end;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The live page's last activity: seeded from the drawn history, moved at
|
|
79
|
+
// every start and end of activity. +now+: the page's clock (epoch ms).
|
|
80
|
+
export function createGapClock(now = () => Date.now()) {
|
|
81
|
+
let last = null;
|
|
82
|
+
const at = (time) => timeOf(time)?.getTime() ?? now();
|
|
83
|
+
return {
|
|
84
|
+
// The history was drawn (a render, a resync): its last activity.
|
|
85
|
+
seed(time) { last = timeOf(time)?.getTime() ?? null; },
|
|
86
|
+
// Activity starts at +time+ (an event's at; none: now): the divider's
|
|
87
|
+
// text when a gap passed since the last, else null.
|
|
88
|
+
start(time) {
|
|
89
|
+
const t = at(time);
|
|
90
|
+
const text = last === null ? null : gapText(last, t, now());
|
|
91
|
+
last = Math.max(last ?? t, t);
|
|
92
|
+
return text;
|
|
93
|
+
},
|
|
94
|
+
// Activity ended (a turn's end): the next gap counts from here.
|
|
95
|
+
end(time) { last = at(time); },
|
|
96
|
+
get last() { return last; },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
--glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
|
|
4
4
|
--diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
|
|
5
5
|
--code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
|
|
6
|
-
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3)}
|
|
6
|
+
--card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
|
|
7
7
|
/* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
|
|
8
8
|
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0b5cd5;--accent-strong:#0550ae;--accent-bg:#e2ecfd;--accent-ring:rgba(11,92,213,.2);--primary:#1f6feb;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b8cef5;--pick-border:#0969da;
|
|
9
9
|
--glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
|
|
10
10
|
--diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
|
|
11
11
|
--code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
|
|
12
|
-
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3)}}
|
|
12
|
+
--card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14);--ws-stale-opacity:.55;--ws-rule:var(--border);--ws-muted:var(--text-muted);--ws-faint:var(--text-faint);--ws-act-border:var(--border-strong);--author-1:#0550ae;--author-2:#8250df;--author-3:#116329;--author-4:#953800;--author-5:#a40e26;--author-6:#7d4e00;--author-owner:var(--text-3);--time-gap:var(--text-faint)}}
|
|
@@ -340,7 +340,9 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
340
340
|
const cutAfter = turn.cut_after != null ? { cut_after: turn.cut_after } : {};
|
|
341
341
|
// A participant's turn names them (TurnAccumulator keeps author, author_name).
|
|
342
342
|
const author = turn.author ? { author: turn.author, ...(turn.author_name ? { author_name: turn.author_name } : {}) } : {};
|
|
343
|
-
|
|
343
|
+
// Its wall time (TurnAccumulator keeps turn_started's at): the gap divider before it.
|
|
344
|
+
const at = typeof turn.at === "string" ? { at: turn.at } : {};
|
|
345
|
+
events.push(withImages({ type: "turn_started", prompt: turn.prompt, origin: turn.origin, continue: !!turn.continue, started_at, ...at, ...cutAfter, ...author }, turn.images));
|
|
344
346
|
let textIteration = null; // a text bubble is open for this iteration
|
|
345
347
|
const closeText = () => {
|
|
346
348
|
if (textIteration !== null) events.push({ type: "generation_completed" });
|
|
@@ -8,7 +8,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
|
|
|
8
8
|
import { commandBlockTemplate, rowHover } from "./command_view.js";
|
|
9
9
|
import { copySource } from "./copy.js";
|
|
10
10
|
import { activityDiffTemplate } from "./diff_view.js";
|
|
11
|
-
import { clip, steerSender } from "./format.js";
|
|
11
|
+
import { clip, fullTime, steerSender } from "./format.js";
|
|
12
12
|
import { rowTitleRef } from "./refs.js";
|
|
13
13
|
import { readLinesTemplate } from "./read_view.js";
|
|
14
14
|
import { handedOverId } from "./task_stop.js";
|
|
@@ -219,9 +219,15 @@ export function shellCommandMessage(content) {
|
|
|
219
219
|
// A `!cmd`'s history bubble: the same markup the live page draws for a
|
|
220
220
|
// command (app.js's drawCommandBubble): the line, the output in a
|
|
221
221
|
// pre.command-output.
|
|
222
|
-
export function commandBubbleTemplate({ line, text }) {
|
|
222
|
+
export function commandBubbleTemplate({ line, text, at = null }) {
|
|
223
223
|
const body = text ? html`<pre class="command-output">${text}</pre>` : nothing;
|
|
224
|
-
return html`<div class="bubble command"><div class="command-line">${line}</div>${body}</div>`;
|
|
224
|
+
return html`<div class="bubble command" title=${timeTitle(at)}><div class="command-line">${line}</div>${body}</div>`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A bubble's hover: when it happened (its message's `at`, MessageTime), in
|
|
228
|
+
// full local time; no title when unknown.
|
|
229
|
+
export function timeTitle(at) {
|
|
230
|
+
return fullTime(at) || nothing;
|
|
225
231
|
}
|
|
226
232
|
|
|
227
233
|
// A user message's body: runs of `>` lines become <blockquote>s, the rest
|
|
@@ -257,9 +263,10 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
|
257
263
|
const klass = `bubble output${cls}${rendered ? " markdown" : ""}`;
|
|
258
264
|
const answerAt = at ?? nothing;
|
|
259
265
|
const source = rendered || user ? copySource(message) : nothing;
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
return html`<div class=${klass} data-answer-at=${answerAt} data-copy-source=${source}
|
|
266
|
+
const title = timeTitle(message?.at);
|
|
267
|
+
if (rendered) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .innerHTML=${message.html}></div>`;
|
|
268
|
+
if (user) return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source}>${userBodyTemplate(message.content)}</div>`;
|
|
269
|
+
return html`<div class=${klass} title=${title} data-answer-at=${answerAt} data-copy-source=${source} .textContent=${String(message?.content ?? "")}></div>`;
|
|
263
270
|
}
|
|
264
271
|
|
|
265
272
|
// A context note's bubble (background the user or another session pushed
|
|
@@ -267,7 +274,7 @@ export function answerBubbleTemplate(message, { cls = "", at = null } = {}) {
|
|
|
267
274
|
// text.
|
|
268
275
|
export function noteBubbleTemplate(note) {
|
|
269
276
|
const from = note?.label ? `note from ${note.label}` : "note";
|
|
270
|
-
return html`<div class="bubble note"><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
277
|
+
return html`<div class="bubble note" title=${timeTitle(note?.at)}><div class="note-line">${from}</div><div class="note-text">${note?.content ?? ""}</div></div>`;
|
|
271
278
|
}
|
|
272
279
|
|
|
273
280
|
// A plugin's steer (a nudge put into the running turn): one line that
|
|
@@ -295,7 +302,7 @@ export function userBubbleTemplate(message, { label = null, state = null, step =
|
|
|
295
302
|
const from = author
|
|
296
303
|
? html`<span class="origin-label author-badge" title=${authorBadgeTitle(author.name)} data-unnamed=${author.unnamed ? "1" : nothing}>${authorBadgeText(author.name)}</span>`
|
|
297
304
|
: label ? html`<span class="origin-label">${label}</span>` : nothing;
|
|
298
|
-
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
305
|
+
return html`<div class=${`bubble user${named ? ` ${named}` : ""}${author ? " by-participant" : ""}`} title=${timeTitle(message?.at)} style=${author ? `--author-color:${author.color}` : nothing} data-author=${author?.author || nothing} data-user-state=${named || nothing} data-step=${step ? Number(step) : nothing} data-copy-source=${copySource({ ...message, role: "user" })} data-user-content=${key !== null ? key : nothing} data-own=${own ? "1" : nothing} data-enqueued-id=${enqueuedId || nothing} data-delivery=${delivery || nothing}>${from}<div class="user-message">${userBodyTemplate(message?.content)}${thumbs}</div>${stateBadgeTemplate(named, delivery)}</div>`;
|
|
299
306
|
}
|
|
300
307
|
|
|
301
308
|
// The notice of a turn that ended with no answer (the server's
|
|
@@ -42,6 +42,7 @@ import { createHold } from "./hold.js";
|
|
|
42
42
|
import { linkedLine } from "./notice_links.js";
|
|
43
43
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
44
44
|
import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
|
|
45
|
+
import { groupGaps, timeGapTemplate } from "./time_gaps.js";
|
|
45
46
|
|
|
46
47
|
// ---- The templates: one step, its thinking, its label ----
|
|
47
48
|
|
|
@@ -847,7 +848,7 @@ function failedLineTemplate(record) {
|
|
|
847
848
|
// web.working_set, never the turn view's): each block stamped with its
|
|
848
849
|
// turn's ordinal and its rows with theirs (working_set_view.js), and the
|
|
849
850
|
// turn's footprint line after it.
|
|
850
|
-
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false }) {
|
|
851
|
+
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now() }) {
|
|
851
852
|
const timingTemplate = (group) => {
|
|
852
853
|
const record = group.record;
|
|
853
854
|
// Numbered by its place in the turn records, as live (a failed turn
|
|
@@ -868,7 +869,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
|
|
|
868
869
|
// as its command bubble, as live does — not as a user bubble.
|
|
869
870
|
const userTemplate = (m, opts = {}) => {
|
|
870
871
|
const shell = shellCommandMessage(m?.content);
|
|
871
|
-
if (shell) return commandBubbleTemplate(shell);
|
|
872
|
+
if (shell) return commandBubbleTemplate({ ...shell, at: m?.at });
|
|
872
873
|
return userBubbleTemplate(m, { label: sourceLabel(m?.source), thumbs: thumbs(m.images), author: authorBadge(m?.author, m?.author_name), ...opts });
|
|
873
874
|
};
|
|
874
875
|
// The first text after a delegate report merged mid-turn, unless it is
|
|
@@ -886,7 +887,11 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false })
|
|
|
886
887
|
const outlines = [];
|
|
887
888
|
const blockTurns = [];
|
|
888
889
|
let turnOrdinal = -1;
|
|
889
|
-
|
|
890
|
+
const groups = turnGroups(items, timing);
|
|
891
|
+
// A quiet stretch before a group: its divider first (time_gaps.js).
|
|
892
|
+
const gaps = groupGaps(groups, items, now);
|
|
893
|
+
for (const [k, group] of groups.entries()) {
|
|
894
|
+
if (gaps[k]) parts.push(timeGapTemplate(gaps[k]));
|
|
890
895
|
if (group.kind === "note") { parts.push(noteBubbleTemplate(items[group.i])); continue; }
|
|
891
896
|
if (group.kind === "steer") { parts.push(steerTemplate(group.i, true)); continue; }
|
|
892
897
|
if (group.kind === "answer") { parts.push(answerTemplate(group.answer, nothing)); continue; }
|
|
@@ -4,18 +4,23 @@ require "json"
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "../atomic_file"
|
|
6
6
|
require_relative "../session"
|
|
7
|
+
require_relative "info_probe"
|
|
8
|
+
require_relative "public_url"
|
|
7
9
|
|
|
8
10
|
module Samagotchi
|
|
9
11
|
module Web
|
|
10
12
|
# Where this machine's chi web listens: $XDG_STATE_HOME/samagotchi/web.json
|
|
11
|
-
# ({pid, port}
|
|
13
|
+
# ({pid, port}, and public_url when it runs with one: config's or
|
|
14
|
+
# --tunnel's), written once its port is bound and removed when it
|
|
12
15
|
# stops (by that process only). A command that names chi web in what it
|
|
13
16
|
# prints (chi share's link) reads the port here before config's
|
|
14
17
|
# web.port, which a chi web started with --port or another config
|
|
15
18
|
# doesn't follow. The file is a hint: a chi web killed hard leaves it,
|
|
16
|
-
# so a reader probes the port (InfoProbe) before trusting it.
|
|
19
|
+
# so a reader probes the port (InfoProbe) before trusting it. One slot:
|
|
20
|
+
# a second chi web on another port takes it over.
|
|
17
21
|
module Running
|
|
18
22
|
FILE = "web.json"
|
|
23
|
+
PROBE_TIMEOUT = 1.0
|
|
19
24
|
|
|
20
25
|
module_function
|
|
21
26
|
|
|
@@ -24,9 +29,10 @@ module Samagotchi
|
|
|
24
29
|
end
|
|
25
30
|
|
|
26
31
|
# @return [String] +path+
|
|
27
|
-
|
|
32
|
+
# @param public_url [String, nil] the https origin it runs behind
|
|
33
|
+
def record(port:, path: self.path, pid: Process.pid, public_url: nil)
|
|
28
34
|
FileUtils.mkdir_p(File.dirname(path))
|
|
29
|
-
AtomicFile.write(path, JSON.generate({ pid: pid, port: port }))
|
|
35
|
+
AtomicFile.write(path, JSON.generate({ pid: pid, port: port, public_url: public_url }.compact))
|
|
30
36
|
end
|
|
31
37
|
|
|
32
38
|
# Removes the file when this process wrote it (a second chi web's
|
|
@@ -41,6 +47,35 @@ module Samagotchi
|
|
|
41
47
|
value.is_a?(Integer) && value.positive? ? value : nil
|
|
42
48
|
end
|
|
43
49
|
|
|
50
|
+
# The public_url the running chi web recorded (a --tunnel's, which no
|
|
51
|
+
# config has), for a link that names it: only while the chi web that
|
|
52
|
+
# answers /api/info on the recorded port is the process that wrote the
|
|
53
|
+
# file (its pid), and only an https origin. A record left by a chi web
|
|
54
|
+
# killed hard, with a reused pid or another chi web (another state dir)
|
|
55
|
+
# on that port, is nil. The origin is this file's, never the probe's.
|
|
56
|
+
# @param probe [#call] (port) → InfoProbe's answer
|
|
57
|
+
# @return [PublicUrl, nil]
|
|
58
|
+
def live_public_url(path: self.path, probe: ->(port) { InfoProbe.call("127.0.0.1", port, timeout: PROBE_TIMEOUT) })
|
|
59
|
+
data = read(path) or return nil
|
|
60
|
+
pid, port, url = data.values_at("pid", "port", "public_url")
|
|
61
|
+
return nil unless pid.is_a?(Integer) && pid.positive? && port.is_a?(Integer) && port.positive? && url.is_a?(String)
|
|
62
|
+
return nil unless alive?(pid)
|
|
63
|
+
|
|
64
|
+
info = probe.call(port)
|
|
65
|
+
return nil unless info.is_a?(Hash) && info["pid"] == pid
|
|
66
|
+
|
|
67
|
+
PublicUrl.from_config(url: url)
|
|
68
|
+
rescue PublicUrl::Invalid
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def alive?(pid)
|
|
73
|
+
Process.kill(0, pid)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
44
79
|
def read(path)
|
|
45
80
|
data = JSON.parse(File.read(path))
|
|
46
81
|
data.is_a?(Hash) ? data : nil
|