samagotchi 0.100.1 → 0.101.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -1
  3. data/bin/chi +1 -0
  4. data/docs/architecture.md +6 -0
  5. data/docs/cli.md +55 -0
  6. data/docs/configuration.md +1 -0
  7. data/docs/internals/engine.md +1 -0
  8. data/docs/internals/working-set.md +130 -0
  9. data/docs/testing.md +1 -1
  10. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  11. data/lib/samagotchi/config.rb +3 -0
  12. data/lib/samagotchi/engine.rb +30 -0
  13. data/lib/samagotchi/host_keys.rb +35 -18
  14. data/lib/samagotchi/testing/fake_executable.rb +6 -16
  15. data/lib/samagotchi/testing/fake_executable_launcher +6 -0
  16. data/lib/samagotchi/tool_response.rb +10 -6
  17. data/lib/samagotchi/tool_runner.rb +25 -6
  18. data/lib/samagotchi/version.rb +1 -1
  19. data/lib/samagotchi/web/app.rb +49 -1
  20. data/lib/samagotchi/web/public/app.js +33 -3
  21. data/lib/samagotchi/web/public/chip_popover.js +56 -17
  22. data/lib/samagotchi/web/public/data.js +6 -0
  23. data/lib/samagotchi/web/public/index.html +127 -1
  24. data/lib/samagotchi/web/public/info_bar.js +16 -4
  25. data/lib/samagotchi/web/public/info_bar_model.js +7 -3
  26. data/lib/samagotchi/web/public/refs.js +10 -1
  27. data/lib/samagotchi/web/public/stage_model.js +6 -5
  28. data/lib/samagotchi/web/public/stage_view.js +34 -2
  29. data/lib/samagotchi/web/public/stream_handlers.js +6 -1
  30. data/lib/samagotchi/web/public/turn_view.js +16 -4
  31. data/lib/samagotchi/web/public/working_set.js +520 -0
  32. data/lib/samagotchi/web/public/working_set_rail.js +278 -0
  33. data/lib/samagotchi/web/public/working_set_view.js +426 -0
  34. data/lib/samagotchi/web/server.rb +6 -4
  35. data/lib/samagotchi/working_set.rb +200 -0
  36. metadata +7 -1
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.100.1"
4
+ VERSION = "0.101.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -25,6 +25,7 @@ require_relative "../session_commands"
25
25
  require_relative "../steer"
26
26
  require_relative "../host_registry"
27
27
  require_relative "../llm_context_override"
28
+ require_relative "../working_set"
28
29
  require_relative "../model_catalog"
29
30
  require_relative "../model_profile"
30
31
  require_relative "../session_setting"
@@ -97,6 +98,8 @@ module Samagotchi
97
98
  # freshly-spawned worker's bridge before answering POST /api/sessions.
98
99
  # @param view ["stage", "turn"] the page's view of a turn (web.view);
99
100
  # ?view=stage|turn overrides it for one page load
101
+ # @param working_set [Boolean] the stage view's working set
102
+ # (web.working_set); ?working_set=1|0 overrides it for one page load
100
103
  # @param annotate_presets [String, Array] the quick replies next to
101
104
  # Annotate (web.annotate_presets, "|"-separated); "" shows none. The
102
105
  # page parses them (annotate_presets.js).
@@ -119,7 +122,8 @@ module Samagotchi
119
122
  annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
120
123
  editor: Config::BY_KEY["web.editor"].default,
121
124
  events_heartbeat: EVENTS_HEARTBEAT, events_queue: EVENTS_QUEUE,
122
- registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil)
125
+ registry: nil, models_wait_timeout: MODELS_WAIT_TIMEOUT, lan: nil,
126
+ working_set: Config::BY_KEY["web.working_set"].default)
123
127
  @manager = manager || SessionManager
124
128
  @registry = registry
125
129
  @registry_given = !registry.nil?
@@ -132,6 +136,7 @@ module Samagotchi
132
136
  @bridge_wait_timeout = bridge_wait_timeout
133
137
  @markdown_renderer = MarkdownRenderer.new(enabled: markdown)
134
138
  @view = view
139
+ @working_set = working_set
135
140
  @annotate_presets = annotate_presets.is_a?(Array) ? annotate_presets.join("|") : annotate_presets.to_s
136
141
  @editor_url = Editor.template(editor)
137
142
  @hub = hub
@@ -183,6 +188,7 @@ module Samagotchi
183
188
  ["GET", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_show, :session],
184
189
  ["POST", %r{\A/api/sessions/([^/]+)/context\z}, :handle_context_add, :session],
185
190
  ["DELETE", %r{\A/api/sessions/([^/]+)/context/([^/]+)\z}, :handle_context_delete, :session],
191
+ ["GET", %r{\A/api/sessions/([^/]+)/working_set\z}, :handle_working_set, :session],
186
192
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :session],
187
193
  ["DELETE", %r{\A/api/sessions/([^/]+)\z}, :handle_delete, :session]
188
194
  ].freeze
@@ -1477,6 +1483,36 @@ module Samagotchi
1477
1483
  error_response(409, e.reason.to_s, "#{e.message}; try again in a moment")
1478
1484
  end
1479
1485
 
