samagotchi 0.76.0 → 0.77.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/docs/internals/engine.md +1 -1
  4. data/docs/plugins.md +42 -0
  5. data/lib/samagotchi/answer_command.rb +5 -5
  6. data/lib/samagotchi/bridge.rb +3 -3
  7. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  8. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/child_reports.rb +1 -1
  11. data/lib/samagotchi/engine.rb +21 -12
  12. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  13. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  14. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  15. data/lib/samagotchi/message_parts.rb +272 -0
  16. data/lib/samagotchi/parent_report.rb +30 -22
  17. data/lib/samagotchi/pending_question.rb +49 -0
  18. data/lib/samagotchi/question_desk.rb +39 -32
  19. data/lib/samagotchi/relay.rb +8 -9
  20. data/lib/samagotchi/relay_verifier.rb +2 -2
  21. data/lib/samagotchi/relay_watcher.rb +2 -2
  22. data/lib/samagotchi/reply_wait.rb +4 -4
  23. data/lib/samagotchi/row_refs.rb +1 -1
  24. data/lib/samagotchi/session.rb +22 -14
  25. data/lib/samagotchi/session_commands.rb +4 -1
  26. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  28. data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
  29. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  30. data/lib/samagotchi/terminal_ui.rb +5 -3
  31. data/lib/samagotchi/tool_output_cut.rb +1 -1
  32. data/lib/samagotchi/tool_response.rb +13 -13
  33. data/lib/samagotchi/tool_row_fields.rb +1 -1
  34. data/lib/samagotchi/tool_runner.rb +16 -9
  35. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  36. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  37. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +2 -2
  40. data/lib/samagotchi/web/public/app.js +17 -55
  41. data/lib/samagotchi/web/public/ctx.js +12 -1
  42. data/lib/samagotchi/web/public/index.html +2 -1
  43. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  44. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  45. data/lib/samagotchi/web/public/toasts.js +69 -0
  46. metadata +6 -2
  47. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -37,7 +37,7 @@ module Samagotchi
37
37
  # @param relay [#open_question, #relay_desk] Peers#relay
38
38
  # @return [Boolean] a relay can take this question
39
39
  def relayable?(relay, question)
40
- !relay.nil? && question.is_a?(Hash) && question[:kind].to_s == Guardrails::Approval::KIND
40
+ !relay.nil? && question.is_a?(PendingQuestion) && question.kind.to_s == Guardrails::Approval::KIND
41
41
  end
42
42
 
43
43
  # Relay the child's pending approval and wait until it settles.
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  # @return [Outcome]
46
46
  def call(child_id, question, relay:, state_dir:, more: 0)
47
47
  child = Session.load(child_id, state_dir: state_dir)
48
- qid = question[:id].to_s
48
+ qid = question.id.to_s
49
49
  desk = relay.relay_desk
50
50
  relay_id = desk.open(child_id: child_id, child_question_id: qid)
51
51
  client = BridgeClient.discover(child_id, session_dir: Session.session_dir(child_id, state_dir: state_dir))
@@ -70,7 +70,7 @@ module Samagotchi
70
70
  end
71
71
 
72
72
  pending = Session.load(child_id, state_dir: state_dir).pending_question
73
- pending && pending[:id].to_s == qid ? nil : "answered_on_child"
73
+ pending && pending.id.to_s == qid ? nil : "answered_on_child"
74
74
  rescue ArgumentError
75
75
  "child_gone"
76
76
  end
@@ -175,7 +175,7 @@ module Samagotchi
175
175
  return if waiting.empty?
176
176
 
177
177
  child_id, question = waiting.first
178
- @done[question[:id].to_s] = true
178
+ @done[question.id.to_s] = true
179
179
  outcome = DelegateRelay.call(child_id, question, relay: @relay, state_dir: @state_dir, more: waiting.size - 1)
180
180
  @on_outcome.call(child_id, outcome)
181
181
  # The next one, if any, right after.
@@ -186,7 +186,7 @@ module Samagotchi
186
186
  # @return [Integer]
187
187
  def count = waiting.size
188
188
 
189
- # @return [Array<Array(String, Hash)>] [child id, pending question],
189
+ # @return [Array<Array(String, PendingQuestion)>] [child id, pending question],
190
190
  # oldest question first
191
191
  def waiting
192
192
  rows = SessionManager.children_of(@parent_id, state_dir: @state_dir).select do |row|
