samagotchi 0.67.0 → 0.68.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/docs/architecture.md +6 -3
  4. data/docs/internals/engine.md +58 -9
  5. data/docs/sessions.md +1 -1
  6. data/docs/testing.md +1 -1
  7. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  8. data/lib/samagotchi/effective_model.rb +283 -0
  9. data/lib/samagotchi/engine.rb +211 -358
  10. data/lib/samagotchi/level_setting_command.rb +99 -0
  11. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  12. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  13. data/lib/samagotchi/session.rb +22 -8
  14. data/lib/samagotchi/session_setting.rb +86 -0
  15. data/lib/samagotchi/thinking_command.rb +24 -76
  16. data/lib/samagotchi/thinking_notices.rb +169 -0
  17. data/lib/samagotchi/version.rb +1 -1
  18. data/lib/samagotchi/web/app.rb +55 -84
  19. data/lib/samagotchi/web/public/app.js +74 -505
  20. data/lib/samagotchi/web/public/bubbles.js +291 -0
  21. data/lib/samagotchi/web/public/cards_place.js +184 -0
  22. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  23. data/lib/samagotchi/web/public/command_list.js +12 -10
  24. data/lib/samagotchi/web/public/index.html +15 -7
  25. data/lib/samagotchi/web/public/info_bar.js +3 -3
  26. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  27. data/lib/samagotchi/web/public/init_row.js +21 -10
  28. data/lib/samagotchi/web/public/level_chip.js +29 -80
  29. data/lib/samagotchi/web/public/lists_view.js +64 -9
  30. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  31. data/lib/samagotchi/web/public/model_picker.js +26 -22
  32. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  33. data/lib/samagotchi/web/public/refs.js +13 -0
  34. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  35. data/lib/samagotchi/web/public/stage_model.js +33 -0
  36. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  37. data/lib/samagotchi/web/public/stage_view.js +56 -335
  38. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  39. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  40. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  41. data/lib/samagotchi/worker.rb +11 -16
  42. metadata +14 -1
@@ -42,7 +42,7 @@ button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline
42
42
  button.ghost{background:var(--bg-raised);color:var(--text-2)}
43
43
  button:disabled{opacity:.5;cursor:not-allowed}
44
44
  main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:auto;background:transparent;min-height:0}
45
- #topStrip{display:grid;grid-template-columns:repeat(var(--strip-cols,3),minmax(0,440px)) 150px;gap:12px;min-height:88px;align-content:start;transition:opacity 280ms ease, transform 280ms ease}
45
+ #topStrip{display:grid;grid-template-columns:repeat(var(--strip-cols,3),minmax(0,440px)) 150px;gap:12px;min-height:88px;align-content:start}
46
46
  #topStrip:empty{display:grid}
47
47
  #center{flex:1;display:flex;flex-direction:column;min-height:45vh;background:var(--bg-panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
48
48
  #stage{flex:1;display:flex;flex-direction:column;position:relative;min-height:160px;background:var(--bg);overflow:hidden}
@@ -578,8 +578,7 @@ body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
578
578
  body.has-session #composer textarea{min-height:0}
579
579
  body.has-session #composeHint{display:none}
580
580
  body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
581
- /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a native select under a drawn caret. */
582
- /* A select is as wide as its widest option: a hidden copy of the chosen name (data-value) sizes the box to that name instead, and the select fills it. */
581
+ /* The model picker (start page only): reads as a label, "host:model ▾", left of Start; a button under a drawn caret that opens a searchable list (model_picker.js). Its name ellipsizes within max-width. */
583
582
  #composer .actions-col .model-pick{order:0;position:relative;display:inline-block;max-width:min(50%,380px);height:28px;min-height:28px;padding:0 22px 0 8px;border-radius:8px;overflow:hidden;background:transparent;color:var(--text-muted);font-size:12px;line-height:28px;text-align:left;text-overflow:ellipsis;transition:background .12s ease,color .12s ease}
584
583
  #composer .actions-col .model-pick:hover,#composer .actions-col .model-pick[aria-expanded="true"]{filter:none;background:color-mix(in srgb,var(--bg-hover) 75%,transparent);color:var(--text-2)}
