samagotchi 0.79.0 → 0.80.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/README.md +7 -5
  4. data/docs/cli.md +24 -7
  5. data/docs/configuration.md +7 -4
  6. data/docs/internals/anthropic.md +3 -1
  7. data/docs/internals/bundles.md +4 -0
  8. data/docs/internals/engine.md +13 -3
  9. data/docs/plugins.md +144 -3
  10. data/docs/releasing.md +2 -2
  11. data/docs/sessions.md +1 -1
  12. data/docs/testing.md +32 -2
  13. data/lib/samagotchi/bootstrap/config_writer.rb +13 -3
  14. data/lib/samagotchi/bootstrap/probe.rb +153 -31
  15. data/lib/samagotchi/bootstrap_command.rb +92 -15
  16. data/lib/samagotchi/bridge.rb +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  19. data/lib/samagotchi/continue_offer.rb +5 -5
  20. data/lib/samagotchi/engine.rb +40 -37
  21. data/lib/samagotchi/guardrails/approval.rb +6 -4
  22. data/lib/samagotchi/guardrails/gate.rb +7 -3
  23. data/lib/samagotchi/hooks/bundle_loader.rb +31 -16
  24. data/lib/samagotchi/hooks/registry.rb +44 -3
  25. data/lib/samagotchi/host_registry.rb +12 -4
  26. data/lib/samagotchi/llm/adapter_common.rb +4 -2
  27. data/lib/samagotchi/llm/anthropic_binding_recovery.rb +3 -0
  28. data/lib/samagotchi/llm/anthropic_messages.rb +2 -1
  29. data/lib/samagotchi/llm/http.rb +12 -7
  30. data/lib/samagotchi/plugin/card.rb +44 -0
  31. data/lib/samagotchi/plugin/context.rb +53 -8
  32. data/lib/samagotchi/plugin/http.rb +154 -0
  33. data/lib/samagotchi/plugin/http_transport.rb +77 -0
  34. data/lib/samagotchi/plugin/loader.rb +29 -10
  35. data/lib/samagotchi/question_answer.rb +29 -0
  36. data/lib/samagotchi/question_desk.rb +20 -19
  37. data/lib/samagotchi/relay_verifier.rb +1 -1
  38. data/lib/samagotchi/testing/boundary.rb +156 -0
  39. data/lib/samagotchi/testing/bundle.rb +349 -0
  40. data/lib/samagotchi/testing/fake_executable.rb +42 -0
  41. data/lib/samagotchi/testing/fake_http.rb +137 -0
  42. data/lib/samagotchi/testing/helpers.rb +56 -0
  43. data/lib/samagotchi/testing/host.rb +76 -0
  44. data/lib/samagotchi/testing/minitest.rb +31 -0
  45. data/lib/samagotchi/testing/recorder.rb +138 -0
  46. data/lib/samagotchi/testing/recording_api.rb +27 -0
  47. data/lib/samagotchi/testing/rspec.rb +31 -0
  48. data/lib/samagotchi/testing/sandbox.rb +163 -0
  49. data/lib/samagotchi/testing/sessions.rb +77 -0
  50. data/lib/samagotchi/testing/verdict.rb +28 -0
  51. data/lib/samagotchi/testing/waiting.rb +36 -0
  52. data/lib/samagotchi/testing.rb +26 -0
  53. data/lib/samagotchi/tools/delegate_relay.rb +7 -6
  54. data/lib/samagotchi/tools/tool_index.rb +3 -0
  55. data/lib/samagotchi/turn_state.rb +28 -1
  56. data/lib/samagotchi/version.rb +1 -1
  57. data/lib/samagotchi/web/public/app.js +47 -23
  58. data/lib/samagotchi/web/public/images.js +33 -0
  59. data/lib/samagotchi/web/public/index.html +71 -38
  60. data/lib/samagotchi/web/public/level_chip.js +11 -11
  61. data/lib/samagotchi/web/public/lists_view.js +19 -14
  62. data/lib/samagotchi/web/public/llm_context_chip.js +15 -15
  63. data/lib/samagotchi/web/public/model_picker.js +1 -1
  64. data/lib/samagotchi/web/public/scope.js +3 -3
  65. data/lib/samagotchi/web/public/strip.js +35 -9
  66. data/lib/samagotchi/web/public/strip_grip.js +76 -0
  67. data/lib/samagotchi/web/public/thinking_chip.js +5 -5
  68. metadata +21 -1
@@ -17,7 +17,8 @@
17
17
  --diff-add-bg:rgba(26,127,55,.12);--diff-add-text:#116329;--diff-del-bg:rgba(207,34,46,.10);--diff-del-text:#82071e;
18
18
  --code-text:#1f2328;--code-keyword:#cf222e;--code-string:#0a3069;--code-comment:#57606a;--code-constant:#0550ae;--code-func:#8250df;--code-type:#953800;
19
19
  --card-hover-border:rgba(31,35,40,.18);--on-solid:#fff;--on-warn:#1f2328;--text-hover:#0d1117;--badge-bg:rgba(31,35,40,.06);--badge-ring:rgba(31,35,40,.15);--shadow-strong:rgba(31,35,40,.18);--backdrop:rgba(13,17,23,.85);--toast-shadow:0 6px 24px rgba(31,35,40,.14)}}
