samagotchi 0.126.0 → 0.128.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/docs/architecture.md +4 -0
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +2 -2
  6. data/docs/guardrails.md +5 -0
  7. data/docs/internals/participants.md +17 -7
  8. data/docs/sessions.md +3 -1
  9. data/docs/testing.md +3 -2
  10. data/lib/samagotchi/bridge.rb +2 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/engine.rb +56 -0
  13. data/lib/samagotchi/guardrails/read_only_shell.rb +48 -1
  14. data/lib/samagotchi/session_manager.rb +5 -0
  15. data/lib/samagotchi/session_participants.rb +14 -1
  16. data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
  20. data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
  21. data/lib/samagotchi/verify_anchor.rb +8 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +12 -0
  24. data/lib/samagotchi/web/participant_access.rb +16 -1
  25. data/lib/samagotchi/web/participant_review.rb +4 -2
  26. data/lib/samagotchi/web/project_list.rb +52 -0
  27. data/lib/samagotchi/web/public/annotate_bar.js +14 -2
  28. data/lib/samagotchi/web/public/app.js +25 -4
  29. data/lib/samagotchi/web/public/composer.css +13 -1
  30. data/lib/samagotchi/web/public/index.html +35 -16
  31. data/lib/samagotchi/web/public/notify.js +4 -1
  32. data/lib/samagotchi/web/public/participant.html +33 -32
  33. data/lib/samagotchi/web/public/participant.js +16 -6
  34. data/lib/samagotchi/web/public/participant_model.js +3 -2
  35. data/lib/samagotchi/web/public/participant_names.js +2 -2
  36. data/lib/samagotchi/web/public/project_picker.js +115 -0
  37. data/lib/samagotchi/web/public/question_card.js +4 -1
  38. data/lib/samagotchi/web/public/restore.js +7 -4
  39. data/lib/samagotchi/web/public/scope.js +8 -10
  40. data/lib/samagotchi/web/public/send_turn.js +3 -1
  41. data/lib/samagotchi/web/public/session_state.js +2 -0
  42. data/lib/samagotchi/web/public/stage_view.js +1 -1
  43. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  44. data/lib/samagotchi/web/public/verdict_card.js +18 -3
  45. data/lib/samagotchi/web/public/verdict_fresh.js +11 -0
  46. data/lib/samagotchi/web/public/verify_card.css +1 -0
  47. data/lib/samagotchi/web/public/verify_preset.js +6 -0
  48. data/lib/samagotchi/web/push/attention.rb +24 -5
  49. data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
  50. data/lib/samagotchi/worker.rb +7 -4
  51. metadata +4 -1
@@ -22,7 +22,7 @@
22
22
  html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
23
23
  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%),var(--bg);background-attachment:fixed;color:var(--text);height:100vh;display:flex;flex-direction:column}
24
24
  /* Glass: the composer card, the strip cards, the all-sessions cards and the question cards (by selector, so app.js is untouched). */
25
- #dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
25
+ .turn-stage,#topStrip .card,#allList .card,.bubble.question,.bubble.plugin-card{background:var(--glass-bg);-webkit-backdrop-filter:blur(18px) saturate(1.35);backdrop-filter:blur(18px) saturate(1.35);border:1px solid var(--glass-border);box-shadow:inset 0 1px 0 var(--glass-highlight),var(--glass-shadow)}
26
26
  #topStrip .card:hover,#allList .card:hover{background:color-mix(in srgb,var(--bg-hover) 75%,transparent);border-color:var(--card-hover-border)}
27
27
  #topStrip .card.active,#allList .card.active{background:color-mix(in srgb,var(--accent-bg) 75%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,transparent)}
28
28
  /* A parent whose folded family holds the open session: a quieter mark than .active. */
