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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/docs/architecture.md +10 -4
  4. data/docs/cli.md +22 -8
  5. data/docs/configuration.md +32 -19
  6. data/docs/context.md +8 -0
  7. data/docs/guardrails.md +2 -1
  8. data/docs/hooks.md +80 -1
  9. data/docs/internals/engine.md +57 -15
  10. data/docs/internals/prompt-caching.md +8 -1
  11. data/docs/plugins.md +49 -6
  12. data/docs/sessions.md +7 -3
  13. data/docs/testing.md +4 -1
  14. data/lib/samagotchi/answer_display.rb +4 -3
  15. data/lib/samagotchi/answer_refs.rb +210 -0
  16. data/lib/samagotchi/bridge/card_store.rb +6 -1
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +27 -3
  19. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  20. data/lib/samagotchi/bundles/github-pr/plugin.rb +70 -14
  21. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -1
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +58 -3
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/engine.rb +47 -9
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/hooks/registry.rb +3 -1
  30. data/lib/samagotchi/interrupted_turn.rb +39 -0
  31. data/lib/samagotchi/kernel_loop.rb +13 -20
  32. data/lib/samagotchi/llm/chat_loop.rb +28 -17
  33. data/lib/samagotchi/log_subscriber.rb +6 -3
  34. data/lib/samagotchi/plugin/api.rb +36 -18
  35. data/lib/samagotchi/reasoning_history.rb +22 -4
  36. data/lib/samagotchi/ref.rb +25 -8
  37. data/lib/samagotchi/reply_wait.rb +6 -2
  38. data/lib/samagotchi/row_refs.rb +126 -0
  39. data/lib/samagotchi/session.rb +40 -4
  40. data/lib/samagotchi/session_manager.rb +64 -6
  41. data/lib/samagotchi/session_metrics.rb +24 -2
  42. data/lib/samagotchi/terminal_ui/formatting.rb +1 -1
  43. data/lib/samagotchi/terminal_ui.rb +30 -3
  44. data/lib/samagotchi/tool_activity.rb +8 -4
  45. data/lib/samagotchi/tool_response.rb +5 -4
  46. data/lib/samagotchi/tool_row_fields.rb +4 -2
  47. data/lib/samagotchi/tool_runner.rb +16 -3
  48. data/lib/samagotchi/tools/builtins.rb +9 -2
  49. data/lib/samagotchi/tools/execute.rb +15 -3
  50. data/lib/samagotchi/tools/peers.rb +3 -2
  51. data/lib/samagotchi/tools/registry.rb +5 -3
  52. data/lib/samagotchi/turn_checkpoint.rb +128 -0
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +19 -1
  55. data/lib/samagotchi/web/message_parts.rb +17 -9
  56. data/lib/samagotchi/web/public/activity.js +4 -1
  57. data/lib/samagotchi/web/public/answer_refs.js +217 -0
  58. data/lib/samagotchi/web/public/app.js +74 -33
  59. data/lib/samagotchi/web/public/chains.js +18 -20
  60. data/lib/samagotchi/web/public/context_chips.js +92 -64
  61. data/lib/samagotchi/web/public/families_view.js +51 -39
  62. data/lib/samagotchi/web/public/index.html +66 -13
  63. data/lib/samagotchi/web/public/keep_focus.js +24 -0
  64. data/lib/samagotchi/web/public/lists_view.js +13 -13
  65. data/lib/samagotchi/web/public/llm_context_chip.js +55 -34
  66. data/lib/samagotchi/web/public/question_card.js +11 -0
  67. data/lib/samagotchi/web/public/question_cards.js +60 -5
  68. data/lib/samagotchi/web/public/ref_menu.js +24 -3
  69. data/lib/samagotchi/web/public/ref_scan.js +5 -3
  70. data/lib/samagotchi/web/public/refs.js +140 -20
  71. data/lib/samagotchi/web/public/scroll.js +42 -0
  72. data/lib/samagotchi/web/public/stage_model.js +22 -3
  73. data/lib/samagotchi/web/public/stage_view.js +299 -70
  74. data/lib/samagotchi/web/public/stream_handlers.js +5 -3
  75. data/lib/samagotchi/web/public/timing.js +1 -1
  76. data/lib/samagotchi/web/public/turn_html.js +5 -4
  77. data/lib/samagotchi/web/public/turn_model.js +49 -0
  78. data/lib/samagotchi/web/public/turn_view.js +121 -17
  79. data/lib/samagotchi/worker.rb +169 -21
  80. data/lib/samagotchi/worker_inbound.rb +5 -3
  81. data/lib/samagotchi/worker_signals.rb +81 -0
  82. 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