585
584
  #composer .actions-col .model-pick[hidden],body.has-session #composer .actions-col .model-pick{display:none}
@@ -827,6 +826,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
827
826
  .llmctx-field>span{flex:0 0 58px;font-size:12px;color:var(--text-muted)}
828
827
  .llmctx-field select,.llmctx-field input{flex:1;min-width:0;box-sizing:border-box;padding:5px 8px;border-radius:7px;border:1px solid var(--border-strong);background:var(--bg-inset);color:var(--text);font:13px/1.4 inherit}
829
828
  .llmctx-field select:focus,.llmctx-field input:focus{outline:none;border-color:var(--accent-border)}
829
+ /* The settings selects draw their own closed box (each browser's native one differs) with the model picker's drawn caret on the label; the open list stays the OS menu. */
830
+ .llmctx-field select{appearance:none;-webkit-appearance:none;padding-right:26px;cursor:pointer}
831
+ .llmctx-field select option{background:var(--bg-raised);color:var(--text)}
832
+ .llmctx-field input::placeholder{color:var(--text-faint);opacity:1}
833
+ .llmctx-field:has(select){position:relative}
834
+ .llmctx-field:has(select)::after{content:"";position:absolute;right:11px;top:50%;width:5px;height:5px;border-right:1.5px solid var(--text-muted);border-bottom:1.5px solid var(--text-muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
830
835
  .llmctx-form .llmctx-set{background:var(--accent-bg);color:var(--accent);border-color:var(--accent-border)}
831
836
  .llmctx-form .llmctx-set:disabled{opacity:.6;cursor:default}
832
837
  /* Attached context (context_chips.js): a row of its own under the meta; not the composer's #chips/.chip. */
@@ -1091,7 +1096,10 @@ body.all-view #center, body.all-view #topStrip{display:none}
1091
1096
  .card.skeleton .line.tiny{width:30%;height:8px}
1092
1097
  @keyframes skeleton-pulse{0%,100%{opacity:1}50%{opacity:.55}}
1093
1098
  @keyframes fadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
1094
- #topStrip:not(.loading) .card, #allList .card{animation:fadeIn .28s ease}
1099
+ /* Only a card new to its list fades in (lists_view.js enteringCards: every draw makes new nodes);
1100
+ --enter-delay carries a fade on across a redraw, --pulse-delay keeps a dot's pulse in phase. */
1101
+ #topStrip:not(.loading) .card.entering, #allList .card.entering{animation:fadeIn .28s ease var(--enter-delay,0s)}
1102
+ @media (prefers-reduced-motion:reduce){.card.entering{animation:none}}
1095
1103
  .card{padding:12px;border-radius:10px;background:var(--bg-panel);border:1px solid var(--border);cursor:pointer;display:flex;flex-direction:column;gap:6px;min-height:72px}
1096
1104
  .card:hover{border-color:var(--border-strong);background:var(--bg-hover)}
1097
1105
  #allList .card{position:relative}
@@ -1116,13 +1124,13 @@ body.all-view #center, body.all-view #topStrip{display:none}
1116
1124
  .card .meta{font-size:11px;color:var(--text-muted);display:flex;gap:6px;align-items:center}
1117
1125
  /* A card's status is a dot (its word is the tooltip); the .status.* pills below are the info bar's. */
1118
1126
  .card .dot,.family-row .dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--text-muted)}
1119
- .card .dot.running,.family-row .dot.running{background:var(--ok);animation:dot-pulse 1.6s ease-in-out infinite}
1127
+ .card .dot.running,.family-row .dot.running{background:var(--ok);animation:dot-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
1120
1128
  .card .dot.idle,.family-row .dot.idle{background:var(--text-muted)}
1121
1129
  .card .dot.stopped,.family-row .dot.stopped{background:transparent;border:1px solid var(--text-faint)}
1122
1130
  .card .dot.completed,.family-row .dot.completed{background:var(--accent)}
1123
1131
  .card .dot.error,.family-row .dot.error{background:var(--danger)}
1124
1132
  @keyframes dot-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--ok) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--ok) 0%,transparent)}}
1125
- .card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out infinite}
1133
+ .card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
1126
1134
  @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