@@ -30,9 +30,9 @@ body{font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetic
30
30
  /* A session that waits on the user (a question, an approval, a card with actions): the warning colour of its question card, also when active or hovered. */
31
31
  #topStrip .card.waiting,#allList .card.waiting{border-color:color-mix(in srgb,var(--warn) 75%,transparent)}
32
32
  #topStrip .card.waiting:hover,#allList .card.waiting:hover{border-color:var(--warn)}
33
- @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
34
- @media (prefers-reduced-transparency:reduce){#dock,.turn-stage,.dock-card,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
35
- body.no-glass #dock,body.no-glass .turn-stage,body.no-glass .dock-card,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
33
+ @supports not ((backdrop-filter:blur(1px)) or (-webkit-backdrop-filter:blur(1px))){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel)}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel))}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel))}}
34
+ @media (prefers-reduced-transparency:reduce){.turn-stage,#topStrip .card,#allList .card,#allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}.bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}}
35
+ body.no-glass .turn-stage,body.no-glass #topStrip .card,body.no-glass #allList .card,body.no-glass #allHead input{background:var(--bg-panel);-webkit-backdrop-filter:none;backdrop-filter:none}
36
36
  body.no-glass .bubble.question{background:color-mix(in srgb,var(--warn) 7%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
37
37
  body.no-glass .bubble.plugin-card{background:color-mix(in srgb,var(--accent) 6%,var(--bg-panel));-webkit-backdrop-filter:none;backdrop-filter:none}
38
38
  button,input,textarea,select{font:inherit}
@@ -566,8 +566,8 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
566
566
  .bubble.output.markdown .code-wrap>pre{margin:0}
567
567
  .code-wrap>.copy-btn{top:5px;right:5px}
568
568
  @media (hover:none){.copy-btn{opacity:.55}}
569
- /* The dock is the composer card: the text field, a toolbar row, the info bar as its footer. */
570
- #dock{display:flex;flex-direction:column;flex-shrink:0;margin:0 12px 12px;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
569
+ /* The dock is the composer card (composer.css .composer-card): the text field, a toolbar row, the info bar as its footer. */
570
+ #dock{flex-shrink:0;margin:0 12px 12px}
571
571
  /* In a session the card floats over the history, so the glass blurs it; the history's bottom padding follows the card (--dock-h, set from dock_reserve.js). */
572
572
  /* The session panel lets the page's glow through (the history's own layers go clear), so the card and its footer read as glass over it, not as a flat box on black. */
573
573
  body.has-session #center{background:color-mix(in srgb,var(--bg) 35%,transparent);border-color:var(--glass-border);position:relative}
@@ -576,9 +576,8 @@ body.has-session #stage,body.has-session #history{background:transparent}
576
576
  body.no-glass.has-session #center{background:var(--bg);border-color:var(--border)}
577
577
  body.has-session #dock{position:absolute;left:16px;right:16px;bottom:16px;margin:0;z-index:3}
578
578
  body.has-session #history{padding-bottom:calc(var(--dock-h,160px) + 32px)}
579
- #dock:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
580
579
  /* The delivery line (delivery_line.js): a session's row above the text field, always there so the composer never changes height: the key hint while idle; while a turn runs, where a message typed now goes in, then the cut and queue links. One quiet row, ellipsised; a phone gets the short text and chips. */
581
- #deliveryLine{display:flex;align-items:center;gap:10px;min-width:0;flex:1 0 100%;height:20px;padding:0 2px;overflow:hidden;font-size:12px;line-height:18px;color:var(--text-muted);user-select:none}
580
+ #deliveryLine{display:flex;align-items:center;gap:10px}
582
581
  #deliveryLine[hidden]{display:none}
583
582
  #deliveryLine .delivery-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
584
583
  #deliveryLine .delivery-where.idle{color:var(--text-faint)}
@@ -672,8 +671,6 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
672
671
  /* D2: its own glass card above the composer card (as a section of one card the composer's focus ring wrapped the turn too):
673
672
  in the stage view #dock is a clear column holding the stage and .dock-card (the composer and its footer, stage_view.js). */
674
673
  body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;border:0;box-shadow:none;-webkit-backdrop-filter:none;backdrop-filter:none;gap:10px}
675
- .dock-card{display:flex;flex-direction:column;border-radius:16px;position:relative;transition:border-color .12s ease,box-shadow .12s ease}
676
- .dock-card:focus-within{border-color:color-mix(in srgb,var(--accent) 70%,transparent);box-shadow:0 0 0 3px var(--accent-ring),var(--glass-shadow)}
677
674
  /* The live slots' heights (headline, tool row, trail); --ts-live-h is their running height with the two 8 px gaps. */
678
675
  .turn-stage{--ts-head-h:42px;--ts-now-h:32px;--ts-trail-h:18px;--ts-live-h:calc(var(--ts-head-h) + var(--ts-now-h) + var(--ts-trail-h) + 16px)}
679
676
  .turn-stage{display:flex;flex-direction:column;min-height:0;max-height:var(--stage-max,60vh);border-left:3px solid var(--accent);border-radius:14px;overflow:hidden}
@@ -995,7 +992,7 @@ button.ws-row:hover:not(:disabled){filter:none;background:var(--bg-hover)}
995
992
  .ts-trail-item{max-width:24ch}
996
993
  }
997
994
  @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}}
998
- #infoBar{display:flex;padding:8px 14px 10px;border-top:1px solid var(--glass-border);background:transparent;border-radius:0 0 16px 16px;color:var(--text-2);gap:10px;align-items:flex-start;flex-wrap:wrap;flex-shrink:0;transition:opacity 280ms ease}
995
+ #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}
999
996
  #infoBar.visible{display:flex}
1000
997
  /* The meta row keeps its room: with a plain flex:1 the bar's own buttons (continue, archive, stop session, delete)
1001
998
  shrink it to a narrow column in a small window and its items wrap into five lines. With a 640px basis they share
@@ -1268,13 +1265,35 @@ body.has-session #newBtn .brand-new{display:inline}
1268
1265
  /* The scope chip: "<project> · all", or "all projects" and where a new session starts (start page only). */
1269
1266
  #scope{display:inline-flex;align-items:center;gap:8px;min-width:0;font-size:12px;color:var(--text-muted)}
1270
1267
  #scope[hidden]{display:none}
