samagotchi 0.115.0 → 0.117.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 +76 -1
- data/bin/chi +2 -0
- data/docs/cli.md +33 -2
- data/docs/configuration.md +13 -0
- data/docs/guardrails.md +188 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
- data/lib/samagotchi/bridge.rb +114 -14
- data/lib/samagotchi/bridge_client.rb +20 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +34 -3
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +166 -2
- data/lib/samagotchi/guardrails/approval.rb +20 -0
- data/lib/samagotchi/guardrails/targets.rb +23 -2
- data/lib/samagotchi/guardrails/verdict.rb +23 -3
- data/lib/samagotchi/guardrails_command.rb +329 -0
- data/lib/samagotchi/hold_stop.rb +89 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_commands.rb +79 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/system_one/answer.rb +78 -0
- data/lib/samagotchi/system_one/approval_question.rb +113 -0
- data/lib/samagotchi/system_one/client.rb +89 -0
- data/lib/samagotchi/system_one/errors.rb +14 -0
- data/lib/samagotchi/system_one/fake_backend.rb +45 -0
- data/lib/samagotchi/system_one/http_backend.rb +129 -0
- data/lib/samagotchi/system_one/log.rb +133 -0
- data/lib/samagotchi/system_one/question.rb +86 -0
- data/lib/samagotchi/system_one/record.rb +83 -0
- data/lib/samagotchi/system_one/self_protect.rb +90 -0
- data/lib/samagotchi/system_one/similar.rb +69 -0
- data/lib/samagotchi/system_one.rb +25 -0
- data/lib/samagotchi/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +20 -1
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/composer_fold.js +8 -8
- data/lib/samagotchi/web/public/composer_grip.js +65 -22
- data/lib/samagotchi/web/public/composer_size.js +40 -5
- data/lib/samagotchi/web/public/data.js +24 -0
- data/lib/samagotchi/web/public/favicon.svg +1 -1
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +21 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -0
- data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
- data/lib/samagotchi/web/public/timing.js +9 -5
- data/lib/samagotchi/web/public/turn_events.js +1 -0
- data/lib/samagotchi/web/public/turn_html.js +13 -2
- data/lib/samagotchi/web/session_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +62 -3
- data/lib/samagotchi/web/session_show.rb +35 -5
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +17 -1
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -236,6 +236,8 @@ module Samagotchi
|
|
|
236
236
|
["POST", %r{\A/api/sessions/([^/]+)/tasks/([^/]+)/stop\z}, :handle_task_stop, :owner, :session],
|
|
237
237
|
["POST", %r{\A/api/sessions/([^/]+)/turn/([^/]+)/dequeue\z}, :handle_dequeue_turn, :participant, :session],
|
|
238
238
|
["POST", %r{\A/api/sessions/([^/]+)/stop\z}, :handle_stop, :owner, :session],
|
|
239
|
+
["GET", %r{\A/api/sessions/([^/]+)/holds\z}, :handle_session_holds, :owner, :session],
|
|
240
|
+
["POST", %r{\A/api/sessions/([^/]+)/holds/stop\z}, :handle_stop_holds, :owner, :session],
|
|
239
241
|
["POST", %r{\A/api/sessions/([^/]+)/restart\z}, :handle_restart, :owner, :session],
|
|
240
242
|
["POST", %r{\A/api/sessions/([^/]+)/archive\z}, :handle_archive, :owner, :session],
|
|
241
243
|
["POST", %r{\A/api/sessions/([^/]+)/unarchive\z}, :handle_unarchive, :owner, :session],
|
|
@@ -247,6 +249,7 @@ module Samagotchi
|
|
|
247
249
|
["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
|
|
248
250
|
["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
|
|
249
251
|
["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
|
|
252
|
+
["POST", %r{\A/api/sessions/([^/]+)/guardrails/wrong\z}, :handle_system_one_wrong, :owner, :session],
|
|
250
253
|
["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
|
|
251
254
|
["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
|
|
252
255
|
["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
|
|
@@ -144,6 +144,16 @@ export function addCompleted(model, event = {}, now = null) {
|
|
|
144
144
|
Object.assign(row, pickFields(event.activity, ACTIVITY_FIELDS));
|
|
145
145
|
// A grep that found nothing (status ok): the row says "no match".
|
|
146
146
|
if (event.activity?.no_match) row.no_match = true;
|
|
147
|
+
// System 1 allowed the call once instead of asking (the row's label); any
|
|
148
|
+
// other guardrail verdict draws nothing.
|
|
149
|
+
const guardrail = event.activity?.guardrail;
|
|
150
|
+
if (guardrail?.decided_by === "system one") {
|
|
151
|
+
row.guardrail = {
|
|
152
|
+
rule: guardrail.rule,
|
|
153
|
+
p: guardrail.p,
|
|
154
|
+
...(guardrail.judgment_id ? { judgment_id: guardrail.judgment_id } : {}),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
147
157
|
// The images the tool returned (a reload in the turn view draws them from
|
|
148
158
|
// the saved parts, turn_html.js toolRowTemplate).
|
|
149
159
|
if (event.images?.length) row.images = event.images;
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
sendTurn,
|
|
5
5
|
cancelTurn,
|
|
6
6
|
stopSession,
|
|
7
|
+
sessionHolds,
|
|
8
|
+
stopSessionHolds,
|
|
7
9
|
restartSession,
|
|
8
10
|
deleteSession,
|
|
9
11
|
archiveSession,
|
|
@@ -50,6 +52,7 @@ import { createGapClock, lastActivity } from "./time_gaps.js";
|
|
|
50
52
|
import { createMarkdownPlace } from "./markdown_place.js";
|
|
51
53
|
import { openerFill, openTitle, restoredComposer } from "./chains.js";
|
|
52
54
|
import { createTaskStopper } from "./task_stop.js";
|
|
55
|
+
import { createSystemOneWrongAction } from "./system_one_wrong.js";
|
|
53
56
|
import { appendQuote } from "./annotations.js";
|
|
54
57
|
import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
|
|
55
58
|
import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
|
|
@@ -97,6 +100,7 @@ import { createToasts } from "./toasts.js";
|
|
|
97
100
|
import { createUnqueue } from "./unqueue.js";
|
|
98
101
|
import { createStartPage } from "./start_page.js";
|
|
99
102
|
import { createSessionActions } from "./session_actions.js";
|
|
103
|
+
import { createStopSheet } from "./stop_sheet.js";
|
|
100
104
|
import { createRename } from "./session_rename.js";
|
|
101
105
|
import { createSendTurn } from "./send_turn.js";
|
|
102
106
|
import { createMarkdownTip } from "./markdown_tip.js";
|
|
@@ -299,6 +303,8 @@ const { placeToast } = createDockReserve({
|
|
|
299
303
|
function turnRoots() {
|
|
300
304
|
return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
|
|
301
305
|
}
|
|
306
|
+
const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => selected });
|
|
307
|
+
turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
|
|
302
308
|
function findInTurnRoots(selector) {
|
|
303
309
|
return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
|
|
304
310
|
}
|
|
@@ -562,12 +568,21 @@ const lists = createListsView({
|
|
|
562
568
|
|
|
563
569
|
// Continue, Archive, Delete and Stop, and the start page when the open
|
|
564
570
|
// session is gone (session_actions.js).
|
|
571
|
+
// The Stop sheet first (stop_sheet.js): its buttons are the two ways to stop,
|
|
572
|
+
// and the sheet is only shown when the worker has something the user can stop.
|
|
573
|
+
const stopSheet = createStopSheet({
|
|
574
|
+
doc: document, win: window,
|
|
575
|
+
stopAll: (id) => sessionActions.stopSelected(id, { stopHolds: true }),
|
|
576
|
+
stopSession: (id) => sessionActions.stopSelected(id),
|
|
577
|
+
});
|
|
578
|
+
|
|
565
579
|
const sessionActions = createSessionActions({
|
|
566
580
|
selected: () => selected,
|
|
567
581
|
state: () => current,
|
|
568
582
|
sessions: () => allSessions,
|
|
569
583
|
setSessions: (list) => { allSessions = list; },
|
|
570
|
-
api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession
|
|
584
|
+
api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession,
|
|
585
|
+
sessionHolds, stopSessionHolds },
|
|
571
586
|
eventsLive: () => !!eventsControl,
|
|
572
587
|
refresh,
|
|
573
588
|
lists,
|
|
@@ -581,6 +596,7 @@ const sessionActions = createSessionActions({
|
|
|
581
596
|
restoreDraft,
|
|
582
597
|
closeLiveStream: () => streamControl?.close(),
|
|
583
598
|
workerGone,
|
|
599
|
+
stopSheet,
|
|
584
600
|
});
|
|
585
601
|
// The title's ✎: /title through the chips' path (session_rename.js), the
|
|
586
602
|
// input over the title row on screen: the top bar's when it shows the
|
|
@@ -1656,6 +1672,9 @@ attachInput.addEventListener("change", () => {
|
|
|
1656
1672
|
attachInput.value = "";
|
|
1657
1673
|
});
|
|
1658
1674
|
// ── Composer height: grows with the text; the top-edge grip sets a floor ───
|
|
1675
|
+
// The floor is saved as it was set; fitPrompt holds it to half the window it is
|
|
1676
|
+
// drawn in (composer_size.js), so a floor saved in a bigger window fits this one
|
|
1677
|
+
// and comes back when the window is big again.
|
|
1659
1678
|
let promptFloor = Number(readStored(GRIP_KEY)) || null;
|
|
1660
1679
|
|
|
1661
1680
|
// The empty box: one line in a session, three on the start page.
|
|
Binary file
|
|
@@ -14,17 +14,17 @@ export function composerFolded({ saved, archivedVisit, visitOpen }) {
|
|
|
14
14
|
return !!saved || !!archivedVisit;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// The composer's grip while it is the fold's handle. Expanded it
|
|
18
|
-
//
|
|
19
|
-
// bar's top edge, pulled up or
|
|
20
|
-
// box holds text or images: folding
|
|
21
|
-
// `waiting` (the attention colour: the session
|
|
22
|
-
// card may not take the focus while a draft is
|
|
23
|
-
// the words.
|
|
17
|
+
// The composer's grip while it is the fold's handle. Expanded it says how to
|
|
18
|
+
// resize it and that a click hides it (a double-click resets the height);
|
|
19
|
+
// folded it is drawn as the handle on the info bar's top edge, pulled up or
|
|
20
|
+
// clicked to open, and marked: `draft` (the box holds text or images: folding
|
|
21
|
+
// never clears them: the accent), `waiting` (the attention colour: the session
|
|
22
|
+
// waits on the user, and its card may not take the focus while a draft is
|
|
23
|
+
// there). The title carries the words.
|
|
24
24
|
// @return {title, folded, draft, waiting}
|
|
25
25
|
export function foldHandle({ folded, draft, waiting }) {
|
|
26
26
|
if (!folded) {
|
|
27
|
-
return { title: "Drag to
|
|
27
|
+
return { title: "Drag to resize, click to hide (double-click resets)", folded: false, draft: false, waiting: false };
|
|
28
28
|
}
|
|
29
29
|
const marked = !!draft;
|
|
30
30
|
let title = "Pull up or click to write";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { GRIP_OPEN_PX, gripFloor, gripFolds } from "./composer_size.js";
|
|
1
|
+
import { GRIP_CLICK_FOLD_MS, GRIP_OPEN_PX, gripFloor, gripFolds, gripGesture } from "./composer_size.js";
|
|
2
2
|
import { COMPOSER_FOLD_KEY, composerFolded, foldHandle, opensOnKey } from "./composer_fold.js";
|
|
3
3
|
import { readStored, removeStored, writeStored } from "./storage.js";
|
|
4
4
|
|
|
5
|
-
// The composer's grip and fold (used by app.js): the grip's drag,
|
|
6
|
-
// fold's state per visit and its page-wide listeners. app.js keeps the box's
|
|
5
|
+
// The composer's grip and fold (used by app.js): the grip's drag, click and keys,
|
|
6
|
+
// the fold's state per visit and its page-wide listeners. app.js keeps the box's
|
|
7
7
|
// height (fitPrompt) and the grip's floor (+floor+ / +setFloor+), which
|
|
8
8
|
// fitPrompt reads; the floor is saved under GRIP_KEY.
|
|
9
9
|
export const GRIP_KEY = "chi_composer_floor";
|
|
@@ -24,22 +24,44 @@ export function createComposerGrip({
|
|
|
24
24
|
// past the fold threshold.
|
|
25
25
|
const gripStartTitle = composeGrip.title;
|
|
26
26
|
let gripWillFold = false;
|
|
27
|
+
// A click on the expanded grip folds GRIP_CLICK_FOLD_MS after it, so a
|
|
28
|
+
// second click in that window can still mean "reset the height" (a
|
|
29
|
+
// double-click). A drag or a key acts at once and takes the wait back.
|
|
30
|
+
let pendingFold = null;
|
|
27
31
|
// This visit's state (a visit is one select() call): opened by the page
|
|
28
32
|
// (a focus, a restored line), archived (decided once, from the visit's
|
|
29
33
|
// first getSession answer).
|
|
30
34
|
const newFoldVisit = () => ({ open: false, archived: false, decided: false });
|
|
31
35
|
let foldVisit = newFoldVisit();
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
function cancelPendingFold() {
|
|
38
|
+
if (!pendingFold) return;
|
|
39
|
+
clearTimeout(pendingFold);
|
|
40
|
+
pendingFold = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The grip's custom height, forgotten only by a double-click.
|
|
44
|
+
function resetFloor() {
|
|
45
|
+
setFloor(null);
|
|
46
|
+
removeStored(GRIP_KEY);
|
|
47
|
+
fitPrompt();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// One release of the grip is one gesture, decided by composer_size.js's
|
|
51
|
+
// gripGesture: a drag down past the box folds the composer on release (a
|
|
52
|
+
// session only: the start page's grip only resizes); from folded, a pull up
|
|
53
|
+
// opens it and the box follows the pointer in the same gesture, a tap opens it
|
|
54
|
+
// and focuses the box; a click on the expanded grip folds it too, GRIP_CLICK_FOLD_MS
|
|
55
|
+
// after the release so a second click in that window can still reset the height.
|
|
56
|
+
// No click listener: a click still fires after pointerdown's preventDefault,
|
|
57
|
+
// so the release is the one that acts.
|
|
38
58
|
composeGrip.addEventListener("pointerdown", (e) => {
|
|
39
59
|
e.preventDefault();
|
|
40
60
|
composeGrip.setPointerCapture(e.pointerId);
|
|
41
61
|
composeGrip.classList.add("dragging");
|
|
42
62
|
const tapY = e.clientY;
|
|
63
|
+
// The height the box had before this press: a drag that folds brings it back.
|
|
64
|
+
const floorBefore = floor();
|
|
43
65
|
let startY = tapY;
|
|
44
66
|
let startH = promptEl.offsetHeight;
|
|
45
67
|
let opened = false;
|
|
@@ -76,17 +98,35 @@ export function createComposerGrip({
|
|
|
76
98
|
};
|
|
77
99
|
const up = (ev) => {
|
|
78
100
|
const folds = end();
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
101
|
+
const gesture = gripGesture({
|
|
102
|
+
session: !!session(),
|
|
103
|
+
folded: isComposerFolded(),
|
|
104
|
+
dy: ev.clientY - tapY,
|
|
105
|
+
willFold: folds,
|
|
106
|
+
pending: !!pendingFold,
|
|
107
|
+
});
|
|
108
|
+
if (gesture === "fold") {
|
|
109
|
+
cancelPendingFold();
|
|
110
|
+
// The drag asked for a smaller box, but opening it brings back the
|
|
111
|
+
// height it had before the press: only a double-click forgets it.
|
|
112
|
+
setFloor(floorBefore);
|
|
113
|
+
writeStored(GRIP_KEY, String(floorBefore || ""));
|
|
83
114
|
setComposerFolded(true);
|
|
84
|
-
} else if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
115
|
+
} else if (gesture === "open") {
|
|
116
|
+
cancelPendingFold();
|
|
117
|
+
setComposerFolded(false);
|
|
118
|
+
promptEl.focus();
|
|
119
|
+
} else if (gesture === "reset") {
|
|
120
|
+
resetFloor();
|
|
121
|
+
} else if (gesture === "hide") {
|
|
122
|
+
writeStored(GRIP_KEY, String(floor() || ""));
|
|
123
|
+
pendingFold = setTimeout(() => {
|
|
124
|
+
pendingFold = null;
|
|
125
|
+
setComposerFolded(true);
|
|
126
|
+
}, GRIP_CLICK_FOLD_MS);
|
|
127
|
+
} else if (gesture === "resize") {
|
|
128
|
+
// A drag that stops short of folding: keep the box at the height it asks for.
|
|
129
|
+
cancelPendingFold();
|
|
90
130
|
writeStored(GRIP_KEY, String(floor() || ""));
|
|
91
131
|
// A pull that opened the box leaves the focus in it, as a tap does.
|
|
92
132
|
if (opened) promptEl.focus();
|
|
@@ -103,15 +143,16 @@ export function createComposerGrip({
|
|
|
103
143
|
});
|
|
104
144
|
composeGrip.addEventListener("dblclick", () => {
|
|
105
145
|
if (session() && isComposerFolded()) return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
fitPrompt();
|
|
146
|
+
cancelPendingFold();
|
|
147
|
+
resetFloor();
|
|
109
148
|
});
|
|
110
149
|
// Enter or Space on the focused handle folds, or opens and focuses the box
|
|
111
|
-
// (a session only; preventDefault: Space would scroll the page).
|
|
150
|
+
// (a session only; preventDefault: Space would scroll the page). At once:
|
|
151
|
+
// the keyboard has no double-click to wait for.
|
|
112
152
|
composeGrip.addEventListener("keydown", (e) => {
|
|
113
153
|
if (!session() || (e.key !== "Enter" && e.key !== " ")) return;
|
|
114
154
|
e.preventDefault();
|
|
155
|
+
cancelPendingFold();
|
|
115
156
|
if (isComposerFolded()) {
|
|
116
157
|
setComposerFolded(false);
|
|
117
158
|
promptEl.focus();
|
|
@@ -135,6 +176,8 @@ export function createComposerGrip({
|
|
|
135
176
|
}
|
|
136
177
|
function applyComposerFolded() {
|
|
137
178
|
const folded = isComposerFolded();
|
|
179
|
+
// Anything else opened the composer: the click's wait for its fold is over.
|
|
180
|
+
if (!folded) cancelPendingFold();
|
|
138
181
|
const was = doc.body.classList.contains("composer-folded");
|
|
139
182
|
doc.body.classList.toggle("composer-folded", folded);
|
|
140
183
|
// Its height was measured while hidden.
|
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
// The composer's height
|
|
2
|
-
//
|
|
1
|
+
// The composer's height and the gestures of the grip on its top edge. The
|
|
2
|
+
// height grows with the text up to a cap; the grip sets a taller floor (the
|
|
3
|
+
// cap then is at least that floor), and no floor — saved in another window or
|
|
4
|
+
// dragged here — takes more than half of the window the box is shown in. A
|
|
5
|
+
// release of the grip is one gesture: a drag that resizes, a drag far enough
|
|
6
|
+
// down that folds the box away, or a click (which, expanded, folds too — after
|
|
7
|
+
// a delay, so a double-click can still reset the height).
|
|
3
8
|
export const PROMPT_MIN_PX = 72; // about three lines of the 15 px field: the start page
|
|
4
9
|
export const PROMPT_LINE_PX = 26; // one line (15 px × 1.45 + 4 padding): in a session
|
|
5
10
|
const PROMPT_CAP_SHARE = 0.35; // of the window's height, for growing with text
|
|
6
11
|
const PROMPT_GRIP_SHARE = 0.5; // of the window's height, for the grip
|
|
7
12
|
export const GRIP_FOLD_PX = 32; // a drag down this far past what the box shows folds the composer (on release)
|
|
8
13
|
export const GRIP_OPEN_PX = 8; // a drag up this far from the folded handle opens it; less is a tap
|
|
14
|
+
export const GRIP_CLICK_FOLD_MS = 250; // how long a click on the expanded grip waits before folding, so a second click in that window can still mean a reset
|
|
9
15
|
|
|
10
16
|
// contentPx: the text's height (scrollHeight plus borders); floorPx: the
|
|
11
17
|
// grip's height, or null; minPx: the empty box's height.
|
|
12
18
|
export function promptHeight({ contentPx, floorPx = null, viewportPx, minPx = PROMPT_MIN_PX }) {
|
|
13
|
-
|
|
19
|
+
// The floor, as THIS window can hold it: a floor saved in a bigger window
|
|
20
|
+
// (a rotated phone, a resized window, another device on the same origin) is
|
|
21
|
+
// held down to the share a drag may set here, and comes back whole when the
|
|
22
|
+
// window is big again — only the height shown shrinks, never the saved floor.
|
|
23
|
+
const floor = gripFloor(floorPx, viewportPx, minPx);
|
|
14
24
|
const cap = Math.max(floor, Math.round(viewportPx * PROMPT_CAP_SHARE));
|
|
15
25
|
return { height: Math.min(Math.max(contentPx, floor), cap), scrolls: contentPx > cap };
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
// A grip drag: the floor it asks for, kept between the minimum and the
|
|
19
|
-
// grip's share of the window.
|
|
29
|
+
// grip's share of the window. The same rule fits a floor saved earlier (see
|
|
30
|
+
// promptHeight), so the box never takes more of a window than a drag in it
|
|
31
|
+
// could have given it.
|
|
20
32
|
export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
21
|
-
return Math.
|
|
33
|
+
return Math.max(minPx, Math.min(px || 0, Math.round(viewportPx * PROMPT_GRIP_SHARE)));
|
|
22
34
|
}
|
|
23
35
|
|
|
24
36
|
// Whether a grip drag asks to fold the composer: it wants the box at least
|
|
@@ -28,3 +40,26 @@ export function gripFloor(px, viewportPx, minPx = PROMPT_MIN_PX) {
|
|
|
28
40
|
export function gripFolds({ requestedPx, shownPx }) {
|
|
29
41
|
return shownPx - requestedPx >= GRIP_FOLD_PX;
|
|
30
42
|
}
|
|
43
|
+
|
|
44
|
+
// What a grip release asks for, decided from the press alone (the strip's
|
|
45
|
+
// grip's twin, strip.js's stripGripAction). +session+: is there a session (the
|
|
46
|
+
// start page's grip only resizes). +folded+: the composer is folded now.
|
|
47
|
+
// +dy+: the release y minus the press y, down positive. +willFold+: the drag is
|
|
48
|
+
// past GRIP_FOLD_PX below what the box shows. +pending+: an earlier click's fold
|
|
49
|
+
// is still waiting its GRIP_CLICK_FOLD_MS, so this release is the second click.
|
|
50
|
+
// fold – hide the box now
|
|
51
|
+
// open – a tap on the folded handle
|
|
52
|
+
// hide – the first click on the expanded grip: fold it after the delay
|
|
53
|
+
// reset – the second click in that delay: a double-click, so the height is reset
|
|
54
|
+
// resize – a drag that stops short: keep the box and save the floor it asks for
|
|
55
|
+
// none – a drag down on the folded handle (pulling up opens it while dragging)
|
|
56
|
+
export function gripGesture({ session, folded, dy, willFold, pending }) {
|
|
57
|
+
if (willFold) return "fold";
|
|
58
|
+
const tap = Math.abs(dy) < GRIP_OPEN_PX;
|
|
59
|
+
if (tap) {
|
|
60
|
+
if (folded) return "open";
|
|
61
|
+
if (!session) return "resize";
|
|
62
|
+
return pending ? "reset" : "hide";
|
|
63
|
+
}
|
|
64
|
+
return folded ? "none" : "resize";
|
|
65
|
+
}
|
|
@@ -180,10 +180,34 @@ export function dismissQuestion(id, questionId, opts = {}) {
|
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Flags a System 1 auto-allow in this session as one it should have asked
|
|
184
|
+
// about; the owner-only route returns 404 for an unknown judgment.
|
|
185
|
+
export function markSystemOneWrong(id, judgmentId, opts = {}) {
|
|
186
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/guardrails/wrong`, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body: JSON.stringify({ id: judgmentId }),
|
|
189
|
+
...opts,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
export function stopSession(id, opts = {}) {
|
|
184
194
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
185
195
|
}
|
|
186
196
|
|
|
197
|
+
// What this session's worker still has that the user can stop (reminders, a
|
|
198
|
+
// background task running, a delegate with a live worker), for the Stop
|
|
199
|
+
// button's sheet: 200 {holds: []} always — no worker, or one that can't say,
|
|
200
|
+
// is an empty list, so Stop stays one tap.
|
|
201
|
+
export function sessionHolds(id, opts = {}) {
|
|
202
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/holds`, { ...opts });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The sheet's "Stop all": the worker stops everything the user can before the
|
|
206
|
+
// Stop TERMs it. 200 {status: "stopped", stopped: {reminders, tasks, delegates}}.
|
|
207
|
+
export function stopSessionHolds(id, opts = {}) {
|
|
208
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/holds/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
209
|
+
}
|
|
210
|
+
|
|
187
211
|
// Kills a background task the session's conversation started (the
|
|
188
212
|
// stop-task button): 200 {status, stop_reason}; 409 it already ended, 503
|
|
189
213
|
// no live worker.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1f6feb"/><stop offset="1" stop-color="#6e40c9"/></linearGradient></defs><rect x="1" y="1" width="30" height="30" rx="7.5" fill="url(#g)"/><g fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7.8 9.6c2.3-1.3 4-.7 5.2 1.4l6 10.1c1.2 2.1 2.9 2.7 5.2 1.4"/><path d="M22.6 9.2 9.4 22.8"/></g></svg>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -184,6 +184,11 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
184
184
|
.ts-now button.task-stop{align-self:center}
|
|
185
185
|
.activity-params{color:var(--text-muted);overflow-wrap:anywhere}
|
|
186
186
|
.activity-alias{color:var(--text-faint);white-space:nowrap}
|
|
187
|
+
.activity-auto{color:var(--accent);font-size:11px;white-space:nowrap;cursor:help}
|
|
188
|
+
button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;border:1px solid var(--border-strong);border-radius:6px;background:transparent;color:var(--text-muted);font-size:10px;line-height:16px;white-space:nowrap}
|
|
189
|
+
button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:var(--text)}
|
|
190
|
+
.system-one-wrong.marked{border-color:var(--accent-border);color:var(--text-3)}
|
|
191
|
+
.activity-row.guardrail-wrong .activity-auto{color:var(--text-muted)}
|
|
187
192
|
.activity-duration{color:var(--text-faint);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
188
193
|
.activity-output{margin:4px 0 2px var(--row-gutter);font-size:12px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;background:var(--bg);border-radius:6px;padding:6px 8px;max-height:160px;overflow:auto}
|
|
189
194
|
.activity-output.tail{display:flex;flex-direction:column-reverse}
|
|
@@ -1221,6 +1226,22 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
|
|
|
1221
1226
|
.push-devices.sheet .pd-done{font-size:13px}
|
|
1222
1227
|
.push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
|
|
1223
1228
|
.push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
|
|
1229
|
+
/* The Stop button's sheet (stop_sheet.js): what still runs, and the three answers; a bottom sheet on a phone. */
|
|
1230
|
+
.stop-sheet .ctx-pop-head strong{font-family:inherit}
|
|
1231
|
+
.stop-sheet.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
|
|
1232
|
+
.ss-lines{list-style:none;display:flex;flex-direction:column;gap:6px;margin:4px 0 10px;padding:0}
|
|
1233
|
+
.ss-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:3px 8px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
|
|
1234
|
+
.ss-line b{color:var(--text-bright)}
|
|
1235
|
+
.ss-items{flex-basis:100%;color:var(--text-2)}
|
|
1236
|
+
.ss-note,.ss-more{font-size:11.5px;color:var(--text-muted)}
|
|
1237
|
+
.ss-acts{display:flex;gap:8px;flex-wrap:wrap}
|
|
1238
|
+
.ss-acts button{padding:8px 12px;border-radius:8px;font-size:13px;cursor:pointer;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text-2)}
|
|
1239
|
+
.ss-acts button:hover{background:var(--bg-hover);color:var(--text-hover)}
|
|
1240
|
+
.ss-stop-all{background:var(--warn-bg);border-color:var(--warn-border);color:var(--warn-text)}
|
|
1241
|
+
.ss-stop-all:hover{background:var(--warn-border);color:var(--text-hover)}
|
|
1242
|
+
.ss-cancel{background:transparent}
|
|
1243
|
+
.stop-sheet.sheet .ss-acts button{flex:1 1 auto;font-size:14px;padding:10px 12px}
|
|
1244
|
+
.ss-foot{font-size:11.5px;color:var(--text-muted);margin-top:6px}
|
|
1224
1245
|
.ctx-pop-head{display:flex;align-items:baseline;gap:8px}
|
|
1225
1246
|
.ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
|
|
1226
1247
|
.ctx-pop-kind{font-size:11px;color:var(--text-muted)}
|
|
@@ -10,7 +10,8 @@ import { inTree } from "./sessions_list.js";
|
|
|
10
10
|
// Hooks: +selected()+ the open session's id or null; +state()+ its fields
|
|
11
11
|
// (session_state.js); +sessions()+ / +setSessions(list)+ the page's list;
|
|
12
12
|
// +api+: {continueSession, startReview, archiveSession, unarchiveSession,
|
|
13
|
-
// deleteSession, stopSession} (data.js);
|
|
13
|
+
// deleteSession, stopSession, sessionHolds, stopSessionHolds} (data.js);
|
|
14
|
+
// +eventsLive()+ the hub's stream is open (else
|
|
14
15
|
// +refresh()+ fetches the list); +lists+ (lists_view.js: render);
|
|
15
16
|
// +select(id)+, +clearSelection()+, +showNotice(text)+, +updateInfoBar()+,
|
|
16
17
|
// +toast(text, action, options)+, +addCreatedSession(created)+,
|
|
@@ -30,6 +31,10 @@ export function createSessionActions({
|
|
|
30
31
|
selected, state, sessions, setSessions, api, eventsLive, refresh, lists,
|
|
31
32
|
select, clearSelection, showNotice, updateInfoBar, toast, addCreatedSession, fillOpener, restoreDraft, closeLiveStream, workerGone,
|
|
32
33
|
alert = (text) => globalThis.alert(text), confirm = (text) => globalThis.confirm(text),
|
|
34
|
+
// The Stop sheet (stop_sheet.js): +open(id, holds)+ shows it and answers
|
|
35
|
+
// true; nothing to stop (or no sheet wired) is false, and Stop is the
|
|
36
|
+
// confirm.
|
|
37
|
+
stopSheet = { open: () => false },
|
|
33
38
|
}) {
|
|
34
39
|
// The session this tab is stopping: its stream may close before the stop
|
|
35
40
|
// request returns (workerGone reads it).
|
|
@@ -64,11 +69,39 @@ export function createSessionActions({
|
|
|
64
69
|
showNotice(goneSessionNotice(id, why));
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
// The info bar's Stop: the open session's worker
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
// The info bar's Stop: the open session's worker. It first asks the worker
|
|
73
|
+
// what still runs that the user can stop (GET …/holds); when there is
|
|
74
|
+
// something, the sheet (stop_sheet.js) asks what to do with it; when there
|
|
75
|
+
// is nothing, or no worker to ask, it is today's confirm and one tap.
|
|
76
|
+
// +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
|
|
77
|
+
// the stop. A delegate or a background task outlives the worker either way
|
|
78
|
+
// (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
|
|
79
|
+
async function stopSelected(id = selected(), { stopHolds = false } = {}) {
|
|
80
|
+
if (!id) return;
|
|
81
|
+
|
|
82
|
+
if (!stopHolds) {
|
|
83
|
+
let holds = [];
|
|
84
|
+
try {
|
|
85
|
+
const answer = await api.sessionHolds(id);
|
|
86
|
+
holds = answer?.holds || [];
|
|
87
|
+
} catch {
|
|
88
|
+
holds = []; // a worker that can't say, or none: stop as before
|
|
89
|
+
}
|
|
90
|
+
if (stopSheet.open(id, holds)) return;
|
|
91
|
+
if (!confirm(`Stop session ${id}?`)) return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Stop all: the worker stops what it can first. A failure is said and
|
|
95
|
+
// doesn't hold the stop: the session goes either way, and the sheet's
|
|
96
|
+
// other button is exactly that.
|
|
97
|
+
if (stopHolds) {
|
|
98
|
+
try {
|
|
99
|
+
await api.stopSessionHolds(id);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
toast(`${cantText("stop what the session runs", e)} — stopping the session anyway`, null, { sticky: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
72
105
|
stoppingId = id;
|
|
73
106
|
try {
|
|
74
107
|
await api.stopSession(id);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The Stop button's sheet (session_actions.js asks the worker first): when
|
|
2
|
+
// the session's worker still has things the user can stop — reminders, a
|
|
3
|
+
// background task that runs, a delegate with a live worker — Stop asks what
|
|
4
|
+
// to do with them before TERMed-ing the worker. Nothing to stop, or no worker
|
|
5
|
+
// to ask, is today's one tap.
|
|
6
|
+
//
|
|
7
|
+
// "Stop all" runs the worker's stop-all step (POST …/holds/stop, the same
|
|
8
|
+
// HoldStop#stop_all the terminal's /exit ask uses) and then today's Stop;
|
|
9
|
+
// "Stop session only" is today's Stop exactly, so what survives keeps running
|
|
10
|
+
// (docs/sessions.md, "Stopping a worker"). Reminders are listed with a note:
|
|
11
|
+
// they live only in the worker, so they end with the session either way.
|
|
12
|
+
//
|
|
13
|
+
// A phone gets it as a bottom sheet, a wider window as a popover at the Stop
|
|
14
|
+
// button (chip_popover.js). Pure model and template plus createStopSheet, so
|
|
15
|
+
// the specs run them under node --test.
|
|
16
|
+
|
|
17
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
18
|
+
import { createChipPopover } from "./chip_popover.js";
|
|
19
|
+
|
|
20
|
+
// At most this many of a kind named in the sheet; the rest are one "+N more".
|
|
21
|
+
export const MAX_NAMED = 3;
|
|
22
|
+
|
|
23
|
+
// The Stop button the sheet opens at.
|
|
24
|
+
export const STOP_ANCHOR = "#infoStopBtn";
|
|
25
|
+
|
|
26
|
+
const KIND_WORDS = {
|
|
27
|
+
reminders: ["reminder", "reminders"],
|
|
28
|
+
background_tasks: ["background task", "background tasks"],
|
|
29
|
+
delegates: ["delegate", "delegates"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// One entry of a hold as the sheet writes it: the command, the reminder's
|
|
33
|
+
// name with its schedule, a delegate's short id and its title. A long command
|
|
34
|
+
// is cut short: the sheet must fit a phone without scrolling.
|
|
35
|
+
function itemWords(item, kind) {
|
|
36
|
+
const label = String(item?.label ?? item?.id ?? "").trim();
|
|
37
|
+
const detail = String(item?.detail ?? "").trim();
|
|
38
|
+
const cut = label.length > 42 ? `${label.slice(0, 42)}…` : label;
|
|
39
|
+
if (kind === "delegates") {
|
|
40
|
+
const id = String(item?.id ?? "").slice(0, 8);
|
|
41
|
+
return `${id} "${cut || "untitled"}"${detail ? ` ${detail}` : ""}`;
|
|
42
|
+
}
|
|
43
|
+
const name = cut || String(item?.id ?? "").slice(0, 8);
|
|
44
|
+
return detail ? `${name} (${detail})` : name;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// What the sheet says from the worker's holds (GET …/holds): null when there
|
|
48
|
+
// is nothing to ask about (no sheet: Stop stays one tap), else its lines, one
|
|
49
|
+
// per kind, and the total.
|
|
50
|
+
// @return {lines: [{kind, count, word, items, more, note}], total} | null
|
|
51
|
+
export function stopSheetModel(holds) {
|
|
52
|
+
const lines = [];
|
|
53
|
+
for (const hold of Array.isArray(holds) ? holds : []) {
|
|
54
|
+
const kind = String(hold?.kind ?? "");
|
|
55
|
+
const items = Array.isArray(hold?.items) ? hold.items : [];
|
|
56
|
+
if (!items.length) continue;
|
|
57
|
+
|
|
58
|
+
const [singular, plural] = KIND_WORDS[kind] ?? [kind || "hold", `${kind || "holds"}s`];
|
|
59
|
+
lines.push({
|
|
60
|
+
kind,
|
|
61
|
+
count: items.length,
|
|
62
|
+
word: items.length === 1 ? singular : plural,
|
|
63
|
+
items: items.slice(0, MAX_NAMED).map((item) => itemWords(item, kind)),
|
|
64
|
+
more: Math.max(0, items.length - MAX_NAMED),
|
|
65
|
+
note: kind === "reminders" ? "they end with the session" : "",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (!lines.length) return null;
|
|
69
|
+
|
|
70
|
+
return { lines, total: lines.reduce((sum, line) => sum + line.count, 0) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lineTemplate(line) {
|
|
74
|
+
return html`<li class="ss-line" data-kind=${line.kind}>
|
|
75
|
+
<b>${line.count} ${line.word}</b>
|
|
76
|
+
${line.note ? html` <span class="ss-note">(${line.note})</span>` : nothing}
|
|
77
|
+
${line.items.length ? html` <span class="ss-items">${line.items.join(" · ")}${line.more ? html` <span class="ss-more">+${line.more} more</span>` : nothing}</span>` : nothing}
|
|
78
|
+
</li>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The sheet's content (a lit-html template).
|
|
82
|
+
export function stopSheetTemplate(model) {
|
|
83
|
+
const things = model.total === 1 ? "1 thing" : `${model.total} things`;
|
|
84
|
+
return html`<div class="ctx-pop-head"><strong>This session still has</strong
|
|
85
|
+
><span class="ctx-pop-kind">${things}</span
|
|
86
|
+
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
87
|
+
><ul class="ss-lines">${model.lines.map(lineTemplate)}</ul
|
|
88
|
+
><div class="ss-acts"><button type="button" class="ss-stop-all" data-act="all">Stop all</button
|
|
89
|
+
><button type="button" class="ss-stop-only" data-act="session">Stop session only</button
|
|
90
|
+
><button type="button" class="ss-cancel" data-act="cancel">Cancel</button></div
|
|
91
|
+
><div class="ss-foot">Stopping the session ends its worker; a background task or a delegate runs on its own, so it stays.</div>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The sheet at the Stop button. +stopAll(id)+ stops what the worker can and
|
|
95
|
+
// then the session; +stopSession(id)+ is today's Stop; +open(id, holds)+ shows
|
|
96
|
+
// it only when there is something (false otherwise, so the caller can fall
|
|
97
|
+
// back to the plain confirm). @return {open, close, isOpen, pop}
|
|
98
|
+
export function createStopSheet({ doc = globalThis.document, win = globalThis.window,
|
|
99
|
+
anchor = STOP_ANCHOR, el = doc.createElement("span"),
|
|
100
|
+
stopAll = () => {}, stopSession = () => {} }) {
|
|
101
|
+
let model = null;
|
|
102
|
+
let forId = null;
|
|
103
|
+
|
|
104
|
+
const shell = createChipPopover({
|
|
105
|
+
id: "stopSheet", className: "ctx-popover stop-sheet", width: 400, el, anchor, openers: anchor,
|
|
106
|
+
summary: () => model, sheet: true, label: "Stop the session?", doc, win,
|
|
107
|
+
show: (data) => render(stopSheetTemplate(data), shell.pop),
|
|
108
|
+
onClose: () => { model = null; forId = null; },
|
|
109
|
+
onAct: (act) => {
|
|
110
|
+
const id = forId;
|
|
111
|
+
if (act === "all") { shell.close(); stopAll(id); }
|
|
112
|
+
else if (act === "session") { shell.close(); stopSession(id); }
|
|
113
|
+
else if (act === "cancel") shell.close();
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function open(id, holds) {
|
|
118
|
+
model = stopSheetModel(holds);
|
|
119
|
+
if (!model) return false;
|
|
120
|
+
forId = id;
|
|
121
|
+
shell.open(doc.querySelector(anchor) ?? el);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { open, close: () => shell.close(), isOpen: shell.isOpen, pop: shell.pop };
|
|
126
|
+
}
|