20
- /* Icon masks shared by the phone info bar and the all view's cards (the stroke only shapes the mask; the icon takes currentColor). */
20
+ /* Icon masks shared by the phone info bar, the all view's cards and the composer's image button (the stroke only shapes the mask; the icon takes currentColor). */
21
+ :root{--icon-image: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%3Crect x='2' y='2.75' width='12' height='10.5' rx='1.5'/%3E%3Ccircle cx='5.75' cy='6.25' r='1.1'/%3E%3Cpath d='m2.5 12.5 3.75-3.75 2.5 2.5 1.75-1.75 3 3'/%3E%3C/svg%3E")}
21
22
  :root{--icon-archive: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%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E");--icon-unarchive: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%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
22
23
  *{box-sizing:border-box;margin:0;padding:0}
23
24
  body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:radial-gradient(1100px 700px at 8% 0%,color-mix(in srgb,var(--accent) 13%,transparent),transparent 70%),radial-gradient(900px 700px at 100% 100%,color-mix(in srgb,var(--think) 9%,transparent),transparent 70%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
@@ -590,18 +591,26 @@ body.has-session #composer{flex-wrap:wrap;row-gap:6px}
590
591
  #lightbox[hidden]{display:none}
591
592
  #lightbox img{max-width:96vw;max-height:94vh;border-radius:6px}
592
593
  #composer textarea{flex:1 1 auto;min-height:72px;resize:none;background:transparent;border:0;color:var(--text);padding:2px;border-radius:0;font-size:15px;line-height:1.45}
593
- #composeGrip{position:relative;height:10px;margin:-5px 0;cursor:ns-resize;z-index:2;touch-action:none}
594
- #composeGrip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
595
- #composeGrip:hover::after,#composeGrip:has(+ #composer:hover)::after,#composeGrip.dragging::after{opacity:1}
596
- #composeGrip:hover::after,#composeGrip.dragging::after{background:var(--accent)}
594
+ /* An edge grip: the thin handle on an edge between two panes (the composer's,
595
+ the latest-sessions strip's). The pill is the handle's look, a 36×4 px bar
596
+ centred on the edge, drawn on hover, while dragging or focused; the id
597
+ rules hold each grip's own box: height and margins (they depend on the flex
598
+ gap around it: none for the composer's, main's 16 px for the strip's) and
599
+ its own states. */
600
+ .edge-grip{position:relative;z-index:2;cursor:ns-resize;touch-action:none}
601
+ .edge-grip::after{content:"";position:absolute;left:50%;top:3px;width:36px;height:4px;margin-left:-18px;border-radius:2px;background:var(--border-strong);opacity:0;transition:opacity .12s ease}
602
+ .edge-grip:hover::after,.edge-grip.dragging::after{opacity:1}
603
+ .edge-grip:hover::after,.edge-grip.dragging::after{background:var(--accent)}
604
+ .edge-grip:focus-visible{outline:none}
605
+ .edge-grip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
606
+ #composeGrip{height:10px;margin:-5px 0}
607
+ #composeGrip:has(+ #composer:hover)::after{opacity:1}
597
608
  /* Folded (composer_grip.js, composer_fold.js): the grip is the handle on the info bar's top edge, drawn always, wider so it reads as a control;
598
609
  draft = the accent, waiting = the attention colour; .will-fold = a drag past the fold threshold (released it hides the box). */
599
610
  body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin-left:-24px}
600
611
  #composeGrip.draft::after{background:var(--accent)}
601
612
  #composeGrip.waiting::after{background:var(--warn)}
602
613
  #composeGrip.will-fold::after{opacity:1;background:var(--accent)}
603
- #composeGrip:focus-visible{outline:none}
604
- #composeGrip:focus-visible::after{opacity:1;box-shadow:0 0 0 2px var(--accent-ring)}
605
614
  /* A phone has no hover: the pill is always there and the hit zone is taller, ~96 px wide and centred (full width would cover the
606
615
  info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
607
616
  border and padding, so it never covers a button of the bar. */
@@ -619,6 +628,12 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
619
628
  #composer .actions-col button{white-space:nowrap;font-size:12px;padding:0 12px;height:28px;min-height:28px;line-height:28px}
620
629
  #composer .actions-col .split{order:3;display:flex;gap:4px;margin-left:auto}
621
630
  #composer #actionBtn{min-width:64px;font-weight:600}
631
+ /* The image button (app.js: a click opens #attachInput, the hidden file picker; a phone's offers the camera and the photo library):
632
+ an icon just left of Start / Send, quiet like the model picker; hidden in a session a terminal owns, and on a phone while a turn runs. */
633
+ #composer .actions-col .attach-btn{order:2;flex:none;width:28px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--text-muted)}
634
+ #composer .actions-col .attach-btn::before{content:"";display:block;width:16px;height:16px;margin:auto;background:currentColor;-webkit-mask:var(--icon-image) center/contain no-repeat;mask:var(--icon-image) center/contain no-repeat}
635
+ #composer .actions-col .attach-btn:hover{filter:none;color:var(--text);background:color-mix(in srgb,var(--bg-hover) 75%,transparent)}
636
+ #composer .actions-col .attach-btn[hidden]{display:none}
622
637
  /* In a session the composer is one row: the text field (one line, growing with its text; fitPrompt sets its height)
623
638
  and Cancel · Send at its bottom right. The key hint is in the placeholder there, so its row goes. */
624
639
  body.has-session #composer{flex-direction:row;align-items:flex-end;padding:10px}
@@ -636,12 +651,12 @@ body.has-session #composer .actions-col{flex-wrap:nowrap;flex-shrink:0}
636
651
  /* A fresh link's (body.fresh-link): the session composer is one row and .actions-col is content-sized there, so a px cap keeps the text field's room and Send in view. :not([hidden]) keeps a failed /api/models' hide (this selector outranks the [hidden] one). */
637
652
  body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){display:inline-block;flex:0 1 auto;min-width:0;max-width:200px}
638
653
  @media(max-width:600px){body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){max-width:100px}}
639
- /* The new chat's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
654
+ /* The new session's LLM context chip, after the picker: as quiet as it; accent once a choice is set (the info bar chip's .own). */
640
655
  #composer .actions-col .llmctx-new{order:0;flex:0 1 auto;min-width:0;max-width:min(40%,260px);height:24px;min-height:24px;line-height:22px;padding:0 6px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--text-muted);font-size:11px;font-weight:400;overflow:hidden;text-overflow:ellipsis}
641
656
  #composer .actions-col .llmctx-new:hover{filter:none;color:var(--text);border-color:var(--accent-border);background:var(--accent-bg)}
642
657
  #composer .actions-col .llmctx-new.own{color:var(--accent);border-color:var(--accent-border)}