1486
+ # ── The working set (web.working_set) ───────────────────────────────
1487
+
1488
+ # GET /api/sessions/:id/working_set: the working set as the model has
1489
+ # it ({items:, at:}, WorkingSet), what the page overlays on its rows'
1490
+ # set when it seeds a session's and when the expanded view opens (a
1491
+ # file changed while the session sat idle). A live worker's snapshot
1492
+ # messages are the truth, as #handle_show's, else the file's; the
1493
+ # strategy is the session's llm ctx chip's, the folder its working
1494
+ # directory.
1495
+ def handle_working_set(_req, id)
1496
+ session = @session_class.load(id, state_dir: default_state_dir)
1497
+ live = bridge_get_json(id, "snapshot")
1498
+ messages = live_or_saved(live && live["snapshot"], "messages") { session.messages }
1499
+ # A snapshot's entries have string keys (paired_runs reads symbols).
1500
+ messages = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
1501
+ llm_context = SessionSetting::LLM_CONTEXT.snapshot_value(live && live["session_state_snapshot"]) do
1502
+ SessionSetting::LLM_CONTEXT.saved_summary(session, registry: host_registry)
1503
+ end
1504
+ snapshot = WorkingSet.build(messages, cwd: session.working_directory, strategy: working_set_strategy(llm_context))
1505
+ json_response(200, snapshot.to_h)
1506
+ end
1507
+
1508
+ # The strategy an llm ctx chip names ("none", "stale,forget"), as
1509
+ # LLMContextView takes it; none when there is no chip.
1510
+ def working_set_strategy(llm_context)
1511
+ named = llm_context.is_a?(Hash) ? (llm_context[:strategy] || llm_context["strategy"]).to_s : ""
1512
+ layers = named.split(",").map(&:strip).reject { |layer| layer.empty? || layer == "none" }
1513
+ layers.empty? ? LLMContextView::NONE : layers.map(&:to_sym)
1514
+ end
1515
+
1480
1516
  # ── Attached context (ContextSources): the session bar's chips ───────
1481
1517
  # A source's command isn't shown (the page may be on a phone over the
1482
1518
  # LAN), and the web adds a URL only, through an installed bundle's
@@ -2126,6 +2162,8 @@ module Samagotchi
2126
2162
  # the one serving now (an upgraded chi web after a reconnect).
2127
2163
  attrs = { "sessions-dir" => sessions_dir_label, "server-dir" => home_label(Dir.pwd), "version" => Samagotchi::VERSION }
2128
2164
  attrs["view"] = view_name(req.params["view"])
2165
+ # Only when on: the page reads its presence (the turn view ignores it).
2166
+ attrs["working-set"] = "1" if working_set?(req.params["working_set"])
2129
2167
  # Always there: an empty one means no presets, not the default.
2130
2168
  attrs["annotate-presets"] = @annotate_presets
2131
2169
  dir, = scope_dir(req.params["dir"])
@@ -2154,6 +2192,16 @@ module Samagotchi
2154
2192
  VIEWS.include?(param) ? param : @view
2155
2193
  end
2156
2194
 
2195
+ # The working set for this page load: ?working_set=1|0 wins, anything
2196
+ # else leaves the config's choice (web.working_set).
2197
+ def working_set?(param)
2198
+ case param
2199
+ when "1" then true
2200
+ when "0" then false
2201
+ else @working_set
2202
+ end
2203
+ end
2204
+
2157
2205
  def json_response(status, payload)
2158
2206
  body = JSON.generate(payload)
2159
2207
  [status, { "Content-Type" => "application/json; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" }, [body]]
@@ -21,6 +21,7 @@ import {
21
21
  detachContext,
22
22
  addContext,
23
23
  renderText,
24
+ getWorkingSet,
24
25
  } from "./data.js";
25
26
  import { createContextChips } from "./context_chips.js";
26
27
  import { createLlmContextControl } from "./llm_context_chip.js";
@@ -37,6 +38,8 @@ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
37
38
  import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
38
39
  import { createCardsPlace } from "./cards_place.js";
39
40
  import { createStageView } from "./stage_view.js";
41
+ import { createWorkingSet, groupItems } from "./working_set_view.js";
42
+ import { turnGroups } from "./timing.js";
40
43
  import { createMarkdownPlace } from "./markdown_place.js";
41
44
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
42
45
  import { createTaskStopper } from "./task_stop.js";
@@ -225,10 +228,21 @@ const markdownPlace = createMarkdownPlace({
225
228
  answerRefsOf,
226
229
  });
227
230
  const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs } = markdownPlace;
231
+ // The stage view's working set (web.working_set, or ?working_set=1|0):
232
+ // what each turn brought into context, in a rail in the stage, a footprint
233
+ // under each finished turn and a "set N" chip in the mem chip's place
234
+ // (working_set_view.js). The turn view ignores it.
235
+ const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
236
+ ? createWorkingSet({
237
+ historyEl, stageRoot: () => turnDom.root, state: () => current, session: () => selected, infoEl: infoTextEl,
238
+ onChange: () => updateInfoBar(), getWorkingSet,
239
+ })
240
+ : null;
241
+ document.body.classList.toggle("working-set", !!workingSet);
228
242
  const turnDom = view === "stage"
229
243
  ? createStageView({
230
244
  ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"), composerEl,
231
- renderMarkdown, linkRefs: linkRefsIn,
245
+ renderMarkdown, linkRefs: linkRefsIn, workingSet,
232
246
  })