1127
1135
  @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting,.family-row .dot.running,.family-row .dot.waiting{animation:none}}
1128
1136
  .card .attn,.family-row .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
@@ -1176,8 +1184,8 @@ a{color:var(--accent)}
1176
1184
  </div>
1177
1185
  <div class="actions-col">
1178
1186
  <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1179
- <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1180
1187
  <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1188
+ <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1181
1189
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1182
1190
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1183
1191
  <div class="split">
@@ -1,8 +1,8 @@
1
1
  // The session bar's info line (#infoText): the open session's id, its copy
2
2
  // chi --attach button, status, model, chain links, parent and delegates,
3
- // llm ctx, thinking, reasoning history and tools chips, model notes, memories, folder, ctx, speed,
3
+ // thinking, reasoning history, llm ctx and tools chips, model notes, memories, folder, ctx, speed,
4
4
  // session clock and the worker's version badge. A lit-html template over a
5
- // plain model app.js builds (updateInfoBar), so the markup lives here and
5
+ // plain model (info_bar_model.js, applied by app.js's updateInfoBar), so the markup lives here and
6
6
  // runs under node --test (spec/web/public/info_bar.test.js).
7
7
  //
8
8
  // It is redrawn every second while a turn runs (the session clock): lit
@@ -72,7 +72,7 @@ export function infoBarHtml(bar) {
72
72
  if (!bar) return nothing;
73
73
  const attach = `chi --attach ${bar.id}`;
74
74
  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)}${llmContextChip(bar.llmContext)}${thinkingChip(bar.thinking)}${reasoningHistoryChip(bar.reasoningHistory)}${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>`;
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>`;
76
76
  }
77
77
 