- .bubble.question.answered,.bubble.question.cancelled{box-shadow:inset 0 1px 0 var(--glass-highlight)}
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:20px;height:20px;padding:0;border:1px solid var(--border-strong);border-radius:5px;background:var(--bg-panel);color:var(--text-muted);font-size:12px;line-height:1;cursor:pointer;box-shadow:0 1px 3px var(--shadow-strong)}
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
- .turn-stage.leaving{transition:height .4s cubic-bezier(.3,.7,.2,1),opacity .3s ease;opacity:0}
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:42px;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}
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:32px;display:flex;align-items:center;gap:8px;min-width:0;padding:0 9px;border-radius:8px;background:var(--accent-tint);font-size:13px}
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:18px;display:flex;gap:10px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-faint);white-space:nowrap}
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.waiting .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
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
- .ts-cloud{display:flex;flex-direction:column;gap:6px}
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-cloud.open .ts-chip-label::before{content:"\25BE "}
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, familyChipHtml, familyRowsHtml } from "./families_view.js";
34
- import { allViewChains, chainChipHtml, chainRowsHtml, chainTitle, foldChains } from "./chains.js";
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 are families_view.js's and
126
- // chains.js's string builders (not lit yet): one element each, as nodes.
127
- const chain = family?.chain ? htmlElement(chainChipHtml(family, { open: chainOpen })) : nothing;
128
- const kids = fam ? htmlElement(familyChipHtml(fam, { open })) : nothing;
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` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
170
- const waitingT = waiting ? html` · <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> ${count}${liveT}${waitingT}`;
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 ? familyRowsHtml(f, { activeId: selected() }) : null;
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 ? chainRowsHtml(f, { activeId: selected() }) : null;
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
- function option(value, label, chosen, escapeHtml) {
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
- // For the new chat (kind NEW_CHAT), +summary+ is the model's and +choice+
165
- // the values the form shows.
166
- export function formHtml(summary, escapeHtml, { running = false, kind = SESSION, choice = null } = {}) {
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
- return `<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong>` +
173
- `<span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span>` +
174
- `<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>` +
175
- `<div class="ctx-pop-meta llmctx-now">${escapeHtml(nowText(summary))}</div>` +
176
- `<label class="llmctx-field"><span>Strategy</span><select name="strategy">` +
177
- option(DEFAULT, follow("strategy"), own.strategy, escapeHtml) +
178
- strategyOptions(own.strategy).map((s) => option(s.value, s.label, own.strategy, escapeHtml)).join("") + `</select></label>` +
179
- `<label class="llmctx-field"><span>Apply</span><select name="apply">` +
180
- option(DEFAULT, follow("apply"), own.apply, escapeHtml) +
181
- APPLIES.map((a) => option(a, a, own.apply, escapeHtml)).join("") + `</select></label>` +
182
- `<label class="llmctx-field"><span>Budget</span><input name="budget" type="text" inputmode="numeric" autocomplete="off" ` +
183
- `placeholder="follow the model (64000, 64k or off)" value="${escapeHtml(own.budget)}"></label>` +
184
- `<div class="ctx-pop-error llmctx-error" hidden></div>` +
185
- `<div class="ctx-pop-meta">${when}${running ? " A turn is running: it applies after this turn." : ""}</div>` +
186
- `<div class="ctx-pop-actions"><button type="button" class="ctx-pop-detach" data-act="reset">follow the model</button>` +
187
- `<button type="submit" class="llmctx-set">Set</button></div></form>`;
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, escapeHtml, summary, session = () => null, running = () => false, run,
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
- pop.innerHTML = formHtml(data, escapeHtml, { running: running(), kind, choice: choice() });
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 and Set's state follow. Another
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
- const now = pop.querySelector(".llmctx-now");
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
- const error = pop.querySelector(".llmctx-error");
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
- const error = pop.querySelector(".llmctx-error");
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, nextCardAction,
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
- function renderQuestionCard(pq) {
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,