643
658
  #composer .actions-col .llmctx-new[hidden],body.has-session #composer .actions-col .llmctx-new{display:none}
644
- /* The new chat's thinking chip, after it: shorter text, so a smaller share of the row. */
659
+ /* The new session's thinking chip, after it: shorter text, so a smaller share of the row. */
645
660
  #composer .actions-col .think-new{max-width:min(25%,140px)}
646
661
  /* Phones (the key hint is gone there): the chips keep their whole text and the row wraps, Start staying right (its margin-left:auto) on the last line; only a model name wider than the row ellipsizes. */
647
662
  @media(max-width:600px){#composer .actions-col .model-pick{flex:0 1 auto;min-width:0;max-width:100%}#composer .actions-col .llmctx-new,#composer .actions-col .think-new{flex:0 0 auto;max-width:100%}}
@@ -975,8 +990,7 @@ body.has-session #notice{display:none}
975
990
  #newBtn .brand-new{font-size:12px;color:var(--text-muted);display:none}
976
991
  body.has-session #newBtn .brand-new{display:inline}
977
992
  #newBtn:hover .brand-new{color:var(--text-2)}
978
- .strip-side{display:flex;flex-direction:column;gap:6px;min-width:0}
979
- /* The scope chip: "<project> · all", or "all projects" and where a new chat starts (start page only). */
993
+ /* The scope chip: "<project> · all", or "all projects" and where a new session starts (start page only). */
980
994
  #scope{display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;color:var(--text-muted)}
981
995
  #scope[hidden]{display:none}
982
996
  #scope .scope-name{flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw}
@@ -989,7 +1003,7 @@ body.has-session #newBtn .brand-new{display:inline}
989
1003
  body.has-session #scope .scope-where{display:none}
990
1004
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
991
1005
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
992
- /* A phone's top bar stays one row: "chi +" (the rest of "+ new chat" goes),
1006
+ /* A phone's top bar stays one row: "chi +" (the rest of "+ new session" goes),
993
1007
  tighter gaps, nothing wraps, and the "← project" link cuts with an
994
1008
  ellipsis before the pill does. */
995
1009
  @media(max-width:600px){
@@ -998,11 +1012,9 @@ body.has-session #scope .scope-where{display:none}
998
1012
  #newBtn .brand-new-word{display:none}
999
1013
  #newBtn .brand-new,#scope .scope-back{white-space:nowrap}
1000
1014
  #scope .scope-back{min-width:0;overflow:hidden;text-overflow:ellipsis}
1001
- #stripShow{padding:3px 8px}
1015
+ #stripStatus{padding:3px 8px}
1002
1016
  #newBtn .brand-new{font-size:16px}
1003
1017
  }
1004
- /* The ▾ glyph draws tiny at the pill's 11 px: at 20 px it is about as tall
1005
- as the pill text's capitals (phone and desktop alike). */
1006
1018
  /* The bell (notifications): quiet at the top bar's right end; filled in the accent when on, struck through when the browser blocks it. */
1007
1019
  /* 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. */
1008
1020
  #sessionTitle{flex:1;min-width:0;font-size:13px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -1014,10 +1026,10 @@ body.has-session #scope .scope-where{display:none}
1014
1026
  @media(max-width:600px){
1015
1027
  #topBar{flex-wrap:wrap;row-gap:2px}
1016
1028
  #sessionTitle{flex-basis:100%;order:10;font-size:12px}
1017
- #scope,#stripShow{flex:1 1 0;min-width:0;max-width:max-content}
1018
- #stripShow{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1029
+ #scope,#stripStatus{flex:1 1 0;min-width:0;max-width:max-content}
1030
+ #stripStatus{flex-grow:1000;overflow:hidden;text-overflow:ellipsis}
1019
1031
  #scope .scope-name{flex-shrink:1;min-width:0}
1020
- #stripShow:has(.live,.waiting) .pill-count{display:none}
1032
+ #stripStatus:has(.live,.waiting) .pill-count{display:none}
1021
1033
  }
1022
1034
  #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)}
1023
1035
  #notifyBtn[hidden]{display:none}
@@ -1028,7 +1040,6 @@ body.has-session #scope .scope-where{display:none}
1028
1040
  #notifyBtn[data-state="on"] .bell{fill:currentColor;fill-opacity:.22}
1029
1041
  #notifyBtn[data-state="denied"]{color:var(--text-faint)}
1030
1042
  #notifyBtn[data-state="denied"] .slash{display:inline}
1031
- #stripShow .pill-arrow{font-size:20px;line-height:0;vertical-align:-3px}
1032
1043
  .card .folder{font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;color:var(--text-3)}
1033
1044
  .card .parent{font-family:ui-monospace,monospace;white-space:nowrap;color:var(--text-3)}
1034
1045
  #infoBar .meta a.parent{text-decoration:none}
@@ -1098,22 +1109,42 @@ body.no-glass :is(#familyPop,#chainPop){background:var(--bg-panel);-webkit-backd
1098
1109
  .scope-error{grid-column:1/-1;font-size:13px;color:var(--warn-text)}
1099
1110
  .strip-toggle{font-size:11px;padding:3px 10px;background:transparent;color:var(--text-muted);border:1px solid var(--border);border-radius:999px}
1100
1111
  .strip-toggle:hover{color:var(--text-2);border-color:var(--border-strong)}
1101
- #stripShow{display:none;white-space:nowrap;flex-shrink:0}
1102
- #stripShow .live{color:var(--ok-text);white-space:nowrap}
1103
- #stripShow .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
1104
- #stripShow .waiting{color:var(--warn-text);white-space:nowrap}
1105
- #stripShow .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
1106
- #stripHide{align-self:flex-end;border:0;padding:0 2px;border-radius:4px}
1107
- #stripHide:hover{text-decoration:underline;color:var(--text-2)}
1108
- /* A narrow window's strip scrolls sideways, which puts its own "hide ▴" out of sight: the top bar has it instead, where the pill comes back, with a finger-sized target. */
1109
- #stripHideTop{display:none;white-space:nowrap;flex-shrink:0}
1112
+ #stripStatus{display:none;white-space:nowrap;flex-shrink:0}
1113
+ #stripStatus .live{color:var(--ok-text);white-space:nowrap}
1114
+ #stripStatus .live-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:4px;vertical-align:1px}
1115
+ #stripStatus .waiting{color:var(--warn-text);white-space:nowrap}
1116
+ #stripStatus .waiting-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--warn);margin-right:4px;vertical-align:1px}
1117
+ /* A narrow window's strip scrolls sideways, and the grip's own target there is thin: the status pill gets a finger-sized one. */
1110
1118
  @media(max-width:900px){
1111
- body:not(.strip-hidden):not(.all-view) #stripHideTop{display:block}
1112
- #stripHide{display:none}
1113
- #stripHideTop,#stripShow{min-height:30px;padding:4px 12px;font-size:12px}
1119
+ #stripStatus{min-height:30px;padding:4px 12px;font-size:12px}
1120
+ }
1121
+ body.strip-hidden #stripStatus{display:block}
1122
+ body.strip-hidden #topStrip, body.all-view #stripStatus{display:none}
1123
+ /* The strip's grip (strip_grip.js, app.js): the handle on the edge between the
1124
+ strip and the chat, the composer's twin (the shared .edge-grip look, its own
1125
+ 10 px box). Its box is centred on #center's top edge: main's 16 px gap stands
1126
+ on both sides because the box's height and margins add up to -26 px, so the
1127
+ distances stay what they were without it (strip → chat 16 px; hidden, bar →
1128
+ chat 10 px). While the strip shows, the pill is drawn on a hover of the strip
1129
+ or of the grip itself, as the composer's is of its box; hidden, it is the
1130
+ always-on handle that brings the strip back (48 px, as the folded composer's,
1131
+ since it is a control then and not only a hint). */
1132
+ #stripGrip{height:10px;margin:-5px 0 -21px}
1133
+ #topStrip:hover + #stripGrip::after{opacity:1}
1134
+ #stripGrip.will-toggle::after{opacity:1;background:var(--accent)}
1135
+ body.strip-hidden #stripGrip::after{opacity:1;width:48px;margin-left:-24px}
1136
+ body.all-view #stripGrip{display:none}
1137
+ /* A phone has no hover: the pill is always drawn, and the hit zone is 28 px
1138
+ tall, ~96 px wide and centred on the edge (main's gap plus the box: the
1139
+ margins add up to -(28+16)) — the mirror of the composer's phone grip: it
1140
+ reaches 7 px over its own side (below the cards; it never covers one) and
1141
+ 21 px into the transcript, with the pill on #center's top edge. The grip sits
1142
+ under the strip, outside its sideways scroller, so a swipe on the cards stays
1143
+ a scroll (touch-action:none is the grip's box, not the strip's). */
1144
+ @media(max-width:600px){
1145
+ #stripGrip{width:96px;height:28px;margin:-7px auto -37px}
1146
+ #stripGrip::after{top:5px;opacity:1}
1114
1147
  }
1115
- body.strip-hidden #stripShow{display:block}
1116
- body.strip-hidden #topStrip, body.all-view #stripShow{display:none}
1117
1148
  #allTile{flex:1;flex-direction:column;justify-content:center;align-items:flex-start;text-align:left;color:var(--text-2);font-size:13px;font-weight:600;width:100%}
1118
1149
  #allTile .count{font-size:11px;font-weight:400;color:var(--text-muted)}
1119
1150
  #allView{display:none;flex:1;min-height:0;flex-direction:column;gap:12px}
@@ -1213,14 +1244,14 @@ a{color:var(--accent)}
1213
1244
  <body>
1214
1245
  <main>
1215
1246
  <div id="topBar">
1216
- <button type="button" id="newBtn" class="brand" title="New chat"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new chat</span></span></button>
1247
+ <button type="button" id="newBtn" class="brand" title="New session"><span class="brand-name">chi</span><span class="brand-new">+<span class="brand-new-word"> new session</span></span></button>
1217
1248
  <div id="scope" hidden></div>
1218
- <button type="button" class="strip-toggle" id="stripShow" title="Show the latest sessions"><span class="pill-arrow" aria-hidden="true">▾</span> sessions</button>
1219
- <button type="button" class="strip-toggle" id="stripHideTop" title="Hide the latest sessions"><span class="pill-arrow" aria-hidden="true">▴</span> hide</button>
1249
+ <button type="button" class="strip-toggle" id="stripStatus" title="All sessions, with search"><span class="pill-count">sessions</span></button>
1220
1250
  <span id="sessionTitle" hidden></span>
1221
1251
  <button type="button" id="notifyBtn" aria-label="Notifications" aria-pressed="false" hidden><svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path class="bell" d="M8 1.75a4 4 0 0 0-4 4v2.6L2.75 10.6v.9h10.5v-.9L12 8.35v-2.6a4 4 0 0 0-4-4Z"/><path class="clapper" d="M6.5 13a1.5 1.5 0 0 0 3 0"/><path class="slash" d="M2.5 2.5l11 11"/></svg></button>
1222
1252
  </div>
1223
1253
  <section id="topStrip" aria-label="Latest sessions" class="loading"><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div><div class="card skeleton"><div class="line long"></div><div class="meta"><span class="line tiny" style="display:inline-block"></span></div></div></section>
1254
+ <div id="stripGrip" class="edge-grip" tabindex="0" role="button" aria-controls="topStrip" aria-label="Latest sessions" title="Drag up or click to hide the latest sessions"></div>
1224
1255
  <section id="center">
1225
1256
  <div id="stage">
1226
1257
  <div id="markdownWarning" class="markdown-warning" hidden></div>
@@ -1231,7 +1262,7 @@ a{color:var(--accent)}
1231
1262
  <div id="lightbox" hidden><img alt=""></div>
1232
1263
  <div id="dock">
1233
1264
  <div id="initRow" role="status" aria-live="polite" hidden></div>
1234
- <div id="composeGrip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1265
+ <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1235
1266
  <div id="composer" style="position:relative">
1236
1267
  <div id="commandList" role="listbox" aria-label="Commands" hidden></div>
1237
1268
  <div id="modelPanel" hidden>
@@ -1244,11 +1275,13 @@ a{color:var(--accent)}
1244
1275
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
1245
1276
  </div>
1246
1277
  <div class="actions-col">
1247
- <button type="button" id="modelPick" class="model-pick" title="The model the new chat starts on" aria-label="Model for the new chat" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1278
+ <button type="button" id="modelPick" class="model-pick" title="The model the new session starts on" aria-label="Model for the new session" aria-haspopup="listbox" aria-expanded="false" hidden><span class="model-pick-name"></span></button>
1248
1279
  <button type="button" id="thinkingNew" class="llmctx-new think-new" aria-haspopup="dialog" hidden></button>
1249
1280
  <button type="button" id="llmContextNew" class="llmctx-new" aria-haspopup="dialog" hidden></button>
1250
1281
  <div id="composeHint">Enter to start · Shift+Enter for a newline</div>
1251
1282
  <button id="cancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
1283
+ <button type="button" id="attachBtn" class="attach-btn" title="Add images (or paste or drop them here)" aria-label="Add images" hidden></button>
1284
+ <input type="file" id="attachInput" accept="image/*" multiple hidden>
1252
1285
  <div class="split">
1253
1286
  <button id="actionBtn">Start</button>
1254
1287
  </div>
@@ -17,25 +17,25 @@
17
17
  // formClass, popoverClass, nowClass the form's, popover's and "Now:"
18
18
  // line's own classes
19
19
  // when(fresh) the form's note on when it applies (fresh: the start
20
- // page's new chat, thinking only)
20
+ // page's new session, thinking only)
21
21
  //
22
- // The start page's new chat (kind NEW_CHAT) has a thinking chip only
22
+ // The start page's new session (kind NEW_SESSION) has a thinking chip only
23
23
  // (thinking_chip.js keeps its choice): the form shows the model's level and
24
24
  // takes a choice for the next create.
25
25
 
26
26
  import { createChipPopover } from "./chip_popover.js";
27
- import { NEW_CHAT, SESSION } from "./llm_context_chip.js";
27
+ import { NEW_SESSION, SESSION } from "./llm_context_chip.js";
28
28
  import { html, render } from "./vendor/lit-html.js";
29
29
 
30
30
  export const DEFAULT = "default";
31
31
 
32
- // The chip: its text, tooltip and whether the session (or the new chat)
32
+ // The chip: its text, tooltip and whether the session (or the new session)
33
33
  // set its own; null without a summary (an older worker, or a host the
34
34
  // setting means nothing on).
35
35
  export function chipModel(cfg, summary, { kind = SESSION } = {}) {
36
36
  if (!summary || !summary.level) return null;
37
37
  const own = !!summary.own;
38
- const what = kind === NEW_CHAT ? "the new chat" : "this session";
38
+ const what = kind === NEW_SESSION ? "the new session" : "this session";
39
39
  const from = own ? what : summary.source || "the model's own";
40
40
  const title = `${cfg.name}: ${summary.level} (${from}). Click to change it for ${what}.`;
41
41
  return { text: `${cfg.prefix} ${summary.level}${own ? ` · ${kind}` : ""}`, title, own };
@@ -57,16 +57,16 @@ export function nowText(summary) {
57
57
 
58
58
  const option = (value, label, chosen) => html`<option value=${value} ?selected=${value === chosen}>${label}</option>`;
59
59
 
60
- // The popover's form (a lit-html template). For the new chat (kind
61
- // NEW_CHAT), +summary+ is the model's and +choice+ the level the form
60
+ // The popover's form (a lit-html template). For the new session (kind
61
+ // NEW_SESSION), +summary+ is the model's and +choice+ the level the form
62
62
  // shows; +now+ is the summary its "Now:" line reads (refresh passes a
63
63
  // newer one, the rest stays as the form opened).
64
64
  export function formTemplate(cfg, summary, { running = false, kind = SESSION, choice = null, now = summary } = {}) {
65
- const fresh = kind === NEW_CHAT;
65
+ const fresh = kind === NEW_SESSION;
66
66
  const chosen = (fresh ? choice : summary.own) || DEFAULT;
67
67
  const follow = summary.own && !fresh ? "follow the model" : `follow the model (${summary.level})`;
68
68
  return html`<form class=${`${cfg.formClass} llmctx-form`}><div class="ctx-pop-head"><strong>${cfg.name}</strong
69
- ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
69
+ ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
70
70
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
71
71
  ><div class=${`ctx-pop-meta ${cfg.nowClass}`}>${nowText(now)}</div
72
72
  ><label class="llmctx-field"><span>Level</span><select name="level">${option(DEFAULT, follow, chosen)}${
@@ -79,7 +79,7 @@ export function formTemplate(cfg, summary, { running = false, kind = SESSION, ch
79
79
  // el is where the chip is drawn (clicks are delegated: #infoText,
80
80
  // info_bar.js); summary(): the open session's summary; session(): its id;
81
81
  // running(): a turn runs; run(line): sends the command. The start page's
82
- // (kind NEW_CHAT): summary() is the picked model's, choice() the pending
82
+ // (kind NEW_SESSION): summary() is the picked model's, choice() the pending
83
83
  // level and choose(next) takes a Set's (null for "follow the model"). id
84
84
  // and anchor (the chip's selector, by default its data attribute) keep it
85
85
  // apart from the other controls on the same element (the anchor defaults
@@ -88,7 +88,7 @@ export function createLevelControl({ cfg, el, summary, session = () => null, run
88
88
  kind = SESSION, choice = () => null, choose = null,
89
89
  id, anchor = cfg.anchor,
90
90
  doc = document, win = window }) {
91
- const fresh = kind === NEW_CHAT;
91
+ const fresh = kind === NEW_SESSION;
92
92
  let initial = null;
93
93
  // What the open form was drawn from: the model's or session's summary
94
94
  // and the form's options.
@@ -21,14 +21,13 @@
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
22
  // updateInfoBar(), updateSessionTitle(), showToast(text, action)
23
23
  // allViewOpen(), openAllView(), leaveAllView(hash)
24
- // setStripHidden(hidden)
25
24
 
26
25
  import { ownerBadge, relativeTime, delegatedBy } from "./format.js";
27
26
  import { html, nothing, render } from "./vendor/lit-html.js";
28
27
  import { cardCtxText, ctxBarTip } from "./ctx.js";
29
28
  import { allScopeHref, cardFolder } from "./scope.js";
30
29
  import { sessionHash } from "./route.js";
31
- import { stripColumns, stripShowParts } from "./strip.js";
30
+ import { stripColumns, stripStatusParts } from "./strip.js";
32
31
  import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
33
32
  import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
34
33
  import {
@@ -192,21 +191,23 @@ function ownerBadgeTemplate(s) {
192
191
  return badge ? html`<span class=${`owner ${badge.kind}`} title=${badge.title}>${badge.text}</span>` : nothing;
193
192
  }
194
193
 
195
- // The strip's end: the All sessions tile (with its count) and hide ▴.
196
- // entering / anim: as a card's (cardTemplate). id and title are bound, as
197
- // class is, so the attributes keep their order.
194
+ // The strip's end: the All sessions tile (with its count), the strip's own
195
+ // last cell (the grip on the edge is the strip's handle now, not a button
196
+ // in here). entering / anim: as a card's (cardTemplate). id and title are
197
+ // bound, as class is, so the attributes keep their order.
198
198
  export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
199
- return html`<div class="strip-side"><button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
199
+ return html`<button type="button" class=${entering ? "card entering" : "card"} id=${"allTile"} style=${anim || nothing} title=${"All sessions, with search"}>All sessions →<span class="count">${count}</span></button>`;
200
200
  }
201
201
 
202
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting"
203
- // (strip.js stripShowParts).
202
+ // The hidden strip's status pill: "5 sessions · ● 1 live · ● 1 waiting"
203
+ // (strip.js stripStatusParts). Not a toggle any more (the grip hides and
204
+ // shows the strip): a click opens the all-sessions view, as the tile does.
204
205
  // The count (and the dot after it) in its own span: a phone's narrow top
205
206
  // bar drops it when live or waiting shows (index.html).
206
207
  export function stripPillTemplate({ count, live, waiting }) {
207
208
  const liveT = live ? html`<span class="live"><span class="live-dot" aria-hidden="true"></span>${live}</span>` : nothing;
208
209
  const waitingT = waiting ? html`${live ? " · " : ""}<span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${waiting}</span>` : nothing;
209
- return html`<span class="pill-arrow" aria-hidden="true">\u25BE</span> <span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
210
+ return html`<span class="pill-count">${count}${live || waiting ? " · " : ""}</span>${liveT}${waitingT}`;
210
211
  }
211
212
 
212
213
  // Draws +template+ into +el+ as new nodes, as an innerHTML write did: no
@@ -222,10 +223,15 @@ export function createListsView(deps) {
222
223
  const {
223
224
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
224
225
  confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
225
- allViewOpen, openAllView, leaveAllView, setStripHidden,
226
+ allViewOpen, openAllView, leaveAllView,
226
227
  } = deps;
227
228
  const $ = (s) => doc.querySelector(s);
228
229
 
230
+ // The hidden strip's status pill opens the all-sessions view (as the tile
231
+ // does), saving the step of showing the strip first. Bound once: the pill
232
+ // element stays, only its content is drawn again (draw()).
233
+ $("#stripStatus").addEventListener("click", openAllView);
234
+
229
235
  // The strip, its count and the hidden strip's pill leave archived sessions
230
236
  // out; the all view shows them with "include archived". sessions() keeps
231
237
  // them all (parent chips, the open session).
@@ -469,7 +475,7 @@ export function createListsView(deps) {
469
475
  // to all of them.
470
476
  function renderTop(cards) {
471
477
  topStripEl.classList.remove("loading");
472
- renderStripShow();
478
+ renderStripStatus();
473
479
  stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
474
480
  noteChainFamilies(cards);
475
481
  const refused = scopeError();
@@ -501,7 +507,6 @@ export function createListsView(deps) {
501
507
  },
502
508
  });
503
509
  $("#allTile").addEventListener("click", openAllView);
504
- $("#stripHide").addEventListener("click", () => setStripHidden(true));
505
510
  familyPop.refresh();
506
511
  if (chainFrom === topStripEl) chainPop.refresh();
507
512
  }
@@ -517,8 +522,8 @@ export function createListsView(deps) {
517
522
  // The hidden strip's pill, following the list: its sessions, the number
518
523
  // the All sessions tile shows, and the live and waiting ones (a waiting
519
524
  // delegate too).
520
- function renderStripShow() {
521
- draw($("#stripShow"), stripPillTemplate(stripShowParts(shownSessions())));
525
+ function renderStripStatus() {
526
+ draw($("#stripStatus"), stripPillTemplate(stripStatusParts(shownSessions())));
522
527
  }
523
528
 
524
529
  // A parent's card holds its delegates (families, by the newest member: a
@@ -7,7 +7,7 @@
7
7
  // worker's snapshot, a command_ran, or the server's own reading of the
8
8
  // session file).
9
9
  //
10
- // The start page has one too (kind "new chat"): it shows the picked
10
+ // The start page has one too (kind "new session"): it shows the picked
11
11
  // model's values (GET /api/models' llm_context) and its form keeps a
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.
@@ -22,7 +22,7 @@ const STRATEGIES = [
22
22
  ];
23
23
  export const APPLIES = ["payoff", "next_request", "turn_end"];
24
24
  export const SESSION = "session";
25
- export const NEW_CHAT = "new chat";
25
+ export const NEW_SESSION = "new session";
26
26
 
27
27
  const DEFAULT = "default";
28
28
  const LAYER_ORDER = ["stale", "forget"];
@@ -43,16 +43,16 @@ export function ownValues(summary) {
43
43
  return { strategy, apply: own.apply || DEFAULT, budget };
44
44
  }
45
45
 
46
- // The start page's choice for the new chat ({strategy: [layers], apply,
46
+ // The start page's choice for the new session ({strategy: [layers], apply,
47
47
  // budget_tokens}, the session file's shape; 0 is off) laid over the
48
48
  // model's summary: what the chip shows, with the chosen fields' source
49
- // "session" ("the new chat"). The model's own summary without a choice.
49
+ // "session" ("the new session"). The model's own summary without a choice.
50
50
  export function withChoice(summary, choice) {
51
51
  if (!summary || !choice) return summary;
52
52
  const merged = { ...summary, own: choice };
53
53
  // (the budget's source and where are budget_source / budget_where)
54
54
  const set = (field, key, value) => Object.assign(merged, { [key]: value, [`${field}_source`]: SESSION,
55
- [`${field}_where`]: "the new chat" });
55
+ [`${field}_where`]: "the new session" });
56
56
  if (Array.isArray(choice.strategy)) set("strategy", "strategy", strategyValue(choice.strategy));
57
57
  if (choice.apply) set("apply", "apply", choice.apply);
58
58
  if (typeof choice.budget_tokens === "number") {
@@ -61,13 +61,13 @@ export function withChoice(summary, choice) {
61
61
  return merged;
62
62
  }
63
63
 
64
- // The chip: its text, tooltip and whether the session (or the new chat)
64
+ // The chip: its text, tooltip and whether the session (or the new session)
65
65
  // set its own; null without a summary (an older worker).
66
66
  export function chipModel(summary, { kind = SESSION } = {}) {
67
67
  if (!summary || !summary.strategy) return null;
68
68
  const own = summary.strategy_source === "session" || !!summary.own;
69
69
  const budget = summary.budget_tokens ? `${summary.budget_tokens} tokens` : "off";
70
- const what = kind === NEW_CHAT ? "the new chat" : "this session";
70
+ const what = kind === NEW_SESSION ? "the new session" : "this session";
71
71
  const title = `LLM context strategy: ${summary.strategy} (${summary.strategy_where}); apply ${summary.apply} ` +
72
72
  `(${summary.apply_where}); budget ${budget} (${summary.budget_where}). Click to change it for ${what}.`;
73
73
  return { text: `llm ctx ${summary.strategy}${own ? ` · ${kind}` : ""}`, title, own };
@@ -107,7 +107,7 @@ export function parseBudget(text) {
107
107
  return tokens >= 4000 && tokens <= 10_000_000 ? tokens : null;
108
108
  }
109
109
 
110
- // The new chat's choice after a Set: +choice+ with the fields the user
110
+ // The new session's choice after a Set: +choice+ with the fields the user
111
111
  // changed (vs +initial+, the values the form opened with) applied, a field
112
112
  // set back to default dropped; null when nothing is left. Throws on a
113
113
  // budget that isn't one.
@@ -160,8 +160,8 @@ export function nowText(summary) {
160
160
  `budget ${budget} (${summary.budget_where})`;
161
161
  }
162
162
 
163
- // The popover's form (a lit-html template). For the new chat (kind
164
- // NEW_CHAT), +summary+ is the model's and +choice+ the values the form
163
+ // The popover's form (a lit-html template). For the new session (kind
164
+ // NEW_SESSION), +summary+ is the model's and +choice+ the values the form
165
165
  // shows; +now+ is the summary its "Now:" line reads (refresh passes a newer
166
166
  // one, the rest stays as the form opened, so lit leaves what the user typed
167
167
  // alone); +error+ a failed Set's message. The selects take their value as
@@ -169,14 +169,14 @@ export function nowText(summary) {
169
169
  // alone, the first pass renderForm makes.
170
170
  export function formTemplate(summary, { running = false, kind = SESSION, choice = null, now = summary, error = null,
171
171
  unset = false } = {}) {
172
- const fresh = kind === NEW_CHAT;
172
+ const fresh = kind === NEW_SESSION;
173
173
  const own = fresh ? ownValues({ own: choice }) : ownValues(summary);
174
174
  const follow = (field) => (summary[`${field}_source`] === "session" ? "follow the model" : `follow the model (${summary[field]})`);
175
- const when = fresh ? "For the new chat's first turn on; not remembered for the next one."
175
+ const when = fresh ? "For the new session's first turn on; not remembered for the next one."
176
176
  : "From the next turn's start. Turning forget on or off re-reads the whole prompt once.";
177
177
  const chosen = (value) => (unset ? nothing : value);
178
178
  return html`<form class="llmctx-form"><div class="ctx-pop-head"><strong>LLM context</strong
179
- ><span class="ctx-pop-kind">${fresh ? NEW_CHAT : "this session"}</span
179
+ ><span class="ctx-pop-kind">${fresh ? NEW_SESSION : "this session"}</span
180
180
  ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
181
181
  ><div class="ctx-pop-meta llmctx-now">${nowText(now)}</div
182
182
  ><label class="llmctx-field"><span>Strategy</span><select name="strategy" .value=${chosen(own.strategy)}>${
@@ -206,7 +206,7 @@ export function renderForm(target, summary, opts = {}) {
206
206
  // (#infoText, info_bar.js: clicks are delegated); summary(): the open
207
207
  // session's llm_context; session(): the open session's id; running(): a
208
208
  // turn runs; run(line): sends the command.
209
- // The start page's (kind NEW_CHAT): summary() is the picked model's,
209
+ // The start page's (kind NEW_SESSION): summary() is the picked model's,
210
210
  // choice() the pending choice, and choose(next) takes a Set's (null for
211
211
  // "follow the model") instead of a command running. id and anchor (the
212
212
  // chip's selector) keep the two controls apart.
@@ -214,7 +214,7 @@ export function createLlmContextControl({ el, summary, session = () => null, run
214
214
  kind = SESSION, choice = () => null, choose = null,
215
215
  id = "llmContextPopover", anchor = ".llmctx-chip",
216
216
  doc = document, win = window }) {
217
- const fresh = kind === NEW_CHAT;
217
+ const fresh = kind === NEW_SESSION;
218
218
  // The values the form opened with.
219
219
  let initial = null;
220
220
  // What the open form was drawn from: the summary, the form's options and
@@ -48,7 +48,7 @@ function readRecent() {
48
48
  }
49
49
 
50
50
  // +onPick+ runs after a pick (the panel is closed by then). +remember+:
51
- // whether a pick is this browser's choice for a new chat (stored, and in
51
+ // whether a pick is this browser's choice for a new session (stored, and in
52
52
  // Recent); false on a fresh chain link, where a pick is that link's only.
53
53
  export function createModelPicker({ button, panel, input, list, composer, center, onPick, remember = () => true }) {
54
54
  let rows = [];
@@ -47,7 +47,7 @@ export function cardFolder(workingDirectory, projectName) {
47
47
 
48
48
  // The header's scope chip: "<project> · all" in a project view (all links
49
49
  // to the same place without ?dir); "all projects" otherwise, with a link
50
- // back to the project it came from and where a new chat starts on the
50
+ // back to the project it came from and where a new session starts on the
51
51
  // start page. +scope+: { dir, projectName, projectDir, serverDir, backDir,
52
52
  // backName }; +error+: why the ?dir= folder can't be shown; +where+:
53
53
  // () => { hash, search } (the page's location, read again at a click: the
@@ -63,6 +63,6 @@ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "",
63
63
  const back = scope.backName
64
64
  ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
65
65
  : nothing;
66
- const newChat = dir ? html`<span class="scope-where" title=${`New chats start in ${dir}`}>new chat in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
67
- return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newChat}`;
66
+ const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
67
+ return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newSession}`;
68
68
  }
@@ -1,3 +1,4 @@
1
+ import { GRIP_OPEN_PX } from "./composer_size.js";
1
2
  import { waitingOn } from "./notify.js";
2
3
 
3
4
  // How many session cards the top strip shows for its width in px: a card
@@ -12,15 +13,16 @@ export function stripColumns(width) {
12
13
  return Math.min(6, Math.max(3, fit));
13
14
  }
14
15
 
15
- // The hidden strip's "▾ sessions" pill carries the list's count, and how
16
- // many of those sessions a worker runs (the cards' green "live" badge), so a
17
- // user who hid the strip still sees there is a live session to go back to.
18
- // And how many wait on the user (notify.js waitingOn: a question, an
19
- // approval, a card), so a hidden strip still says one needs an answer.
20
- // Plain text parts; live and waiting are "" when none. Zero sessions keep
21
- // the bare label. The count is sessions (a folded delegate counts), the
22
- // number the "All sessions" tile shows.
23
- export function stripShowParts(sessions) {
16
+ // The hidden strip's status pill carries the list's count, and how many of
17
+ // those sessions a worker runs (the cards' green "live" badge), so a user
18
+ // who hid the strip still sees there is a live session to go back to. And
19
+ // how many wait on the user (notify.js waitingOn: a question, an approval, a
20
+ // card), so a hidden strip still says one needs an answer; a click on it
21
+ // opens the all-sessions view (lists_view.js). Plain text parts; live and
22
+ // waiting are "" when none. Zero sessions keep the bare label. The count is
23
+ // sessions (a folded delegate counts), the number the "All sessions" tile
24
+ // shows.
25
+ export function stripStatusParts(sessions) {
24
26
  const list = sessions || [];
25
27
  const n = list.length;
26
28
  const liveN = list.filter((s) => s && s.owner === "worker").length;
@@ -37,6 +39,30 @@ export function stripShowParts(sessions) {
37
39
  // cut off below the strip.
38
40
  export const SHORT_WINDOW_QUERY = "(max-height: 700px)";
39
41
 
42
+ // The strip's grip: the handle on the edge between the strip and the chat
43
+ // (the composer's grip's twin, mirrored: that one folds the box down, this
44
+ // one puts the strip away up). +dy+ is the release y minus the press y, so a
45
+ // drag up is negative. Decided on release only (the strip snaps, it does not
46
+ // follow the pointer): |dy| below GRIP_OPEN_PX is a tap, which toggles either
47
+ // way (a hidden strip's grip has no other way to come back, and a shown
48
+ // one's neither); a longer drag only fires the way it points: up (past
49
+ // STRIP_HIDE_PX) hides the shown strip, down (past GRIP_OPEN_PX) shows the
50
+ // hidden one. Anything else is null: a short drag the wrong way does nothing.
51
+ export const STRIP_HIDE_PX = 32; // a drag up this far hides the shown strip
52
+ export function stripGripAction({ hidden, dy }) {
53
+ if (Math.abs(dy) < GRIP_OPEN_PX) return hidden ? "show" : "hide"; // a tap
54
+ if (hidden) return dy >= GRIP_OPEN_PX ? "show" : null;
55
+ return dy <= -STRIP_HIDE_PX ? "hide" : null;
56
+ }
57
+
58
+ // The grip's title (D7: the grip owns it; composer_grip.js's drawFoldControls
59
+ // writes the composer's the same way). willToggle: the drag in progress would
60
+ // act on release.
61
+ export function stripGripTitle({ hidden, willToggle }) {
62
+ if (willToggle) return hidden ? "Release to show the latest sessions" : "Release to hide the latest sessions";
63
+ return hidden ? "Drag down or click to show the latest sessions" : "Drag up or click to hide the latest sessions";
64
+ }
65
+
40
66
  // Whether the strip is hidden: the user's own hide (saved), or a short
41
67
  // window unless the user showed it (the pill) since it got short.
42
68
  export function stripAutoHidden({ saved, short, shownWhileShort }) {