samagotchi 0.143.0 → 0.145.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -1
  3. data/docs/bundle-authoring.md +1 -1
  4. data/docs/cli.md +21 -4
  5. data/docs/configuration.md +1 -1
  6. data/docs/guardrails.md +6 -3
  7. data/docs/hooks.md +5 -1
  8. data/docs/internals/bridge.md +2 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +1 -1
  11. data/docs/internals/web-frontend.md +59 -2
  12. data/docs/plugins.md +4 -2
  13. data/docs/releasing.md +3 -2
  14. data/docs/testing.md +1 -1
  15. data/lib/samagotchi/bridge/sse_writer.rb +11 -1
  16. data/lib/samagotchi/bundle_command/check.rb +13 -2
  17. data/lib/samagotchi/bundle_settings_check.rb +34 -1
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +4 -4
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +13 -6
  20. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/cost-cap/manifest.yml +4 -4
  22. data/lib/samagotchi/bundles/cost-cap/plugin.rb +4 -1
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +4 -2
  25. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +4 -2
  26. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  28. data/lib/samagotchi/bundles/work-cap/manifest.yml +4 -4
  29. data/lib/samagotchi/bundles/work-cap/plugin.rb +17 -8
  30. data/lib/samagotchi/context_status.rb +1 -1
  31. data/lib/samagotchi/engine.rb +17 -7
  32. data/lib/samagotchi/kernel_loop.rb +7 -0
  33. data/lib/samagotchi/live_versions.rb +28 -2
  34. data/lib/samagotchi/llm/chat_loop.rb +5 -0
  35. data/lib/samagotchi/llm/utf8_scrub.rb +5 -3
  36. data/lib/samagotchi/plugin/api.rb +2 -2
  37. data/lib/samagotchi/plugin/context.rb +3 -2
  38. data/lib/samagotchi/plugin/peer.rb +1 -1
  39. data/lib/samagotchi/plugin_session_state.rb +6 -3
  40. data/lib/samagotchi/self_report.rb +15 -13
  41. data/lib/samagotchi/session_access/local.rb +7 -10
  42. data/lib/samagotchi/session_commands.rb +2 -1
  43. data/lib/samagotchi/system_one/answer.rb +8 -5
  44. data/lib/samagotchi/system_one/client.rb +17 -0
  45. data/lib/samagotchi/system_one/http_backend.rb +23 -10
  46. data/lib/samagotchi/testing/boundary.rb +76 -0
  47. data/lib/samagotchi/testing/env_not_shown.rb +17 -0
  48. data/lib/samagotchi/testing.rb +1 -0
  49. data/lib/samagotchi/update_command.rb +8 -7
  50. data/lib/samagotchi/version.rb +1 -1
  51. data/lib/samagotchi/web/public/annotate_bar.js +1 -1
  52. data/lib/samagotchi/web/public/app.js +15 -3
  53. data/lib/samagotchi/web/public/chip_popover.js +14 -4
  54. data/lib/samagotchi/web/public/composer_fold.js +5 -2
  55. data/lib/samagotchi/web/public/composer_grip.js +45 -2
  56. data/lib/samagotchi/web/public/copy.js +1 -1
  57. data/lib/samagotchi/web/public/index.html +79 -40
  58. data/lib/samagotchi/web/public/info_bar_model.js +6 -4
  59. data/lib/samagotchi/web/public/more_sheet.js +212 -0
  60. data/lib/samagotchi/web/public/question_cards.js +26 -3
  61. data/lib/samagotchi/web/public/ref_scan.js +4 -2
  62. data/lib/samagotchi/web/public/stage_frame.js +58 -13
  63. data/lib/samagotchi/web/public/stage_model.js +2 -1
  64. data/lib/samagotchi/web/public/stage_view.js +52 -20
  65. data/lib/samagotchi/web/public/turn_block.js +43 -9
  66. data/lib/samagotchi/worker.rb +21 -4
  67. metadata +3 -1
@@ -168,7 +168,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
168
168
  .init-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
169
169
  .init-task.done .init-text{color:var(--ok-text)}
170
170
  .init-mark{flex:none;width:10px;text-align:center;color:var(--ok)}
171
- .init-spin{flex:none;width:10px;height:10px;border:1.5px solid var(--border-strong);border-top-color:var(--accent);border-radius:50%;animation:init-spin .8s linear infinite}
171
+ .init-spin{flex:none;width:10px;height:10px;border:1.5px solid var(--border-strong);border-top-color:var(--accent);border-radius:50%;animation:init-spin .8s steps(16) infinite}
172
172
  @keyframes init-spin{to{transform:rotate(360deg)}}
173
173
  @media (prefers-reduced-motion:reduce){.init-spin{animation:none;border-top-color:var(--accent)}.init-task{transition:none}}
174
174
  .markdown-warning{margin:0 16px 4px;padding:8px 10px;border:1px solid var(--warn);border-radius:8px;background:var(--warn-bg);color:var(--warn-bright);font-size:12px;line-height:1.4}
@@ -240,7 +240,7 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
240
240
  .activity-status.error::before,.activity-status.blocked::before{content:"\2715"}
241
241
  .activity-status.stopped::before{content:"\25A0";font-size:10px}
242
242
  .activity-status.running::before{content:"";display:inline-block;box-sizing:border-box;width:10px;height:10px;vertical-align:-1px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%}
243
- .turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s linear infinite}
243
+ .turn-work:not(.done) .activity-status.running::before{animation:init-spin .8s steps(16) infinite}
244
244
  .activity-state{flex:none;white-space:nowrap;font-size:11px;text-transform:uppercase;letter-spacing:.03em;font-weight:600}
245
245
  :is(.activity-status,.activity-state).running{color:var(--warn)}
246
246
  :is(.activity-status,.activity-state).ok{color:var(--ok)}