1271
- #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}
1268
+ #scope .scope-name{display:inline-flex;align-items:center;flex-shrink:0;padding:2px 9px;border:1px solid var(--border-strong);border-radius:999px;background:transparent;color:var(--text-2);font:inherit;line-height:inherit;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40vw;cursor:pointer;transition:none}
1269
+ #scope .scope-name:hover:not(:disabled){filter:none}
1272
1270
  #scope .scope-name.error{color:var(--warn-text);border-color:var(--warn-border)}
1273
1271
  #scope .scope-all{color:var(--text-muted);text-decoration:none}
1274
1272
  #scope .scope-all:hover{color:var(--accent);text-decoration:underline}
1275
1273
  #scope .scope-where{display:inline-flex;gap:5px;min-width:0;white-space:nowrap;font-size:11px}
1276
1274
  /* The path cuts on the left, so its last folders stay in view. */
1277
1275
  #scope .scope-where .path{overflow:hidden;text-overflow:ellipsis;direction:rtl;min-width:0;font-family:ui-monospace,monospace}
1276
+ #projectPicker{width:min(380px,calc(100vw - 32px))}
1277
+ .project-picker.sheet{border-radius:14px;padding:14px 16px}
1278
+ .project-picker .ctx-pop-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
1279
+ .project-picker .ctx-pop-head strong{font-family:inherit}
1280
+ .project-list{display:flex;flex-direction:column;gap:6px;margin:2px 0;padding:0}
1281
+ .project-row{display:flex;align-items:center;gap:12px;padding:8px 10px;border:1px solid transparent;border-radius:9px;background:var(--bg-inset);color:var(--text);text-decoration:none}
1282
+ .project-row:hover{background:var(--bg-hover);color:var(--text-hover)}
1283
+ .project-row:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
1284
+ .project-row[aria-current="page"]{border-color:var(--accent-border);background:var(--accent-bg)}
1285
+ .project-main{display:flex;flex:1;align-items:center;gap:7px;min-width:0;flex-wrap:wrap}
1286
+ .project-name{font-weight:600;overflow-wrap:anywhere}
1287
+ .project-root{flex-basis:100%;min-width:0;color:var(--text-muted);font:11px ui-monospace,monospace;overflow-wrap:anywhere}
1288
+ .project-meta{display:flex;flex-shrink:0;align-items:center;gap:7px;color:var(--text-muted);font-size:11px;white-space:nowrap}
1289
+ .project-default{padding:1px 6px;border-radius:999px;background:var(--bg-raised);color:var(--text-muted);font-size:10px}
1290
+ .project-check{color:var(--accent);font-weight:700}
1291
+ .project-hint{flex-basis:100%;color:var(--text-muted);font-size:12px}
1292
+ /* The divider is its own hairline above the row: a border-top on the rounded row curved up at both ends. */
1293
+ .project-all{position:relative;margin-top:12px;background:transparent}
1294
+ .project-all::before{content:"";position:absolute;left:-1px;right:-1px;top:-9px;height:1px;background:var(--border);pointer-events:none}
1295
+ .project-note{padding:5px 2px;color:var(--text-muted);font-size:12px}
1296
+ .project-error{color:var(--warn-text)}
1278
1297
  body.has-session #scope .scope-where{display:none}
1279
1298
  /* A phone has no room for both; the hidden strip's pill (count, live) wins. */
1280
1299
  @media(max-width:600px){body.strip-hidden #scope .scope-where{display:none}}
@@ -1525,7 +1544,7 @@ body.all-view #center, body.all-view #topStrip{display:none}
1525
1544
  .card .dot.waiting,.family-row .dot.waiting{background:var(--warn);animation:attn-pulse 1.6s ease-in-out var(--pulse-delay,0s) infinite}
1526
1545
  @keyframes attn-pulse{0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--warn) 60%,transparent)}50%{box-shadow:0 0 0 4px color-mix(in srgb,var(--warn) 0%,transparent)}}
1527
1546
  @media (prefers-reduced-motion:reduce){.card .dot.running,.card .dot.waiting,.family-row .dot.running,.family-row .dot.waiting{animation:none}}
1528
- .card .attn,.family-row .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
1547
+ .card .attn,.family-row .attn,.project-picker .attn{font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;white-space:nowrap;flex-shrink:0;background:var(--warn-bg);color:var(--warn-text);border:1px solid var(--warn-border)}
1529
1548
  .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)}
1530
1549
  .status.running{background:var(--ok-bg);color:var(--ok-text)}
1531
1550
  .status.idle{background:var(--bg-raised)}
@@ -1569,7 +1588,7 @@ a{color:var(--accent)}
1569
1588
  <div id="notice" role="status" hidden></div>
1570
1589
  </div>
1571
1590
  <div id="lightbox" hidden><img alt=""></div>
1572
- <div id="dock">
1591
+ <div id="dock" class="composer-card">
1573
1592
  <div id="initRow" role="status" aria-live="polite" hidden></div>
1574
1593
  <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Message box" title="Drag to make the message box taller (double-click resets)"></div>
1575
1594
  <div id="composer" style="position:relative">
@@ -1578,7 +1597,7 @@ a{color:var(--accent)}
1578
1597
  <label class="model-search"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14" stroke-linecap="round"/></svg><input id="modelSearch" type="text" placeholder="Search models" autocomplete="off" spellcheck="false" role="combobox" aria-label="Search models" aria-autocomplete="list" aria-controls="modelList" aria-expanded="false"></label>