78
78
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip.
@@ -0,0 +1,110 @@
1
+ // What the session bar shows for the open session, as plain data: the info
2
+ // line's model (info_bar.js renders it) and its buttons' states (continue,
3
+ // delete, archive, stop), from the page's session state (session_state.js),
4
+ // its list and its in-flight actions. Pure: app.js's updateInfoBar applies
5
+ // it, every second while a turn runs (the session clock).
6
+ import { canStopSession, childrenSummary, delegatedBy, modelLabel, promptNotesChip } from "./format.js";
7
+ import { chainDate, chainNeighbours } from "./chains.js";
8
+ import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
9
+ import { formatDuration, sessionDurationNow } from "./timing.js";
10
+ import { workerBadge } from "./update_notice.js";
11
+
12
+ // The open session's worker runs an older chi than the newest installed:
13
+ // a badge, with Restart when the worker can (else the stop command).
14
+ // @param id the open session's id (null: none)
15
+ // @param state the open session's fields (session_state.js)
16
+ // @param versions {served, installed}: the chi serving the page, the newest
17
+ export function workerBadgeFor(id, state, versions) {
18
+ if (!id || state.owner !== "worker") return null;
19
+ return workerBadge({ workerVersion: state.workerVersion, features: state.workerFeatures,
20
+ installed: versions.installed, served: versions.served, sessionId: id });
21
+ }
22
+
23
+ // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
24
+ // shows it (info_bar.js chainLinkTemplate), or null. +cardTitle+: the
25
+ // lists' title for a session (a chain's opener reads its first link's).
26
+ export function chainLinkView(link, cardTitle) {
27
+ if (!link) return null;
28
+ return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? cardTitle(link.session) : "",
29
+ archived: !!link.session?.archived };
30
+ }
31
+
32
+ // The bar's buttons: continue (not on a link continued already, which has
33
+ // its → link, nor a terminal's session; "continuing…" while one runs),
34
+ // delete, archive (or unarchive), stop (while a worker runs the session).
35
+ export function infoButtons({ id, state, chain, archived, continuing, streaming }) {
36
+ const busy = !!id && continuing;
37
+ return {
38
+ continue: { shown: !!id && !chain.next && state.owner !== "tui", disabled: busy, text: busy ? "continuing…" : "continue →" },
39
+ delete: { shown: !!id },
40
+ archive: {
41
+ shown: !!id,
42
+ archived,
43
+ text: archived ? "unarchive" : "archive",
44
+ title: archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good",
45
+ },
46
+ stop: { shown: !!id && canStopSession({ owner: state.owner, streaming }) },
47
+ };
48
+ }
49
+
50
+ // The info line's model (info_bar.js renderInfoBar's +bar+).
51
+ // @param id the open session's id
52
+ // @param state the open session's fields (session_state.js)
53
+ // @param sessions the page's list (allSessions: archived ones too)
54
+ // @param chain chainNeighbours' {prev, next} for +id+
55
+ // @param cardTitle (session) => the lists' title for it
56
+ // @param badge workerBadgeFor's, or null
57
+ // @param restarting whether this session's Restart is under way
58
+ // @param running whether its turn runs (the session clock ticks)
59
+ // @param now the clock (ms)
60
+ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, now }) {
61
+ const tokens = state.timing.tokens;
62
+ const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
63
+ { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
64
+ budget: state.llmContext?.budget_tokens });
65
+ const speedTextNow = lastSpeedText(tokens);
66
+ const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
67
+ const sessionDuration = sessionDurationNow(state.timing, running);
68
+ // A delegated session names its parent, as a link to it.
69
+ const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
70
+ return {
71
+ id,
72
+ copied: now - state.attachCopiedAt < 2000,
73
+ status: state.status,
74
+ model: modelLabel(state.model, ...(state.served || [])),
75
+ // A link of a session chain: ← the previous link, → the next one (each
76
+ // only while the list holds it).
77
+ chain: { prev: chainLinkView(chain.prev, cardTitle), next: chainLinkView(chain.next, cardTitle) },
78
+ parent: parentChip ? { id: state.parentId, title: parentChip.title } : null,
79
+ // A parent names its delegates; the link opens all sessions.
80
+ kids: childrenSummary({ id }, sessions),
81
+ llmContext: state.llmContext,
82
+ thinking: state.thinking,
83
+ reasoningHistory: state.reasoningHistory,
84
+ // The tools chip (toolkit_chip.js): null on full.
85
+ toolkit: state.toolkit,
86
+ // The model notes the session's prompt carried.
87
+ notes: promptNotesChip(state.promptNotes),
88
+ memories: state.usedMemories,
89
+ preloaded: state.preloadedMemories,
90
+ muted: state.mutedMemories,
91
+ dir: state.dir,
92
+ ctx: { text: ctxBarText(state.ctxPct, state.ctxWindowSource), tip: ctxTip },
93
+ speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
94
+ clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
95
+ worker: badge ? { badge, busy: restarting } : null,
96
+ };
97
+ }
98
+
99
+ // The whole bar for the open session +id+ (null: the start page, no line).
100
+ // @param sessions the page's list; +continuing+, +restarting+: Sets of ids
101
+ // @return {buttons, bar}
102
+ export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, now }) {
103
+ const chain = id ? chainNeighbours(sessions, id, { continues: state.continues }) : { prev: null, next: null };
104
+ const archived = !!sessions.find((s) => s.id === id)?.archived;
105
+ const buttons = infoButtons({ id, state, chain, archived, continuing: continuing.has(id), streaming });
106
+ if (!id) return { buttons, bar: null };
107
+ const badge = workerBadgeFor(id, state, versions);
108
+ const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, now });
109
+ return { buttons, bar };
110
+ }
@@ -2,9 +2,10 @@
2
2
  // download) as a small row above the composer: a spinner line per running
3
3
  // task ("mcp · Starting MCP server chrome (first run, saving its tools)…"),
4
4
  // a "✓ chrome ready, 3 tools" line when one is done, which fades. A failed
5
- // one leaves the row: its warn card says why. No DOM here, so node --test
6
- // covers it; app.js draws the row.
7
- import { escapeHtml as esc } from "./format.js";
5
+ // one leaves the row: its warn card says why. The row is a lit-html
6
+ // template (initRowTemplate); app.js renders it into #initRow.
7
+ import { repeat } from "./vendor/directives/repeat.js";
8
+ import { html } from "./vendor/lit-html.js";
8
9
 
9
10
  // How long a done line stays before it fades out.
10
11
  export const DONE_MS = 2500;
