samagotchi 0.66.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -1
|
@@ -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
|
-
|
|
161
|
-
|
|
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
|
|
441
|
-
|
|
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) {
|
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// choice for the next create only (POST /api/sessions' llm_context), never
|
|
13
13
|
// remembered: nothing is sent unless the user sets one.
|
|
14
14
|
|
|
15
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
15
16
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const STRATEGIES = [
|
|
18
19
|
{ value: "none", label: "none" },
|
|
19
20
|
{ value: "stale", label: "stale" },
|
|
20
21
|
{ value: "stale,forget", label: "stale + forget (experimental)" },
|
|
@@ -144,7 +145,7 @@ export function choiceWords(choice) {
|
|
|
144
145
|
|
|
145
146
|
// The strategy list, with the session's own value when it isn't one of them
|
|
146
147
|
// (forget alone, a newer chi's layer).
|
|
147
|
-
|
|
148
|
+
function strategyOptions(chosen) {
|
|
148
149
|
if (chosen === DEFAULT || STRATEGIES.some((s) => s.value === chosen)) return STRATEGIES;
|
|
149
150
|
return [...STRATEGIES, { value: chosen, label: chosen }];
|
|
150
151
|
}
|
|
@@ -201,113 +202,40 @@ export function renderForm(target, summary, opts = {}) {
|
|
|
201
202
|
render(formTemplate(summary, opts), target);
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
// the room above is short of its height and below has more; capped to the
|
|
209
|
-
// room on that side (it scrolls), never past the window's edges.
|
|
210
|
-
// @return {{left, top?, bottom?, maxHeight?}}
|
|
211
|
-
export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
212
|
-
const left = Math.max(EDGE, Math.min(rect.left, innerWidth - width - EDGE));
|
|
213
|
-
const above = rect.top - GAP - EDGE;
|
|
214
|
-
const below = innerHeight - rect.bottom - GAP - EDGE;
|
|
215
|
-
const down = height > above && below > above;
|
|
216
|
-
const room = Math.max(0, down ? below : above);
|
|
217
|
-
const place = down ? { left, top: rect.bottom + GAP } : { left, bottom: Math.max(EDGE, innerHeight - rect.top + GAP) };
|
|
218
|
-
return height > room ? { ...place, maxHeight: room } : place;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// The chip's popover. el: where the chip is drawn (#infoText, info_bar.js:
|
|
222
|
-
// clicks are delegated); summary(): the open session's llm_context;
|
|
223
|
-
// session(): the open session's id; running(): a turn runs; run(line):
|
|
224
|
-
// sends the command.
|
|
205
|
+
// The chip's popover (chip_popover.js' shell). el: where the chip is drawn
|
|
206
|
+
// (#infoText, info_bar.js: clicks are delegated); summary(): the open
|
|
207
|
+
// session's llm_context; session(): the open session's id; running(): a
|
|
208
|
+
// turn runs; run(line): sends the command.
|
|
225
209
|
// The start page's (kind NEW_CHAT): summary() is the picked model's,
|
|
226
210
|
// choice() the pending choice, and choose(next) takes a Set's (null for
|
|
227
211
|
// "follow the model") instead of a command running. id and anchor (the
|
|
228
212
|
// chip's selector) keep the two controls apart.
|
|
229
213
|
export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
|
|
230
214
|
kind = SESSION, choice = () => null, choose = null,
|
|
231
|
-
id = "llmContextPopover", anchor
|
|
215
|
+
id = "llmContextPopover", anchor = ".llmctx-chip",
|
|
232
216
|
doc = document, win = window }) {
|
|
233
217
|
const fresh = kind === NEW_CHAT;
|
|
234
|
-
|
|
235
|
-
pop.id = id;
|
|
236
|
-
pop.className = "ctx-popover llmctx-popover";
|
|
237
|
-
pop.setAttribute("role", "dialog");
|
|
238
|
-
pop.hidden = true;
|
|
239
|
-
doc.body.appendChild(pop);
|
|
240
|
-
// The values the form opened with, and the session it is for.
|
|
218
|
+
// The values the form opened with.
|
|
241
219
|
let initial = null;
|
|
242
|
-
let openFor = null;
|
|
243
220
|
// What the open form was drawn from: the summary, the form's options and
|
|
244
|
-
// the summary its "Now:" line reads.
|
|
245
|
-
// open draws a fresh form (unsent edits are gone).
|
|
221
|
+
// the summary its "Now:" line reads.
|
|
246
222
|
let shown = null;
|
|
247
223
|
const draw = (changes = {}) => {
|
|
248
224
|
shown = { ...shown, ...changes };
|
|
249
|
-
renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
|
|
225
|
+
renderForm(shell.pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
|
|
250
226
|
};
|
|
251
227
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
innerWidth: win.innerWidth, innerHeight: win.innerHeight });
|
|
258
|
-
pop.style.left = `${at.left}px`;
|
|
259
|
-
pop.style.top = at.top === undefined ? "" : `${at.top}px`;
|
|
260
|
-
pop.style.bottom = at.bottom === undefined ? "" : `${at.bottom}px`;
|
|
261
|
-
if (at.maxHeight !== undefined) pop.style.maxHeight = `${at.maxHeight}px`;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function open(anchor) {
|
|
265
|
-
const data = summary();
|
|
266
|
-
if (!data) return;
|
|
228
|
+
// A refresh (a newer summary): an open form keeps what the user typed;
|
|
229
|
+
// only its "Now:" line follows (the same template again: lit writes that
|
|
230
|
+
// line alone).
|
|
231
|
+
function show(data, opening) {
|
|
232
|
+
if (!opening) return draw({ now: data });
|
|
267
233
|
initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
|
|
268
|
-
openFor = session();
|
|
269
234
|
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
270
235
|
draw({ now: data, error: null });
|
|
271
|
-
pop.hidden = false;
|
|
272
|
-
place(anchor);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function close() {
|
|
276
|
-
pop.hidden = true;
|
|
277
|
-
initial = null;
|
|
278
|
-
openFor = null;
|
|
279
|
-
shown = null;
|
|
280
|
-
render(nothing, pop);
|
|
281
236
|
}
|
|
282
237
|
|
|
283
|
-
function
|
|
284
|
-
|
|
285
|
-
// A new summary (a command_ran, a re-read): an open form keeps what the
|
|
286
|
-
// user typed; only its "Now:" line follows (the same template again: lit
|
|
287
|
-
// writes that line alone). Another session closes it.
|
|
288
|
-
function refresh() {
|
|
289
|
-
if (!isOpen()) return;
|
|
290
|
-
const data = summary();
|
|
291
|
-
if (!data || session() !== openFor) return close();
|
|
292
|
-
draw({ now: data });
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
el.addEventListener("click", (e) => {
|
|
296
|
-
const anchor = e.target.closest(anchorSel);
|
|
297
|
-
if (!anchor) return;
|
|
298
|
-
e.stopPropagation();
|
|
299
|
-
if (isOpen()) close();
|
|
300
|
-
else open(anchor);
|
|
301
|
-
});
|
|
302
|
-
// An edit makes an error the last Set showed stale.
|
|
303
|
-
const clearError = () => {
|
|
304
|
-
if (shown?.error) draw({ error: null });
|
|
305
|
-
};
|
|
306
|
-
pop.addEventListener("input", clearError);
|
|
307
|
-
pop.addEventListener("change", clearError);
|
|
308
|
-
pop.addEventListener("submit", (e) => {
|
|
309
|
-
e.preventDefault();
|
|
310
|
-
const form = e.target;
|
|
238
|
+
function submit(form) {
|
|
311
239
|
const values = { strategy: form.elements.strategy.value, apply: form.elements.apply.value,
|
|
312
240
|
budget: form.elements.budget.value };
|
|
313
241
|
if (fresh) {
|
|
@@ -318,29 +246,32 @@ export function createLlmContextControl({ el, summary, session = () => null, run
|
|
|
318
246
|
draw({ error: err.message });
|
|
319
247
|
return;
|
|
320
248
|
}
|
|
321
|
-
close();
|
|
249
|
+
shell.close();
|
|
322
250
|
choose(next);
|
|
323
251
|
return;
|
|
324
252
|
}
|
|
325
253
|
const line = commandLine(values, initial);
|
|
326
|
-
close();
|
|
254
|
+
shell.close();
|
|
327
255
|
if (line) run(line);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function act(name) {
|
|
259
|
+
if (name !== "reset") return;
|
|
260
|
+
shell.close();
|
|
261
|
+
if (fresh) choose(null);
|
|
262
|
+
else run(commandLine());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const shell = createChipPopover({
|
|
266
|
+
id, className: "ctx-popover llmctx-popover", width: 360, el, anchor, summary, session, show,
|
|
267
|
+
onClose: () => { initial = null; shown = null; }, onSubmit: submit, onAct: act, doc, win,
|
|
328
268
|
});
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
else run(commandLine());
|
|
336
|
-
}
|
|
337
|
-
});
|
|
338
|
-
doc.addEventListener("click", (e) => {
|
|
339
|
-
if (isOpen() && !pop.contains(e.target) && !e.target.closest?.(anchorSel)) close();
|
|
340
|
-
});
|
|
341
|
-
doc.addEventListener("keydown", (e) => {
|
|
342
|
-
if (e.key === "Escape" && isOpen()) close();
|
|
343
|
-
});
|
|
269
|
+
// An edit makes an error the last Set showed stale.
|
|
270
|
+
const clearError = () => {
|
|
271
|
+
if (shown?.error) draw({ error: null });
|
|
272
|
+
};
|
|
273
|
+
shell.pop.addEventListener("input", clearError);
|
|
274
|
+
shell.pop.addEventListener("change", clearError);
|
|
344
275
|
|
|
345
|
-
return { close, refresh, get open() { return isOpen(); } };
|
|
276
|
+
return { close: shell.close, refresh: shell.refresh, get open() { return shell.isOpen(); } };
|
|
346
277
|
}
|
|
@@ -9,26 +9,32 @@
|
|
|
9
9
|
// has it. The search input keeps the focus:
|
|
10
10
|
// ↑/↓ PageUp/PageDown move, ⏎ picks, Esc and Tab close without a change.
|
|
11
11
|
|
|
12
|
-
import { escapeHtml } from "./format.js";
|
|
13
12
|
import { moveSelection } from "./command_complete.js";
|
|
14
13
|
import {
|
|
15
14
|
MODEL_KEY, RECENT_KEY, groupRows, matchModels, modelRows, pickModel, pushRecent, recentRows, rowNote, rowTitle,
|
|
16
15
|
} from "./model_pick.js";
|
|
16
|
+
import { html, nothing, render as draw } from "./vendor/lit-html.js";
|
|
17
17
|
|
|
18
18
|
const PAGE = 8;
|
|
19
19
|
const MAX_HEIGHT = 360;
|
|
20
20
|
const MIN_ROOM = 200;
|
|
21
21
|
const GAP = 8;
|
|
22
22
|
|
|
23
|
-
// The text with the [start, end) ranges in <mark
|
|
24
|
-
function
|
|
25
|
-
|
|
23
|
+
// The text with the [start, end) ranges in <mark> (lit-html parts).
|
|
24
|
+
function markedTemplate(text, marks) {
|
|
25
|
+
const parts = [];
|
|
26
26
|
let at = 0;
|
|
27
27
|
for (const [a, b] of marks || []) {
|
|
28
|
-
|
|
28
|
+
parts.push(text.slice(at, a), html`<mark>${text.slice(a, b)}</mark>`);
|
|
29
29
|
at = b;
|
|
30
30
|
}
|
|
31
|
-
|
|
31
|
+
parts.push(text.slice(at));
|
|
32
|
+
return parts;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A group's head ("Recent", a host with its notes).
|
|
36
|
+
function headTemplate(label, note = "") {
|
|
37
|
+
return html`<div class="model-group" role="presentation">${label}${note ? html` <span>${note}</span>` : nothing}</div>`;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
function readRecent() {
|
|
@@ -58,40 +64,38 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
58
64
|
button.classList.toggle("unavailable", rows.some((r) => r.name === name && r.unavailable === true));
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
// One option row. The active one is drawn so here and kept so by
|
|
68
|
+
// showActive (the arrows and the pointer move it without a redraw).
|
|
69
|
+
function rowTemplate(row, i, { hostMarks = [], idMarks = [], showHost = !row.isDefaultHost } = {}) {
|
|
70
|
+
const host = showHost
|
|
71
|
+
? html`<span class="model-host">${markedTemplate(row.host, hostMarks)}</span><span class="model-dot"> · </span>` : nothing;
|
|
63
72
|
const noteText = rowNote(row, defaultName);
|
|
64
|
-
const note = noteText ? `<span class="model-note">${noteText}</span>` :
|
|
73
|
+
const note = noteText ? html`<span class="model-note">${noteText}</span>` : nothing;
|
|
65
74
|
const cls = `model-option${i === active ? " active" : ""}${row.name === chosen ? " chosen" : ""}${row.unavailable ? " unavailable" : ""}`;
|
|
66
|
-
|
|
67
|
-
const title = titleText ? ` title="${escapeHtml(titleText)}"` : "";
|
|
68
|
-
return `<div class="${cls}" role="option" id="model-option-${i}" aria-selected="${i === active}" data-index="${i}"${title}>` +
|
|
69
|
-
`<span class="model-id">${host}${markedHtml(row.id, idMarks)}</span>${note}</div>`;
|
|
75
|
+
return html`<div class=${cls} role="option" id=${`model-option-${i}`} aria-selected=${String(i === active)} data-index=${i} title=${rowTitle(row) || nothing}><span class="model-id">${host}${markedTemplate(row.id, idMarks)}</span>${note}</div>`;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function render() {
|
|
73
79
|
const query = input.value.trim();
|
|
74
|
-
|
|
80
|
+
const parts = [];
|
|
75
81
|
shown = [];
|
|
76
|
-
const add = (row, opts) => {
|
|
82
|
+
const add = (row, opts) => { parts.push(rowTemplate(row, shown.length, opts)); shown.push(row); };
|
|
77
83
|
if (query) {
|
|
78
84
|
for (const m of matchModels(rows, query)) add(m.row, { hostMarks: m.hostMarks, idMarks: m.idMarks });
|
|
79
|
-
if (!shown.length) html
|
|
85
|
+
if (!shown.length) parts.push(html`<div class="model-empty" role="presentation">Nothing matches</div>`);
|
|
80
86
|
} else {
|
|
81
87
|
const recent = recentRows(rows, readRecent());
|
|
82
|
-
const head = (label, note = "") =>
|
|
83
|
-
`<div class="model-group" role="presentation">${escapeHtml(label)}${note ? ` <span>${escapeHtml(note)}</span>` : ""}</div>`;
|
|
84
88
|
if (recent.length) {
|
|
85
|
-
|
|
89
|
+
parts.push(headTemplate("Recent"));
|
|
86
90
|
recent.forEach((row) => add(row));
|
|
87
91
|
}
|
|
88
92
|
for (const group of groupRows(rows)) {
|
|
89
|
-
|
|
93
|
+
parts.push(headTemplate(group.host, [group.isDefault ? "default host" : "", group.unavailable ? "host down" : ""].filter(Boolean).join(" · ")));
|
|
90
94
|
group.rows.forEach((row) => add(row, { showHost: false }));
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
if (active >= shown.length) active = 0;
|
|
94
|
-
list
|
|
98
|
+
draw(parts, list);
|
|
95
99
|
showActive();
|
|
96
100
|
}
|
|
97
101
|
|
|
@@ -144,7 +148,7 @@ export function createModelPicker({ button, panel, input, list, composer, center
|
|
|
144
148
|
function close({ refocus = false } = {}) {
|
|
145
149
|
if (!isOpen()) return;
|
|
146
150
|
panel.hidden = true;
|
|
147
|
-
list
|
|
151
|
+
draw(nothing, list);
|
|
148
152
|
shown = [];
|
|
149
153
|
button.setAttribute("aria-expanded", "false");
|
|
150
154
|
input.setAttribute("aria-expanded", "false");
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// summaries (GET /api/events). The page shows an OS notification and a
|
|
3
3
|
// title badge for what it returns, while the tab is not in front.
|
|
4
4
|
|
|
5
|
+
import { clip } from "./format.js";
|
|
6
|
+
|
|
5
7
|
// The bell's state. OS notifications need a secure context: on chi web
|
|
6
8
|
// over http at a LAN address the API is missing or always denied, so the
|
|
7
9
|
// bell is hidden there (the title badge still counts).
|
|
@@ -16,7 +18,7 @@ export function notifyState({ hasNotification, secureContext, permission, wanted
|
|
|
16
18
|
// chi ran for a delegate child's report (last_turn origin delegate_report)
|
|
17
19
|
// or an attached context change (context): nobody started it, so nobody
|
|
18
20
|
// watched it.
|
|
19
|
-
|
|
21
|
+
const DONE_MIN_SECONDS = 10;
|
|
20
22
|
const UNWATCHED_ORIGINS = ["delegate_report", "context"];
|
|
21
23
|
|
|
22
24
|
// The step-limit question (kind "continue": a turn ran out of iterations
|
|
@@ -90,7 +92,7 @@ const cardKey = (sessionId, cardId) => `${sessionId}:card:${cardId}`;
|
|
|
90
92
|
// @param prev the summary held before (null for a session not seen yet)
|
|
91
93
|
// @param next the new summary (null when the session is gone)
|
|
92
94
|
// @return [String] keys, maybe none
|
|
93
|
-
|
|
95
|
+
function closedKeys(prev, next) {
|
|
94
96
|
const keys = [];
|
|
95
97
|
const q = prev?.pending_question;
|
|
96
98
|
const nq = next?.pending_question;
|
|
@@ -147,7 +149,7 @@ export function trackAttention(state, type, data = {}) {
|
|
|
147
149
|
// No question or answer text (it would show on a lock screen).
|
|
148
150
|
export function attentionText(session, attention) {
|
|
149
151
|
const name = String(session?.title || session?.first_preview || session?.last_prompt || "chi session").trim();
|
|
150
|
-
const title = name
|
|
152
|
+
const title = clip(name, 61);
|
|
151
153
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
152
154
|
const body = {
|
|
153
155
|
question: "needs an answer",
|
|
@@ -171,7 +173,7 @@ export function attentionText(session, attention) {
|
|
|
171
173
|
// lowest claim (a tab id) shows it (4.11). Without BroadcastChannel every
|
|
172
174
|
// tab goes on alone, at once.
|
|
173
175
|
export const NOTIFY_CHANNEL = "chi-notify";
|
|
174
|
-
|
|
176
|
+
const NOTIFY_HOLD_MS = 300;
|
|
175
177
|
// How long a key seen in front is remembered (its frame may come late).
|
|
176
178
|
const SEEN_TTL_MS = 10 * 60 * 1000;
|
|
177
179
|
|
|
@@ -70,7 +70,7 @@ export function approvalView(pq) {
|
|
|
70
70
|
const reason = a.reason ? String(a.reason) : "(no reason given)";
|
|
71
71
|
return {
|
|
72
72
|
// A plugin tool's label ("chrome: screenshot"), as its row shows it;
|
|
73
|
-
// else the tool the call acts as (an MCP tool behind
|
|
73
|
+
// else the tool the call acts as (an MCP tool behind call_tool).
|
|
74
74
|
tool: a.label || a.acts_as || a.tool || "",
|
|
75
75
|
// A tool without a command or path (an MCP tool): its compact args.
|
|
76
76
|
what: command || paths.join("\n") || a.args || "",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// are left alone, inline code only when it is the ref alone, a bare URL's
|
|
8
8
|
// text too.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { refSpan } from "./refs.js";
|
|
11
11
|
|
|
12
12
|
// A path (no spaces), a line, an optional end line and an optional column
|
|
13
13
|
// (kept in the text, not used). LineLinker::REF.
|
|
@@ -30,7 +30,7 @@ const SKIP = "pre, a, [data-ref-kind]";
|
|
|
30
30
|
// The ref a match names: { path, line, endLine } or null. A path has a `/`
|
|
31
31
|
// or an extension (`10:30` is a time, `host:8080` a port); line 0 is never
|
|
32
32
|
// a ref; an end line not after the line is dropped.
|
|
33
|
-
|
|
33
|
+
function refOf(groups) {
|
|
34
34
|
const path = groups?.path || "";
|
|
35
35
|
if (!path.includes("/") && !/\.[A-Za-z0-9]+$/.test(path)) return null;
|
|
36
36
|
if (groups.from === undefined) return { path, line: null, endLine: null };
|
|
@@ -168,12 +168,6 @@ function wrapRefs(node, hits) {
|
|
|
168
168
|
node.replaceWith(frag);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
function refSpan(doc, ref) {
|
|
172
|
-
const holder = doc.createElement("div");
|
|
173
|
-
holder.innerHTML = `<span class="ref"${refAttrs(ref)}></span>`;
|
|
174
|
-
return holder.firstChild;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
171
|
// [start, end] of each fenced block (an unclosed one runs to the end).
|
|
178
172
|
function fencedBlocks(text) {
|
|
179
173
|
const blocks = [];
|
|
@@ -32,6 +32,19 @@ export function refAttrs(ref) {
|
|
|
32
32
|
return ` data-ref-kind="${escapeHtml(ref.kind)}"${attr("path", ref.path)}${attr("line", ref.line)}${attr("end-line", ref.end_line)}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// A span with refAttrs' ref class and data attributes, for a page that
|
|
36
|
+
// builds DOM (ref_scan.js).
|
|
37
|
+
export function refSpan(doc, ref) {
|
|
38
|
+
const span = doc.createElement("span");
|
|
39
|
+
span.className = "ref";
|
|
40
|
+
if (!ref?.kind) return span;
|
|
41
|
+
span.dataset.refKind = ref.kind;
|
|
42
|
+
for (const [name, value] of [["refPath", ref.path], ["refLine", ref.line], ["refEndLine", ref.end_line]]) {
|
|
43
|
+
if (value) span.dataset[name] = String(value);
|
|
44
|
+
}
|
|
45
|
+
return span;
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
// A kind, as the server's Ref allows it (Ref::KIND).
|
|
36
49
|
const KIND = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
37
50
|
|
|
@@ -50,6 +50,15 @@ export function emptySessionState() {
|
|
|
50
50
|
reasoningHistory: null,
|
|
51
51
|
toolkit: null,
|
|
52
52
|
timing: normalizeTiming(),
|
|
53
|
+
// The event id (or, from an older worker, the event_seq) the session was
|
|
54
|
+
// rendered at (null: no live worker then), where a stream opened later
|
|
55
|
+
// has to start; set by the page's resync, not by the payload.
|
|
56
|
+
streamSeq: null,
|
|
57
|
+
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
58
|
+
// new turn has started on it yet (app.js followWokenWorker).
|
|
59
|
+
streamFromGuess: false,
|
|
60
|
+
// When the info bar's "copy chi --attach" copied (its "copied ✓").
|
|
61
|
+
attachCopiedAt: 0,
|
|
53
62
|
};
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -65,6 +74,11 @@ export function sessionStateFrom(data) {
|
|
|
65
74
|
served: s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null,
|
|
66
75
|
dir: s.working_directory,
|
|
67
76
|
owner: s.owner || null,
|
|
77
|
+
// Its live worker's chi and features (null and [] without one): a
|
|
78
|
+
// switch shows its stale-worker badge, task stop and queue at once,
|
|
79
|
+
// not at the session's next hub event (followSelectedSession).
|
|
80
|
+
workerVersion: s.worker_version || null,
|
|
81
|
+
workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
|
|
68
82
|
timing: normalizeTiming(data.timing),
|
|
69
83
|
};
|
|
70
84
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalize } from "./format.js";
|
|
1
|
+
import { clip, normalize } from "./format.js";
|
|
2
2
|
|
|
3
3
|
// Where the open session's title shows: once on screen. Its strip card says
|
|
4
4
|
// it when the strip is shown and has an `.active` card for it; otherwise
|
|
@@ -21,6 +21,5 @@ export function topBarTitle({ title, cardShown, stripHidden, allView }) {
|
|
|
21
21
|
export function tabTitle({ title, base }) {
|
|
22
22
|
const text = normalize(title);
|
|
23
23
|
if (!text) return base;
|
|
24
|
-
|
|
25
|
-
return `${cut} · ${base}`;
|
|
24
|
+
return `${clip(text, TAB_TITLE_CHARS + 1)} · ${base}`;
|
|
26
25
|
}
|
|
@@ -188,14 +188,6 @@ export function familySummary(f) {
|
|
|
188
188
|
return delegatesSummary(f.members.map((m) => m.session));
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
// The chip's words: "▸ 3 delegates · 1 live · 1 waiting".
|
|
192
|
-
export function familyChipText(sum) {
|
|
193
|
-
if (!sum) return "";
|
|
194
|
-
const waiting = sum.counts?.waiting || 0;
|
|
195
|
-
return [`\u25B8 ${sum.total} ${sum.total === 1 ? "delegate" : "delegates"}`,
|
|
196
|
-
sum.live ? `${sum.live} live` : "", waiting ? `${waiting} waiting` : ""].filter(Boolean).join(" \u00b7 ");
|
|
197
|
-
}
|
|
198
|
-
|
|
199
191
|
// Whether a family's list is open: the user's toggle (+toggles+, head id →
|
|
200
192
|
// bool) wins; else open when a member waits on the user (the head's own
|
|
201
193
|
// wait is on its card already) or a search matched a member.
|