@@ -365,8 +365,9 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
365
365
  .gen.live{border-top-color:transparent;background:var(--accent-tint);border-radius:8px;padding:6px 9px 8px calc(9px + var(--step-indent));margin-left:-9px;margin-right:-9px}
366
366
  .gen.live>summary{color:var(--accent);white-space:normal}
367
367
  /* The live step's marker: a pulsing accent dot in place of the chevron (the block's own left bar stays the only one). */
368
- .gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:step-pulse 1.6s ease-in-out infinite}
369
- @keyframes step-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--accent) 55%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--accent) 0%,transparent)}}
368
+ .gen.live>summary::before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 2px;border-radius:50%;background:var(--accent);vertical-align:middle;animation:live-blink 2.4s steps(16) infinite}
369
+ /* Two segments on purpose: steps() applies per segment, so 2 x 16 / 2.4 s = 13 updates/s (docs/internals/web-frontend.md, Frames). */
370
+ @keyframes live-blink{0%,100%{opacity:1}50%{opacity:.45}}
370
371
  @media (prefers-reduced-motion:reduce){.gen.live>summary::before{animation:none}}
371
372
  /* A step's thinking is a one-line ticker: the newest complete sentence,
372
373
  ellipsed to one line (the collapsed .bubble.thinking look, scoped to .gen
@@ -392,10 +393,8 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
392
393
  .outline-fold:hover{color:var(--text-muted)}
393
394
  .turn-work.outline>.gen.folded.unfolded{margin-left:var(--step-indent)}
394
395
  .gen-text{margin-top:6px;white-space:pre-wrap;word-break:break-word;font-size:15px;line-height:1.55}
395
- /* 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. */
396
+ /* 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; a new line appears at once (its fade was most of a streaming answer's animation cost, docs/internals/web-frontend.md, Frames). */
396
397
  .gen-text.streaming>.narration{max-height:4.65em;overflow:auto;scrollbar-width:thin;white-space:normal}
397
- .narration>.sentence{animation:fadeIn 160ms ease}
398
- @media (prefers-reduced-motion:reduce){.narration>.sentence{animation:none}}
399
398
  .gen:not(.live) .gen-text{font-size:14px;color:var(--text-2)}
400
399
  .gen-text:empty{display:none}
401
400
  .gen .activity-body{margin-top:6px}
@@ -527,7 +526,7 @@ button.system-one-wrong,.system-one-wrong.marked{flex:none;padding:1px 6px;borde
527
526
  .question-relayed a{color:var(--text);font-weight:600}
528
527
  .approval-tool{font-weight:700;color:var(--text);margin-bottom:4px}
529
528
  .approval-what{margin:0 0 6px;padding:8px 10px;background:var(--bg-inset);border:1px solid var(--warn-border);border-radius:6px;white-space:pre-wrap;word-break:break-all;color:var(--warn-bright);font-size:12.5px}
530
- .approval-where,.approval-why{font-size:12px;color:var(--text-muted);word-break:break-all}
529
+ .approval-where,.approval-why{font-size:12px;color:var(--text-muted);overflow-wrap:anywhere}
531
530
  .approval-why{color:var(--warn-text)}
532
531
  .approval-label{color:var(--text-faint)}
533
532
  /* A diff (diff_view.js): the approval card's dry run and a tool row's change. */
@@ -763,7 +762,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
763
762
  @keyframes ts-arrive{from{opacity:0;transform:translateY(60px)}}
764
763
  .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}
765
764
  .turn-stage.collapsed .ts-status{padding-bottom:8px;cursor:pointer}
766
- .ts-mark{flex:none;width:7px;height:7px;border-radius:50%;background:var(--accent);animation:step-pulse 1.6s ease-in-out infinite}
765
+ .ts-mark{flex:none;width:7px;height:7px;border-radius:50%;background:var(--accent);animation:live-blink 2.4s steps(16) infinite}
767
766
  .turn-stage.ended .ts-mark{width:auto;height:auto;background:none;animation:none;font-size:12px;line-height:1}
768
767
  .turn-stage[data-phase="answered"] .ts-mark,.turn-stage[data-phase="answered"] .ts-phase{color:var(--ok)}
769
768
  .turn-stage[data-phase="canceled"] .ts-mark,.turn-stage[data-phase="canceled"] .ts-phase,.turn-stage[data-phase="waiting for you"] .ts-phase{color:var(--warn-text)}
@@ -814,13 +813,13 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
814
813
  its room, read from the top. */
815
814
  .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}
816
815
  /* The question room (stage_view.js drawRoom): a model's own question whose asking text overflows that cap. The slot loses its
817
- cap and its own scroll, and the stage's one scroll covers the whole reply (the stage's cap rises for the turn, stage_frame.js).
816
+ cap and its own scroll, and the stage's one scroll covers the whole reply (the stage's cap rises until the next step, stage_frame.js).
818
817
  --stage-max also scales the extras, the cloud and the queued slot: a ceiling only, content that fit stays as it is. */
819
818
  .turn-stage.question-room .ts-asking{max-height:none;overflow:visible}
820
819
  .turn-stage.asking .ts-headline{display:none}
821
820
  @keyframes ts-fade{from{opacity:.35}}
822
821
  .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}
823
- .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}
822
+ .ts-spin{flex:none;width:11px;height:11px;border:2px solid var(--warn);border-right-color:transparent;border-radius:50%;animation:init-spin .8s steps(16) infinite}
824
823
  .ts-idle{flex:none;width:11px;height:11px;border:2px solid var(--border-strong);border-radius:50%}
825
824
  .ts-tool{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;font-weight:600;color:var(--accent)}
826
825
  .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)}
@@ -837,9 +836,12 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
837
836
  /* Rows that arrive during the turn (cards, queued prompts, commands, notes) and after its end (the end line): capped, own scroll. */
838
837
  .ts-extras,.ts-tail{display:flex;flex-direction:column;gap:8px;max-height:calc(var(--stage-max,60vh) * .4);overflow:auto;scrollbar-width:thin}