@@ -42,19 +43,29 @@ export function applyInitEvent(tasks, type, data) {
42
43
  return data.id;
43
44
  }
44
45
 
46
+ // A done line's fade starts (DONE_MS after it was done): the line draws
47
+ // faded until dropDone takes it.
48
+ export function fadeDone(tasks, id) {
49
+ const task = tasks.get(id);
50
+ if (task?.status === "done") task.fading = true;
51
+ return tasks;
52
+ }
53
+
45
54
  // Drop a done line (its fade ended); a task started again under the id stays.
46
55
  export function dropDone(tasks, id) {
47
56
  if (tasks.get(id)?.status === "done") tasks.delete(id);
48
57
  return tasks;
49
58
  }
50
59
 
51
- // @returns the row's inner HTML ("" with nothing to show)
52
- export function initRowHtml(tasks) {
53
- return [...tasks.entries()].map(([id, t]) => {
54
- const bundle = `<span class="init-bundle">${esc(t.bundle)}</span>`;
60
+ // @returns the row's lines (a lit-html template; none with nothing to show),
61
+ // keyed by the task's id: a line keeps its node while the lines around it
62
+ // come and go (a faded line's node is never reused for the next one).
63
+ export function initRowTemplate(tasks) {
64
+ return repeat(tasks.entries(), ([id]) => id, ([id, t]) => {
65
+ const bundle = html`<span class="init-bundle">${t.bundle}</span>`;
55
66
  if (t.status === "done") {
56
- return `<div class="init-task done" data-id="${esc(id)}"><span class="init-mark" aria-hidden="true">✓</span>${bundle}<span class="init-text">${esc(t.summary || `${t.label}: done`)}</span></div>`;
67
+ return html`<div class=${`init-task done${t.fading ? " fading" : ""}`} data-id=${id}><span class="init-mark" aria-hidden="true">✓</span>${bundle}<span class="init-text">${t.summary || `${t.label}: done`}</span></div>`;
57
68
  }
58
- return `<div class="init-task running" data-id="${esc(id)}"><span class="init-spin" aria-hidden="true"></span>${bundle}<span class="init-text">${esc(t.label)}…</span></div>`;
59
- }).join("");
69
+ return html`<div class="init-task running" data-id=${id}><span class="init-spin" aria-hidden="true"></span>${bundle}<span class="init-text">${t.label}…</span></div>`;
70
+ });
60
71
  }
@@ -23,8 +23,9 @@
23
23
  // (thinking_chip.js keeps its choice): the form shows the model's level and
24
24
  // takes a choice for the next create.
25
25
 
26
- import { NEW_CHAT, SESSION, popoverPlace } from "./llm_context_chip.js";
27
- import { html, nothing, render } from "./vendor/lit-html.js";
26
+ import { createChipPopover } from "./chip_popover.js";
27
+ import { NEW_CHAT, SESSION } from "./llm_context_chip.js";
28
+ import { html, render } from "./vendor/lit-html.js";
28
29
 
29
30
  export const DEFAULT = "default";
30
31
 
@@ -74,101 +75,49 @@ export function formTemplate(cfg, summary, { running = false, kind = SESSION, ch
74
75
  ><div class="ctx-pop-actions"><button type="submit" class="llmctx-set">Set</button></div></form>`;
75
76
  }
76
77
 
77
- // The chip's popover, as createLlmContextControl's: el is where the chip
78
- // is drawn (clicks are delegated: #infoText, info_bar.js);
79
- // summary(): the open session's summary; session(): its id; running(): a
80
- // turn runs; run(line): sends the command. The start page's (kind
81
- // NEW_CHAT): summary() is the picked model's, choice() the pending level
82
- // and choose(next) takes a Set's (null for "follow the model"). id and
83
- // anchor (the chip's selector, by default its data attribute) keep it
78
+ // The chip's popover (chip_popover.js' shell), as createLlmContextControl's:
79
+ // el is where the chip is drawn (clicks are delegated: #infoText,
80
+ // info_bar.js); summary(): the open session's summary; session(): its id;
81
+ // running(): a turn runs; run(line): sends the command. The start page's
82
+ // (kind NEW_CHAT): summary() is the picked model's, choice() the pending
83
+ // level and choose(next) takes a Set's (null for "follow the model"). id
84
+ // and anchor (the chip's selector, by default its data attribute) keep it
84
85
  // apart from the other controls on the same element (the anchor defaults
85
86
  // to the config's).
86
87
  export function createLevelControl({ cfg, el, summary, session = () => null, running = () => false, run,
87
88
  kind = SESSION, choice = () => null, choose = null,
88
- id, anchor: anchorSel = cfg.anchor,
89
+ id, anchor = cfg.anchor,
89
90
  doc = document, win = window }) {
90
91
  const fresh = kind === NEW_CHAT;
91
- const pop = doc.createElement("div");
92
- pop.id = id;
93
- pop.className = `ctx-popover ${cfg.popoverClass}`;
94
- pop.setAttribute("role", "dialog");
95
- pop.hidden = true;
96
- doc.body.appendChild(pop);
97
92
  let initial = null;
98
- let openFor = null;
99
93
  // What the open form was drawn from: the model's or session's summary
100
- // and the form's options. A closed popover is empty, so each open draws
101
- // a fresh form (an unsent pick is gone).
94
+ // and the form's options.
102
95
  let shown = null;
103
- const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), pop);
96
+ const draw = (now) => render(formTemplate(cfg, shown.data, { ...shown.opts, now }), shell.pop);
104
97
 
105
- function place(anchor) {
106
- const width = Math.min(320, win.innerWidth - 32);
107
- pop.style.width = `${width}px`;
108
- pop.style.maxHeight = "";
109
- const at = popoverPlace(anchor.getBoundingClientRect(), { width, height: pop.offsetHeight,
110
- innerWidth: win.innerWidth, innerHeight: win.innerHeight });
111
- pop.style.left = `${at.left}px`;
112
- pop.style.top = at.top === undefined ? "" : `${at.top}px`;
113
- pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
114
- if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
115
- }
116
-
117
- function open(anchor) {
118
- const data = summary();
119
- if (!data) return;
120
- initial = (fresh ? choice() : data.own) || DEFAULT;
121
- openFor = session();
122
- shown = { data, opts: { running: running(), kind, choice: choice() } };
123
- draw(data);
124
- pop.hidden = false;
125
- place(anchor);
126
- }
127
-
128
- function close() {
129
- pop.hidden = true;
130
- initial = null;
131
- openFor = null;
132
- shown = null;
133
- render(nothing, pop);
134
- }
135
-
136
- function isOpen() { return !pop.hidden; }
137
-
138
- // A new summary: an open form keeps the user's pick; only "Now:" follows
139
- // (the same template again: lit writes that line alone).
140
- function refresh() {
141
- if (!isOpen()) return;
142
- const data = summary();
143
- if (!data || session() !== openFor) return close();
98
+ // A refresh (a newer summary): an open form keeps the user's pick; only
99
+ // "Now:" follows (the same template again: lit writes that line alone).
100
+ function show(data, opening) {
101
+ if (opening) {
102
+ initial = (fresh ? choice() : data.own) || DEFAULT;
103
+ shown = { data, opts: { running: running(), kind, choice: choice() } };
104
+ }
144
105
  draw(data);
145
106
  }
146
107
 
147
- el.addEventListener("click", (e) => {
148
- const anchor = e.target.closest(anchorSel);
149
- if (!anchor) return;
150
- e.stopPropagation();
151
- if (isOpen()) close();
152
- else open(anchor);
153
- });
154
- pop.addEventListener("submit", (e) => {
155
- e.preventDefault();
156
- const level = e.target.elements.level.value;
108
+ function submit(form) {
109
+ const level = form.elements.level.value;
157
110
  const was = initial;
158
- close();
111
+ shell.close();
159
112
  if (fresh) return choose(level === DEFAULT ? null : level);
160
113
  const line = commandLine(cfg, level, was);
161
114
  if (line) run(line);
162
- });
163
- pop.addEventListener("click", (e) => {
164
- if (e.target.closest("[data-act]")?.dataset.act === "close") close();
165
- });
166
- doc.addEventListener("click", (e) => {
167
- if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
168
- });
169
- doc.addEventListener("keydown", (e) => {
170
- if (e.key === "Escape" && isOpen()) close();
115
+ }
116
+
117
+ const shell = createChipPopover({
118
+ id, className: `ctx-popover ${cfg.popoverClass}`, width: 320, el, anchor, summary, session, show,
119
+ onClose: () => { initial = null; shown = null; }, onSubmit: submit, doc, win,
171
120
  });
172
121
 
173
- return { close, refresh, get open() { return isOpen(); } };
122
+ return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
174
123
  }
@@ -83,6 +83,40 @@ export function stripCountText(shown, total) {
83
83
  : `${total - shown} archived`;
84
84
  }
85
85
 
86
+ // How long a new card fades in and a status dot's pulse period: index.html's
87
+ // `.card.entering` fadeIn (.28s) and `.card .dot` dot-pulse / attn-pulse (1.6s).
88
+ export const CARD_FADE_MS = 280;
89
+ export const DOT_PULSE_MS = 1600;
90
+
91
+ // Which of a list's cards fade in. Every draw makes new nodes (draw), and a
92
+ // new node starts its CSS animation again: so only a card the list did not
93
+ // draw last time (+prev+: id → when it was first drawn, from performance.now();
94
+ // none for a list not drawn yet, or emptied: every card is new) gets
95
+ // `entering`, and one drawn again during its own fade keeps it, carried on
96
+ // from where it was (+entering+: id → ms since its first draw, the negative
97
+ // animation-delay) instead of starting over or snapping to full.
98
+ // +ids+: the cards drawn now. +drawn+ is the next draw's +prev+ (only +ids+).
99
+ export function enteringCards(prev, ids, now, fadeMs = CARD_FADE_MS) {
100
+ const drawn = new Map();
101
+ const entering = new Map();
102
+ for (const id of ids) {
103
+ const at = prev?.get(id) ?? now;
104
+ drawn.set(id, at);
105
+ if (now - at < fadeMs) entering.set(id, Math.max(0, now - at));
106
+ }
107
+ return { drawn, entering };
108
+ }
109
+
110
+ // A card's inline animation phases: its fade's delay (+enterMs+ into it) and
111
+ // the status dots' pulse, phase-locked to the clock (+now+, performance.now())
112
+ // so a redrawn dot carries on pulsing where the last one was. "" for neither.
113
+ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_PULSE_MS) {
114
+ const parts = [];
115
+ if (enterMs) parts.push(`--enter-delay:-${Math.round(enterMs)}ms`);
116
+ if (now !== null) parts.push(`--pulse-delay:-${Math.round(now % period)}ms`);
117
+ return parts.join(";");
118
+ }
119
+
86
120
  // A card: the first message as its title, then status, how long ago, owner
87
121
  // and memories. The id is in the tooltip (and the search) only. A session
88
122
  // that waits on the user (a question, an approval, a card with actions)
@@ -95,11 +129,13 @@ export function stripCountText(shown, total) {
95
129
  // member does; with rows (all sessions) an open family lists its members
96
130
  // inline, the strip's opens in a popover; matchIds: a search's matching
97
131
  // members. A family in a session chain (chains.js foldChains) carries its
98
- // `day N` chip, which opens the other links in a popover.
132
+ // `day N` chip, which opens the other links in a popover. entering: a card
133
+ // new to its list (enteringCards: it fades in); anim: its inline animation
134
+ // phases (cardAnimStyle).
99
135
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
100
136
  // open session), chainOpen (its chain's popover is open), parent (the
101
137
  // delegating session, or null).
102
- export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
138
+ export function cardTemplate(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null, entering = false, anim = "" } = {}, { title = "", projectName = null, activeId = null, chainOpen = false, parent = null } = {}) {
103
139
  const fam = family && family.members.length ? family : null;
104
140
  const ts = s.updated_at || s.created_at || "";
105
141
  const tip = `${s.id} · updated ${ts} · ${s.status}`;
@@ -128,9 +164,9 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
128
164
  // Its delegates and its chain's other links: the card is .contains-active while one is open.
129
165
  const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
130
166
  ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
131
- const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}`;
167
+ const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}${entering ? " entering" : ""}`;
132
168
  const recap = s.recap ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
133
- return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
169
+ return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}><div class="preview">${title}</div>${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
134
170
  }
135
171
 
136
172
  function cardToolsTemplate(s, { deletable, archivable }) {
@@ -157,8 +193,10 @@ function ownerBadgeTemplate(s) {
157
193
  }
158
194
 
159
195
  // The strip's end: the All sessions tile (with its count) and hide ▴.
160
- export function stripTileTemplate(count) {
161
- return html`<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
196
+ // entering / anim: as a card's (cardTemplate). id and title are bound, as
197
+ // class is, so the attributes keep their order.
198
+ export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
199
+ return html`<div class="strip-side"><button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
162
200
  }
163
201
 
164
202
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
@@ -261,6 +299,18 @@ export function createListsView(deps) {
261
299
  return ordered;
262
300
  }
263
301
 
302
+ // The cards each list actually drew (the strip's after its cut to what
303
+ // fits; not drawnOrder, which select mode reads), id → when first drawn:
304
+ // a card not in it fades in (enteringCards). A list drawn empty forgets.
305
+ const lastDrawn = new Map(); // list element → Map(id → performance.now() at its first draw)
306
+ const STRIP_TILE = "#allTile"; // the All sessions tile's id there (no session id starts with #)
307
+ function enteringFor(el, ids) {
308
+ const now = win.performance?.now() ?? Date.now();
309
+ const { drawn, entering } = enteringCards(lastDrawn.get(el), ids, now);
310
+ lastDrawn.set(el, drawn);
311
+ return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id), now }) });
312
+ }
313
+
264
314
  // The strip's cards: stripOrder, held under the pointer, as many as fit.
265
315
  function stripCards() {
266
316
  return listOrder(topStripEl, stripOrder(shownSessions(), sessions()), familyId).slice(0, stripCols);
@@ -425,6 +475,7 @@ export function createListsView(deps) {
425
475
  const refused = scopeError();
426
476
  if (refused) {
427
477
  draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
478
+ lastDrawn.delete(topStripEl);
428
479
  familyPop.close();
429
480
  chainPop.close();
430
481
  return;
@@ -433,12 +484,14 @@ export function createListsView(deps) {
433
484
  // Only archived sessions: no cards, but the tile stays, the way to them.
434
485
  if (!all.length) {
435
486
  topStripEl.replaceChildren();
487
+ lastDrawn.delete(topStripEl);
436
488
  familyPop.close();
437
489
  chainPop.close();
438
490
  return;
439
491
  }
440
- const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length));
441
- draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id }))}${tile}`);
492
+ const anim = enteringFor(topStripEl, [...cards.map(familyId), STRIP_TILE]);
493
+ const tile = stripTileTemplate(stripCountText(shownSessions().length, all.length), anim(STRIP_TILE));
494
+ draw(topStripEl, html`${cards.map((f) => cardFor(f.head, { family: f, open: familyPop.openHead() === f.head.id, ...anim(familyId(f)) }))}${tile}`);
442
495
  bindCards(topStripEl, select, {
443
496
  onFamily: (id, _chip, e) => {
444
497
  familyPop.toggle(id);
@@ -490,6 +543,7 @@ export function createListsView(deps) {
490
543
  updateSelectBar();
491
544
  if (!fams.length) {
492
545
  drawnOpen.clear();
546
+ lastDrawn.delete(allListEl);
493
547
  draw(allListEl, html`<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`);
494
548
  if (chainFrom === allListEl) chainPop.refresh();
495
549
  return;
@@ -501,11 +555,12 @@ export function createListsView(deps) {
501
555
  ? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
502
556
  : { deletable: true, archivable: true });
503
557
  drawnOpen.clear();
558
+ const anim = enteringFor(allListEl, fams.map(familyId));
504
559
  draw(allListEl, html`${fams.map((f) => {
505
560
  const matched = !!f.matchIds?.size;
506
561
  const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
507
562
  drawnOpen.set(f.head.id, open);
508
- return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
563
+ return cardFor(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null, ...anim(familyId(f)) });
509
564
  })}`);
510
565
  bindCards(allListEl, (id, e) => {
511
566
  if (selecting.on) {