@@ -194,10 +194,10 @@ module Samagotchi
194
194
  end
195
195
  rows.filter_map do |row|
196
196
  pending = Session.load(row[:id], state_dir: @state_dir).pending_question
197
- [row[:id], pending] if pending && pending[:id].to_s == row[:waiting_id].to_s
197
+ [row[:id], pending] if pending && pending.id.to_s == row[:waiting_id].to_s
198
198
  rescue ArgumentError
199
199
  nil
200
- end.sort_by { |_, pending| pending[:created_at].to_s }
200
+ end.sort_by { |_, pending| pending.created_at.to_s }
201
201
  end
202
202
 
203
203
  private
@@ -91,7 +91,7 @@ module Samagotchi
91
91
  # asked), the relayed question no longer reported: a turn that
92
92
  # ends with no reply during the relay's POST or after it still
93
93
  # ends the wait.
94
- baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
94
+ baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question.id)
95
95
  end
96
96
  rescue ArgumentError => e
97
97
  "Error: #{e.message}"
@@ -3,7 +3,6 @@
3
3
  require "net/http"
4
4
  require "uri"
5
5
  require "ipaddr"
6
- require "nokogiri"
7
6
  require_relative "../version"
8
7
 
9
8
  module Samagotchi
@@ -128,6 +127,9 @@ module Samagotchi
128
127
  def self.extract_text_from_html(html)
129
128
  return "Error: empty HTML response" if html.nil? || html.empty?
130
129
 
130
+ # Load lazily so workers don't pay for Nokogiri on every start.
131
+ require "nokogiri"
132
+
131
133
  doc = Nokogiri::HTML(html)
132
134
 
133
135
  # Extract noscript text before removing it
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.76.0"
4
+ VERSION = "0.77.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -43,7 +43,7 @@ require_relative "../recap_store"
43
43
  require_relative "markdown_renderer"
44
44
  require_relative "capabilities"
45
45
  require_relative "editor"
46
- require_relative "message_parts"
46
+ require_relative "../message_parts"
47
47
  require_relative "session_summary"
48
48
  require_relative "../log"
49
49
  require_relative "../paths"
@@ -855,7 +855,7 @@ module Samagotchi
855
855
  current_turn = turn_snapshot["current_turn"]
856
856
  current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
857
857
  elsif owner&.worker?
858
- session.pending_question
858
+ session.pending_question&.to_h
859
859
  end
860
860
  end
861
861
 
@@ -45,6 +45,7 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
45
45
  import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
46
46
  import { createCardsPlace } from "./cards_place.js";
47
47
  import { createStageView, FOLD_MS } from "./stage_view.js";
48
+ import { createMarkdownPlace } from "./markdown_place.js";
48
49
  import { reducedMotion, STAGE_GAP } from "./stage_frame.js";
49
50
  import { chainNeighbours, continueRefusal, continuedText, freshLink, openChildrenTemplate, openerText, openTitle } from "./chains.js";
50
51
  import { createTaskStopper } from "./task_stop.js";
@@ -86,6 +87,7 @@ import { liveSlots } from "./stage_model.js";
86
87
  import { currentGen } from "./turn_model.js";
87
88
  import { toolName } from "./activity.js";
88
89
  import { toastBottom } from "./toast_place.js";
90
+ import { createToasts } from "./toasts.js";
89
91
 
90
92
  const $ = (s) => document.querySelector(s);
91
93
  const topStripEl = $("#topStrip");
@@ -231,26 +233,25 @@ const viewArgs = {
231
233
  // the route (its sidecar features).
232
234
  taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
233
235
  };
