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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -1
- data/docs/architecture.md +6 -3
- data/docs/internals/engine.md +58 -9
- data/docs/sessions.md +1 -1
- data/docs/testing.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +211 -358
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/session.rb +22 -8
- 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/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +55 -84
- data/lib/samagotchi/web/public/app.js +74 -505
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- 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/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 +36 -105
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/ref_scan.js +1 -7
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +33 -0
- 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/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/worker.rb +11 -16
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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)}${
|
|
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.
|
|
6
|
-
//
|
|
7
|
-
import {
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
59
|
-
})
|
|
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 {
|
|
27
|
-
import {
|
|
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:
|
|
78
|
-
// is drawn (clicks are delegated: #infoText,
|
|
79
|
-
// summary(): the open session's summary; session(): its id;
|
|
80
|
-
// turn runs; run(line): sends the command. The start page's
|
|
81
|
-
// NEW_CHAT): summary() is the picked model's, choice() the pending
|
|
82
|
-
// and choose(next) takes a Set's (null for "follow the model"). id
|
|
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
|
|
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.
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
148
|
-
const
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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) {
|