839
838
  .turn-stage.held-wait .ts-extras{max-height:calc(var(--stage-max,60vh) * .7)}
839
+ /* A blocking card is read whole in the stage's one scroll (from its top, stage_frame.js revealTop), not in a smaller scroller of
840
+ its own: its question and command never sit above a nested view's edge. Answered, the slot has its cap again. */
841
+ .turn-stage.waiting :is(.ts-extras,.ts-tail){max-height:none;overflow:visible}
840
842
  /* The card under the live slots stays put from its arrival to the next step: waiting, the slots keep at least their running
841
843
  height (the asking text in the headline's place, the tool row's and the trail's room left blank); answered, the height
842
- they had while waiting (stage_view.js). */
844
+ they had while waiting. The next step lets it go with the frame (stage_view.js releaseHeldWait). */
843
845
  .turn-stage.waiting .ts-live{min-height:var(--ts-live-h)}
844
846
  .turn-stage.held-wait:not(.waiting) .ts-live{min-height:max(var(--ts-live-h),var(--live-held,0px))}
845
847
  /* A 1 px mark at the tallest content's end, so the scroll extent never drops while a turn runs (stage_view.js fitFrame). */
@@ -891,8 +893,9 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
891
893
  .ts-tick.blocked{background:var(--danger-text);opacity:1}
892
894
  /* A call the user's Stop cut: the ■ STOPPED rows' amber, still (running blinks). */
893
895
  .ts-tick.stopped{background:var(--warn-text);opacity:.8}
894
- .ts-tick.running{background:var(--warn);opacity:1;animation:ts-blink 1s ease-in-out infinite}
895
- @keyframes ts-blink{50%{opacity:.35}}
896
+ .ts-tick.running{background:var(--warn);opacity:1;animation:live-blink 2.4s steps(16) infinite}
897
+ /* A blocking card waits: nothing moves, since movement means chi is working. (.ts-spin is in .ts-now, not displayed then; an asking card's turn goes on, no .waiting.) */
898
+ .turn-stage.waiting .ts-mark,.turn-stage.waiting .ts-tick.running,.turn-stage.waiting .gen.live>summary::before,.turn-stage.waiting .turn-work:not(.done) .activity-status.running::before{animation:none}
896
899
  .ts-cloud-body{max-height:calc(var(--stage-max,60vh) * .62);overflow:auto;scrollbar-width:thin;border-left:1px solid var(--border);margin-left:6px;padding-left:10px}
897
900
  .ts-cloud-body[hidden]{display:none}
898
901
  .turn-stage .turn-work{border:0;background:transparent;padding:0;border-radius:0}
@@ -1077,8 +1080,11 @@ button.ws-row:hover:not(:disabled){filter:none}@media(hover:hover){button.ws-row
1077
1080
  .ts-defer{display:none}
1078
1081
  .ts-cancel{padding:1px 8px}
1079
1082
  .ts-trail-item{max-width:24ch}
1083
+ /* An approval's Submit in sight on a phone: its folder, reason and rows a little tighter. */
1084
+ .approval-where,.approval-why{line-height:1.35}
1085
+ .bubble.question.approval .question-option{padding:5px 8px}
1080
1086
  }