236
+ const markdownPlace = createMarkdownPlace({
237
+ capabilities: () => pageCapabilities,
238
+ renderText,
239
+ roots: () => turnRoots(),
240
+ touchedFiles,
241
+ currentDir: () => current.dir,
242
+ linkRefs,
243
+ applyFinalAnswer,
244
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
245
+ normalize,
246
+ pairAnswerRefs,
247
+ });
248
+ const { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
234
249
  const turnDom = view === "stage"
235
250
  ? createStageView({
236
251
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
237
252
  renderMarkdown, linkRefs: linkRefsIn,
238
253
  })
239
254
  : createTurnView(viewArgs);
240
- // A text the page already shows as its markdown HTML (POST /api/render,
241
- // sanitised there), or null: markdown off here (no request), or a failed
242
- // read (the raw text stays).
243
- function renderMarkdown(text) {
244
- if (pageCapabilities.display !== true) return Promise.resolve(null);
245
- return renderText(text)
246
- .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
247
- .catch(() => null);
248
- }
249
- // The `path:line` refs in one rendered text (the stage's asking slot, a
250
- // kept reply), as linkTextRefs links the answers'.
251
- function linkRefsIn(el) {
252
- linkRefs(el, touchedFiles(turnRoots()), current.dir);
253
- }
254
255
  // Where a running turn's rows can be: the history, and the stage while it
255
256
  // holds a turn (lookups by id, copy, annotate, card actions).
256
257
  function turnRoots() {
@@ -978,32 +979,6 @@ async function readTurnEnd() {
978
979
  } catch(_){}
979
980
  }
980
981
 
981
- // The turn's answer as its rendered markdown and with its plugin refs,
982
- // once it is final (the turn-end read, an answer_display re-read:
983
- // answer_refs.js applyFinalAnswer); then the refs.
984
- function applyFinalMarkdown(messages) {
985
- applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
986
- linkTextRefs();
987
- }
988
-
989
- // The refs on the answers' and closed steps' texts, on this page: first
990
- // each answer's own (a plugin's, answer_refs.js), then the `path:line`
991
- // refs that name a file the session's rows (and cards) touched
992
- // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
993
- // turn's end and after a reload's render. A ref already made stays as it
994
- // is.
995
- function linkTextRefs() {
996
- const roots = turnRoots();
997
- const touched = touchedFiles(roots);
998
- for (const root of roots) {
999
- for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
1000
- }
1001
- if (!touched.size) return;
1002
- for (const root of roots) {
1003
- for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, current.dir);
1004
- }
1005
- }
1006
-
1007
982
  function seedSeenFromDom() {
1008
983
  shownTexts.seed(findInTurnRoots(".bubble").map((b) => normalize(b.textContent)));
1009
984
  }
@@ -2053,24 +2028,11 @@ function placeToast() {
2053
2028
  else root.removeProperty("--toast-bottom");
2054
2029
  }
2055
2030
  }
2031
+ const toasts = createToasts({ el: $("#toast"), onChange: placeToast });
2056
2032
  // The model panel opening, closing or changing height (its list filters).
2057
2033
  new ResizeObserver(() => placeToast()).observe(document.getElementById("modelPanel"));
2058
2034
 