233
247
  : createTurnView(viewArgs);
234
248
  // The history's bottom reserve and the toast's place follow the composer
@@ -862,6 +876,7 @@ function renderSessionView(data, { kept = [] } = {}) {
862
876
  reasoningHistoryControl.refresh();
863
877
  if (data.messages && data.messages.length) renderHistory(data.messages);
864
878
  else renderHistory(data.history || []);
879
+ seedWorkingSet(data);
865
880
  restoreCommandBubbles(historyEl, kept);
866
881
  // A failed turn's prompt went back to the user: shown after the
867
882
  // conversation, marked failed, as it was live (prompt_restored).
@@ -921,13 +936,27 @@ function renderSessionView(data, { kept = [] } = {}) {
921
936
  updateComposerMode();
922
937
  }
923
938
 
939
+ // The working set's turns from a session's messages, as turnHistoryNodes
940
+ // numbers them; the running turn (the replay draws it in the stage) isn't
941
+ // one of them yet.
942
+ function seedWorkingSet(data) {
943
+ if (!workingSet) return;
944
+ const items = data.messages || [];
945
+ const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, current.timing).filter((g) => g.kind === "turn") : [];
946
+ if (data.current_turn && groups.length) groups.pop();
947
+ workingSet.seed(groups.map((g, i) => {
948
+ const text = String(items[g.user]?.content ?? "").replace(/\s+/g, " ").trim();
949
+ return { items: groupItems(g, i), prompt: text.length > 60 ? `${text.slice(0, 59)}\u2026` : text };
950
+ }));
951
+ }
952
+
924
953
  function renderHistory(items) {
925
954
  if (!items.length) {
926
955
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
927
956
  return;
928
957
  }
929
958
  if (typeof items[0] === "object" && items[0].role) {
930
- historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
959
+ historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
931
960
  linkTextRefs();
932
961
  } else {
933
962
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -1088,7 +1117,7 @@ function updateInfoBar() {
1088
1117
  const { buttons, bar } = sessionBar({
1089
1118
  id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1090
1119
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1091
- now: Date.now(),
1120
+ now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined,
1092
1121
  });
1093
1122
  const stopBtn = document.getElementById("infoStopBtn");
1094
1123
  const deleteBtn = document.getElementById("infoDeleteBtn");
@@ -1269,6 +1298,7 @@ const streamHandlers = createStreamHandlers({
1269
1298
  llmContextControl,
1270
1299
  thinkingControl,
1271
1300
  reasoningHistoryControl,
1301
+ workingSet,
1272
1302
  sentIds,
1273
1303
  earlyRestores,
1274
1304
  stream: {
@@ -1,14 +1,19 @@
1
1
  // The popover shell a session-bar chip opens (llm_context_chip.js,
2
- // level_chip.js): a div.ctx-popover on the body, placed at the chip,
3
- // opened by a click on it (delegated on +el+), closed by a second click,
4
- // its × (data-act="close"), a click outside or Esc. Another session's
5
- // summary (refresh) closes it. What it draws, and what a Set or another
6
- // data-act does, is the caller's.
2
+ // level_chip.js, the working set's): a div.ctx-popover on the body, placed
3
+ // at the chip, opened by a click on it (delegated on +el+) or by open()
4
+ // (another opener: the working set's rail and footprint), closed by a second
5
+ // click, its × (data-act="close"), a click outside or Esc; focus goes back
6
+ // to the opener. Another session's summary (refresh) closes it. What it
7
+ // draws, and what a Set or another data-act does, is the caller's. With
8
+ // +sheet+, a window narrower than SHEET_MAX gets it as a bottom sheet.
7
9
 
8
10
  import { nothing, render } from "./vendor/lit-html.js";
9
11
 
10
12
  const EDGE = 16;
11
13
  const GAP = 8;
14
+ // Under this window width a +sheet+ popover is a bottom sheet.
15
+ export const SHEET_MAX = 600;
16
+ const SHEET_EDGE = 8;
12
17
 
13
18
  // Where the popover goes for the chip's +rect+: above it, or below when
14
19
  // the room above is short of its height and below has more; capped to the
@@ -24,26 +29,48 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
24
29
  return height > room ? { ...place, maxHeight: room } : place;
25
30
  }
26
31
 
32
+ // A bottom sheet's place in a phone's window: the window's width less its
33
+ // edges, at its bottom, at most 60 % of its height (the rest scrolls).
34
+ // @return {left, bottom, width, maxHeight}
35
+ export function sheetPlace({ innerWidth, innerHeight }) {
36
+ return { left: SHEET_EDGE, bottom: SHEET_EDGE, width: innerWidth - 2 * SHEET_EDGE, maxHeight: Math.round(innerHeight * 0.6) };
37
+ }
38
+
27
39
  // id, className: the popover's; width: its widest (px); el: where the chip
28
- // is drawn; anchor: the chip's selector; summary(): what the form is drawn
29
- // from (null: nothing opens); session(): the open session's id.
40
+ // is drawn; anchor: the chip's selector; openers: the selector of every
41
+ // element that opens it (a click there is not a click outside; default the
42
+ // anchor's); summary(anchor): what the form is drawn from (null: nothing
43
+ // opens); session(): the open session's id.
30
44
  // show(data, opening): draws the form into the popover (opening: a fresh
31
45
  // open, else a refresh with a newer summary); onClose(): the caller forgets
32
46
  // its open state; onSubmit(form); onAct(name): a data-act other than close.
33
- // @return {pop, close, refresh, isOpen}
34
- export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, summary,
47
+ // sheet: a bottom sheet in a narrow window (sheetPlace).
48
+ // @return {pop, open, close, refresh, isOpen, opener}
49
+ export function createChipPopover({ id, className, width: widest, el, anchor: anchorSel, openers = anchorSel, summary,
35
50
  session = () => null, show, onClose = () => {}, onSubmit, onAct = () => {},
36
- doc = document, win = window }) {
51
+ sheet = false, doc = document, win = window }) {
37
52
  const pop = doc.createElement("div");
38
53
  pop.id = id;
39
54
  pop.className = className;
40
55
  pop.setAttribute("role", "dialog");
41
56
  pop.hidden = true;
42
57
  doc.body.appendChild(pop);
43
- // The session the open form is for.
58
+ // The session the open form is for, and the element that opened it.
44
59
  let openFor = null;
60
+ let opener = null;
45
61
 
46
62
  function place(anchor) {
63
+ const narrow = sheet && win.innerWidth < SHEET_MAX;
64
+ pop.classList.toggle("sheet", narrow);
65
+ if (narrow) {
66
+ const at = sheetPlace({ innerWidth: win.innerWidth, innerHeight: win.innerHeight });
67
+ pop.style.width = `${at.width}px`;
68
+ pop.style.left = `${at.left}px`;
69
+ pop.style.top = "";
70
+ pop.style.bottom = `${at.bottom}px`;
71
+ pop.style.maxHeight = `${at.maxHeight}px`;
72
+ return;
73
+ }
47
74
  const width = Math.min(widest, win.innerWidth - 32);
48
75
  pop.style.width = `${width}px`;
49
76
  pop.style.maxHeight = "";
@@ -55,22 +82,29 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
55
82
  if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
56
83
  }
57
84
 
85
+ // Opens at +anchor+ (open already: drawn anew there).
58
86
  function open(anchor) {
59
- const data = summary();
87
+ const data = summary(anchor);
60
88
  if (!data) return;
61
89
  openFor = session();
90
+ opener = anchor;
62
91
  show(data, true);
63
92
  pop.hidden = false;
64
93
  place(anchor);
65
94
  }
66
95
 
67
96
  // A closed popover is empty, so each open draws a fresh form (unsent
68
- // edits are gone).
69
- function close() {
97
+ // edits are gone). Focus that was in it (or +refocus+: Esc) goes back to
98
+ // its opener.
99
+ function close({ refocus = false } = {}) {
100
+ const had = refocus || pop.contains(doc.activeElement);
101
+ const back = opener;
70
102
  pop.hidden = true;
71
103
  openFor = null;
104
+ opener = null;
72
105
  onClose();
73
106
  render(nothing, pop);
107
+ if (had && back?.isConnected) back.focus?.();
74
108
  }
75
109
 
76
110
  function isOpen() { return !pop.hidden; }
@@ -84,6 +118,11 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
84
118
  show(data, false);
85
119
  }
86
120
 
121
+ // A popover's own re-placement (its content grew or shrank).
122
+ function replace() {
123
+ if (isOpen() && opener?.isConnected) place(opener);
124
+ }
125
+
87
126
  // The click goes on to the document: another chip's popover (or the
88
127
  // context popover) open meanwhile closes there. This one's own closer
89
128
  // leaves its chip's click alone.
@@ -103,11 +142,11 @@ export function createChipPopover({ id, className, width: widest, el, anchor: an
103
142
  else if (act) onAct(act);
104
143
  });
105
144
  doc.addEventListener("click", (e) => {
106
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
145
+ if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(openers)) close();
107
146
  });
108
147
  doc.addEventListener("keydown", (e) => {
109
- if (e.key === "Escape" && isOpen()) close();
148
+ if (e.key === "Escape" && isOpen()) close({ refocus: true });
110
149
  });
111
150
 
112
- return { pop, close, refresh, isOpen };
151
+ return { pop, open, close, refresh, isOpen, replace, opener: () => opener };
113
152
  }
@@ -298,6 +298,12 @@ export function openEvents(handlers = {}, opts = {}) {
298
298
  };
299
299
  }
300
300
 
301
+ // A session's working set as the model has it (WorkingSet: {items, at}),
302
+ // what the stage view's working set overlays on its rows (web.working_set).
303
+ export function getWorkingSet(id, opts = {}) {
304
+ return api(`/api/sessions/${encodeURIComponent(id)}/working_set`, opts);
305
+ }
306
+
301
307
  // A session's attached context (chi context): the sources it sees, one
302
308
  // with its text, and detach (a session's) or mute here (a project's).
303
309
  export function listContext(id, opts = {}) {
@@ -867,6 +867,132 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
867
867
  .turn-stage .outline-fold{display:none}
868
868
  .turn-stage .turn-work.outline>.gen.folded{display:block}
869
869
  .turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
870
+ /* The working set (web.working_set, working_set_rail.js): a rail on the right of the live slots, out of their flow (absolute,
871
+ the live block's full height), so it never sets their height; its lines are fixed 18 px slots, each at its place by a
872
+ transform (a fold fades the absorbed lines out in place and slides the ones below up once). A stage narrower than 760 px
873
+ has it as a row under the trail (a fixed line, part of the live block from the turn's start); a phone has neither (the
874
+ session bar's chip is its entry). body.working-set: the flag is on (app.js). */
875
+ body.working-set .turn-stage{container-type:inline-size}
876
+ body.working-set .ts-live{position:relative;padding-right:264px}
877
+ .ts-ws{--ws-line:18px;position:absolute;top:0;right:0;bottom:0;width:248px;box-sizing:border-box;padding:0 4px 0 12px;border-left:1px solid var(--border);border-radius:0 6px 6px 0;display:flex;flex-direction:column;overflow:hidden;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;line-height:var(--ws-line);color:var(--text-2);cursor:pointer}
878
+ .ts-ws:hover{background:var(--bg-hover)}
879
+ .ts-ws:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
880
+ .ws-head{flex:none;display:flex;align-items:center;gap:6px;min-width:0;height:var(--ws-line);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-muted);white-space:nowrap}
881
+ .ws-count{margin-right:auto;font-weight:400;letter-spacing:0;color:var(--text-faint);font-variant-numeric:tabular-nums}
882
+ .ws-tick{display:none}
883
+ .ws-fold{flex:none;width:18px;height:18px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--text-muted);font-size:12px;line-height:18px;opacity:0;transition:opacity .15s}
884
+ .ws-fold:hover:not(:disabled){filter:none;background:var(--bg-raised);color:var(--text)}
885
+ .ts-ws:hover .ws-fold,.ts-ws:focus-within .ws-fold,.ts-ws.collapsed .ws-fold{opacity:1}
886
+ @media (hover:none){.ws-fold{opacity:1}}
887
+ .ws-ring{flex:none;width:14px;height:14px}
888
+ .ws-ring circle{fill:none;stroke-width:2.4}
889
+ .ws-ring .track{stroke:var(--border-strong)}
890
+ .ws-ring .fill{stroke:var(--accent);stroke-linecap:round;transform:rotate(-90deg);transform-origin:50% 50%;transition:stroke-dasharray .4s ease}
891
+ .ws-ring.warn .fill{stroke:var(--warn)}
892
+ .ws-ring.danger .fill{stroke:var(--danger)}
893
+ .ws-list{position:relative;flex:1;min-height:0;overflow:hidden}
894
+ .ts-ws.empty .ws-list::before{content:"nothing read yet";font-family:ui-sans-serif,system-ui,sans-serif;font-style:italic;color:var(--text-faint)}
895
+ .ws-item,.ws-more{position:absolute;top:0;left:0;right:0;height:var(--ws-line);transition:transform .18s cubic-bezier(.3,.7,.2,1),opacity .15s ease,background-color .8s ease}
896
+ .ws-item{display:flex;align-items:center;gap:5px;white-space:nowrap;overflow:hidden;border-radius:4px}
897
+ .ws-item .k{flex:none;width:12px;text-align:center;color:var(--text-faint)}
898
+ .ws-item.memory .k{color:var(--think-text)}
899
+ .ws-item.url .k{color:var(--accent)}
900
+ .ws-item .t{min-width:0;overflow:hidden;text-overflow:ellipsis}
901
+ .ws-item .n{flex:none;color:var(--text-faint);font-variant-numeric:tabular-nums}
902
+ .ws-item .e{flex:none;color:var(--accent);font-weight:600;font-variant-numeric:tabular-nums}
903
+ .ws-item .c{flex:none;color:var(--text-faint);font-variant-numeric:tabular-nums}
904
+ .ws-item .n:empty,.ws-item .e:empty,.ws-item .c:empty{display:none}
905
+ /* The server's states (WorkingSet): stale faded; forgotten struck, faint and faded (a group only when all its members are). The words are the popover's and the titles' (colour is never alone). */
906
+ .ws-item.stale{opacity:.55}
907
+ .ws-item.forgotten{opacity:.5}
908
+ .ws-item.forgotten .t,.ws-row.forgotten .t,.ws-foot .l.forgotten{text-decoration:line-through;color:var(--text-faint)}
909
+ .ws-row.stale .k,.ws-row.stale .t,.ws-foot .l.stale{opacity:.55}
910
+ .ws-row.forgotten .k,.ws-row.forgotten .t,.ws-foot .l.forgotten{opacity:.5}
911
+ .ws-item.enter{animation:ws-in .2s ease}
912
+ @keyframes ws-in{from{opacity:0}}
913
+ .ws-item.leave,.ws-more.leave{opacity:0;pointer-events:none}
914
+ /* Just entered (FLASH_MS): an accent dot for the glyph and a tint; it settles (the tint fades 800 ms). */
915
+ .ws-item.fresh{background:var(--accent-tint)}
916
+ .ws-item.fresh .k{font-size:0;display:flex;align-items:center;justify-content:center;height:var(--ws-line)}
917
+ .ws-item.fresh .k::before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--accent-ring)}
918
+ .ws-more{padding-left:17px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
919
+ /* Folded by hand (kept per browser): the head line alone at the right, the newest entry ticking through it. */
920
+ .ts-ws.collapsed{bottom:auto;width:auto;max-width:248px;height:var(--ws-line);border-left:0;padding-left:6px}
921
+ .ts-ws.collapsed .ws-list{display:none}
922
+ .ts-ws.collapsed .ws-count{margin-right:0}
923
+ .ts-ws.collapsed .ws-tick{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;margin-right:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:400;letter-spacing:0;text-transform:none;color:var(--text-2)}
924
+ .ts-ws.collapsed .ws-tick:not(:empty)::before{content:"+";margin-right:5px;color:var(--accent);font-weight:700}
925
+ .ws-tick.fresh{animation:ts-fade .3s ease}
926
+ /* The row form, in a stage under 760 px: one line under the trail (its room, 18 px and a gap, kept while a card waits). */
927
+ @container (max-width:759px){
928
+ body.working-set .ts-live{padding-right:0;--ws-row-h:26px}
929
+ .ts-ws{position:static;width:auto;height:var(--ws-line);flex-direction:row;align-items:center;gap:10px;padding:0;border-left:0;border-radius:6px}
930
+ .ts-ws .ws-head{flex:none}
931
+ .ts-ws .ws-count{margin-right:0}
932
+ .ts-ws .ws-list{display:flex;align-items:center;gap:12px;height:var(--ws-line)}
933
+ .ws-item,.ws-more{position:static;flex:none;max-width:30ch;transform:none !important;transition:none}
934
+ .ws-more{padding-left:0}
935
+ .ws-item.leave,.ws-more.leave{display:none}
936
+ .turn-stage.waiting .ts-ws{display:none}
937
+ }
938
+ body.working-set .turn-stage.waiting .ts-live{min-height:calc(var(--ts-live-h) + var(--ws-row-h,0px))}
939
+ body.working-set .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(calc(var(--ts-live-h) + var(--ws-row-h,0px)),var(--live-held,0px))}
940
+ /* A finished turn's footprint, under its closed block (working_set_view.js): one faint line; a click opens its list. */
941
+ .ws-foot{display:block;width:fit-content;max-width:calc(100% - 13px);box-sizing:border-box;margin:-4px 0 0 13px;padding:0 6px;border-radius:6px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;line-height:18px;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
942
+ .ws-foot:hover{color:var(--text-muted);background:var(--bg-hover)}
943
+ .ws-foot:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
944
+ .ws-foot .w{font-family:ui-sans-serif,system-ui,sans-serif}
945
+ .ws-foot .sep{color:var(--border-strong)}
946
+ .ws-foot .e{color:var(--accent)}
947
+ .ws-foot .k{color:var(--think-text)}
948
+ .ws-foot .url .k{color:var(--accent)}
949
+ .ws-foot .f{text-decoration:line-through}
950
+ /* A step tag (s3, t2·s3, prompt): where an item entered; a button jumps to its call. */
951
+ .ws-step{display:inline-block;padding:0 4px;border:0;border-radius:4px;background:var(--badge-bg);font-family:ui-sans-serif,system-ui,sans-serif;font-size:10.5px;line-height:14px;font-weight:400;color:var(--text-faint);font-variant-numeric:tabular-nums;vertical-align:1px}
952
+ .ws-step.p{color:var(--think-text)}
953
+ button.ws-step{cursor:pointer}
954
+ button.ws-step:hover:not(:disabled){filter:none;background:var(--accent-bg);color:var(--accent)}
955
+ /* The expanded view (a chip popover; a bottom sheet on a phone). */
956
+ .ws-popover{gap:0}
957
+ .ws-popover .ctx-pop-head{align-items:center;flex-wrap:wrap;margin-bottom:4px}
958
+ .ws-scope{display:inline-flex;gap:2px}
959
+ .ws-scope button{padding:1px 8px;border:1px solid transparent;border-radius:999px;background:transparent;color:var(--text-muted);font-size:11px;line-height:16px}
960
+ .ws-scope button.on{color:var(--text-2);border-color:var(--border-strong);background:var(--bg-raised)}
961
+ .ws-scope button:hover:not(:disabled){filter:none;color:var(--text)}
962
+ .ws-popover .ctx-pop-meta{margin-top:6px}
963
+ .ws-popover .ws-hint{margin-top:10px;color:var(--text-faint)}
964
+ .ws-row{display:flex;align-items:center;gap:8px;width:100%;min-width:0;height:28px;box-sizing:border-box;padding:0 6px;border:0;border-radius:6px;background:transparent;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:28px;color:var(--text-2);text-align:left}
965
+ .ws-row:hover{background:var(--bg-hover)}
966
+ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
967
+ .ws-row .k{flex:none;width:12px;text-align:center;color:var(--text-faint)}
968
+ .ws-row.memory .k{color:var(--think-text)}
969
+ .ws-row.url .k{color:var(--accent)}
970
+ .ws-row .t{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:inherit}
971
+ .ws-row a.t{text-decoration:none}
972
+ .ws-row a.t:hover{text-decoration:underline}
973
+ .ws-row .e{flex:none;color:var(--accent);font-weight:600}
974
+ .ws-row .n{flex:none;color:var(--text-faint)}
975
+ .ws-row .st{flex:none;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
976
+ .ws-row .chev{flex:none;margin-left:auto;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint)}
977
+ .ws-row .ws-step{flex:none}
978
+ .ws-row .tk,.ws-row .c{flex:none;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;color:var(--text-faint);font-variant-numeric:tabular-nums}
979
+ .ws-row.member{padding-left:26px}
980
+ .ws-row.fresh{background:var(--accent-tint)}
981
+ .ws-row.fresh .k{color:var(--accent)}
982
+ .ws-row.muted{opacity:.6}
983
+ .ws-popover.sheet{border-radius:14px}
984
+ /* A phone's sheet: the name, the scope and × on the first line, the count and the ring under them. */
985
+ .ws-popover.sheet .ctx-pop-close{order:2}
986
+ .ws-popover.sheet .ctx-pop-head>.ctx-pop-kind{order:3;flex:1 0 calc(100% - 30px)}
987
+ .ws-popover.sheet .ctx-pop-head>.ws-ring{order:4}
988
+ .ws-popover.sheet .ws-row{height:30px;line-height:30px}
989
+ /* The session bar's chip, in the mem chip's place: a dot (the accent: changed since its list was last opened) and the count. */
990
+ #infoBar .meta .ws-chip{display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border:1px solid transparent;cursor:pointer;font-family:inherit;font-weight:400;white-space:nowrap;font-variant-numeric:tabular-nums}
991
+ #infoBar .meta .ws-chip:hover:not(:disabled){filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
992
+ .ws-dot{width:6px;height:6px;border-radius:50%;background:var(--text-faint);opacity:.6}
993
+ .ws-chip.changed .ws-dot{background:var(--accent);opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
994
+ @media(max-width:600px){body.working-set .ts-live{padding-right:0;--ws-row-h:0px}.ts-ws{display:none}}
995
+ @media (prefers-reduced-motion:reduce){.ws-item,.ws-more,.ws-ring .fill{transition:none}.ws-item.enter,.ws-tick.fresh{animation:none}}
870
996
  @media(max-width:600px){
871
997
  .bubble.user.queued[data-delivery="queue"]{flex-wrap:wrap}
872
998
  .bubble.user.queued[data-delivery="queue"] .origin-label,.bubble.user.queued[data-delivery="queue"] .state-badge{order:0}
@@ -974,7 +1100,7 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
974
1100
  #infoBar .meta .id{order:-2}
975
1101
  #infoBar .meta .status{order:-1}
976
1102
  /* The memory chip right after the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
977
- #infoBar .meta .mem-chip{order:-1}
1103
+ #infoBar .meta .mem-chip,#infoBar .meta .ws-chip{order:-1}
978
1104
  /* A parent's children chip too: its waiting part is what to see. */
979
1105
  #infoBar .meta .children-chip{order:-1}
980
1106
  /* A chain's ← → links too: the way to the other days. */
@@ -57,6 +57,15 @@ function kidsTemplate(kids) {
57
57
  return html`<a class=${`model children-chip${kids.waitingText ? " waiting" : ""}`} href=${ALL_SESSIONS_HASH} title=${`${kids.title} (all sessions)`}>${kids.text}${waiting}</a>`;
58
58
  }
59
59
 
60
+ // The working set's chip (web.working_set), in the mem chip's place: a dot
61
+ // (the accent when the set changed since its list was last opened) and
62
+ // "set N"; nothing while the set is empty. +chip+: {n, changed} or null.
63
+ export function workingSetChipTemplate(chip) {
64
+ if (!chip) return nothing;
65
+ const title = `Working set: ${chip.n} item${chip.n === 1 ? "" : "s"} this session brought into context (files, memories, URLs)${chip.changed ? ", changed since you last looked" : ""}. Open the list.`;
66
+ return html`<button type="button" class=${`model ws-chip${chip.changed ? " changed" : ""}`} title=${title} aria-label=${title} aria-haspopup="dialog"><span class="ws-dot" aria-hidden="true"></span>set ${chip.n}</button>`;
67
+ }
68
+
60
69
  // The info line for +bar+, nothing without a session (the start page hides
61
70
  // the bar). +bar+:
62
71
  // id, copied (the copy button just copied), status, model ({text, title,
@@ -65,18 +74,21 @@ function kidsTemplate(kids) {
65
74
  // childrenSummary), llmContext, thinking and reasoningHistory (the chips'
66
75
  // summaries; reasoningHistory null on a native host: no chip), toolkit
67
76
  // (the tools chip's summary, null on full: no chip), notes
68
- // ({text, title} or null), memories (the used names), dir, ctx ({text,
77
+ // ({text, title} or null), memories (the used names), workingSet
78
+ // (web.working_set on: {chip}, the working set's chip in place of the
79
+ // mem chip; null: off), dir, ctx ({text,
69
80
  // tip}), speed ({text, tip} or null), clock (the session time's text, ""
70
81
  // for none), worker ({badge, busy} or null).
71
82
  export function infoBarHtml(bar) {
72
83
  if (!bar) return nothing;
73
84
  const attach = `chi --attach ${bar.id}`;
74
85
  const memories = bar.memories || [];
75
- return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${llmContextChip(bar.llmContext)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
86
+ return html`<div class="meta"><span class="id" title=${bar.id}>${String(bar.id).slice(0, 8)}</span><button type="button" class="copy-attach" title=${`Copy "${attach}" to attach a terminal`}>${bar.copied ? "copied ✓" : "copy chi --attach"}</button><span class=${bar.status ? `status ${bar.status}` : "status"}>${bar.status || ""}</span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${llmContextChip(bar.llmContext)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class="model speed" title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}${bar.clock ? html`<span class="model session-time">${bar.clock}</span>` : nothing}${workerBadgeTemplate(bar.worker)}</div>`;
76
87
  }
77
88
 
78
- // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
89
+ // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip
90
+ // (with the working set on, its list says them instead).
79
91
  export function renderInfoBar(el, bar) {
80
92
  render(infoBarHtml(bar), el);
81
- el.title = bar ? memoriesTooltip(bar.memories, bar.preloaded, bar.muted) : "";
93
+ el.title = bar && !bar.workingSet ? memoriesTooltip(bar.memories, bar.preloaded, bar.muted) : "";
82
94
  }
@@ -63,7 +63,9 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
63
63
  // @param restarting whether this session's Restart is under way
64
64
  // @param running whether its turn runs (the session clock ticks)
65
65
  // @param now the clock (ms)
66
- export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, now }) {
66
+ // @param workingSet web.working_set on: the chip's {n, changed}, or null
67
+ // while the set is empty; undefined: off (the mem chip shows)
68
+ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, now, workingSet = undefined }) {
67
69
  const tokens = state.timing.tokens;
68
70
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
69
71
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
@@ -96,6 +98,8 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
96
98
  // The model notes the session's prompt carried.
97
99
  notes: promptNotesChip(state.promptNotes),
98
100
  memories: state.usedMemories,
101
+ // On, the working set's chip stands in the mem chip's place.
102
+ workingSet: workingSet === undefined ? null : { chip: workingSet },
99
103
  preloaded: state.preloadedMemories,
100
104
  muted: state.mutedMemories,
101
105
  dir: state.dir,
@@ -109,12 +113,12 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
109
113
  // The whole bar for the open session +id+ (null: the start page, no line).
110
114
  // @param sessions the page's list; +continuing+, +restarting+: Sets of ids
111
115
  // @return {buttons, bar}
112
- export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, now }) {
116
+ export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, now, workingSet = undefined }) {
113
117
  const chain = id ? chainNeighbours(sessions, id, { continues: state.continues }) : { prev: null, next: null };
114
118
  const archived = !!sessions.find((s) => s.id === id)?.archived;
115
119
  const buttons = infoButtons({ id, state, chain, archived, continuing: continuing.has(id), streaming });
116
120
  if (!id) return { buttons, bar: null };
117
121
  const badge = workerBadgeFor(id, state, versions);
118
- const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, now });
122
+ const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, now, workingSet });
119
123
  return { buttons, bar };