1081
- @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}}
1087
+ @media (prefers-reduced-motion:reduce){.turn-stage.enter,.ts-arrive,.ts-headline.fresh,.ts-tick.running,.ts-mark,.ts-spin,.turn-work:not(.done) .activity-status.running::before{animation:none}.turn-stage.leaving,#center.reserve-easing #history{transition:none}}
1082
1088
  #infoBar{display:flex;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}
1083
1089
  #infoBar.visible{display:flex}
1084
1090
  /* The meta row keeps its room: with a plain flex:1 the bar's own buttons (continue, archive, stop session, delete)
@@ -1340,44 +1346,75 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1340
1346
  @media(hover:hover){.ctx-pop-actions button:hover{color:var(--text-hover)}}
1341
1347
  .ctx-pop-actions .ctx-pop-detach{color:var(--text-muted);background:transparent}
1342
1348
  @media(hover:hover){.ctx-pop-actions .ctx-pop-detach:hover{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 55%,transparent)}}
1343
- /* Phones: the footer is one line; its meta scrolls sideways (all of it stays reachable). */
1349
+ /* ⋯ (more_sheet.js): a phone's alone. */
1350
+ #infoBar #infoMoreBtn{display:none}
1351
+ /* Phones: the footer is one line with no swiping: id, status, model (it gives way first), ⚙ and ⋯. The other chips and the
1352
+ four buttons stay in the DOM, unseen: ⋯'s sheet clicks them, and what they open is placed at ⋯ (chip_popover.js). */
1344
1353
  @media(max-width:600px){
1345
1354
  #infoBar{flex-wrap:nowrap;align-items:center;gap:6px;padding:6px 10px 8px}
1346
- #infoBar .meta{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;white-space:nowrap}
1347
- #infoBar .meta::-webkit-scrollbar{display:none}
1355
+ #infoBar .info{flex:1 1 0}
1356
+ #infoBar .meta{flex-wrap:nowrap;white-space:nowrap;gap:6px}
1357
+ #infoBar .meta .id{padding:2px 3px}
1358
+ #infoBar .meta .status{padding:2px 5px}
1348
1359
  #infoBar .meta>*,#infoBar .meta .bar-group>*{flex-shrink:0}
1349
- /* One scrolling run, as before the groups: their chips are the meta's (the orders below reach across them). */
1360
+ /* One run: the groups' chips are the meta's. */
1350
1361
  #infoBar .meta .bar-group{display:contents}
1351
1362
  #infoBar .meta .bar-sep{display:none}
1352
- #infoBar .meta .id{order:-2}
1353
- #infoBar .meta .status{order:-1}
1363
+ #infoBar .meta :is(.tools-chip,.notes-chip,.mem-chip,.ws-chip,.chain-link,.parent,.children-chip,.participants-chip,.dir,.ctx,.speed,.session-time,.worker-badge){display:none}
1364
+ #infoBar>button:not(#infoMoreBtn){display:none}
1365
+ #infoBar .meta .status+.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
1354
1366
  /* ⚙ alone, the start page's dot when the session set its own (its values in the tooltip and accessible name). */
1355
1367
  #infoBar .meta .settings-chip .sc-own{display:none}
1356
- #infoBar .meta .settings-chip.own{padding-right:10px}
1368
+ #infoBar .meta .settings-chip.own{padding-right:6px}
1357
1369
  #infoBar .meta .settings-chip.own::after{content:"";position:absolute;top:3px;right:3px;width:5px;height:5px;border-radius:50%;background:var(--accent)}
1358
- /* The memory chip right after the status (same order, later in the DOM): the meta scrolls, and only its start is in sight. */
1359
- #infoBar .meta .mem-chip,#infoBar .meta .ws-chip{order:-1}
1360
- /* A parent's children chip too: its waiting part is what to see. */
1361
- #infoBar .meta .children-chip{order:-1}
1362
- /* A chain's ← → links too: the way to the other days. */
1363
- #infoBar .meta .chain-link{order:-1}
1364
- /* ⚙ too (an icon here): after the model chip a long model name would push it out of sight. */
1365
- #infoBar .meta .settings-chip{order:-1}
1366
- /* The chips get a second line of their own (only when there are some), scrolling sideways. */
1370
+ /* ⚙ and ⋯: bigger to the thumb than to the eye. 2 px of negative margin keep the row its height, and each one's ::before reaches the foot's edges for the tap. */
1371
+ #infoBar .meta .settings-chip{margin:-2px 0 -2px auto;min-width:32px;min-height:30px;justify-content:center}
1372
+ #infoBar .meta .settings-chip::before,#infoBar #infoMoreBtn::before{content:"";position:absolute;inset:-4px -3px -6px}
1373
+ #infoBar #infoMoreBtn{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:none;width:38px;height:30px;margin:-2px 0;padding:0;font-size:16px;line-height:1;letter-spacing:1px;color:var(--text-3);background:var(--bg-raised);border:1px solid var(--border-strong)}
1374
+ #infoBar #infoMoreBtn[aria-expanded="true"]{color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
1375
+ #infoBar #infoMoreBtn[data-attention]::after{content:"";position:absolute;top:4px;right:4px;width:6px;height:6px;border-radius:50%;background:var(--accent)}
1376
+ #infoBar #infoMoreBtn[data-attention="warn"]::after{background:var(--warn-bright)}
1377
+ /* The attached context's chips get a second line of their own (only when there are some), scrolling sideways. */
1367
1378
  #infoBar:has(#contextChips:not([hidden])){flex-wrap:wrap;row-gap:4px}
1368
- #contextChips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
1379
+ #infoBar:has(#contextChips:not([hidden])) .info{flex-basis:calc(100% - 44px)}
1380
+ #contextChips{order:1;flex:1 0 100%;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none}
1369
1381
  #contextChips::-webkit-scrollbar{display:none}
1370
1382
  #contextChips>*{flex-shrink:0}
1371
1383
  #infoBar button{padding:4px 8px}
1372
- /* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
1373
- #infoBar>button{font-size:0;line-height:0;padding:5px 7px}
1374
- #infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
1375
- #infoBar #infoArchiveBtn{--icon:var(--icon-archive)}
1376
- #infoBar #infoArchiveBtn.archived{--icon:var(--icon-unarchive)}
1377
- #infoBar #infoContinueBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8h9M8.5 4.5 12 8l-3.5 3.5'/%3E%3C/svg%3E")}
1378
- #infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
1379
- #infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
1380
1384
  }
1385
+ /* ⋯'s sheet (more_sheet.js): a row per chip the phone's bar folded away, the telemetry, the actions. */
1386
+ .more-sheet{gap:2px;font-size:13.5px}
1387
+ .more-sheet.sheet{border-radius:14px;padding:10px 14px 12px;box-shadow:0 0 0 100vmax color-mix(in srgb,var(--bg) 62%,transparent)}
1388
+ .more-grip{flex:none;align-self:center;width:34px;height:4px;border-radius:2px;background:var(--border-strong);margin-bottom:4px}
1389
+ .more-rows{display:flex;flex-direction:column}
1390
+ .more-row{display:flex;align-items:center;justify-content:space-between;gap:12px;box-sizing:border-box;width:100%;min-height:40px;padding:6px 0;border:0;border-bottom:1px solid var(--border);border-radius:0;background:transparent;color:var(--text);font:inherit;text-align:left;text-decoration:none;cursor:pointer}
1391
+ div.more-row{cursor:default}
1392
+ .more-label{flex:none;color:var(--text-3)}
1393
+ .more-value{display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0}
1394
+ .more-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1395
+ .more-row.warn .more-text{color:var(--warn-text);font-weight:600}
1396
+ a.more-row .more-text{color:var(--accent)}
1397
+ .more-dot{flex:none;width:6px;height:6px;border-radius:50%;background:var(--accent)}
1398
+ .more-dot.warn{background:var(--warn-bright)}
1399
+ .more-sheet .chip-icon{flex:none;width:12px;height:12px;font-size:0;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
1400
+ .more-sheet .tc-kit{--icon:var(--icon-toolkit)}
1401
+ .more-info{padding:6px 0 8px;border-bottom:1px solid var(--border);color:var(--text-muted);font-size:12px;line-height:1.45;overflow-wrap:anywhere}
1402
+ .more-restart{flex:none;min-height:30px;padding:2px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
1403
+ .more-restart:disabled{opacity:.7;cursor:default}
1404
+ .more-telemetry{display:flex;align-items:baseline;gap:6px;padding:8px 0;font-size:11.5px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
1405
+ .more-telemetry>*{flex:none}
1406
+ .more-telemetry>*+*::before{content:"·";margin-right:6px}
1407
+ /* Its start gives way, as the bar's folder does: the end names the project. Its separator goes with it, so it isn't the clipped side's. */
1408
+ .more-telemetry .more-dir{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font-family:ui-monospace,monospace;direction:rtl;text-align:left}
1409
+ .more-telemetry .more-dir::before{content:none}
1410
+ .more-telemetry .more-clock::after{content:"·";margin-left:6px}
1411
+ .more-actions{display:flex;gap:6px}
1412
+ .more-act{flex:1 1 0;min-width:0;min-height:40px;padding:0 2px;border-radius:9px;font-size:12px;font-weight:600;color:var(--text-2);background:transparent;border:1px solid var(--border-strong)}
1413
+ .more-act.accent{color:var(--accent);background:var(--bg-raised);border-color:var(--accent-border)}
1414
+ .more-act:disabled{opacity:.7;cursor:progress}
1415
+ .more-act.stop{color:var(--warn-text)}
1416
+ .more-act.delete{color:var(--danger-text)}
1417
+ @media(hover:hover){.more-row:hover .more-label{color:var(--text)}.more-act:hover:not(:disabled){background:var(--bg-hover)}}
1381
1418
  #empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
1382
1419
  #empty.hidden{display:none !important}
1383
1420
  /* Start page (no session): the composer alone, centered; no info bar. */
@@ -1702,6 +1739,7 @@ body.all-view #allHead{display:flex}
1702
1739
  .card .stopped-badge,.family-row .stopped-badge{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn);color:var(--bg)}
1703
1740
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
1704
1741
  .status.idle{background:var(--bg-raised)}
1742
+ .status.waiting{background:var(--warn-bg);color:var(--warn-text)}
1705
1743
  .status.completed{background:var(--accent-bg);color:var(--accent)}
1706
1744
  .status.error{background:var(--danger-bg-dim);color:var(--danger-text)}
1707
1745
  .card .owner,.family-row .owner{font-size:11px;padding:2px 6px;border-radius:999px}
@@ -1793,6 +1831,7 @@ a{color:var(--accent)}
1793
1831
  <button class="ghost" id="infoArchiveBtn" title="Archive: hide from the lists, keep for good" style="display:none">archive</button>
1794
1832
  <button class="ghost" id="infoStopBtn" title="Stop session (terminate worker)" style="display:none">stop session</button>
1795
1833
  <button class="ghost" id="infoDeleteBtn" title="Delete session (for good)" style="display:none">delete</button>
1834
+ <button type="button" class="ghost" id="infoMoreBtn" data-anchor-stand-in aria-label="More" title="More" aria-haspopup="dialog" aria-expanded="false"><span aria-hidden="true">⋯</span></button>
1796
1835
  </div>
1797
1836
  </div>
1798
1837
  </section>
@@ -65,11 +65,12 @@ export function infoButtons({ id, state, chain, archived, continuing, streaming
65
65
  // @param badge workerBadgeFor's, or null
66
66
  // @param restarting whether this session's Restart is under way
67
67
  // @param running whether its turn runs (the session clock ticks)
68
+ // @param waiting whether the session waits on the user (app.js sessionWaits)
68
69
  // @param workingSet web.working_set on: the chip's {n, changed} (n 0 while
69
70
  // the set is empty); undefined: off (the mem chip shows)
70
71
  // @param participants the participants chip (participants_panel.js
71
72
  // chipModel), null before the list is read
72
- export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, workingSet = undefined, participants = null }) {
73
+ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting, running, waiting = false, workingSet = undefined, participants = null }) {
73
74
  const tokens = state.timing.tokens;
74
75
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
75
76
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
@@ -85,7 +86,8 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
85
86
  const parentChip = delegatedBy(state.parentId, sessions.find((x) => x.id === state.parentId) || null);
86
87
  return {
87
88
  id,
88
- status: state.status,
89
+ // Its turn runs but a card blocks it: the word `chi sessions list` has for it.
90
+ status: running && waiting ? "waiting" : state.status,
89
91
  model: modelLabel(state.model, ...(state.served || [])),
90
92
  // A link of a session chain: ← the previous link, → the next one (each
91
93
  // only while the list holds it).
@@ -122,12 +124,12 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
122
124
  // The whole bar for the open session +id+ (null: the start page, no line).
123
125
  // @param sessions the page's list; +continuing+, +restarting+: Sets of ids
124
126
  // @return {buttons, bar}
125
- export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, workingSet = undefined, participants = null }) {
127
+ export function sessionBar({ id, state, sessions, versions, cardTitle, continuing, restarting, streaming, running, waiting = false, workingSet = undefined, participants = null }) {
126
128
  const chain = id ? chainNeighbours(sessions, id, { continues: state.continues }) : { prev: null, next: null };
127
129
  const archived = !!sessions.find((s) => s.id === id)?.archived;
128
130
  const buttons = infoButtons({ id, state, chain, archived, continuing: continuing.has(id), streaming });
129
131
  if (!id) return { buttons, bar: null };
130
132
  const badge = workerBadgeFor(id, state, versions);
131
- const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, workingSet, participants });
133
+ const bar = infoBarModel({ id, state, sessions, chain, cardTitle, badge, restarting: restarting.has(id), running, waiting, workingSet, participants });
132
134
  return { buttons, bar };
133
135
  }
@@ -0,0 +1,212 @@
1
+ // The phone's session bar keeps id, status, model and ⚙ on its row; the rest
2
+ // sits behind ⋯ (#infoMoreBtn, index.html's CSS shows it at the page's phone
3
+ // width only). This is its bottom sheet, on chip_popover.js' shell as the ⚙
4
+ // panel is: a row for each chip the row folded away, one telemetry line, and
5
+ // the bar's four actions.
6
+ //
7
+ // The chips and the buttons stay where they are, display:none: a row or an
8
+ // action clicks the real one (`go:`), so its handlers, confirmations and state
9
+ // have one home (session_actions.js' Invite toast clicks the participants chip
10
+ // the same way). What a chip then opens places itself and gives focus back at
11
+ // ⋯, chip_popover.js' stand-in for an anchor with no box ([data-anchor-stand-in]).
12
+ // A chip with a tooltip and no popover (🧰, memories, model notes) unfolds its
13
+ // tooltip under the row (`info:`): a phone has no tooltips. The model has a row
14
+ // too: its chip stays on the bar but is the first to give way, cut short.
15
+ //
16
+ // app.js's updateInfoBar calls update() on every redraw (every second while a
17
+ // turn runs): lit writes only what changed, into slots that keep their place,
18
+ // so an open sheet stays current and keeps its focus. A module of its own: the
19
+ // page's modules are cached at unversioned URLs (docs/internals/web-frontend.md).
20
+
21
+ import { createChipPopover, SHEET_TALL } from "./chip_popover.js";
22
+ import { memChipText } from "./format.js";
23
+ import { CLOCK_NONE, memoriesTooltip, NEXT, PREV } from "./info_bar.js";
24
+ import { chipModel as toolkitChip } from "./toolkit_chip.js";
25
+ import { html, nothing, render } from "./vendor/lit-html.js";
26
+
27
+ // The real element each `go:` act clicks.
28
+ const TARGETS = {
29
+ workingSet: "#infoText .ws-chip",
30
+ participants: "#infoText .participants-chip",
31
+ delegates: "#infoText .children-chip",
32
+ restart: "#infoText .worker-restart",
33
+ continue: "#infoContinueBtn",
34
+ archive: "#infoArchiveBtn",
35
+ stop: "#infoStopBtn",
36
+ delete: "#infoDeleteBtn",
37
+ };
38
+
39
+ // Every row, in the sheet's order; each draws into a slot of its own.
40
+ const ROW_KEYS = ["model", "toolkit", "workingSet", "memories", "participants", "prev", "next", "parent", "delegates", "notes", "worker"];
41
+ const ACTION_KEYS = ["continue", "archive", "stop", "delete"];
42
+
43
+ const short = (id) => String(id).slice(0, 8);
44
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
45
+ const capital = (text) => text.charAt(0).toUpperCase() + text.slice(1);
46
+
47
+ function toolkitRow(toolkit) {
48
+ const chip = toolkitChip(toolkit.summary, toolkit.defaultKit);
49
+ const kit = chip.text || toolkit.summary?.label || toolkit.summary?.name || toolkit.defaultKit || "full";
50
+ return { key: "toolkit", label: "Toolkit", value: kit, icon: "tc-kit", info: chip.title };
51
+ }
52
+
53
+ function workingSetRow({ n, changed }) {
54
+ const count = n ? plural(n, "item") : "empty";
55
+ return { key: "workingSet", label: "Working set", value: changed ? `${count}, changed` : count, go: "workingSet", attention: changed ? "accent" : null };
56
+ }
57
+
58
+ function participantsRow(chip) {
59
+ return { key: "participants", label: "Participants", value: chip.count ? chip.text : "Invite", go: "participants", attention: chip.present ? "accent" : null };
60
+ }
61
+
62
+ function delegatesRow(kids) {
63
+ const value = kids.waitingText ? `${kids.text} · ${kids.waitingText}` : kids.text;
64
+ return { key: "delegates", label: "Delegates", value, go: "delegates", attention: kids.waitingText ? "warn" : null };
65
+ }
66
+
67
+ // The model's title unfolds only when it says more than the row already does
68
+ // (a name cut at 40, what the server serves instead).
69
+ function modelRow({ text, title }) {
70
+ return { key: "model", label: "Model", value: text, info: title === text ? null : title };
71
+ }
72
+
73
+ function workerRow({ badge, busy }) {
74
+ const row = { key: "worker", label: "Worker", value: badge.text, attention: "warn" };
75
+ return badge.restart ? { ...row, restart: { busy } } : { ...row, info: badge.title };
76
+ }
77
+
78
+ // The rows +bar+ (info_bar_model.js infoBarModel) has, each only when the
79
+ // session has its chip: {key, label, value, and how a tap acts: go (a TARGETS
80
+ // key), href, info (the text it unfolds), restart ({busy}); attention: "accent"
81
+ // or "warn" when the chip asks for a look; icon: a chip icon's class}.
82
+ export function moreRows(bar) {
83
+ if (!bar) return [];
84
+ const memories = bar.memories || [];
85
+ return [
86
+ bar.model && modelRow(bar.model),
87
+ bar.toolkit && toolkitRow(bar.toolkit),
88
+ bar.workingSet?.chip && workingSetRow(bar.workingSet.chip),
89
+ !bar.workingSet && memories.length > 0
90
+ && { key: "memories", label: "Memories", value: memChipText(memories), info: memoriesTooltip(memories, bar.preloaded, bar.muted) },
91
+ bar.participants && participantsRow(bar.participants),
92
+ bar.chain?.prev && { key: "prev", label: "Previous link", value: `${PREV} ${bar.chain.prev.date || short(bar.chain.prev.id)}`, href: bar.chain.prev.id },
93
+ bar.chain?.next && { key: "next", label: "Next link", value: `${bar.chain.next.date || short(bar.chain.next.id)} ${NEXT}`, href: bar.chain.next.id },
94
+ bar.parent && { key: "parent", label: "Delegated by", value: `↑ ${short(bar.parent.id)}`, href: bar.parent.id },
95
+ bar.kids && delegatesRow(bar.kids),
96
+ bar.notes && { key: "notes", label: "Model notes", value: bar.notes.text.replace(/^notes: /, ""), info: bar.notes.title },
97
+ bar.worker?.badge && workerRow(bar.worker),
98
+ ].filter(Boolean);
99
+ }
100
+
101
+ // ⋯'s dot: what behind it asks for a look. {kind} is the strongest ("warn":
102
+ // a delegate waiting, an older worker; "accent": the working set changed, a
103
+ // participant here; null: none), {label} ⋯'s accessible name, which says why.
104
+ export function moreAttention(bar) {
105
+ const rows = moreRows(bar).filter((row) => row.attention);
106
+ const kind = rows.some((row) => row.attention === "warn") ? "warn" : rows.length ? "accent" : null;
107
+ const why = rows.map((row) => `${row.label}: ${row.value}`).join(", ");
108
+ return { kind, label: why ? `More: ${why}` : "More" };
109
+ }
110
+
111
+ // The four actions as the sheet draws them from +buttons+ (info_bar_model.js
112
+ // infoButtons), each with the rule its button has; Continue takes the accent
113
+ // on an archived session, where it is the thing to do.
114
+ export function moreActions(buttons) {
115
+ const all = [
116
+ buttons.continue.shown && { key: "continue", text: capital(buttons.continue.text.replace(/ →$/, "")), disabled: buttons.continue.disabled, accent: !!buttons.archive.archived },
117
+ buttons.archive.shown && { key: "archive", text: capital(buttons.archive.text), title: buttons.archive.title },
118
+ buttons.stop.shown && { key: "stop", text: "Stop" },
119
+ buttons.delete.shown && { key: "delete", text: "Delete" },
120
+ ];
121
+ return all.filter(Boolean);
122
+ }
123
+
124
+ function rowBody(row) {
125
+ const dot = row.attention ? html`<span class=${`more-dot ${row.attention}`} aria-hidden="true"></span>` : nothing;
126
+ const icon = row.icon ? html`<span class=${`chip-icon ${row.icon}`} aria-hidden="true"></span>` : nothing;
127
+ return html`<span class="more-label">${row.label}</span><span class="more-value">${dot}${icon}<span class="more-text">${row.value}</span></span>`;
128
+ }
129
+
130
+ function rowTemplate(row, { shownInfo, hash }) {
131
+ const cls = `more-row${row.attention ? ` ${row.attention}` : ""}`;
132
+ if (row.href) return html`<a class=${cls} href=${hash(row.href)} data-act="link">${rowBody(row)}</a>`;
133
+ if (row.go) return html`<button type="button" class=${cls} data-act=${`go:${row.go}`}>${rowBody(row)}</button>`;
134
+ if (row.restart) {
135
+ return html`<div class=${cls}>${rowBody(row)}<button type="button" class="more-restart" data-act="go:restart" ?disabled=${row.restart.busy}>${row.restart.busy ? "restarting…" : "Restart"}</button></div>`;
136
+ }
137
+ if (!row.info) return html`<div class=${cls}>${rowBody(row)}</div>`;
138
+ const open = shownInfo === row.key;
139
+ return html`<button type="button" class=${cls} data-act=${`info:${row.key}`} aria-expanded=${open ? "true" : "false"}>${rowBody(row)}</button>${open ? html`<div class="more-info">${row.info}</div>` : nothing}`;
140
+ }
141
+
142
+ // The sheet for +bar+ and +buttons+. +shownInfo+: the key of the row whose
143
+ // tooltip is unfolded; +hash+: a session's id to its link.
144
+ export function moreSheetTemplate({ bar, buttons, shownInfo = null, hash }) {
145
+ const rows = moreRows(bar);
146
+ const actions = moreActions(buttons);
147
+ const speed = bar.speed;
148
+ return html`<div class="more-grip" aria-hidden="true"></div
149
+ ><div class="more-rows">${ROW_KEYS.map((key) => {
150
+ const row = rows.find((r) => r.key === key);
151
+ return row ? rowTemplate(row, { shownInfo, hash }) : nothing;
152
+ })}</div
153
+ ><div class="more-telemetry"><span class="more-ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span
154
+ >${speed ? html`<span class="more-speed" title=${speed.tip}>${speed.text}</span>` : nothing
155
+ }<span class="more-clock">${bar.clock || CLOCK_NONE}</span
156
+ ><span class="more-dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span></div
157
+ ><div class="more-actions">${ACTION_KEYS.map((key) => {
158
+ const act = actions.find((a) => a.key === key);
159
+ if (!act) return nothing;
160
+ return html`<button type="button" class=${`more-act ${key}${act.accent ? " accent" : ""}`} data-act=${`go:${key}`}
161
+ title=${act.title || nothing} ?disabled=${!!act.disabled}>${act.text}</button>`;
162
+ })}</div>`;
163
+ }
164
+
165
+ // ⋯'s sheet. el: where ⋯ is drawn (#infoBar); button: ⋯ itself; session(): the
166
+ // open session's id; hash: a session's id to its link (route.js sessionHash).
167
+ // @return {update({bar, buttons}), close, isOpen, pop}
168
+ export function createMoreSheet({ el, button, session = () => null, hash, doc = document, win = window }) {
169
+ let bar = null;
170
+ let buttons = null;
171
+ let shownInfo = null;
172
+
173
+ function draw() {
174
+ render(moreSheetTemplate({ bar, buttons, shownInfo, hash }), shell.pop);
175
+ }
176
+
177
+ const shell = createChipPopover({
178
+ id: "moreSheet", className: "ctx-popover more-sheet", width: 400, el, anchor: `#${button.id}`,
179
+ summary: () => bar, session, show: () => draw(), sheet: true, sheetShare: SHEET_TALL, label: "More about this session",
180
+ onOpen: () => shell.pop.querySelector(".more-row, .more-act")?.focus(),
181
+ onClose: () => { shownInfo = null; },
182
+ onAct: (act) => {
183
+ const [what, name] = act.split(":");
184
+ if (what === "info") {
185
+ shownInfo = shownInfo === name ? null : name;
186
+ return draw();
187
+ }
188
+ // After this click is done: it goes on to the document, where what the
189
+ // target opens would take it as a click outside, and a link still has
190
+ // to follow its href.
191
+ globalThis.setTimeout(() => {
192
+ shell.close();
193
+ if (what === "go") doc.querySelector(TARGETS[name])?.click();
194
+ }, 0);
195
+ },
196
+ doc, win,
197
+ });
198
+
199
+ function update(next) {
200
+ bar = next.bar;
201
+ buttons = next.buttons;
202
+ const { kind, label } = moreAttention(bar);
203
+ if (kind) button.dataset.attention = kind;
204
+ else delete button.dataset.attention;
205
+ button.setAttribute("aria-label", label);
206
+ button.title = label;
207
+ // Another session, or none, closes it; else the open sheet follows.
208
+ shell.refresh();
209
+ }
210
+
211
+ return { update, close: () => shell.close(), isOpen: shell.isOpen, pop: shell.pop };
212
+ }
@@ -111,13 +111,18 @@ export function createQuestionCards({
111
111
  }
112
112
 
113
113
  const options = Array.isArray(pq.options) ? pq.options : [];
114
+ // An approval's last option denies: here that is the Deny button beside
115
+ // Submit, one tap, so no radio says it again. The option stays in the
116
+ // data (a terminal, chi answer and a relay answer it by its label).
117
+ const denies = approval && options[options.length - 1] === "Deny";
118
+ const shown = denies ? options.slice(0, -1) : options;
114
119
  if (stepLimit) {
115
120
  renderStepLimitActions(card, pq, options);
116
- } else if (options.length) {
121
+ } else if (shown.length) {
117
122
  const list = doc.createElement("div");
118
123
  list.className = "question-options";
119
124
  const inputType = pq.multi_select ? "checkbox" : "radio";
120
- options.forEach((label, i) => {
125
+ shown.forEach((label, i) => {
121
126
  const row = doc.createElement("label");
122
127
  row.className = "question-option";
123
128
  const input = doc.createElement("input");
@@ -155,6 +160,7 @@ export function createQuestionCards({
155
160
  if (pq.allow_freeform) {
156
161
  freeformInput = doc.createElement("input");
157
162
  freeformInput.type = "text";
163
+ noContactFill(freeformInput);
158
164
  freeformInput.className = "question-freeform";
159
165
  freeformInput.placeholder = approval ? "Reason for the model (with Deny, or alone to deny)…" : "Other…";
160
166
  card.appendChild(freeformInput);
@@ -201,7 +207,13 @@ export function createQuestionCards({
201
207
  err.classList.add("hidden");
202
208
  submit.disabled = true;
203
209
  dismiss.disabled = true;
204
- api.dismissQuestion(sessionId(), pq.id).catch((e) => {
210
+ // A reason typed in the box goes with the Deny: the answer carries it,
211
+ // a dismissal has no text.
212
+ const reason = denies && freeformInput ? freeformInput.value.trim() : "";
213
+ const sent = reason
214
+ ? api.sendAnswer(sessionId(), { id: pq.id, selected: ["Deny"], freeform: reason })
215
+ : api.dismissQuestion(sessionId(), pq.id);
216
+ sent.catch((e) => {
205
217
  submit.disabled = false;
206
218
  dismiss.disabled = false;
207
219
  err.textContent = answerErrorText(e);
@@ -228,6 +240,15 @@ export function createQuestionCards({
228
240
  focusWhenIdle(card, pq.id, { staged });
229
241
  }
230
242
 
243
+ // A card's text field isn't a contact form: what iOS Safari respects
244
+ // against its "AutoFill Contact" bar (no autocomplete, a name with no
245
+ // contact word in it). Autocorrect stays: the reader writes prose here.
246
+ function noContactFill(input) {
247
+ input.setAttribute("autocomplete", "off");
248
+ input.setAttribute("autocorrect", "on");
249
+ input.name = "chi-card-note";
250
+ }
251
+
231
252
  function isPlainEnter(e) {
232
253
  return e.key === "Enter" && !e.isComposing && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
233
254
  }
@@ -258,6 +279,7 @@ export function createQuestionCards({
258
279
  function renderStepLimitActions(card, pq, options) {
259
280
  const reason = doc.createElement("input");
260
281
  reason.type = "text";
282
+ noContactFill(reason);
261
283
  reason.className = "question-freeform continue-reason";
262
284
  reason.placeholder = CONTINUE_REASON_PLACEHOLDER;
263
285
  const err = doc.createElement("div");
@@ -590,6 +612,7 @@ export function createQuestionCards({
590
612
  no.addEventListener("click", () => answer(continueLine("no")));
591
613
  const reason = doc.createElement("input");
592
614
  reason.type = "text";
615
+ noContactFill(reason);
593
616
  reason.className = "continue-reason";
594
617
  reason.placeholder = "Why not? (the model reads it)";
595
618
  const noBecause = doc.createElement("button");
@@ -21,8 +21,10 @@ const REF_OR_PATH = /(?<![\w./@+~\\:-])(?<path>[\w./@+~-]*[\w@+~-])(?::(?<from>\
21
21
  const PATH_ALONE = /^(?<path>[\w./@+~-]*[\w@+~-])$/;
22
22
  const URL_SPAN = /[a-z][a-z0-9+.-]*:\/\/\S+/gi;
23
23
  const MARKDOWN_LINK = /\[[^\]]*\]\([^)]*\)/g;
24
- // A fenced code block's opening line (up to 3 spaces, then ``` or ~~~).
25
- const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
24
+ // A fenced code block's opening line (up to 3 spaces, then ``` or ~~~). A
25
+ // backtick fence's info string carries no backtick: "```foo``` first" is
26
+ // inline code, and as a fence it would stay open over the rest of the text.
27
+ const FENCE_OPEN = /^ {0,3}(`{3,}(?![^\n]*`)|~{3,})/;
26
28
  // Elements whose text is never scanned: code blocks, links (a github-pr
27
29
  // PR link stays the PR's), a ref already made.
28
30
  const SKIP = "pre, a, [data-ref-kind]";