samagotchi 0.61.0 → 0.62.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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -1
  3. data/docs/architecture.md +3 -0
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +8 -0
  9. data/docs/internals/engine.md +24 -11
  10. data/docs/internals/prompt-caching.md +6 -1
  11. data/docs/plugins.md +42 -3
  12. data/docs/sessions.md +4 -4
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  18. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  19. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  20. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/engine.rb +9 -0
  23. data/lib/samagotchi/engine_peers.rb +1 -0
  24. data/lib/samagotchi/kernel_loop.rb +7 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +23 -15
  26. data/lib/samagotchi/plugin/api.rb +36 -18
  27. data/lib/samagotchi/reasoning_history.rb +22 -4
  28. data/lib/samagotchi/row_refs.rb +126 -0
  29. data/lib/samagotchi/session_metrics.rb +24 -2
  30. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  31. data/lib/samagotchi/terminal_ui.rb +26 -3
  32. data/lib/samagotchi/tool_activity.rb +8 -4
  33. data/lib/samagotchi/tool_response.rb +5 -4
  34. data/lib/samagotchi/tool_row_fields.rb +4 -2
  35. data/lib/samagotchi/tool_runner.rb +16 -3
  36. data/lib/samagotchi/tools/builtins.rb +9 -2
  37. data/lib/samagotchi/tools/execute.rb +15 -3
  38. data/lib/samagotchi/tools/peers.rb +3 -2
  39. data/lib/samagotchi/tools/registry.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +14 -1
  42. data/lib/samagotchi/web/message_parts.rb +17 -9
  43. data/lib/samagotchi/web/public/activity.js +4 -1
  44. data/lib/samagotchi/web/public/app.js +59 -15
  45. data/lib/samagotchi/web/public/chains.js +18 -20
  46. data/lib/samagotchi/web/public/context_chips.js +92 -64
  47. data/lib/samagotchi/web/public/families_view.js +51 -39
  48. data/lib/samagotchi/web/public/index.html +61 -10
  49. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  50. data/lib/samagotchi/web/public/lists_view.js +13 -13
  51. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  52. data/lib/samagotchi/web/public/question_card.js +11 -0
  53. data/lib/samagotchi/web/public/question_cards.js +60 -5
  54. data/lib/samagotchi/web/public/ref_menu.js +18 -5
  55. data/lib/samagotchi/web/public/refs.js +113 -15
  56. data/lib/samagotchi/web/public/scroll.js +42 -0
  57. data/lib/samagotchi/web/public/stage_model.js +22 -3
  58. data/lib/samagotchi/web/public/stage_view.js +299 -70
  59. data/lib/samagotchi/web/public/timing.js +1 -1
  60. data/lib/samagotchi/web/public/turn_html.js +5 -4
  61. data/lib/samagotchi/web/public/turn_model.js +49 -0
  62. data/lib/samagotchi/web/public/turn_view.js +104 -10
  63. data/lib/samagotchi/worker.rb +3 -2
  64. data/lib/samagotchi/worker_inbound.rb +5 -3
  65. metadata +3 -1
@@ -1696,7 +1696,8 @@ module Samagotchi
1696
1696
  source = if live
1697
1697
  persisted.merge(live_metrics).merge(
1698
1698
  "started_at" => persisted["started_at"] || live_metrics["started_at"],
1699
- "turn_records" => merge_timing_records(persisted["turn_records"], live_metrics["turn_records"]),
1699
+ "turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
1700
+ live_metrics["turn_records"]),
1700
1701
  "tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
1701
1702
  )
1702
1703
  else
@@ -1777,6 +1778,18 @@ module Samagotchi
1777
1778
  AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
1778
1779
  end
1779
1780
 
1781
+ # The saved turn records without the one the live worker is running:
1782
+ # its turn checkpoint saves that turn's record so far as a killed
1783
+ # turn's (canceled, worker_stopped, SessionMetrics#persist's
1784
+ # running_turn), which is only true once the worker is gone.
1785
+ def without_running_turn(records, live_metrics)
1786
+ active = live_metrics["active_turn"]
1787
+ id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
1788
+ return records if id.nil?
1789
+
1790
+ Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
1791
+ end
1792
+
1780
1793
  def merge_timing_records(persisted, live)
1781
1794
  (Array(persisted) + Array(live)).each_with_object({}) do |record, records|
1782
1795
  next unless record.is_a?(Hash)
@@ -9,6 +9,7 @@ require_relative "../tools/builtins"
9
9
  require_relative "../image_store"
10
10
  require_relative "../llm_context_edit"
11
11
  require_relative "../token_usage"
12
+ require_relative "../row_refs"
12
13
 
13
14
  module Samagotchi
14
15
  module Web