120
124
  }
@@ -413,10 +413,19 @@ function lineIn(row, line) {
413
413
  export function showInTurn(target, { behavior = smoothScroll() } = {}) {
414
414
  const row = turnRowFor(target);
415
415
  if (!row) return null;
416
+ return showRow(row, { line: target.line, behavior });
417
+ }
418
+
419
+ // Shows a tool row: what hides it opened (its step, its turn's block; a
420
+ // view that hides its rows its own way opens on the row's ref:reveal
421
+ // event), its diff (or the read showing +line+) opened, scrolled to the
422
+ // line (else the row) and both flashed. The working set's step tags jump
423
+ // here. The row.
424
+ export function showRow(row, { line: lineNo = null, behavior = smoothScroll() } = {}) {
416
425
  const view = row.ownerDocument.defaultView || globalThis;
417
426
  const reveal = new view.CustomEvent("ref:reveal", { bubbles: true, detail: {} });
418
427
  row.dispatchEvent(reveal);
419
- const line = target.line ? lineIn(row, target.line) : null;
428
+ const line = lineNo ? lineIn(row, lineNo) : null;
420
429
  const fold = line?.closest("details.activity-diff, details.activity-read") || row.querySelector("details.activity-diff");
421
430
  for (let details = (line || fold || row).closest("details"); details; details = details.parentElement?.closest("details")) {
422
431
  details.open = true;
@@ -225,11 +225,12 @@ export function handOffDue({ ended, pending, inUse, quietSinceMs, nowMs }) {
225
225
  }
226
226
 
227
227
  // A handed-off turn's nodes in the order the turn view leaves them: the
228
- // prompt bubble (none for a reminder turn), the block, the extras in
229
- // arrival order, the answer's pieces, the timing line, the end line, the
230
- // kept cards, then the lines queued for after the turn (they run next).
231
- export function handOffOrder({ prompt = null, block = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
232
- return [prompt, block, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
228
+ // prompt bubble (none for a reminder turn), the block, its working set's
229
+ // footprint line (web.working_set), the extras in arrival order, the
230
+ // answer's pieces, the timing line, the end line, the kept cards, then the
231
+ // lines queued for after the turn (they run next).
232
+ export function handOffOrder({ prompt = null, block = null, footprint = null, extras = [], answer = [], timing = null, end = null, kept = [], queued = [] }) {
233
+ return [prompt, block, footprint, ...extras, ...answer, timing, end, ...kept, ...queued].filter(Boolean);
233
234
  }
234
235
 
235
236
  // The rows that wait for the running turn's end to run as their own: a