1579
1598
  <div id="modelList" role="listbox" aria-label="Models"></div>
1580
1599
  </div>
1581
- <div id="deliveryLine" aria-live="off" hidden></div>
1600
+ <div id="deliveryLine" class="composer-line" aria-live="off" hidden></div>
1582
1601
  <div class="compose-col">
1583
1602
  <div id="chips" hidden></div>
1584
1603
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
@@ -1597,7 +1616,7 @@ a{color:var(--accent)}
1597
1616
  </div>
1598
1617
  </div>
1599
1618
  </div>
1600
- <div id="infoBar">
1619
+ <div id="infoBar" class="composer-card-foot">
1601
1620
  <div class="info" id="infoText"></div>
1602
1621
  <div id="contextChips" aria-label="Attached context" hidden></div>
1603
1622
  <button class="ghost" id="infoCancelBtn" title="Cancel the running turn, whoever sent it" style="display:none">Stop turn</button>
@@ -3,6 +3,7 @@
3
3
  // title badge for what it returns, while the tab is not in front.
4
4
 
5
5
  import { clip } from "./format.js";
6
+ import { isParticipantAuthor } from "./participant_model.js";
6
7
 
7
8
  // The bell's state. OS notifications need a secure context: on chi web
8
9
  // over http at a LAN address the API is missing or always denied, so the