@@ -37,7 +38,8 @@ module Samagotchi
37
38
  # A plugin tool's params are what its live row showed (its preview),
38
39
  # saved with the result as tool_params (native: one per call, chat: one
39
40
  # per result); its label ("chrome: screenshot") likewise as tool_labels,
40
- # the part's label:. The web server has no Engine and runs no plugins, so a
41
+ # the part's label:, and its row refs as tool_refs (RowRefs, checked
42
+ # again here), the part's refs:. The web server has no Engine and runs no plugins, so a
41
43
  # result without them (older sessions) falls back to the given
42
44
  # registry: the built-ins', and a plugin tool's arguments as key="value"
43
45
  # from the parsers' args:, as the live row shows them for a tool
@@ -106,11 +108,12 @@ module Samagotchi
106
108
  shown = saved_list(responses, :tool_params, calls.length)
107
109
  labels = saved_list(responses, :tool_labels, calls.length)
108
110
  diffs = saved_list(responses, :tool_diffs, calls.length)
111
+ refs = saved_list(responses, :tool_refs, calls.length)
109
112
  ids = saved_list(responses, :tool_ids, calls.length)
110
113
  images = split_images(responses, calls.length)
111
114
  calls.each_with_index.map do |call, i|
112
- tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], cwd: cwd,
113
- tool_id: ids[i], marks: marks)
115
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i], diff: diffs[i], refs: refs[i],
116
+ cwd: cwd, tool_id: ids[i], marks: marks)
114
117
  end
115
118
  end
116
119
 
@@ -144,7 +147,8 @@ module Samagotchi
144
147
  response = by_id[field(raw, :id).to_s] || (responses[i] && !unique.call(ids[i]) ? responses[i] : nil)
145
148
  tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
146
149
  response && field(response, :images), label: field(response, :tool_labels),
147
- diff: field(response, :tool_diffs), cwd: cwd,
150
+ diff: field(response, :tool_diffs),
151
+ refs: field(response, :tool_refs), cwd: cwd,
148
152
  tool_id: Array(field(response, :tool_ids)).first, marks: marks)
149
153
  end
150
154
  end
@@ -224,10 +228,12 @@ module Samagotchi
224
228
 
225
229
  # +shown+ is the saved params line, when it is a String.
226
230
  # +diff+ is what an edit/write changed (EditPreview.change), saved as
227
- # tool_diffs. +cwd+: the session's working directory, for the title.
231
+ # tool_diffs. +refs+: a plugin tool's row refs, saved as tool_refs
232
+ # (RowRefs.for_display checks them again against +cwd+). +cwd+: the
233
+ # session's working directory, for the title.
228
234
  # +tool_id+: its output's ToolIds id, with its ✂ mark in +marks+.
229
- def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, cwd: nil, tool_id: nil,
230
- marks: {})
235
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil, diff: nil, refs: nil, cwd: nil,
236
+ tool_id: nil, marks: {})
231
237
  name = call[:name].to_s
232
238
  params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
233
239
  part = { tool: name, params: params.to_s }
@@ -240,9 +246,11 @@ module Samagotchi
240
246
  part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
241
247
  part[:output_truncated] = true if output.length > OUTPUT_MAX
242
248
  end
243
- refs = Array(images).select { |ref| ref.is_a?(Hash) }
244
- part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
249
+ shots = Array(images).select { |image| image.is_a?(Hash) }
250
+ part[:images] = shots.map { |image| ImageStore.symbolize(image).slice(:file, :name, :width, :height) } unless shots.empty?
245
251
  part[:diff] = diff if diff.is_a?(Hash)
252
+ row_refs = RowRefs.for_display(refs, cwd: cwd)
253
+ part[:refs] = row_refs unless row_refs.empty?
246
254
  if tool_id
247
255
  part[:tool_id] = tool_id.to_s
248
256
  mark = mark_of(tool_id.to_s, marks, output)
