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