@@ -163,7 +164,9 @@ export function attentionText(session, attention) {
163
164
  const title = clip(name, 61);
164
165
  const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
165
166
  const verdict = session?.last_verdict;
166
- const who = verdict?.by_name || (verdict?.by ? "A participant" : "You");
167
+ // An owner's verdict has no participant author (none, "" or "owner").
168
+ const byParticipant = isParticipantAuthor(verdict?.by);
169
+ const who = verdict?.by_name || (byParticipant ? "A participant" : "You");
167
170
  const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
168
171
  const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
169
172
  const verdictBody = `${who}: ${verdictIcon} ${verdict?.verdict || ""} — ${verdictReason}`;
@@ -105,42 +105,43 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
105
105
  .p-tool-one.error{color:var(--danger-text)}
106
106
  .p-tag{font-size:11px;padding:0 6px;border-radius:999px;background:var(--bg-raised);color:var(--text-3)}
107
107
  .p-waiting{align-self:flex-start;font-size:13px;color:var(--warn-text);background:var(--warn-bg);border:1px solid var(--warn-border);border-radius:999px;padding:3px 12px}
108
- .p-dock{border-top:1px solid var(--border);background:var(--bg-panel);padding:10px 16px calc(10px + env(safe-area-inset-bottom))}
108
+ /* The dock is the owner's composer card (index.html's #dock): a glass card over the page's bottom edge, holding the
109
+ comment box (key hint, text field, usage footer); the queue and errors sit above it. */
110
+ .p-dock{padding:6px 16px calc(16px + env(safe-area-inset-bottom))}
109
111
  .p-dock-in{max-width:760px;margin:0 auto;display:flex;flex-direction:column;gap:8px}
110
112
  .p-queue{display:flex;flex-direction:column;gap:4px;font-size:13px}
111
113
  .p-queued{display:flex;align-items:center;gap:8px;color:var(--text-3)}
112
114
  .p-queued span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
113
115
  .p-queue-note{font-size:12.5px;color:var(--text-muted)}
114
116
  .p-error{font-size:13px;color:var(--danger-text);background:var(--danger-bg-dim);border-radius:8px;padding:6px 10px}
115
- /* The comment box: the shared composer (composer.css, composer_mount.js), as the owner's session composer lays it out
116
- (participant.js sets body.has-session for a participant: one row, the text field and Send at its bottom right). Its
117
- grip straddles the dock's top border; folded, the dock keeps the queue and the grip as the handle. No `/` list,
118
- delivery line or model picker here. */
119
- #composer{background:var(--bg-inset);border:1px solid var(--border-strong);border-radius:12px}
120
- #composer:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-ring)}
121
- body.has-session #composer{padding:6px 6px 6px 10px}
117
+ /* The comment box: the shared composer in the shared card (composer.css, composer_mount.js), as the owner's session
118
+ composer lays it out (participant.js sets body.has-session for a participant): the key hint as the composer's line,
119
+ the text field and Send, the usage as the card's footer. Its grip sits on the card's top edge, as the owner's does;
120
+ folded, the card keeps its footer under the grip, the owner's info bar's way. No `/` list, delivery choices or model
121
+ picker here. */
122
122
  #composer textarea{font:inherit;line-height:1.45;padding:2px 0}
123
123
  #composer textarea:disabled{cursor:not-allowed}
124
124
  #composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
125
- .p-dock #composeGrip{margin:-15px 0 5px}
126
- body.composer-folded .p-hint{display:none}
127
- /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line
128
- beside the hint; a phone shows it alone, one line, the end cut. Folded, it goes with the hint. */
129
- .p-foot{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px;min-width:0}
130
- .p-foot .p-hint{flex:1 1 320px;min-width:0}
125
+ /* The key hint wraps to at most two lines (a narrow card cuts the second's end); the whole text is its title. */
126
+ .p-hint.composer-line{color:var(--text-faint);height:auto;max-height:36px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2}
127
+ /* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line in
128
+ the card's footer, the end cut. Folded, it stays under the grip. */
129
+ .p-foot{min-width:0}
130
+ .p-foot:has(> .p-usage[hidden]){display:none}
131
+ /* Folded before the usage loads, the card keeps a footer's height under its grip, not a 2 px sliver. */
132
+ body.composer-folded .p-foot:has(> .p-usage[hidden]){display:block;min-height:18px}
131
133
  .p-usage{display:flex;align-items:baseline;min-width:0;max-width:100%;font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
132
134
  .p-usage span{flex:0 0 auto}
133
135
  .p-usage span+span::before{content:"·";padding:0 6px;color:var(--text-faint)}
134
136
  .p-usage span.model{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
135
137
  .p-usage .warn{color:var(--warn-text)}
136
138
  .p-usage .over{color:var(--danger-text);font-weight:600}
137
- body.composer-folded .p-foot{display:none}
139
+ body.composer-folded .p-foot{border-top-color:transparent}
138
140
  button{font:inherit;cursor:pointer;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);color:var(--text);padding:6px 12px}
139
141
  button:hover{background:var(--bg-hover)}
140
142
  button.primary{background:var(--primary);border-color:var(--primary);color:var(--on-solid);height:42px;padding:0 18px}
141
143
  button.primary:disabled{opacity:.5;cursor:default}
142
144
  button.small{font-size:12px;padding:2px 8px}
143
- .p-hint{font-size:12px;color:var(--text-faint)}
144
145
  .p-ended{max-width:30rem;margin:18vh auto 0;padding:0 16px;text-align:center;display:flex;flex-direction:column;gap:10px}
145
146
  .p-ended h1{font-size:20px;color:var(--text-bright)}
146
147
  .p-ended p{color:var(--text-3)}
@@ -151,12 +152,10 @@ button.small{font-size:12px;padding:2px 8px}
151
152
  .p-people .p-person:nth-child(n+4){display:none}
152
153
  main{padding:12px 16px 6px}
153
154
  .p-bubble{padding:9px 12px}
154
- .p-hint,.p-note{display:none}
155
+ .p-hint.composer-line,.p-note{display:none}
155
156
  }
156
157
  /* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
157
158
  @media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
158
- /* A phone's grip (composer.css: 26 px tall, the pill at 17 px): the pill on the dock's top border. */
159
- @media (max-width:600px){.p-dock #composeGrip{margin:-29px auto 3px}}
160
159
  </style>
161
160
  </head>
162
161
  <body>
@@ -185,25 +184,27 @@ button.small{font-size:12px;padding:2px 8px}
185
184
  <div class="p-thread" id="thread"></div>
186
185
  </main>
187
186
  <footer class="p-dock" id="dock" hidden>
188
- <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
189
187
  <div class="p-dock-in">
190
188
  <div class="p-queue" id="queue"></div>
191
189
  <div class="p-error" id="error" role="alert" hidden></div>
192
- <div id="composer">
193
- <div class="compose-col">
194
- <div id="chips" hidden></div>
195
- <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
190
+ <div class="p-card composer-card">
191
+ <div id="composeGrip" class="edge-grip" tabindex="0" aria-label="Comment box" title="Drag to make the comment box taller (double-click resets)"></div>
192
+ <div id="composer">
193
+ <div class="p-hint composer-line" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
194
+ <div class="compose-col">
195
+ <div id="chips" hidden></div>
196
+ <textarea id="comment" rows="1" placeholder="Comment or ask" aria-label="Comment or ask"></textarea>
197
+ </div>
198
+ <div class="actions-col">
199
+ <button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
200
+ <input type="file" id="attachInput" accept="image/*" multiple hidden>
201
+ <div class="split"><button type="button" class="primary" id="send">Send</button></div>
202
+ </div>
196
203
  </div>
197
- <div class="actions-col">
198
- <button type="button" id="attachBtn" class="attach-btn" title="Add images" aria-label="Add images" hidden></button>
199
- <input type="file" id="attachInput" accept="image/*" multiple hidden>
200
- <div class="split"><button type="button" class="primary" id="send">Send</button></div>
204
+ <div class="p-foot composer-card-foot">
205
+ <div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
201
206
  </div>
202
207
  </div>
203
- <div class="p-foot">
204
- <div class="p-usage" id="usage" aria-label="Your usage" hidden></div>
205
- <div class="p-hint" id="hint">⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. The model answers in a read-only turn; the owner sees everything.</div>
206
- </div>
207
208
  </div>
208
209
  </footer>
209
210
  </div>
@@ -12,7 +12,7 @@
12
12
  // (spend vs cap, queue, link expiry, model). The owner's participant view
13
13
  // mounts none.
14
14
  import {
15
- authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
15
+ authorColor, participantAuthor, isParticipantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
16
16
  liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
17
17
  modeCopy,
18
18
  } from "./participant_model.js";
@@ -22,10 +22,11 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
22
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
- import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
25
+ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
26
26
  import { appendQuote } from "./annotations.js";
27
27
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
28
  import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
29
+ import { CHECK_NOT_ON_PAGE } from "./verdict_card.js";
29
30
  import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
30
31
  import { mountComposer } from "./composer_mount.js";
31
32
  import { PROMPT_LINE_PX } from "./composer_size.js";
@@ -234,6 +235,7 @@ function render() {
234
235
  const copy = modeCopy(viewer.mode);
235
236
  $("note").textContent = ownerView ? "" : copy.note;
236
237
  $("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
238
+ $("hint").title = $("hint").textContent; // at most two lines in the card: the whole text on hover
237
239
  $("head").hidden = false;
238
240
  $("main").hidden = false;
239
241
  renderPlan();
@@ -342,7 +344,7 @@ function messageNode(message) {
342
344
  const who = el("div", "p-who");
343
345
  who.append(el("b", "", authorLabel(message)));
344
346
  if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
345
- if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
347
+ if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
346
348
  node.append(who);
347
349
  const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
348
350
  node.append(el("div", "p-bubble", text));
@@ -412,13 +414,17 @@ function renderThread() {
412
414
  const author = record.by || null;
413
415
  const viewer = data.viewer || {};
414
416
  let who = ownerView ? "You" : "Owner";
415
- if (author && author !== "owner") {
417
+ if (isParticipantAuthor(author)) {
416
418
  if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
417
419
  else {
418
420
  who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
419
421
  }
420
422
  }
421
- placeVerdictWithFresh(thread, record, { who, author }, verifyFreshState());
423
+ placeVerdictWithFresh(thread, record, {
424
+ who,
425
+ author,
426
+ onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
427
+ }, verifyFreshState());
422
428
  }
423
429
  if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
424
430
  thread.dataset.drawn = "1";
@@ -430,10 +436,12 @@ function renderThread() {
430
436
  // does, and no one here gets a link to the child's session.
431
437
  function verifyFreshState() {
432
438
  if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
439
+ const unavailable = data.session?.verify_unavailable === true;
433
440
  return {
434
441
  verdicts: data.verdicts || [],
435
442
  running: data.fresh_running,
436
- canStart: !ownerView && !ended && !!data.viewer?.can_comment,
443
+ canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
444
+ unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
437
445
  start: async (of) => {
438
446
  const { ok, body } = await api(freshURL(sessionId, of), {
439
447
  method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
@@ -649,6 +657,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
649
657
  // while this participant can't comment.
650
658
  const annotateBar = createAnnotateBar({
651
659
  presets: withVerify(parsePresets(body.annotatePresets)),
660
+ disabledPreset: (preset) => preset === VERIFY_PRESET
661
+ && data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
652
662
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
653
663
  roots: () => [$("planDoc"), $("thread")],
654
664
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -9,6 +9,7 @@ import { fullTime, gapText } from "./format.js";
9
9
  // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
10
10
  export const PARTICIPANT_PREFIX = "p:";
11
11
  export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
12
+ export const isParticipantAuthor = (by) => typeof by === "string" && by.startsWith(PARTICIPANT_PREFIX);
12
13
 
13
14
  // Six author colours (tokens.css --author-1..6), one per participant id,
14
15
  // the same on every page that shows them (the owner's badges too).
@@ -22,13 +23,13 @@ export function authorSlot(author) {
22
23
 
23
24
  // The CSS colour of an author: a participant's slot, else the owner's.
24
25
  export function authorColor(author) {
25
- return author ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
+ return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
26
27
  }
27
28
 
28
29
  // Who wrote a user message or started a turn, as the page names them.
29
30
  export function authorLabel(item) {
30
31
  if (item?.mine) return "You";
31
- if (item?.author) return item.author_name || "A participant";
32
+ if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
32
33
  if (item?.automatic) return "Automatic";
33
34
  return "Owner";
34
35
  }
@@ -4,7 +4,7 @@
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
- import { authorColor, participantAuthor } from "./participant_model.js";
7
+ import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
8
8
 
9
9
  export const UNNAMED = "a participant";
10
10
 
@@ -28,7 +28,7 @@ export function participantName(author) {
28
28
  // +mode+: the turn's participant_mode stamp ("review", "pair"; the
29
29
  // message's, or turn_started's), kept as mode: when known.
30
30
  export function authorBadge(author, name = null, mode = null) {
31
- if (!author || author === "owner") return null;
31
+ if (!isParticipantAuthor(author)) return null;
32
32
  const known = name || participantName(author);
33
33
  return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
34
34
  }
@@ -0,0 +1,115 @@
1
+ // The project picker opened by the top-bar scope chip. The server supplies
2
+ // the project roots and their current/default marks; links carry the view
3
+ // without carrying a session hash into another project's list.
4
+
5
+ import { html, nothing, render } from "./vendor/lit-html.js";
6
+ import { allScopeHref, projectScopeHref } from "./scope.js";
7
+ import { createChipPopover } from "./chip_popover.js";
8
+
9
+ function homeHref(search) {
10
+ const view = new URLSearchParams(search || "").get("view");
11
+ return view ? `/?view=${encodeURIComponent(view)}` : "/";
12
+ }
13
+
14
+ function sessionCount(count) {
15
+ const n = Number(count) || 0;
16
+ return `${n} session${n === 1 ? "" : "s"}`;
17
+ }
18
+
19
+ function projectRow(row, search) {
20
+ return html`<a class=${`project-row${row.default ? " default" : ""}${row.current ? " current" : ""}`}
21
+ href=${row.default ? homeHref(search) : projectScopeHref(row.dir, "", search)}
22
+ aria-current=${row.current ? "page" : nothing}>
23
+ <span class="project-main"><span class="project-name">${row.name}</span
24
+ >${row.default ? html`<span class="project-default">default</span>` : nothing
25
+ }<span class="project-root">${row.root_label}</span></span>
26
+ <span class="project-meta">${Number(row.waiting) > 0 ? html`<span class="attn">${Number(row.waiting)} waiting</span>` : nothing
27
+ }<span class="project-count">${sessionCount(row.sessions)}</span>${row.current ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}</span>
28
+ </a>`;
29
+ }
30
+
31
+ // +rows+ is the API's ordered list. When there is no project scope, the
32
+ // all-projects row is current; a bad ?dir= has no current project to mark.
33
+ export function pickerTemplate({ rows = [], loading = false, error = "", search = "", currentDir = null,
34
+ allCurrent = false } = {}) {
35
+ const allHref = allScopeHref("", currentDir, search);
36
+ return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
37
+ data-act="close" aria-label="Close">×</button></div>
38
+ ${loading ? html`<div class="project-note" role="status">Loading projects…</div>` : nothing}
39
+ ${error ? html`<div class="project-note project-error" role="alert">${error}</div>` : nothing}
40
+ <nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row, search))}
41
+ <a id="projectAll" class=${`project-row project-all${allCurrent ? " current" : ""}`}
42
+ href=${allHref} aria-current=${allCurrent ? "page" : nothing}>
43
+ <span class="project-main"><span class="project-name">All projects</span
44
+ ><span class="project-hint">Every session</span></span>
45
+ ${allCurrent ? html`<span class="project-check" aria-hidden="true">✓</span>` : nothing}
46
+ </a>
47
+ </nav>`;
48
+ }
49
+
50
+ // +scopeError+ returns the page's current folder error, if any. +where+
51
+ // supplies the live query string so links retain ?view= without a hash.
52
+ export function createProjectPicker({ el, scope, scopeError = () => null, api, where = () => ({ search: "" }),
53
+ doc = globalThis.document, win = globalThis.window }) {
54
+ let rows = [];
55
+ let loading = false;
56
+ let error = "";
57
+ let loads = 0;
58
+
59
+ const currentDir = () => scopeError() ? null : scope.dir || null;
60
+ const paint = (focus = false) => {
61
+ const failedScope = !!scopeError();
62
+ render(pickerTemplate({
63
+ rows, loading, error, search: where().search, currentDir: currentDir(),
64
+ allCurrent: !scope.dir && !failedScope,
65
+ }), shell.pop);
66
+ if (focus) {
67
+ const target = shell.pop.querySelector('.project-row[aria-current="page"]') ||
68
+ shell.pop.querySelector(".project-row");
69
+ target?.focus();
70
+ }
71
+ };
72
+
73
+ async function load(focus = false) {
74
+ const turn = ++loads;
75
+ loading = true;
76
+ error = "";
77
+ if (shell.isOpen()) {
78
+ paint();
79
+ shell.replace();
80
+ }
81
+ try {
82
+ // A dead ?dir (the folder-not-found chip) is left out: the list is the way out of it.
83
+ const dir = currentDir();
84
+ const answer = await api(`/api/projects${dir ? `?dir=${encodeURIComponent(dir)}` : ""}`);
85
+ if (!Array.isArray(answer)) throw new Error("the server returned an invalid project list");
86
+ if (turn !== loads) return;
87
+ rows = answer;
88
+ } catch (e) {
89
+ if (turn !== loads) return;
90
+ error = `Can't read projects: ${e?.message || e}`;
91
+ rows = [];
92
+ }
93
+ if (turn !== loads) return;
94
+ loading = false;
95
+ if (shell.isOpen()) {
96
+ paint(focus);
97
+ shell.replace();
98
+ }
99
+ }
100
+
101
+ const shell = createChipPopover({
102
+ id: "projectPicker", className: "ctx-popover project-picker", width: 380, el, anchor: "#scopePick",
103
+ summary: () => ({}), sheet: true, label: "Projects", doc, win,
104
+ show: () => {
105
+ rows = [];
106
+ loading = true;
107
+ error = "";
108
+ paint();
109
+ void load(true);
110
+ },
111
+ onClose: () => { loads += 1; },
112
+ });
113
+
114
+ return { pop: shell.pop, isOpen: shell.isOpen, load };
115
+ }
@@ -3,6 +3,7 @@
3
3
  // command or paths, where and why. Its options end in "Deny"; the answer's
4
4
  // last option denies. A page that doesn't know approvals still shows the
5
5
  // plain question text, which says the same.
6
+ import { isParticipantAuthor, PARTICIPANT_PREFIX } from "./participant_model.js";
6
7
 
7
8
  export function isApproval(pq) {
8
9
  return !!pq && pq.kind === "approval";
@@ -68,7 +69,9 @@ export function relayView(pq) {
68
69
  export function approvalAsker(pq) {
69
70
  const a = (pq && pq.approval) || {};
70
71
  if (!a.author) return null;
71
- return a.author_name ? String(a.author_name) : String(a.author).replace(/^p:/, "");
72
+ const author = String(a.author);
73
+ return a.author_name ? String(a.author_name)
74
+ : isParticipantAuthor(author) ? author.slice(PARTICIPANT_PREFIX.length) : author;
72
75
  }
73
76
 
74
77
  // @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
@@ -7,18 +7,21 @@ import { clientLabel, isOwn, withImages } from "./turn_events.js";
7
7
  // What a prompt_restored (the worker rolled a failed turn back and handed its
8
8
  // prompt back) does in this tab: `refill` is the text for an empty composer,
9
9
  // only for a prompt this page sent (`sentIds`, its acks' enqueued_ids), so a
10
- // replay after a reload never refills stale text; `own`/`label` mark the
10
+ // replay after a reload never refills stale text, with a Verify turn's claim
11
+ // (`verify`) so a resend still records a verdict; `own`/`label` mark the
11
12
  // failed bubble shown again after the resync.
12
13
  export function restoreAction(event, { myId, sentIds, composerEmpty }) {
13
14
  const origin = event?.origin || {};
14
15
  const own = isOwn(origin.client_id, myId);
15
16
  const sent = own && !!origin.enqueued_id && sentIds.has(origin.enqueued_id);
16
- // The images go back as chips with the text.
17
+ // The images go back as chips with the text, and its Verify claim.
18
+ const back = sent && composerEmpty;
17
19
  return withImages({
18
- refill: sent && composerEmpty ? event.prompt : null,
20
+ refill: back ? event.prompt : null,
19
21
  own,
20
22
  label: own ? null : clientLabel(origin.client_id),
21
- }, sent && composerEmpty ? event.images : null);
23
+ ...(back && event.verify ? { verify: event.verify } : {}),
24
+ }, back ? event.images : null);
22
25
  }
23
26
 
24
27
  // A turn that fails fast can have its prompt_restored come over the stream
@@ -49,24 +49,22 @@ export function cardFolder(workingDirectory, projectName) {
49
49
  return folder === projectName ? "" : folder;
50
50
  }
51
51
 
52
- // The header's scope chip: "<project> · all" in a project view (all links
53
- // to the same place without ?dir); "all projects" otherwise, with a link
54
- // back to the project it came from and where a new session starts on the
55
- // start page. +scope+: { dir, projectName, projectDir, serverDir, backDir,
56
- // backName }; +error+: why the ?dir= folder can't be shown; +where+:
57
- // () => { hash, search } (the page's location, read again at a click: the
58
- // links follow an open session or the all-sessions view).
52
+ // The header's project chip opens the picker. The "all" link remains the
53
+ // direct way to every session from a project view. +scope+: { dir,
54
+ // projectName, projectDir, serverDir, backDir, backName }; +error+: why
55
+ // the ?dir= folder can't be shown; +where+: () => { hash, search } (the
56
+ // page's location, read again at a click).
59
57
  export function scopeTemplate(scope, { error = null, where = () => ({ hash: "", search: "" }) } = {}) {
60
58
  const at = where();
61
59
  const allHref = () => allScopeHref(where().hash, scope.dir, where().search);
62
60
  const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir, at.search)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
63
- if (error) return html`<span class="scope-name error" title=${error}>folder not found</span>${all}`;
64
- if (scope.projectName) return html`<span class="scope-name" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</span>${all}`;
61
+ if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
62
+ if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
65
63
  const dir = scope.dir || scope.serverDir;
66
64
  const backHref = () => projectScopeHref(scope.backDir, where().hash, where().search);
67
65
  const back = scope.backName
68
66
  ? 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>`
69
67
  : nothing;
70
68
  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;
71
- return html`<span class="scope-name" title="Sessions of every project">all projects</span>${back}${newSession}`;
69
+ return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
72
70
  }
@@ -29,7 +29,7 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
29
29
  // +alert+: the browser's.
30
30
  export function createSendTurn({
31
31
  session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
32
- verify = () => null, clearVerify = () => {},
32
+ verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
33
33
  historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
34
34
  addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
35
35
  leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
@@ -158,6 +158,8 @@ export function createSendTurn({
158
158
  const restored = restoreOnAck(ack?.enqueued_id, earlyRestores, { myId: clientId, sentIds });
159
159
  // A restored line must be seen: the composer opens for this visit.
160
160
  if (restored?.refill && sentTo === session()) composer.openForVisit();
161
+ // Its Verify claim too, so the resend still records a verdict.
162
+ if (restored?.refill && restored.verify && sentTo === session()) restoreVerify(restored.verify);
161
163
  const before = promptEl.value;
162
164
  promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
163
165
  // A line back from sentTo is a draft of that session, wherever the page is now.