@@ -45,8 +45,11 @@ export function newActivity() {
45
45
  // ref: what the row traces to (the server's FileRef: {kind: "file",
46
46
  // path, line?, end_line?}, the file a file tool touched), which
47
47
  // refs.js makes the row's title and diff lines open.
48
+ // refs: a plugin tool's refs (the server's RowRefs: [{kind, target,
49
+ // label?, actions}]): the first on the row's title, the others in its
50
+ // menu (refs.js bindRowRefs).
48
51
  export const ACTIVITY_FIELDS = Object.freeze(["title", "called_as"]);
49
- export const EVENT_FIELDS = Object.freeze(["view", "ref"]);
52
+ export const EVENT_FIELDS = Object.freeze(["view", "ref", "refs"]);
50
53
  const ROW_FIELDS = Object.freeze([...ACTIVITY_FIELDS, ...EVENT_FIELDS]);
51
54
 
52
55
  // The +fields+ of +source+ that are set, as an object (for a spread).
@@ -38,9 +38,9 @@ import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
38
  import { createModelPicker } from "./model_picker.js";
39
39
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
40
40
  import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
- import { createTurnView, turnHistoryNodes } from "./turn_view.js";
41
+ import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
42
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
43
- import { createStageView } from "./stage_view.js";
43
+ import { createStageView, FOLD_MS } from "./stage_view.js";
44
44
  import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
45
45
  import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
@@ -55,7 +55,7 @@ import { createCommandList } from "./command_list.js";
55
55
  import { createPromptHistory } from "./prompt_history.js";
56
56
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
57
57
  import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
58
- import { shouldFollowScroll, keepFollowing } from "./scroll.js";
58
+ import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
59
59
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
60
60
  import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
61
61
  import { createNotifications } from "./notifications.js";
@@ -264,6 +264,7 @@ const questionCards = createQuestionCards({
264
264
  isNearBottom: () => isNearBottom(),
265
265
  scrollToEnd: () => { historyEl.scrollTop = historyEl.scrollHeight; },
266
266
  removeHintIfEmpty: () => removeHintIfEmpty(),
267
+ askingText: () => turnDom.askingText(),
267
268
  });
268
269
  // The event id (or, from an older worker, the event_seq) the selected session
269
270
  // was rendered at (null: no live worker then), where a stream opened later
@@ -1101,6 +1102,7 @@ function showRecap(text, turnsSince = 0) {
1101
1102
  details.appendChild(body);
1102
1103
  // A stale one sits where it was written, above the turns it doesn't cover.
1103
1104
  // Never in the stage: a recap is between turns.
1105
+ turnDom.settleFold?.();
1104
1106
  const before = recapPlace(turnPromptBubbles(), turnsSince);
1105
1107
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1106
1108
  }
@@ -1299,12 +1301,15 @@ function turnAnswerEl(turnEls) {
1299
1301
  function placeResolvedQuestion(entry, before) {
1300
1302
  const pq = entry.pending_question;
1301
1303
  if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1302
- questionCards.renderQuestion(pq);
1304
+ const turnEls = reloadedTurnEls(before);
1305
+ const asked = Number(entry.iteration) || 1;
1306
+ // The reply that asked: its step's text in the reloaded block (the card
1307
+ // keeps it, as live), never the running turn's.
1308
+ const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
1309
+ questionCards.renderQuestion(pq, { said });
1303
1310
  const card = questionCards.questionCard();
1304
1311
  if (!card || card.dataset.qid !== String(pq.id)) return;
1305
1312
  questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1306
- const turnEls = reloadedTurnEls(before);
1307
- const asked = Number(entry.iteration) || 1;
1308
1313
  const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1309
1314
  historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1310
1315
  }
@@ -1509,6 +1514,7 @@ function currentSessionDuration() {
1509
1514
 
1510
1515
  // The stage takes a running turn's rows (its extras); else the history.
1511
1516
  function appendToHistory(el) {
1517
+ // (The stage's place first moves a turn still sliding away into the history.)
1512
1518
  if (turnDom.place?.(el)) return;
1513
1519
  appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1514
1520
  }
@@ -2039,15 +2045,51 @@ fitPrompt();
2039
2045
  // The card floats over the history: keep the history's bottom padding at the
2040
2046
  // card's height, and stay pinned to the end if the reader was there, since the
2041
2047
  // card grows (text, chips, Cancel, a wrapping footer) under a reader at the end.
2048
+ // In the stage view the stage floats above the card, out of the dock's flow
2049
+ // (stage_view.js): the reserve counts it too, with its 10 px gap, while it
2050
+ // shows.
2042
2051
  const dockEl = $("#dock");
2043
2052
  const centerEl = $("#center");
2044
- new ResizeObserver(() => {
2045
- // The stage's height too: a reader at the end stays there.
2046
- const follow = atHistoryEnd();
2047
- centerEl.style.setProperty("--dock-h", `${dockEl.offsetHeight}px`);
2048
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2053
+ // A leaving stage (the hand-off's slide) no longer counts: the reserve eases
2054
+ // down over the slide (index.html, #center.reserve-easing).
2055
+ const stageEl = view === "stage" ? turnDom.root : null;
2056
+ const STAGE_GAP = 10;
2057
+ function dockReserve() {
2058
+ const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
2059
+ return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
2060
+ }
2061
+ const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
2062
+ // A reader at the end stays there when the reserve changes: at once for the
2063
+ // composer card, eased for the stage (+fromStage+: it appeared, grew or
2064
+ // leaves), so the history glides rather than jumps. An eased follow running
2065
+ // counts as at the end.
2066
+ function updateReserve({ fromStage = false } = {}) {
2067
+ const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
2068
+ const leaving = !!stageEl?.classList.contains("leaving");
2069
+ centerEl.classList.toggle("reserve-easing", leaving);
2070
+ const reserve = `${dockReserve()}px`;
2071
+ const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
2072
+ if (changed) centerEl.style.setProperty("--dock-h", reserve);
2073
+ if (changed && follow) {
2074
+ if (fromStage) easeScrollTo(historyEl, historyEnd, { ms: leaving ? FOLD_MS : FOLLOW_MS, reduce: matchMedia("(prefers-reduced-motion: reduce)").matches });
2075
+ else if (historyEl.dataset.following === undefined) historyEl.scrollTop = historyEl.scrollHeight;
2076
+ }
2049
2077
  placeToast();
2050
- }).observe(dockEl);
2078
+ }
2079
+ const dockObserver = new ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
2080
+ dockObserver.observe(dockEl);
2081
+ if (stageEl) {
2082
+ dockObserver.observe(stageEl);
2083
+ // Only showing, hiding and leaving change the reserve (not the stage's
2084
+ // other classes: waiting, in-use…).
2085
+ let stageShown = "";
2086
+ new MutationObserver(() => {
2087
+ const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
2088
+ if (shown === stageShown) return;
2089
+ stageShown = shown;
2090
+ updateReserve({ fromStage: true });
2091
+ }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
2092
+ }
2051
2093
  window.addEventListener("resize", () => placeToast());
2052
2094
 
2053
2095
  promptEl.addEventListener("paste", (e) => {
@@ -2260,14 +2302,16 @@ async function setArchived(id, archive, { undo = true } = {}) {
2260
2302
 
2261
2303
  // The toast sits above the session's composer card (measured: it grows with
2262
2304
  // a wrapping footer or a taller box), so it never covers the card's
2263
- // controls; with no card in sight (the start page, all sessions) the CSS's
2264
- // 24 px from the bottom.
2305
+ // controls, and above the stage while it shows; with no card in sight (the
2306
+ // start page, all sessions) the CSS's 24 px from the bottom.
2265
2307
  function placeToast() {
2266
2308
  const gap = 12;
2267
2309
  const dock = document.getElementById("dock");
2268
2310
  const r = dock.getBoundingClientRect();
2269
2311
  const over = document.body.classList.contains("has-session") && r.height > 0;
2270
- const bottom = over ? `${Math.round(window.innerHeight - r.top + gap)}px` : "";
2312
+ const stage = document.getElementById("turnStage");
2313
+ const top = stage && !stage.hidden ? Math.min(r.top, stage.getBoundingClientRect().top) : r.top;
2314
+ const bottom = over ? `${Math.round(window.innerHeight - top + gap)}px` : "";
2271
2315
  const root = document.documentElement.style;
2272
2316
  if (root.getPropertyValue("--toast-bottom") !== bottom) {
2273
2317
  if (bottom) root.setProperty("--toast-bottom", bottom);
@@ -4,10 +4,13 @@
4
4
  // through `continues`, forward through the session whose `continues` names
5
5
  // this one. The lists fold a chain into its latest listed link's card (a
6
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.
7
+ // previous and the next link. Pure: lists_view.js places the chip and rows
8
+ // (lit-html templates) and app.js the toast's markup, and they bind them.
8
9
  import { escapeHtml, normalize, previewForCard, relativeTime } from "./format.js";
9
10
  import { stoppedBadge, waitingBadge } from "./sessions_list.js";
10
11
  import { waitingOn } from "./notify.js";
12
+ import { archivedBadge, rowBadgesTemplate, rowDotTemplate } from "./families_view.js";
13
+ import { html, nothing } from "./vendor/lit-html.js";
11
14
 
12
15
  // The opener a new link's composer is filled with (never sent: the user
13
16
  // edits or sends it). The same for every chain.
@@ -148,10 +151,11 @@ export function chainOthers(f) {
148
151
  // The chip in a chain's card: "day 3" (the head's place; "day 2 of 3" when
149
152
  // later links aren't listed), with a live dot / "waiting" when one of the
150
153
  // 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.
154
+ // brings it back, but it stays folded here). nothing for a family in no
155
+ // chain. A lit-html template, as families_view.js's chip.
152
156
  // @param open whether the list is open (aria-expanded)
153
- export function chainChipHtml(f, { open = false } = {}) {
154
- if (!f?.chain) return "";
157
+ export function chainChipTemplate(f, { open = false } = {}) {
158
+ if (!f?.chain) return nothing;
155
159
  const { links, at } = f.chain;
156
160
  const others = chainOthers(f);
157
161
  const live = others.filter(({ session: s }) => s.owner === "worker").length;
@@ -162,9 +166,10 @@ export function chainChipHtml(f, { open = false } = {}) {
162
166
  const later = links.length - 1 - at;
163
167
  const parts = [earlier ? `${earlier} earlier ${earlier === 1 ? "link" : "links"}` : "", later ? `${later} later` : ""].filter(Boolean);
164
168
  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>`;
169
+ const liveT = live ? html`<span class="cc-live"> · <span class="live-dot" aria-hidden="true"></span>${live} live</span>` : nothing;
170
+ const waitingT = waiting ? html` · <span class="cc-waiting">${waiting} waiting</span>` : nothing;
171
+ return html`<button type="button" class=${`chain-chip${waiting ? " waiting" : ""}`} aria-expanded=${open ? "true" : "false"}
172
+ title=${title}><span class="cc-arrow" aria-hidden="true">↩</span>${text}${liveT}${waitingT}</button>`;
168
173
  }
169
174
 
170
175
  // The chain's list (the chip's popover): one row per other link, newest
@@ -172,7 +177,7 @@ export function chainChipHtml(f, { open = false } = {}) {
172
177
  // its preview), the waiting / stopped badge, archived. A row's click opens
173
178
  // that link (families_view.js's popover binds .family-row[data-id]).
174
179
  // @param activeId the open session's id (its row is .active)
175
- export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
180
+ export function chainRowsTemplate(f, { activeId = null, now = Date.now() } = {}) {
176
181
  const byId = new Map((f?.chain?.links || []).map((s) => [s.id, s]));
177
182
  const rows = chainOthers(f).map(({ session: s, day }) => {
178
183
  const cls = ["family-row", "chain-row"];
@@ -180,22 +185,15 @@ export function chainRowsHtml(f, { activeId = null, now = Date.now() } = {}) {
180
185
  if (s.archived) cls.push("archived");
181
186
  const waiting = waitingBadge(s, { inFamily: true });
182
187
  const stopped = waiting ? null : stoppedBadge(s);
183
- const dotWord = waiting ? waiting.title : s.status;
184
188
  const date = chainDate(s.created_at);
185
189
  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
190
  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>`;
191
+ return html`<div class=${cls.join(" ")} data-id=${s.id} style=${"--depth:1"} role=${"listitem"}><button type="button" class="fr-open"
192
+ title=${tip}>${rowDotTemplate(s, waiting)}<span class="cr-day">day ${day}</span>${
193
+ date ? html`<span class="cr-date">${date}</span>` : nothing}<span class="fr-preview">${s.recap || chainTitle(s, byId)}</span>${
194
+ rowBadgesTemplate(waiting, stopped)}${s.archived ? archivedBadge : nothing}</button></div>`;
197
195
  });
198
- return `<div class="family-rows chain-rows" role="list">${rows.join("")}</div>`;
196
+ return html`<div class="family-rows chain-rows" role="list">${rows}</div>`;
199
197
  }
200
198
 
201
199
  // What the page does with a refused continue (POST /api/sessions
@@ -7,6 +7,9 @@
7
7
  // provider can attach one: GET …/context's can_add_url) opens a form that
8
8
  // attaches a URL (a GitHub PR); the web never adds a command.
9
9
 
10
+ import { html, nothing, render } from "./vendor/lit-html.js";
11
+ import { keepFocus } from "./keep_focus.js";
12
+
10
13
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
11
14
  export function ageText(iso, now = Date.now()) {
12
15
  const at = Date.parse(iso || "");
@@ -38,33 +41,40 @@ export function chipModel(rows, now = Date.now()) {
38
41
  });
39
42
  }
40
43
 
41
- // addUrl: end with the "+ URL" chip.
42
- export function chipsHtml(chips, escapeHtml, { addUrl = false } = {}) {
43
- return chips.map((chip) => `<button type="button" class="ctx-chip ${chip.state}" data-name="${escapeHtml(chip.name)}" ` +
44
- `title="${escapeHtml(chip.title)}" aria-haspopup="dialog">` +
45
- `${chip.state === "unread" ? '<span class="ctx-dot" aria-hidden="true"></span>' : ""}${escapeHtml(chip.label)}</button>`).join("") +
46
- (addUrl ? '<button type="button" class="ctx-chip ctx-add" data-act="add" title="Attach a URL (a GitHub PR) the agent can read and hears about when it changes" aria-haspopup="dialog">+ URL</button>' : "");
44
+ const ADD_TITLE = "Attach a URL (a GitHub PR) the agent can read and hears about when it changes";
45
+
46
+ // The chips (a lit-html template; attributes after a bound one are bound
47
+ // too, so they keep their order). addUrl: end with the "+ URL" chip;
48
+ // open: the chip whose popover is open (a source's name), addOpen: the
49
+ // "+ URL" form is.
50
+ export function chipsTemplate(chips, { addUrl = false, open = null, addOpen = false } = {}) {
51
+ return html`${chips.map((chip) => html`<button type="button" class=${`ctx-chip ${chip.state}${chip.name === open ? " open" : ""}`}
52
+ data-name=${chip.name} title=${chip.title} aria-haspopup=${"dialog"}>${
53
+ chip.state === "unread" ? html`<span class="ctx-dot" aria-hidden="true"></span>` : nothing}${chip.label}</button>`)}${
54
+ addUrl ? html`<button type="button" class=${`ctx-chip ctx-add${addOpen ? " open" : ""}`} data-act=${"add"} title=${ADD_TITLE}
55
+ aria-haspopup=${"dialog"}>+ URL</button>` : nothing}`;
47
56
  }
48
57
 
49
- // The "+ URL" popover: the URL, an optional why, Attach; +url+ and +error+
50
- // after a try that failed.
51
- export function addFormHtml(escapeHtml, { url = "", error = null } = {}) {
52
- return `<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong>` +
53
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
54
- `<div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div>` +
55
- `<input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" value="${escapeHtml(url)}" required autocomplete="off">` +
56
- `<input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off">` +
57
- (error ? `<div class="ctx-pop-error" role="alert">${escapeHtml(error)}</div>` : "") +
58
- `<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit">Attach</button></div></form>`;
58
+ // The "+ URL" popover: the URL, an optional why, Attach; +error+ after a
59
+ // try that failed, +busy+ while one runs.
60
+ export function addFormTemplate({ error = null, busy = false } = {}) {
61
+ return html`<form class="ctx-add-form"><div class="ctx-pop-head"><strong>Attach a URL</strong
62
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
63
+ ><div class="ctx-pop-meta">A link an installed bundle knows (github-pr: a pull request). The agent gets a note when it changes.</div
64
+ ><input class="ctx-add-input" name="url" type="url" inputmode="url" placeholder="https://github.com/owner/repo/pull/123" required autocomplete="off"
65
+ ><input class="ctx-add-input" name="why" type="text" placeholder="why (optional)" maxlength="300" autocomplete="off"
66
+ >${error ? html`<div class="ctx-pop-error" role="alert">${error}</div>` : nothing
67
+ }<div class="ctx-pop-actions"><button type="submit" class="ctx-add-submit" ?disabled=${busy}>Attach</button></div></form>`;
59
68
  }
60
69
 
61
- // The popover's inside for one row (GET …/context's).
62
- export function popoverHtml(row, escapeHtml, now = Date.now()) {
70
+ // The popover's inside for one row (GET …/context's); +text+ the row's
71
+ // text once "view text" read it (null before).
72
+ export function popoverTemplate(row, { text = null, now = Date.now() } = {}) {
63
73
  const kind = row.kind === "cmd" ? `runs every ${row.every_seconds ? `${row.every_seconds}s` : "few minutes"}` : "pushed";
64
74
  const url = hintUrl(row.hint);
65
75
  const hint = url
66
- ? `<a class="ctx-pop-hint" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
67
- : (row.hint ? `<div class="ctx-pop-hint">${escapeHtml(row.hint)}</div>` : "");
76
+ ? html`<a class="ctx-pop-hint" href=${url} target=${"_blank"} rel=${"noopener noreferrer"}>${url}</a>`
77
+ : (row.hint ? html`<div class="ctx-pop-hint">${row.hint}</div>` : nothing);
68
78
  const age = ageText(row.fetched_at, now);
69
79
  const meta = [age ? (age === "now" ? "fetched just now" : `fetched ${age} ago`) : "no text yet",
70
80
  row.unread ? "the agent hasn't read this change" : (row.has_text ? "the agent has read it" : "")]
@@ -73,29 +83,32 @@ export function popoverHtml(row, escapeHtml, now = Date.now()) {
73
83
  const detachTitle = row.scope === "project"
74
84
  ? "The project's source: this session stops seeing it (chi context unmute brings it back)"
75
85
  : "Remove it from this session";
76
- return `<div class="ctx-pop-head"><strong>${escapeHtml(row.name)}</strong>` +
77
- `<span class="ctx-pop-kind">${escapeHtml(row.scope)} · ${escapeHtml(kind)}</span>` +
78
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
79
- (row.why ? `<div class="ctx-pop-why">${escapeHtml(row.why)}</div>` : "") +
80
- hint +
81
- (row.summary ? `<div class="ctx-pop-summary">${escapeHtml(row.summary)}</div>` : "") +
82
- `<div class="ctx-pop-meta">${escapeHtml(meta)}</div>` +
83
- (row.error ? `<div class="ctx-pop-error">last refresh failed: ${escapeHtml(row.error)}</div>` : "") +
84
- `<pre class="ctx-pop-text" hidden></pre>` +
85
- `<div class="ctx-pop-actions">` +
86
- (row.has_text ? `<button type="button" data-act="view">view text</button>` : "") +
87
- `<button type="button" class="ctx-pop-detach" data-act="detach" title="${escapeHtml(detachTitle)}">${detach}</button></div>`;
86
+ return html`<div class="ctx-pop-head"><strong>${row.name}</strong
87
+ ><span class="ctx-pop-kind">${row.scope} · ${kind}</span
88
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
89
+ >${row.why ? html`<div class="ctx-pop-why">${row.why}</div>` : nothing}${hint}${
90
+ row.summary ? html`<div class="ctx-pop-summary">${row.summary}</div>` : nothing
91
+ }<div class="ctx-pop-meta">${meta}</div
92
+ >${row.error ? html`<div class="ctx-pop-error">last refresh failed: ${row.error}</div>` : nothing
93
+ }<pre class="ctx-pop-text" ?hidden=${text === null}>${text ?? ""}</pre
94
+ ><div class="ctx-pop-actions">${row.has_text && text === null ? html`<button type="button" data-act="view">view text</button>` : nothing
95
+ }<button type="button" class="ctx-pop-detach" data-act="detach" title=${detachTitle}>${detach}</button></div>`;
88
96
  }
89
97
 
90
98
  // The chips for the session bar. el: #contextChips; api: {list(id),
91
- // show(id, name), detach(id, name), add(id, {url, why})}; escapeHtml;
92
- // toast(text).
93
- export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc = document, win = window }) {
99
+ // show(id, name), detach(id, name), add(id, {url, why})}; toast(text).
100
+ // Chips, popover and form are lit templates: a redraw (a refresh, the
101
+ // minute's tick) keeps their nodes, so focus and typed text stay.
102
+ export function createContextChips({ el, api, toast = () => {}, doc = document, win = window }) {
94
103
  let sessionId = null;
95
104
  let rows = [];
96
105
  let canAdd = false;
97
106
  let openName = null;
98
107
  let loadSeq = 0;
108
+ // The open row's text once read (view text), and the "+ URL" form's
109
+ // state ({error, busy}).
110
+ let text = null;
111
+ let addState = {};
99
112
  const pop = doc.createElement("div");
100
113
  pop.id = "contextPopover";
101
114
  pop.className = "ctx-popover";
@@ -106,17 +119,20 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
106
119
  // openName ADD: the "+ URL" form is open.
107
120
  const ADD = "\u0000add";
108
121
 
109
- function render() {
122
+ function drawChips() {
110
123
  const chips = chipModel(rows);
111
- el.innerHTML = chipsHtml(chips, escapeHtml, { addUrl: canAdd });
124
+ // A source inserted before the focused chip: the focus stays on its source.
125
+ keepFocus(el, "data-name", () => render(chipsTemplate(chips, { addUrl: canAdd, open: openName === ADD ? null : openName,
126
+ addOpen: openName === ADD }), el));
112
127
  el.hidden = chips.length === 0 && !canAdd;
113
128
  // The "+ URL" chip alone sits inline, not on a row of its own.
114
129
  el.classList.toggle("only-add", chips.length === 0 && canAdd);
115
- if (openName === ADD) {
116
- if (canAdd) el.querySelector(".ctx-add")?.classList.add("open");
117
- else close();
118
- } else if (openName && !chips.some((chip) => chip.name === openName)) close();
119
- else if (openName) el.querySelector(`.ctx-chip[data-name="${openName}"]`)?.classList.add("open");
130
+ }
131
+
132
+ // The chips, and the open popover closed when what it is for has gone.
133
+ function draw() {
134
+ if (openName === ADD ? !canAdd : openName && !chipModel(rows).some((chip) => chip.name === openName)) close();
135
+ else drawChips();
120
136
  }
121
137
 
122
138
  async function refresh() {
@@ -134,10 +150,11 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
134
150
  rows = [];
135
151
  canAdd = false;
136
152
  }
137
- render();
138
- if (openName && openName !== ADD) {
153
+ draw();
154
+ // An open popover follows its row, but not while it shows the text.
155
+ if (openName && openName !== ADD && text === null) {
139
156
  const row = rows.find((r) => r.name === openName);
140
- if (row && pop.querySelector(".ctx-pop-text")?.hidden !== false) fill(row);
157
+ if (row) fill(row);
141
158
  }
142
159
  }
143
160
 
@@ -146,7 +163,6 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
146
163
  rows = [];
147
164
  canAdd = false;
148
165
  close();
149
- render();
150
166
  return refresh();
151
167
  }
152
168
 
@@ -155,11 +171,10 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
155
171
  rows = [];
156
172
  canAdd = false;
157
173
  close();
158
- render();
159
174
  }
160
175
 
161
176
  function fill(row) {
162
- pop.innerHTML = popoverHtml(row, escapeHtml);
177
+ render(popoverTemplate(row, { text }), pop);
163
178
  }
164
179
 
165
180
  // Above the chip, inside the window with a 16px gutter.
@@ -174,19 +189,30 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
174
189
  function open(name, chip) {
175
190
  const row = rows.find((r) => r.name === name);
176
191
  if (!row) return;
192
+ // Another popover's nodes go, so this one starts fresh.
193
+ render(nothing, pop);
177
194
  openName = name;
195
+ text = null;
178
196
  fill(row);
179
197
  pop.hidden = false;
180
198
  place(chip);
181
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c.dataset.name === name));
199
+ drawChips();
182
200
  }
183
201
 
184
- function openAdd(chip, state = {}) {
202
+ const drawAdd = (changes = {}) => {
203
+ addState = { ...addState, ...changes };
204
+ render(addFormTemplate(addState), pop);
205
+ };
206
+
207
+ function openAdd(chip) {
208
+ render(nothing, pop);
185
209
  openName = ADD;
186
- pop.innerHTML = addFormHtml(escapeHtml, state);
210
+ text = null;
211
+ addState = {};
212
+ drawAdd();
187
213
  pop.hidden = false;
188
214
  place(chip);
189
- el.querySelectorAll(".ctx-chip").forEach((c) => c.classList.toggle("open", c === chip));
215
+ drawChips();
190
216
  pop.querySelector('input[name="url"]')?.focus();
191
217
  }
192
218
 
@@ -195,8 +221,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
195
221
  const url = form.elements.url.value.trim();
196
222
  const why = form.elements.why.value.trim();
197
223
  if (!id || !url) return;
198
- const button = form.querySelector(".ctx-add-submit");
199
- if (button) button.disabled = true;
224
+ drawAdd({ busy: true });
200
225
  try {
201
226
  const data = await api.add(id, { url, why: why || undefined });
202
227
  if (id !== sessionId) return;
@@ -205,32 +230,35 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
205
230
  toast(`Attached ${data.name}: the agent gets a note before your next message`);
206
231
  } catch (e) {
207
232
  if (id !== sessionId || openName !== ADD) return;
208
- const chip = el.querySelector(".ctx-add");
209
- if (chip) openAdd(chip, { url, error: String(e.message).replace(/ \(\d+\)$/, "") });
233
+ // The same form again: what the user typed stays; the URL gets focus.
234
+ drawAdd({ busy: false, error: String(e.message).replace(/ \(\d+\)$/, "") });
235
+ pop.querySelector('input[name="url"]')?.focus();
210
236
  }
211
237
  }
212
238
 
213
239
  function close() {
214
240
  openName = null;
241
+ text = null;
242
+ addState = {};
215
243
  pop.hidden = true;
216
- el.querySelectorAll(".ctx-chip.open").forEach((c) => c.classList.remove("open"));
244
+ render(nothing, pop);
245
+ drawChips();
217
246
  }
218
247
 
219
248
  async function viewText() {
220
249
  const name = openName;
221
- const pre = pop.querySelector(".ctx-pop-text");
222
- if (!name || !pre) return;
250
+ if (!name || name === ADD) return;
223
251
  try {
224
252
  const data = await api.show(sessionId, name);
225
253
  if (name !== openName) return;
226
- pre.textContent = data.text || "";
227
- pre.hidden = false;
228
- pop.querySelector('[data-act="view"]')?.remove();
254
+ const row = rows.find((r) => r.name === name);
255
+ if (!row) return;
256
+ text = data.text || "";
257
+ fill(row);
229
258
  } catch (e) {
230
259
  toast(`Can't read ${name}: ${String(e.message).replace(/ \(\d+\)$/, "")}`);
231
260
  }
232
261
  }
233
-
234
262
  async function detach() {
235
263
  const row = rows.find((r) => r.name === openName);
236
264
  if (!row) return;
@@ -279,7 +307,7 @@ export function createContextChips({ el, api, escapeHtml, toast = () => {}, doc
279
307
  });
280
308
  win.addEventListener("focus", () => refresh());
281
309
  // The ages move on by themselves.
282
- win.setInterval(() => { if (rows.length) render(); }, 60_000);
310
+ win.setInterval(() => { if (rows.length) drawChips(); }, 60_000);
283
311
 
284
312
  return { load, clear, refresh, close, get rows() { return rows; } };
285
313
  }