2059
- // sticky: stays until its action or its ✕ (else gone after 6 s). +text+:
2060
- // words, or a template (chains.js openChildrenTemplate: a link in it, a
2061
- // delegate a continue waits for, opens that session and hides the toast).
2062
- let toastTimer = null;
2063
- function showToast(text, action = null, { sticky = false } = {}) {
2064
- const el = $("#toast");
2065
- const hide = () => { el.hidden = true; };
2066
- render(html`<span @click=${(e) => { if (e.target.closest?.("a[href]")) hide(); }}>${text}</span>${action
2067
- ? html`<button type="button" class="toast-action" @click=${() => { hide(); action.run(); }}>${action.label}</button>`
2068
- : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${hide}>✕</button>` : nothing}`, el);
2069
- el.hidden = false;
2070
- placeToast();
2071
- clearTimeout(toastTimer);
2072
- if (!sticky) toastTimer = setTimeout(hide, 6000);
2073
- }
2035
+ function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
2074
2036
 
2075
2037
  // Annotate (annotate_bar.js): a selection in the conversation offers
2076
2038
  // "Annotate" and the presets, which append a quote to the composer, and the
@@ -208,6 +208,17 @@ export function modelSegmentPrefillText(row) {
208
208
  return `switch prefill ${countK(tokens)} uncached prompt${cost ? ` ≈${cost}` : ""}`;
209
209
  }
210
210
 
211
+ // A ref past +max+ characters with its middle cut to one "…":
212
+ // the tooltip is a native title, which grows as wide as its longest line.
213
+ // The host's start and the model's end stay; the model chip has the full ref.
214
+ const TIP_REF_MAX = 40;
215
+ export function shortRef(text, max = TIP_REF_MAX) {
216
+ const chars = [...text];
217
+ if (chars.length <= max) return text;
218
+ const head = Math.floor((max - 1) / 3);
219
+ return `${chars.slice(0, head).join("")}…${chars.slice(chars.length - (max - 1 - head)).join("")}`;
220
+ }
221
+
211
222
  // The tooltip's rows per model, with ≥ 2 (one model: none): "model ·
212
223
  // turns · in / out · cost", a switch's prefill under its row.
213
224
  function modelSegmentLines(segments) {
@@ -215,7 +226,7 @@ function modelSegmentLines(segments) {
215
226
  const lines = ["per model:"];
216
227
  for (const row of segments) {
217
228
  const turns = num(row.turns) || 0;
218
- const parts = [modelSegmentLabel(row), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
229
+ const parts = [shortRef(modelSegmentLabel(row)), `${turns} ${turns === 1 ? "turn" : "turns"}`, modelSegmentTokensText(row)];
219
230
  const cost = modelSegmentCostText(row);
220
231
  if (cost) parts.push(cost);
221
232
  lines.push(` ${parts.join(" · ")}`);
@@ -1121,8 +1121,9 @@ body.all-view #center, body.all-view #topStrip{display:none}
1121
1121
  .card.archived:hover{opacity:.85}
1122
1122
  .card .archived-badge,.family-row .archived-badge{font-size:10px;padding:1px 6px;border-radius:6px;border:1px solid var(--border-strong);color:var(--text-muted)}
1123
1123
  /* bottom: above the session's composer card when one shows (--toast-bottom, app.js placeToast). */
1124
- #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;gap:12px;align-items:center;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1124
+ #toast{position:fixed;left:50%;bottom:var(--toast-bottom,24px);transform:translateX(-50%);width:max-content;max-width:calc(100vw - 32px);box-sizing:border-box;z-index:50;display:flex;flex-direction:column;align-items:center;gap:8px}
1125
1125
  #toast[hidden]{display:none}
1126
+ #toast .toast-item{display:flex;gap:12px;align-items:center;width:max-content;max-width:100%;box-sizing:border-box;padding:8px 14px;border-radius:10px;background:var(--bg-raised);color:var(--text);border:1px solid var(--border-strong);box-shadow:var(--toast-shadow);font-size:13px}
1126
1127
  #toast .toast-action{background:transparent;border:0;color:var(--accent);font-weight:600;cursor:pointer;padding:2px 4px}
1127
1128
  #toast .toast-close{background:transparent;border:0;color:var(--text-muted);cursor:pointer;padding:2px 4px;margin-left:-6px}
1128
1129
  #toast .toast-close:hover{color:var(--text)}
@@ -0,0 +1,53 @@
1
+ // Where the page's rendered markdown and its refs go (moved out of app.js):
2
+ // a text as its markdown HTML, the `path:line` refs in one rendered text,
3
+ // a final answer's rendering and refs, and the refs across the page. The
4
+ // page's state comes in as getters (capabilities, roots, currentDir), read
5
+ // at each call.
6
+ export function createMarkdownPlace({
7
+ capabilities, renderText, roots, touchedFiles, currentDir, linkRefs, applyFinalAnswer, findInTurnRoots, normalize,
8
+ pairAnswerRefs,
9
+ }) {
10
+ // A text the page already shows as its markdown HTML (POST /api/render,
11
+ // sanitised there), or null: markdown off here (no request), or a failed
12
+ // read (the raw text stays).
13
+ function renderMarkdown(text) {
14
+ if (capabilities().display !== true) return Promise.resolve(null);
15
+ return renderText(text)
16
+ .then((d) => (typeof d?.html === "string" && d.html ? d.html : null))
17
+ .catch(() => null);
18
+ }
19
+
20
+ // The `path:line` refs in one rendered text (the stage's asking slot, a
21
+ // kept reply), as linkTextRefs links the answers'.
22
+ function linkRefsIn(el) {
23
+ linkRefs(el, touchedFiles(roots()), currentDir());
24
+ }
25
+
26
+ // The turn's answer as its rendered markdown and with its plugin refs,
27
+ // once it is final (the turn-end read, an answer_display re-read:
28
+ // answer_refs.js applyFinalAnswer); then the refs.
29
+ function applyFinalMarkdown(messages) {
30
+ applyFinalAnswer(messages, findInTurnRoots(".bubble.output"), normalize);
31
+ linkTextRefs();
32
+ }
33
+
34
+ // The refs on the answers' and closed steps' texts, on this page: first
35
+ // each answer's own (a plugin's, answer_refs.js), then the `path:line`
36
+ // refs that name a file the session's rows (and cards) touched
37
+ // (ref_scan.js), which leave a plugin's alone. Not mid-stream: at a
38
+ // turn's end and after a reload's render. A ref already made stays as it
39
+ // is.
40
+ function linkTextRefs() {
41
+ const pageRoots = roots();
42
+ const touched = touchedFiles(pageRoots);
43
+ for (const root of pageRoots) {
44
+ for (const el of root.querySelectorAll(".bubble.output")) pairAnswerRefs(el);
45
+ }
46
+ if (!touched.size) return;
47
+ for (const root of pageRoots) {
48
+ for (const el of root.querySelectorAll(".bubble.output, .gen:not(.live) > .gen-text")) linkRefs(el, touched, currentDir());
49
+ }
50
+ }
51
+
52
+ return { renderMarkdown, linkRefsIn, applyFinalMarkdown, linkTextRefs };
53
+ }
@@ -75,12 +75,16 @@ export function createStreamHandlers(deps) {
75
75
  generation_started: (data) => {
76
76
  // The request after a /model's unresolved window started: whatever it
77
77
  // brings, the ctx tooltip no longer waits for it (ctxBarTip).
78
- state().ctxUnknownAfterSwitch = false;
78
+ const current = state();
79
+ let contextChanged = current.ctxUnknownAfterSwitch !== false;
80
+ current.ctxUnknownAfterSwitch = false;
79
81
  if (data?.context_window_tokens) {
80
- const current = state();
82
+ const source = data.context_window_source || null;
83
+ contextChanged ||= current.ctxWindow !== data.context_window_tokens || current.ctxWindowSource !== source;
81
84
  current.ctxWindow = data.context_window_tokens;
82
- current.ctxWindowSource = data.context_window_source || null;
85
+ current.ctxWindowSource = source;
83
86
  }
87
+ if (contextChanged) updateInfoBar();
84
88
  liveTurn.timing.setNote("");
85
89
  // The step's clock (the composer's delivery line): the page's own at a
86
90
  // live start; a replay's comes from the snapshot's generation age,
@@ -0,0 +1,69 @@
1
+ import { html, nothing, render } from "./vendor/lit-html.js";
2
+
3
+ // The page's toasts (app.js showToast): a stack in #toast, newest last, at
4
+ // most MAX_TOASTS. Each item has its own 6 s timer, or none when sticky: a
5
+ // sticky one stays until its action, its ✕ or a link in it is clicked, so a
6
+ // later notice can't hide an error. +text+: words, or a template
7
+ // (chains.js openChildrenTemplate: a link in it opens that session and hides
8
+ // the toast). The same text again while it shows reuses its item, moved to
9
+ // the end with a fresh timer. When a 4th comes, the oldest non-sticky goes
10
+ // (the oldest of all if every one is sticky).
11
+ const TOAST_MS = 6000;
12
+ const MAX_TOASTS = 3;
13
+
14
+ export function createToasts({ el, onChange = () => {}, doc = globalThis.document }) {
15
+ const entries = [];
16
+
17
+ function sync() {
18
+ el.hidden = entries.length === 0;
19
+ onChange();
20
+ }
21
+
22
+ function remove(entry, notify = true) {
23
+ const index = entries.indexOf(entry);
24
+ if (index < 0) return;
25
+ if (entry.timer !== null) clearTimeout(entry.timer);
26
+ entry.timer = null;
27
+ entries.splice(index, 1);
28
+ entry.el.remove();
29
+ if (notify) sync();
30
+ }
31
+
32
+ function draw(entry, text, action, sticky) {
33
+ render(html`<span class="toast-message" @click=${(e) => { if (e.target.closest?.("a[href]")) remove(entry); }}>${text}</span>${action
34
+ ? html`<button type="button" class="toast-action" @click=${() => { remove(entry); action.run(); }}>${action.label}</button>`
35
+ : nothing}${sticky ? html`<button type="button" class="toast-close" title="Dismiss" aria-label="Dismiss" @click=${() => remove(entry)}>✕</button>` : nothing}`,
36
+ entry.el);
37
+ }
38
+
39
+ function show(text, action = null, { sticky = false } = {}) {
40
+ const candidate = { el: doc.createElement("div"), timer: null, sticky, key: null };
41
+ candidate.el.className = "toast-item";
42
+ draw(candidate, text, action, sticky);
43
+ candidate.key = candidate.el.querySelector(".toast-message").textContent;
44
+
45
+ const duplicate = entries.find((entry) => entry.key === candidate.key);
46
+ if (duplicate) {
47
+ if (duplicate.timer !== null) clearTimeout(duplicate.timer);
48
+ duplicate.timer = null;
49
+ duplicate.sticky = sticky;
50
+ duplicate.key = candidate.key;
51
+ draw(duplicate, text, action, sticky);
52
+ entries.push(...entries.splice(entries.indexOf(duplicate), 1));
53
+ el.appendChild(duplicate.el);
54
+ if (!sticky) duplicate.timer = setTimeout(() => remove(duplicate), TOAST_MS);
55
+ sync();
56
+ return;
57
+ }
58
+
59
+ if (entries.length === MAX_TOASTS) {
60
+ remove(entries.find((entry) => !entry.sticky) || entries[0], false);
61
+ }
62
+ entries.push(candidate);
63
+ el.appendChild(candidate.el);
64
+ if (!sticky) candidate.timer = setTimeout(() => remove(candidate), TOAST_MS);
65
+ sync();
66
+ }
67
+
68
+ return { show };
69
+ }
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.76.0
4
+ version: 0.77.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -393,6 +393,7 @@ files:
393
393
  - lib/samagotchi/memory_bundle/trash.rb
394
394
  - lib/samagotchi/memory_bundle/uninstaller.rb
395
395
  - lib/samagotchi/memory_paths.rb
396
+ - lib/samagotchi/message_parts.rb
396
397
  - lib/samagotchi/model_catalog.rb
397
398
  - lib/samagotchi/model_list_store.rb
398
399
  - lib/samagotchi/model_match.rb
@@ -412,6 +413,7 @@ files:
412
413
  - lib/samagotchi/paths.rb
413
414
  - lib/samagotchi/peer_sources.rb
414
415
  - lib/samagotchi/pending_input_queue.rb
416
+ - lib/samagotchi/pending_question.rb
415
417
  - lib/samagotchi/plugin/api.rb
416
418
  - lib/samagotchi/plugin/attached_context.rb
417
419
  - lib/samagotchi/plugin/context.rb
@@ -485,6 +487,7 @@ files:
485
487
  - lib/samagotchi/terminal_ui/input_support.rb
486
488
  - lib/samagotchi/terminal_ui/line_reader.rb
487
489
  - lib/samagotchi/terminal_ui/live_region.rb
490
+ - lib/samagotchi/terminal_ui/model_segments_text.rb
488
491
  - lib/samagotchi/terminal_ui/one_shot_sink.rb
489
492
  - lib/samagotchi/terminal_ui/plain_surface.rb
490
493
  - lib/samagotchi/terminal_ui/question_prompt.rb
@@ -573,7 +576,6 @@ files:
573
576
  - lib/samagotchi/web/info_probe.rb
574
577
  - lib/samagotchi/web/lan.rb
575
578
  - lib/samagotchi/web/markdown_renderer.rb
576
- - lib/samagotchi/web/message_parts.rb
577
579
  - lib/samagotchi/web/public/activity.js
578
580
  - lib/samagotchi/web/public/annotate_actions.js
579
581
  - lib/samagotchi/web/public/annotate_bar.js
@@ -615,6 +617,7 @@ files:
615
617
  - lib/samagotchi/web/public/live_timing.js
616
618
  - lib/samagotchi/web/public/live_turn.js
617
619
  - lib/samagotchi/web/public/llm_context_chip.js
620
+ - lib/samagotchi/web/public/markdown_place.js
618
621
  - lib/samagotchi/web/public/model_pick.js
619
622
  - lib/samagotchi/web/public/model_picker.js
620
623
  - lib/samagotchi/web/public/notice_links.js
@@ -651,6 +654,7 @@ files:
651
654
  - lib/samagotchi/web/public/thinking_ticker.js
652
655
  - lib/samagotchi/web/public/timing.js
653
656
  - lib/samagotchi/web/public/toast_place.js
657
+ - lib/samagotchi/web/public/toasts.js
654
658
  - lib/samagotchi/web/public/toolkit_chip.js
655
659
  - lib/samagotchi/web/public/turn_events.js
656
660
  - lib/samagotchi/web/public/turn_html.js