samagotchi 0.60.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 +99 -1
- data/docs/architecture.md +10 -4
- 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 +80 -1
- data/docs/internals/engine.md +57 -15
- data/docs/internals/prompt-caching.md +8 -1
- data/docs/plugins.md +49 -6
- data/docs/sessions.md +7 -3
- data/docs/testing.md +4 -1
- data/lib/samagotchi/answer_display.rb +4 -3
- data/lib/samagotchi/answer_refs.rb +210 -0
- data/lib/samagotchi/bridge/card_store.rb +6 -1
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
- 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 +47 -9
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/hooks/registry.rb +3 -1
- data/lib/samagotchi/interrupted_turn.rb +39 -0
- data/lib/samagotchi/kernel_loop.rb +13 -20
- data/lib/samagotchi/llm/chat_loop.rb +28 -17
- data/lib/samagotchi/log_subscriber.rb +6 -3
- data/lib/samagotchi/plugin/api.rb +36 -18
- data/lib/samagotchi/reasoning_history.rb +22 -4
- data/lib/samagotchi/ref.rb +25 -8
- data/lib/samagotchi/reply_wait.rb +6 -2
- data/lib/samagotchi/row_refs.rb +126 -0
- data/lib/samagotchi/session.rb +40 -4
- data/lib/samagotchi/session_manager.rb +64 -6
- data/lib/samagotchi/session_metrics.rb +24 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +30 -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/turn_checkpoint.rb +128 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +19 -1
- data/lib/samagotchi/web/message_parts.rb +17 -9
- data/lib/samagotchi/web/public/activity.js +4 -1
- data/lib/samagotchi/web/public/answer_refs.js +217 -0
- data/lib/samagotchi/web/public/app.js +74 -33
- 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 +66 -13
- 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 +24 -3
- data/lib/samagotchi/web/public/ref_scan.js +5 -3
- data/lib/samagotchi/web/public/refs.js +140 -20
- 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/stream_handlers.js +5 -3
- 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 +121 -17
- data/lib/samagotchi/worker.rb +169 -21
- data/lib/samagotchi/worker_inbound.rb +5 -3
- data/lib/samagotchi/worker_signals.rb +81 -0
- metadata +8 -1
|
@@ -283,6 +283,15 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
283
283
|
.gen details.thinking summary.thinking-fade{animation:thinking-fade 120ms ease-out}
|
|
284
284
|
@keyframes thinking-fade{from{opacity:.4}to{opacity:1}}
|
|
285
285
|
@media (prefers-reduced-motion:reduce){.gen details.thinking summary.thinking-fade{animation:none}}
|
|
286
|
+
/* A finished block's outline (turn_view.js outlineBlock): open on its narrated steps' rows; a run of steps that said
|
|
287
|
+
nothing is one "· N steps" fold, a row like a step's, that shows them. */
|
|
288
|
+
.turn-work.outline{--step-indent:14px}
|
|
289
|
+
.turn-work.outline>.gen.folded:not(.unfolded){display:none}
|
|
290
|
+
.outline-fold{margin-top:8px;padding-top:6px;padding-left:var(--step-indent);border-top:1px solid var(--border);font-size:12px;font-weight:500;color:var(--text-faint);cursor:pointer;user-select:none;border-radius:4px}
|
|
291
|
+
.outline-fold::before{content:"· "}
|
|
292
|
+
.outline-fold.open::before{content:"▾ "}
|
|
293
|
+
.outline-fold:hover{color:var(--text-muted)}
|
|
294
|
+
.turn-work.outline>.gen.folded.unfolded{margin-left:var(--step-indent)}
|
|
286
295
|
.gen-text{margin-top:6px;white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.55}
|
|
287
296
|
/* The live step's narration box: the complete sentences so far, one per line, three visual lines high (3 × the 1.55 line-height), scrolled to the newest; each new line fades in. */
|
|
288
297
|
.gen-text.streaming>.narration{max-height:4.65em;overflow:auto;scrollbar-width:thin;white-space:normal}
|
|
@@ -310,6 +319,12 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
310
319
|
.bubble.question>summary::-webkit-details-marker{display:none}
|
|
311
320
|
.bubble.question>summary::before{content:"▸ ";color:var(--warn)}
|
|
312
321
|
.bubble.question[open]>summary::before{content:"▾ "}
|
|
322
|
+
/* An answered card keeps the reply that asked it (question_cards.js): muted under its "Q → A" line, two lines while
|
|
323
|
+
closed (newlines as spaces); open, the summary's copy goes and the body's starts the card, whole, as written and
|
|
324
|
+
selectable. */
|
|
325
|
+
.bubble.question>summary .question-said{max-width:72ch;margin:2px 0 1px 1.05em;font-size:12.5px;font-weight:400;line-height:1.5;color:var(--text-muted);white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden}
|
|
326
|
+
.bubble.question[open]>summary .question-said{display:none}
|
|
327
|
+
.question-said-full{margin:4px 0 6px;font-size:13px;line-height:1.55;color:var(--text-muted);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
313
328
|
.bubble.command{border-left:3px solid var(--text-faint);background:var(--bg-panel);font-size:13px}
|
|
314
329
|
.bubble.note{border-left:3px dotted var(--text-faint);background:transparent;font-size:12px;color:var(--text-muted);opacity:.85}
|
|
315
330
|
.note-line{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text-faint);margin-bottom:2px}
|
|
@@ -336,7 +351,8 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
336
351
|
.bubble.question.answered{border-left-color:var(--ok)}
|
|
337
352
|
.bubble.question.cancelled{border-left-color:var(--text-muted);opacity:.75}
|
|
338
353
|
/* A settled card stops floating: the highlight stays, the drop shadow goes. */
|
|
339
|
-
|
|
354
|
+
/* Closed, it is a bare line (no card to light): wider with its kept reply, a highlight would read as a stray rule. */
|
|
355
|
+
.bubble.question.answered[open],.bubble.question.cancelled[open]{box-shadow:inset 0 1px 0 var(--glass-highlight)}
|
|
340
356
|
.question-text{font-size:14px;color:var(--text);margin-bottom:8px;white-space:pre-wrap}
|
|
341
357
|
.question-options{display:flex;flex-direction:column;gap:4px;margin-bottom:8px}
|
|
342
358
|
.question-option{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text-2);padding:6px 8px;border:1px solid var(--border);border-radius:8px;cursor:pointer}
|
|
@@ -382,16 +398,20 @@ button.task-stop:hover:not(:disabled){background:var(--danger-bg-dim);filter:non
|
|
|
382
398
|
.diff code{display:block;min-width:max-content}
|
|
383
399
|
.diff-line{display:block;padding:0 10px;white-space:pre;color:var(--text-2)}
|
|
384
400
|
/* Refs (refs.js): text until hovered or focused, and only with a target for this viewer (refs.js marks it: data-ref-main, the main one). A link ref keeps its link look. */
|
|
385
|
-
.ref[data-ref-main]:not(a):is(:hover,:focus-visible){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
|
|
386
|
-
.ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible)::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
|
|
401
|
+
.ref[data-ref-main]:not(a):is(:hover,:focus-visible,[data-ref-more]){color:var(--accent);text-decoration:underline dotted;text-underline-offset:3px;cursor:pointer}
|
|
402
|
+
.ref[data-ref-main="editor"]:not(a):is(:hover,:focus-visible,[data-ref-more])::after{content:"\2197";margin-left:.3em;color:var(--text-faint);display:inline-block}
|
|
387
403
|
.ref[data-ref-kind]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
|
|
388
404
|
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
389
405
|
:is(.activity-row,.bubble.approval) .diff-line[data-ref-main]:hover::after{content:"\2197 " attr(data-line);float:right;padding-left:12px;color:var(--text-faint)}
|
|
390
406
|
.activity-row .read-line[data-ref-main]:hover{cursor:pointer;box-shadow:inset 2px 0 var(--accent)}
|
|
391
407
|
.activity-row .read-line[data-ref-main]:hover::before{color:var(--accent)}
|
|
392
408
|
/* A ref's ▾ and its menu (ref_menu.js): one each, placed next to the ref. */
|
|
393
|
-
.ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:
|
|
409
|
+
.ref-more{position:fixed;z-index:60;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:1px solid var(--border-strong);border-radius:4px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
|
|
394
410
|
.ref-more[hidden]{display:none}
|
|
411
|
+
/* A ref makes room after itself for its ▾ while it shows (ref_menu.js), and keeps its hover look (its ↗ too) until the ▾ goes: the ▾ never covers what follows it. */
|
|
412
|
+
.ref[data-ref-more]{padding-right:19px}
|
|
413
|
+
/* …unless the room would push it onto the next line: it ends its line, and the ▾ goes past its end. */
|
|
414
|
+
.ref[data-ref-more="end"]{padding-right:0}
|
|
395
415
|
.ref-more:hover{color:var(--accent);border-color:var(--accent)}
|
|
396
416
|
.ref-menu{position:fixed;z-index:61;min-width:160px;max-width:calc(100vw - 16px);padding:4px;border:1px solid var(--border-strong);border-radius:8px;background:var(--bg-panel);box-shadow:var(--toast-shadow);animation:fadeIn .12s ease-out}
|
|
397
417
|
.ref-menu [role="menuitem"]{display:block;width:100%;padding:6px 10px;border:0;border-radius:5px;background:none;color:var(--text);font:inherit;font-size:13px;text-align:left;cursor:pointer;white-space:nowrap}
|
|
@@ -592,7 +612,13 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
|
|
|
592
612
|
body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;border:0;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;gap:10px}
|
|
593
613
|
.dock-card{display:flex;flex-direction:column;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
|
|
594
614
|
.dock-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
|
|
615
|
+
/* The live slots' heights (headline, tool row, trail); --ts-live-h is their running height with the two 8 px gaps. */
|
|
616
|
+
.turn-stage{--ts-head-h:42px;--ts-now-h:32px;--ts-trail-h:18px;--ts-live-h:calc(var(--ts-head-h) + var(--ts-now-h) + var(--ts-trail-h) + 16px)}
|
|
595
617
|
.turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
|
|
618
|
+
/* Out of the dock's flow: it floats 10 px above the composer card, so its rise, growth and leaving never move #dock (a
|
|
619
|
+
bottom-pinned box changing height is a layout shift, animated or not). Painted before .dock-card (no z-index): the command
|
|
620
|
+
list (z-index 20 inside .dock-card) opens over its lower edge. The history's bottom reserve counts it (app.js, --dock-h). */
|
|
621
|
+
body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-bottom:10px}
|
|
596
622
|
.turn-stage[hidden]{display:none}
|
|
597
623
|
.turn-stage.ended{border-left-color:var(--border-strong)}
|
|
598
624
|
.turn-stage[data-phase="answered"]{border-left-color:var(--ok)}
|
|
@@ -601,7 +627,10 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
601
627
|
.turn-stage[data-phase="no answer"]{border-left-color:var(--warn)}
|
|
602
628
|
.turn-stage.enter{animation:ts-rise .35s cubic-bezier(.3,.7,.2,1)}
|
|
603
629
|
@keyframes ts-rise{from{opacity:0;transform:translateY(14px)}}
|
|
604
|
-
|
|
630
|
+
/* The hand-off: the stage slides away under the composer still drawn (a transform: nothing moves; stage_view.js handOff),
|
|
631
|
+
then the turn arrives in the history. Meanwhile the history's bottom reserve eases down (app.js). */
|
|
632
|
+
.turn-stage.leaving{transition:transform .42s cubic-bezier(.4,0,.6,1),opacity .42s ease-in;transform:translateY(40px);opacity:0;pointer-events:none}
|
|
633
|
+
#center.reserve-easing #history{transition:padding-bottom .42s cubic-bezier(.4,0,.6,1)}
|
|
605
634
|
.ts-arrive{animation:ts-arrive .5s cubic-bezier(.3,.7,.2,1)}
|
|
606
635
|
@keyframes ts-arrive{from{opacity:0;transform:translateY(60px)}}
|
|
607
636
|
.ts-status{flex:none;display:flex;align-items:center;gap:8px;min-width:0;padding:8px 8px 0 14px;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
@@ -638,20 +667,24 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
638
667
|
.ts-prompt:not(.full) .user-message blockquote{display:inline;border:0;padding:0;margin:0}
|
|
639
668
|
/* The live slots: fixed heights (two headline lines, one tool row, one trail line). */
|
|
640
669
|
.ts-live{display:flex;flex-direction:column;gap:8px}
|
|
641
|
-
.ts-headline{height:
|
|
670
|
+
.ts-headline{height:var(--ts-head-h);font-size:14px;line-height:21px;color:var(--narr);overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;cursor:pointer;border-radius:6px}
|
|
642
671
|
.ts-headline:hover{background:var(--bg-hover)}
|
|
643
672
|
.ts-headline:not(.thinking)::before{content:"\203A";color:var(--accent);font-weight:700;margin-right:7px}
|
|
644
673
|
.ts-headline.thinking{font-size:13.5px;font-style:italic;color:var(--text-muted)}
|
|
645
674
|
.ts-think-tag{font-style:normal;font-size:12.5px;color:var(--think-text);margin-right:6px}
|
|
646
675
|
.ts-headline.fresh{animation:ts-fade .3s ease}
|
|
676
|
+
/* While a question or approval waits: the whole reply that asked it, raw as a closed step's text, in the headline's
|
|
677
|
+
place; capped so the card keeps its room, read from the top (stage_view.js drawAsking). */
|
|
678
|
+
.ts-asking{max-height:calc(var(--stage-max,60vh) * .35);overflow:auto;scrollbar-width:thin;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.55;color:var(--narr);border-left:2px solid var(--border-strong);padding-left:10px}
|
|
679
|
+
.turn-stage.asking .ts-headline{display:none}
|
|
647
680
|
@keyframes ts-fade{from{opacity:.35}}
|
|
648
|
-
.ts-now{height:
|
|
681
|
+
.ts-now{height:var(--ts-now-h);display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
|
|
649
682
|
.ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s linear infinite}
|
|
650
683
|
.ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
|
|
651
684
|
.ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
|
|
652
685
|
.ts-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--text-2)}
|
|
653
686
|
.ts-title.quiet{font-family:inherit;font-style:italic;color:var(--text-faint)}
|
|
654
|
-
.ts-trail{height:
|
|
687
|
+
.ts-trail{height:var(--ts-trail-h);display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
|
|
655
688
|
.ts-trail-item{flex:none;max-width:36ch;overflow:hidden;text-overflow:ellipsis}
|
|
656
689
|
.ts-trail-item:is(.error,.blocked){color:var(--danger-text)}
|
|
657
690
|
.ts-trail-item.warn{color:var(--warn-text)}
|
|
@@ -662,7 +695,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
662
695
|
.turn-stage.waiting .ts-now,.turn-stage.waiting .ts-trail{display:none}
|
|
663
696
|
/* Rows that arrive during the turn (cards, queued prompts, commands, notes) and after its end (the end line): capped, own scroll. */
|
|
664
697
|
.ts-extras,.ts-tail{display:flex;flex-direction:column;gap:8px;max-height:calc(var(--stage-max,60vh) * .4);overflow:auto;scrollbar-width:thin}
|
|
665
|
-
.turn-stage.
|
|
698
|
+
.turn-stage.held-wait .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
|
|
699
|
+
/* The card under the live slots stays put from its arrival to the next step: waiting, the slots keep at least their running
|
|
700
|
+
height (the asking text in the headline's place, the tool row's and the trail's room left blank); answered, the height
|
|
701
|
+
they had while waiting (stage_view.js). */
|
|
702
|
+
.turn-stage.waiting .ts-live{min-height:var(--ts-live-h)}
|
|
703
|
+
.turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(var(--ts-live-h),var(--live-held,0px))}
|
|
704
|
+
/* A 1 px mark at the tallest content's end, so the scroll extent never drops while a turn runs (stage_view.js fitFrame). */
|
|
705
|
+
.ts-hold{position:absolute;left:0;top:0;width:1px;height:1px;visibility:hidden;pointer-events:none}
|
|
666
706
|
.ts-extras>*,.ts-tail>*{flex-shrink:0}
|
|
667
707
|
.ts-extras>.bubble.user{max-width:none;margin:0;padding:4px 10px;font-size:13px;line-height:1.45;background:transparent;color:var(--text-2)}
|
|
668
708
|
.ts-extras>.bubble.user>.copy-btn{display:none}
|
|
@@ -676,12 +716,14 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
676
716
|
.ts-answer>.bubble.output{border-left:0;background:transparent;border:0;padding:0}
|
|
677
717
|
.ts-answer>.bubble.output>.copy-btn{top:0;right:0}
|
|
678
718
|
/* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
|
|
679
|
-
.
|
|
719
|
+
/* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
|
|
720
|
+
.ts-foot{flex:none;padding:0 14px 10px}
|
|
721
|
+
.ts-foot[hidden],.turn-stage.collapsed .ts-foot{display:none}
|
|
680
722
|
.ts-chip{display:flex;align-items:center;gap:10px;flex-wrap:nowrap;width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:9px;background:transparent;color:var(--text-muted);text-align:left}
|
|
681
723
|
.ts-chip:hover:not(:disabled){filter:none;background:var(--bg-hover)}
|
|
682
724
|
.ts-chip-label{font-size:12.5px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}
|
|
683
725
|
.ts-chip-label::before{content:"\25B8 ";color:var(--accent)}
|
|
684
|
-
.ts-
|
|
726
|
+
.ts-foot.open .ts-chip-label::before{content:"\25BE "}
|
|
685
727
|
/* One row whatever the count (the stage's height stays): the newest ticks show, the oldest are cut on the left. */
|
|
686
728
|
.ts-ticks{display:flex;flex-wrap:nowrap;justify-content:flex-end;overflow:hidden;gap:2px;flex:0 1 auto;min-width:0}
|
|
687
729
|
.ts-tick{flex:none}
|
|
@@ -700,14 +742,18 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
700
742
|
.ts-cloud-body[hidden]{display:none}
|
|
701
743
|
.turn-stage .turn-work{border:0;background:transparent;padding:0;border-radius:0}
|
|
702
744
|
.turn-stage .turn-work>summary{display:none}
|
|
745
|
+
/* The ended turn's cloud shows every step: no folds there. */
|
|
746
|
+
.turn-stage .outline-fold{display:none}
|
|
747
|
+
.turn-stage .turn-work.outline>.gen.folded{display:block}
|
|
703
748
|
.turn-stage .ts-cloud-body .gen:first-of-type{margin-top:0;border-top:0}
|
|
704
749
|
@media(max-width:600px){
|
|
705
750
|
.ts-status{padding-left:10px}
|
|
706
751
|
.ts-scroll{padding:8px 10px 10px}
|
|
752
|
+
.ts-foot{padding:0 10px 10px}
|
|
707
753
|
.ts-defer{display:none}
|
|
708
754
|
.ts-trail-item{max-width:24ch}
|
|
709
755
|
}
|
|
710
|
-
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving{transition:none}}
|
|
756
|
+
@media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
|
|
711
757
|
#infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
|
|
712
758
|
#infoBar.visible{display:flex}
|
|
713
759
|
#infoBar .info{flex:1;min-width:0;font-size:13px;line-height:1.4;word-break:break-word;display:flex;flex-direction:column;gap:2px}
|
|
@@ -861,10 +907,17 @@ body.has-session #scope .scope-where{display:none}
|
|
|
861
907
|
/* The open session's title, when no strip card in sight shows it (session_title.js): the bar's middle, cut with an ellipsis; the whole first message in its tooltip. */
|
|
862
908
|
#sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
863
909
|
#sessionTitle[hidden]{display:none}
|
|
864
|
-
/* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line.
|
|
910
|
+
/* A phone: the title is its own row under the bar, so "chi + · pill · bell" stay one line. Nothing on that line wraps to a
|
|
911
|
+
row of its own when the pill's text grows mid-turn ("· 1 waiting"): the scope chip and the pill have a base size of 0 (what
|
|
912
|
+
a wrapping line counts) and grow to their text, the pill first; the scope chip cuts with an ellipsis before the pill does.
|
|
913
|
+
The pill drops its session count while it says live or waiting, so the scope chip keeps its words. */
|
|
865
914
|
@media(max-width:600px){
|
|
866
915
|
#topBar{flex-wrap:wrap;row-gap:2px}
|
|
867
916
|
#sessionTitle{flex-basis:100%;order:10;font-size:12px}
|
|
917
|
+
#scope,#stripShow{flex:1 1 0;min-width:0;max-width:max-content}
|
|
918
|
+
#stripShow{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
|
|
919
|
+
#scope .scope-name{flex-shrink:1;min-width:0}
|
|
920
|
+
#stripShow:has(.live,.waiting) .pill-count{display:none}
|
|
868
921
|
}
|
|
869
922
|
#notifyBtn{margin-left:auto;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;min-height:0;background:transparent;border:1px solid transparent;border-radius:999px;color:var(--text-muted)}
|
|
870
923
|
#notifyBtn[hidden]{display:none}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// lit reuses a list's nodes by position (vendor/lit-html.js has no keyed
|
|
2
|
+
// repeat): after an item is inserted before the focused one, the focused
|
|
3
|
+
// node holds another item. keepFocus draws with +draw+ and puts the focus
|
|
4
|
+
// back on the item it was on, by its +attr+ (data-id, data-name): the same
|
|
5
|
+
// node inside it (by class), the item itself else; blurred when the item is
|
|
6
|
+
// gone, so no other item keeps it. Nothing is done when the focus wasn't
|
|
7
|
+
// inside +root+.
|
|
8
|
+
export function keepFocus(root, attr, draw) {
|
|
9
|
+
const doc = root.ownerDocument;
|
|
10
|
+
const itemOf = (node) => (node && node !== root && root.contains(node) ? node.closest(`[${attr}]`) : null);
|
|
11
|
+
const was = doc.activeElement;
|
|
12
|
+
const item = itemOf(was);
|
|
13
|
+
const key = item && root.contains(item) ? item.getAttribute(attr) : null;
|
|
14
|
+
const inner = item && was !== item ? was.className : null;
|
|
15
|
+
draw();
|
|
16
|
+
if (key === null || itemOf(doc.activeElement)?.getAttribute(attr) === key) return;
|
|
17
|
+
const target = [...root.querySelectorAll(`[${attr}]`)].find((el) => el.getAttribute(attr) === key);
|
|
18
|
+
if (!target) {
|
|
19
|
+
if (root.contains(doc.activeElement)) doc.activeElement.blur?.();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const same = inner && [...target.querySelectorAll("*")].find((el) => el.className === inner);
|
|
23
|
+
(same || target).focus();
|
|
24
|
+
}
|
|
@@ -24,14 +24,13 @@
|
|
|
24
24
|
// setStripHidden(hidden)
|
|
25
25
|
|
|
26
26
|
import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
|
|
27
|
-
import { htmlElement } from "./turn_html.js";
|
|
28
27
|
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
29
28
|
import { cardCtxText, ctxBarTip } from "./ctx.js";
|
|
30
29
|
import { allScopeHref, cardFolder } from "./scope.js";
|
|
31
30
|
import { sessionHash } from "./route.js";
|
|
32
31
|
import { stripColumns, stripShowParts } from "./strip.js";
|
|
33
|
-
import { createFamilyPop,
|
|
34
|
-
import { allViewChains,
|
|
32
|
+
import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
|
|
33
|
+
import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
|
|
35
34
|
import {
|
|
36
35
|
batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
|
|
37
36
|
listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
|
|
@@ -122,11 +121,10 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
|
|
|
122
121
|
const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
|
|
123
122
|
const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
|
|
124
123
|
const familyWaiting = !!family && familyWaits(family);
|
|
125
|
-
// The family and chain chips and rows
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const famRows = fam && rows && open ? htmlElement(familyRowsHtml(fam, { activeId, matchIds, archivable: !selectable })) : nothing;
|
|
124
|
+
// The family and chain chips and rows (families_view.js, chains.js).
|
|
125
|
+
const chain = family?.chain ? chainChipTemplate(family, { open: chainOpen }) : nothing;
|
|
126
|
+
const kids = fam ? familyChipTemplate(fam, { open }) : nothing;
|
|
127
|
+
const famRows = fam && rows && open ? familyRowsTemplate(fam, { activeId, matchIds, archivable: !selectable }) : nothing;
|
|
130
128
|
// Its delegates and its chain's other links: the card is .contains-active while one is open.
|
|
131
129
|
const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
|
|
132
130
|
...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
|
|
@@ -165,10 +163,12 @@ export function stripTileTemplate(count) {
|
|
|
165
163
|
|
|
166
164
|
// The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
|
|
167
165
|
// (strip.js stripShowParts).
|
|
166
|
+
// The count (and the dot after it) in its own span: a phone's narrow top
|
|
167
|
+
// bar drops it when live or waiting shows (index.html).
|
|
168
168
|
export function stripPillTemplate({ count, live, waiting }) {
|
|
169
|
-
const liveT = live ? html
|
|
170
|
-
const waitingT = waiting ? html
|
|
171
|
-
return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span>
|
|
169
|
+
const liveT = live ? html`<span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
|
|
170
|
+
const waitingT = waiting ? html`${live ? " · " : ""}<span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
|
|
171
|
+
return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> <span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
// Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
|
|
@@ -274,7 +274,7 @@ export function createListsView(deps) {
|
|
|
274
274
|
win,
|
|
275
275
|
rowsFor: (id) => {
|
|
276
276
|
const f = stripFamilies.get(id);
|
|
277
|
-
return f && f.members.length ?
|
|
277
|
+
return f && f.members.length ? familyRowsTemplate(f, { activeId: selected() }) : null;
|
|
278
278
|
},
|
|
279
279
|
anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
|
|
280
280
|
onPick: (id) => {
|
|
@@ -302,7 +302,7 @@ export function createListsView(deps) {
|
|
|
302
302
|
win,
|
|
303
303
|
rowsFor: (id) => {
|
|
304
304
|
const f = chainFamilies.get(id);
|
|
305
|
-
return f?.chain ?
|
|
305
|
+
return f?.chain ? chainRowsTemplate(f, { activeId: selected() }) : null;
|
|
306
306
|
},
|
|
307
307
|
anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
|
|
308
308
|
onPick: (id) => {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// choice for the next create only (POST /api/sessions' llm_context), never
|
|
13
13
|
// remembered: nothing is sent unless the user sets one.
|
|
14
14
|
|
|
15
|
-
import { html, nothing } from "./vendor/lit-html.js";
|
|
15
|
+
import { html, nothing, render } from "./vendor/lit-html.js";
|
|
16
16
|
|
|
17
17
|
export const STRATEGIES = [
|
|
18
18
|
{ value: "none", label: "none" },
|
|
@@ -149,9 +149,7 @@ export function strategyOptions(chosen) {
|
|
|
149
149
|
return [...STRATEGIES, { value: chosen, label: chosen }];
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
return `<option value="${escapeHtml(value)}"${value === chosen ? " selected" : ""}>${escapeHtml(label)}</option>`;
|
|
154
|
-
}
|
|
152
|
+
const option = (value, label) => html`<option value=${value}>${label}</option>`;
|
|
155
153
|
|
|
156
154
|
// The popover's form: the values now (each with its source), and the
|
|
157
155
|
// session's own to change.
|
|
@@ -161,30 +159,46 @@ export function nowText(summary) {
|
|
|
161
159
|
`budget ${budget} (${summary.budget_where})`;
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
//
|
|
165
|
-
// the values the form
|
|
166
|
-
|
|
162
|
+
// The popover's form (a lit-html template). For the new chat (kind
|
|
163
|
+
// NEW_CHAT), +summary+ is the model's and +choice+ the values the form
|
|
164
|
+
// shows; +now+ is the summary its "Now:" line reads (refresh passes a newer
|
|
165
|
+
// one, the rest stays as the form opened, so lit leaves what the user typed
|
|
166
|
+
// alone); +error+ a failed Set's message. The selects take their value as
|
|
167
|
+
// a property (.value), set after their options: +unset+ draws the options
|
|
168
|
+
// alone, the first pass renderForm makes.
|
|
169
|
+
export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary, error = null,
|
|
170
|
+
unset = false } = {}) {
|
|
167
171
|
const fresh = kind === NEW_CHAT;
|
|
168
172
|
const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
|
|
169
173
|
const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
|
|
170
174
|
const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
|
|
171
175
|
: "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
176
|
+
const chosen = (value) => (unset ? nothing : value);
|
|
177
|
+
return html`<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong
|
|
178
|
+
><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
|
|
179
|
+
><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
|
|
180
|
+
><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
|
|
181
|
+
><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
|
|
182
|
+
option(DEFAULT, follow("strategy"))}${strategyOptions(own.strategy).map((s) => option(s.value, s.label))}</select></label
|
|
183
|
+
><label class="llmctx-field"><span>Apply</span><select name="apply" .value=${chosen(own.apply)}>${
|
|
184
|
+
option(DEFAULT, follow("apply"))}${APPLIES.map((a) => option(a, a))}</select></label
|
|
185
|
+
><label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off"
|
|
186
|
+
placeholder="follow the model (64000, 64k or off)" .value=${own.budget}></label
|
|
187
|
+
><div class="ctx-pop-error llmctx-error" ?hidden=${!error}>${error || ""}</div
|
|
188
|
+
><div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div
|
|
189
|
+
><div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button
|
|
190
|
+
><button type="submit" class="llmctx-set">Set</button></div></form>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Draws the form into +target+ (formTemplate's options). A first draw (no
|
|
194
|
+
// form there yet) makes two passes, the options and then the selects'
|
|
195
|
+
// values: lit sets an element's own bindings before its children, so a
|
|
196
|
+
// select's .value would land before its options exist. A later draw is
|
|
197
|
+
// one pass, and lit writes only what changed since the last (the "Now:"
|
|
198
|
+
// line, the error), never a value the user changed.
|
|
199
|
+
export function renderForm(target, summary, opts = {}) {
|
|
200
|
+
if (!target.firstElementChild) render(formTemplate(summary, { ...opts, unset: true }), target);
|
|
201
|
+
render(formTemplate(summary, opts), target);
|
|
188
202
|
}
|
|
189
203
|
|
|
190
204
|
const EDGE = 16;
|
|
@@ -212,7 +226,7 @@ export function popoverPlace(rect, { width, height, innerWidth, innerHeight }) {
|
|
|
212
226
|
// choice() the pending choice, and choose(next) takes a Set's (null for
|
|
213
227
|
// "follow the model") instead of a command running. id and anchor (the
|
|
214
228
|
// chip's selector) keep the two controls apart.
|
|
215
|
-
export function createLlmContextControl({ el,
|
|
229
|
+
export function createLlmContextControl({ el, summary, session = () => null, running = () => false, run,
|
|
216
230
|
kind = SESSION, choice = () => null, choose = null,
|
|
217
231
|
id = "llmContextPopover", anchor: anchorSel = ".llmctx-chip",
|
|
218
232
|
doc = document, win = window }) {
|
|
@@ -226,6 +240,14 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
226
240
|
// The values the form opened with, and the session it is for.
|
|
227
241
|
let initial = null;
|
|
228
242
|
let openFor = null;
|
|
243
|
+
// What the open form was drawn from: the summary, the form's options and
|
|
244
|
+
// the summary its "Now:" line reads. A closed popover is empty, so each
|
|
245
|
+
// open draws a fresh form (unsent edits are gone).
|
|
246
|
+
let shown = null;
|
|
247
|
+
const draw = (changes = {}) => {
|
|
248
|
+
shown = { ...shown, ...changes };
|
|
249
|
+
renderForm(pop, shown.data, { ...shown.opts, now: shown.now, error: shown.error });
|
|
250
|
+
};
|
|
229
251
|
|
|
230
252
|
function place(anchor) {
|
|
231
253
|
const width = Math.min(360, win.innerWidth - 32);
|
|
@@ -244,7 +266,8 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
244
266
|
if (!data) return;
|
|
245
267
|
initial = fresh ? ownValues({ own: choice() }) : ownValues(data);
|
|
246
268
|
openFor = session();
|
|
247
|
-
|
|
269
|
+
shown = { data, opts: { running: running(), kind, choice: choice() } };
|
|
270
|
+
draw({ now: data, error: null });
|
|
248
271
|
pop.hidden = false;
|
|
249
272
|
place(anchor);
|
|
250
273
|
}
|
|
@@ -253,19 +276,20 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
253
276
|
pop.hidden = true;
|
|
254
277
|
initial = null;
|
|
255
278
|
openFor = null;
|
|
279
|
+
shown = null;
|
|
280
|
+
render(nothing, pop);
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
function isOpen() { return !pop.hidden; }
|
|
259
284
|
|
|
260
285
|
// A new summary (a command_ran, a re-read): an open form keeps what the
|
|
261
|
-
// user typed; only its "Now:" line
|
|
262
|
-
// session closes it.
|
|
286
|
+
// user typed; only its "Now:" line follows (the same template again: lit
|
|
287
|
+
// writes that line alone). Another session closes it.
|
|
263
288
|
function refresh() {
|
|
264
289
|
if (!isOpen()) return;
|
|
265
290
|
const data = summary();
|
|
266
291
|
if (!data || session() !== openFor) return close();
|
|
267
|
-
|
|
268
|
-
if (now) now.textContent = nowText(data);
|
|
292
|
+
draw({ now: data });
|
|
269
293
|
}
|
|
270
294
|
|
|
271
295
|
el.addEventListener("click", (e) => {
|
|
@@ -277,8 +301,7 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
277
301
|
});
|
|
278
302
|
// An edit makes an error the last Set showed stale.
|
|
279
303
|
const clearError = () => {
|
|
280
|
-
|
|
281
|
-
if (error && !error.hidden) { error.hidden = true; error.textContent = ""; }
|
|
304
|
+
if (shown?.error) draw({ error: null });
|
|
282
305
|
};
|
|
283
306
|
pop.addEventListener("input", clearError);
|
|
284
307
|
pop.addEventListener("change", clearError);
|
|
@@ -292,9 +315,7 @@ export function createLlmContextControl({ el, escapeHtml, summary, session = ()
|
|
|
292
315
|
try {
|
|
293
316
|
next = mergeChoice(choice(), values, initial);
|
|
294
317
|
} catch (err) {
|
|
295
|
-
|
|
296
|
-
error.textContent = err.message;
|
|
297
|
-
error.hidden = false;
|
|
318
|
+
draw({ error: err.message });
|
|
298
319
|
return;
|
|
299
320
|
}
|
|
300
321
|
close();
|
|
@@ -102,6 +102,17 @@ function relayClosedText(relay, reason) {
|
|
|
102
102
|
return reason ? `Left open in ${relay.who} (${reason})` : `Left open in ${relay.who}`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// Whether the card keeps the reply that asked it once resolved: the model's
|
|
106
|
+
// own question and an own approval (their call ran in the asking step). Not
|
|
107
|
+
// the step limit (asked between turns), a hook's question (not asked by a
|
|
108
|
+
// call) or a delegate's relayed approval (the parent's text is about its
|
|
109
|
+
// own call, the card's delegate line says what the child asks).
|
|
110
|
+
export function keepsAskingText(pq) {
|
|
111
|
+
if (!pq) return false;
|
|
112
|
+
if (isApproval(pq)) return !relayView(pq);
|
|
113
|
+
return !pq.kind || pq.kind === "question";
|
|
114
|
+
}
|
|
115
|
+
|
|
105
116
|
// The line a resolved card shows.
|
|
106
117
|
export function resultText(pq, { answer = null, cancelled = false, reason = "" } = {}) {
|
|
107
118
|
const approval = isApproval(pq);
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
17
|
import {
|
|
18
|
-
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue,
|
|
19
|
-
relayedToView, resultText, summaryText,
|
|
18
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, deniedReason, isContinue, keepsAskingText,
|
|
19
|
+
nextCardAction, relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
22
22
|
import { clientLabel, isOwn } from "./turn_events.js";
|
|
@@ -28,13 +28,15 @@ import { continueLine } from "./commands.js";
|
|
|
28
28
|
// @param isNearBottom () => whether the history follows its end
|
|
29
29
|
// @param scrollToEnd () scrolls the history to its end
|
|
30
30
|
// @param removeHintIfEmpty () drops the history's placeholder hint
|
|
31
|
+
// @param askingText () => the running turn's asking reply (turn_model.js
|
|
32
|
+
// askingText via the page's view), kept by a question or own approval card
|
|
31
33
|
// @param api {sendAnswer, dismissQuestion, sendCommand} (data.js)
|
|
32
34
|
// @param doc the page's document
|
|
33
|
-
// @return {renderQuestion(pq), resolveQuestion(id, opts), removeQuestion(),
|
|
35
|
+
// @return {renderQuestion(pq, {said}), resolveQuestion(id, opts), removeQuestion(),
|
|
34
36
|
// questionCard() (the live card or null), pendingQuestion() (or null),
|
|
35
37
|
// renderContinue(offer), resolveContinue(data), forgetContinue()}
|
|
36
38
|
export function createQuestionCards({
|
|
37
|
-
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty,
|
|
39
|
+
sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
|
|
38
40
|
api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
|
|
39
41
|
}) {
|
|
40
42
|
// Live ask_user_question card, keyed by pending question id. Re-rendered from
|
|
@@ -43,8 +45,12 @@ export function createQuestionCards({
|
|
|
43
45
|
let pending = null;
|
|
44
46
|
// The continue offer's card while the offer is pending.
|
|
45
47
|
let continueEl = null;
|
|
48
|
+
// card → the reply that asked it, drawn under the summary once resolved.
|
|
49
|
+
const saidOf = new WeakMap();
|
|
46
50
|
|
|
47
|
-
|
|
51
|
+
// +said+: the asking reply when the caller knows it (a reloaded card: its
|
|
52
|
+
// step's text, "" for none); else the running turn's (askingText).
|
|
53
|
+
function renderQuestionCard(pq, { said = null } = {}) {
|
|
48
54
|
const action = nextCardAction({ cardId: questionEl?.dataset.qid ?? null, pendingId: pending?.id ?? null }, pq);
|
|
49
55
|
if (action === "same") return;
|
|
50
56
|
// A resolved card stays where it was; only a pending one is replaced.
|
|
@@ -60,6 +66,8 @@ export function createQuestionCards({
|
|
|
60
66
|
const card = doc.createElement("details");
|
|
61
67
|
card.className = approval ? "bubble question approval" : stepLimit ? "bubble question step-limit" : "bubble question";
|
|
62
68
|
card.dataset.qid = pq.id;
|
|
69
|
+
const asked = keepsAskingText(pq) ? String(said ?? askingText() ?? "").trim() : "";
|
|
70
|
+
if (asked) saidOf.set(card, asked);
|
|
63
71
|
// Open while pending: the question must be actionable.
|
|
64
72
|
card.open = true;
|
|
65
73
|
|
|
@@ -174,11 +182,41 @@ export function createQuestionCards({
|
|
|
174
182
|
});
|
|
175
183
|
card.appendChild(dismiss);
|
|
176
184
|
|
|
185
|
+
// Enter in the response box or on an option submits (never denies or
|
|
186
|
+
// dismisses: that stays a click).
|
|
187
|
+
card.addEventListener("keydown", (e) => {
|
|
188
|
+
if (!isPlainEnter(e) || submit.disabled) return;
|
|
189
|
+
const t = e.target;
|
|
190
|
+
if (t !== freeformInput && !t?.closest?.(".question-option")) return;
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
submit.click();
|
|
193
|
+
});
|
|
194
|
+
|
|
177
195
|
// Read before the card makes the history taller.
|
|
178
196
|
const follow = isNearBottom();
|
|
179
197
|
place(card);
|
|
180
198
|
questionEl = card;
|
|
181
199
|
if (follow) scrollToEnd();
|
|
200
|
+
focusWhenIdle(card, pq.id);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isPlainEnter(e) {
|
|
204
|
+
return e.key === "Enter" && !e.isComposing && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A new card takes the focus while nobody is typing: the focus is on
|
|
208
|
+
// the page or an empty composer. Checked after the caller is done, so a
|
|
209
|
+
// card drawn already answered (the history replay) never takes it.
|
|
210
|
+
function focusWhenIdle(card, id) {
|
|
211
|
+
queueMicrotask(() => {
|
|
212
|
+
if (questionEl !== card || String(pending?.id) !== String(id)) return;
|
|
213
|
+
const prompt = doc.getElementById?.("prompt");
|
|
214
|
+
const active = doc.activeElement;
|
|
215
|
+
const idle = !active || active === doc.body || (active === prompt && !prompt.value);
|
|
216
|
+
if (!idle || (prompt && prompt.value)) return;
|
|
217
|
+
const target = card.querySelector(".question-option input") || card.querySelector(".question-freeform");
|
|
218
|
+
target?.focus();
|
|
219
|
+
});
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
// The step-limit question's actions: a button per option (Continue,
|
|
@@ -358,6 +396,23 @@ export function createQuestionCards({
|
|
|
358
396
|
// unless the user opened or closed it by hand.
|
|
359
397
|
const summary = questionEl.querySelector("summary");
|
|
360
398
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
399
|
+
// The reply that asked, kept with the card: what made the question
|
|
400
|
+
// answerable stays with it in the history. Muted under the "Q → A" line
|
|
401
|
+
// in two lines while the card is closed (a copy screen readers skip: not
|
|
402
|
+
// the toggle's label), and whole, selectable, at the top of the body
|
|
403
|
+
// when it is open (a drag in the summary would toggle the card).
|
|
404
|
+
const asked = saidOf.get(questionEl);
|
|
405
|
+
if (summary && asked) {
|
|
406
|
+
const line = doc.createElement("div");
|
|
407
|
+
line.className = "question-said";
|
|
408
|
+
line.setAttribute("aria-hidden", "true");
|
|
409
|
+
line.textContent = asked;
|
|
410
|
+
summary.appendChild(line);
|
|
411
|
+
const full = doc.createElement("div");
|
|
412
|
+
full.className = "question-said-full";
|
|
413
|
+
full.textContent = asked;
|
|
414
|
+
questionEl.insertBefore(full, summary.nextSibling);
|
|
415
|
+
}
|
|
361
416
|
// 4.02: a reopened card's summary already carries the result (the
|
|
362
417
|
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
363
418
|
// stays for a hand-toggled card, whose expanded body the user looks at,
|