samagotchi 0.136.1 → 0.138.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -7
  5. data/docs/context.md +7 -9
  6. data/docs/guardrails.md +12 -5
  7. data/docs/internals/engine.md +4 -4
  8. data/docs/internals/participants.md +2 -1
  9. data/docs/internals/web-frontend.md +19 -9
  10. data/docs/plugins.md +2 -2
  11. data/docs/sessions.md +8 -8
  12. data/docs/testing.md +2 -1
  13. data/lib/samagotchi/bridge.rb +4 -0
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/desktop/macos.rb +3 -2
  16. data/lib/samagotchi/engine.rb +6 -1
  17. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  18. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  19. data/lib/samagotchi/guardrails/mise_paths.rb +36 -0
  20. data/lib/samagotchi/guardrails/outside.rb +5 -5
  21. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  22. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  23. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  24. data/lib/samagotchi/guardrails/review_paths.rb +3 -2
  25. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  26. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/message_display.rb +6 -3
  29. data/lib/samagotchi/web/participant_review.rb +2 -1
  30. data/lib/samagotchi/web/public/answer_fold.js +24 -6
  31. data/lib/samagotchi/web/public/app.js +24 -19
  32. data/lib/samagotchi/web/public/chains.js +6 -5
  33. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  34. data/lib/samagotchi/web/public/composer.css +15 -11
  35. data/lib/samagotchi/web/public/composer_grip.js +11 -13
  36. data/lib/samagotchi/web/public/composer_mount.js +2 -2
  37. data/lib/samagotchi/web/public/context_chips.js +29 -98
  38. data/lib/samagotchi/web/public/copy.js +8 -5
  39. data/lib/samagotchi/web/public/data.js +2 -0
  40. data/lib/samagotchi/web/public/families_view.js +30 -38
  41. data/lib/samagotchi/web/public/index.html +176 -132
  42. data/lib/samagotchi/web/public/info_bar.js +19 -8
  43. data/lib/samagotchi/web/public/info_bar_model.js +3 -1
  44. data/lib/samagotchi/web/public/lists_view.js +69 -42
  45. data/lib/samagotchi/web/public/participants_panel.js +4 -4
  46. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  47. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  48. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  49. data/lib/samagotchi/web/public/scope.js +3 -3
  50. data/lib/samagotchi/web/public/send_turn.js +4 -2
  51. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  52. data/lib/samagotchi/web/public/stage_view.js +51 -2
  53. data/lib/samagotchi/web/public/stale_params.js +26 -0
  54. data/lib/samagotchi/web/public/start_settings.js +1 -1
  55. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  56. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  57. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  58. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  59. data/lib/samagotchi/web/session_summary.rb +1 -1
  60. metadata +5 -1
@@ -1,8 +1,10 @@
1
- // The session bar's info line (#infoText): the open session's id (a button
2
- // that copies the full id), status, model, the ⚙ settings chip (thinking,
3
- // reasoning history, LLM context), the 🧰 toolkit chip, chain links, parent
4
- // and delegates, model notes, memories, folder, ctx, speed,
5
- // session clock and the worker's version badge. A lit-html template over a
1
+ // The session bar's info line (#infoText), three groups with hairlines
2
+ // between them: the setup (the open session's id, a button that copies the
3
+ // full id; status, model, the ⚙ settings chip (thinking, reasoning history,
4
+ // LLM context), the 🧰 toolkit chip, model notes, memories or the working
5
+ // set), the relations (chain links, the parent, delegates, participants)
6
+ // and the telemetry (folder, ctx, speed, the session clock, the worker's
7
+ // version badge). A lit-html template over a
6
8
  // plain model (info_bar_model.js, applied by app.js's updateInfoBar), so the markup lives here and
7
9
  // runs under node --test (spec/web/public/info_bar.test.js).
8
10
  //
@@ -21,7 +23,7 @@ import { chipTemplate as participantsChip } from "./participants_panel.js";
21
23
  export const PREV = "←";
22
24
  // The session clock's placeholder before its first turn: the chip shows
23
25
  // from the start, so the first turn doesn't add it to the line.
24
- export const CLOCK_NONE = "session –";
26
+ export const CLOCK_NONE = "–";
25
27
  export const NEXT = "→";
26
28
 
27
29
  // A chain's ← / → link (chains.js chainNeighbours): its date or time (else
@@ -71,7 +73,7 @@ export function workingSetChipTemplate(chip) {
71
73
  const what = chip.n ? `${chip.n} item${chip.n === 1 ? "" : "s"} this session brought into context` : "nothing this session brought into context yet";
72
74
  const title = `Working set: ${what} (files, memories, URLs)${chip.changed ? ", changed since you last looked" : ""}. Open the list.`;
73
75
  const cls = `model ws-chip${chip.changed ? " changed" : ""}${chip.n ? "" : " empty"}`;
74
- return html`<button type="button" class=${cls} title=${title} aria-label=${title} aria-haspopup="dialog"><span class="ws-dot" aria-hidden="true"></span>set ${chip.n}</button>`;
76
+ return html`<button type="button" class=${cls} title=${title} aria-label=${title} aria-haspopup="dialog" aria-expanded="false"><span class="ws-dot" aria-hidden="true"></span>set ${chip.n}</button>`;
75
77
  }
76
78
 
77
79
  // The info line for +bar+, nothing without a session (the start page hides
@@ -90,10 +92,19 @@ export function workingSetChipTemplate(chip) {
90
92
  // first generation; null: no chip), clock (the session time's text, "" for
91
93
  // none: the muted CLOCK_NONE), worker ({badge, busy} or null), participants (the
92
94
  // participants chip: participants_panel.js chipModel, or null).
95
+ // Three span.bar-group (setup, relations, telemetry), a span.bar-sep
96
+ // between two drawn ones; the relations only when it has a part.
93
97
  export function infoBarHtml(bar) {
94
98
  if (!bar) return nothing;
95
99
  const memories = bar.memories || [];
96
- return html`<div class="meta"><button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>delegated by ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}</div>`;
100
+ const setup = html`<button type="button" class="id" title=${`Session id ${bar.id}: click to copy it`}>${String(bar.id).slice(0, 8)}</button><span class=${bar.status ? `status ${bar.status}` : "status"}><span>${bar.status || ""}</span></span><span class=${`model${bar.model.mismatch ? " served-mismatch" : ""}`} title=${bar.model.title}>${bar.model.text}</span>${settingsChip(bar.settings)}${toolkitChip(bar.toolkit)}${bar.notes ? html`<span class="model notes-chip" title=${bar.notes.title}>${bar.notes.text}</span>` : nothing}${bar.workingSet ? workingSetChipTemplate(bar.workingSet.chip) : memories.length ? html`<span class="model mem-chip" title=${memChipTitle(memories)}>${memChipText(memories)}</span>` : nothing}`;
101
+ const related = !!(bar.chain?.prev || bar.chain?.next || bar.parent || bar.kids || bar.participants);
102
+ // The parent reads "↑ <short id>"; its title says "delegated by …".
103
+ const relations = related
104
+ ? html`<span class="bar-sep" aria-hidden="true"></span><span class="bar-group relations">${bar.chain?.prev ? chainLinkTemplate(bar.chain.prev, PREV, "The previous link of this chain") : nothing}${bar.chain?.next ? chainLinkTemplate(bar.chain.next, NEXT, "The next link of this chain") : nothing}${bar.parent ? html`<a class="model parent" href=${sessionHash(bar.parent.id)} title=${bar.parent.title}>↑ ${String(bar.parent.id).slice(0, 8)}</a>` : nothing}${kidsTemplate(bar.kids)}${participantsChip(bar.participants)}</span>`
105
+ : nothing;
106
+ const telemetry = html`<span class="dir" title=${bar.dir || ""}><bdi>${bar.dir || ""}</bdi></span><span class="model ctx" title=${bar.ctx.tip}>${bar.ctx.text}</span>${bar.speed ? html`<span class=${`model speed${bar.speed.empty ? " empty" : ""}`} title=${bar.speed.tip}>${bar.speed.text}</span>` : nothing}<span class=${`model session-time${bar.clock ? "" : " empty"}`} title=${bar.clock ? "This session's time: its turns, and the running one" : "This session's time: no turn yet"}>${bar.clock || CLOCK_NONE}</span>${workerBadgeTemplate(bar.worker)}`;
107
+ return html`<div class="meta"><span class="bar-group setup">${setup}</span>${relations}<span class="bar-sep" aria-hidden="true"></span><span class="bar-group telemetry">${telemetry}</span></div>`;
97
108
  }
98
109
 
99
110
  // Draws +bar+ into +el+ (#infoText), with the memories in its tooltip
@@ -112,7 +112,9 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
112
112
  // speed, so it doesn't join the line mid-turn.
113
113
  speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`, empty: false }
114
114
  : { text: SPEED_NONE, tip: "Decode speed of the last generation: none yet", empty: true },
115
- clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
115
+ // A session with no turn yet has no time of its own: the placeholder, not "0ms".
116
+ // A running first turn still ticks from 0.
117
+ clock: sessionDuration === null || (!running && sessionDuration === 0) ? "" : formatDuration(sessionDuration),
116
118
  worker: badge ? { badge, busy: restarting } : null,
117
119
  };
118
120
  }
@@ -20,19 +20,19 @@
20
20
  // select(id) open a session
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
22
  // updateSessionTitle(), showToast(text, action)
23
- // allViewOpen(), openAllView(), leaveAllView(hash)
23
+ // allViewOpen(), openAllView(), closeAllView(), leaveAllView(hash)
24
24
  // renameShown(owner) whether a card's title has its ✎ (session_title.js)
25
25
  // onRename() the active card's ✎ clicked (session_rename.js open)
26
26
  // renaming() whether that rename input is open (the strip holds)
27
27
 
28
- import { ownerBadge, relativeTime, delegatedBy, cardTitleSource } from "./format.js";
28
+ import { ownerBadge, relativeTime, delegatedBy, cardTitleSource, previewForCard } from "./format.js";
29
29
  import { html, nothing, render } from "./vendor/lit-html.js";
30
30
  import { cardCtxText, ctxBarTip } from "./ctx.js";
31
31
  import { allScopeHref, cardFolder } from "./scope.js";
32
32
  import { sessionHash } from "./route.js";
33
33
  import { stripColumns, stripStatusParts } from "./strip.js";
34
34
  import { createFamilyPop, familyChipTemplate, familyRowsTemplate } from "./families_view.js";
35
- import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains } from "./chains.js";
35
+ import { allViewChains, chainChipTemplate, chainRowsTemplate, chainTitle, foldChains, isOpener } from "./chains.js";
36
36
  import {
37
37
  batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, heldOrder,
38
38
  listedSessions, rangeIds, runBatch, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText,
@@ -70,10 +70,10 @@ export function allViewFamilies(listed, every, { query = "", includeArchived = f
70
70
  return { all, shown: waitingFirst(shown), delegates };
71
71
  }
72
72
 
73
- // The all view's count: "14 (+7 delegates)", or "3 / 14" under a search.
73
+ // The all view's count: "14 · 7 delegates", or "3 / 14" under a search.
74
74
  export function allCountText(shown, total, delegates) {
75
75
  return shown === total
76
- ? `${total}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
76
+ ? `${total}${delegates ? ` · ${delegates} ${delegates === 1 ? "delegate" : "delegates"}` : ""}`
77
77
  : `${shown} / ${total}`;
78
78
  }
79
79
 
@@ -85,10 +85,8 @@ export function stripCountText(shown, total) {
85
85
  : `${total - shown} archived`;
86
86
  }
87
87
 
88
- // How long a new card fades in and a status dot's pulse period: index.html's
89
- // `.card.entering` fadeIn (.28s) and `.card .dot` dot-pulse / attn-pulse (1.6s).
88
+ // How long a new card fades in: index.html's `.card.entering` fadeIn (.28s).
90
89
  export const CARD_FADE_MS = 280;
91
- export const DOT_PULSE_MS = 1600;
92
90
 
93
91
  // Which of a list's cards fade in. Every draw makes new nodes (draw), and a
94
92
  // new node starts its CSS animation again: so only a card the list did not
@@ -109,20 +107,39 @@ export function enteringCards(prev, ids, now, fadeMs = CARD_FADE_MS) {
109
107
  return { drawn, entering };
110
108
  }
111
109
 
112
- // A card's inline animation phases: its fade's delay (+enterMs+ into it) and
113
- // the status dots' pulse, phase-locked to the clock (+now+, performance.now())
114
- // so a redrawn dot carries on pulsing where the last one was. "" for neither.
115
- export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_PULSE_MS) {
116
- const parts = [];
117
- if (enterMs) parts.push(`--enter-delay:-${Math.round(enterMs)}ms`);
118
- if (now !== null) parts.push(`--pulse-delay:-${Math.round(now % period)}ms`);
119
- return parts.join(";");
110
+ // A card's inline animation: its fade's delay (+enterMs+ into it, so a
111
+ // redrawn card carries its fade on). "" for none.
112
+ export function cardAnimStyle({ enterMs = null } = {}) {
113
+ return enterMs ? `--enter-delay:-${Math.round(enterMs)}ms` : "";
120
114
  }
121
115
 
122
- // A card: the first message as its title, then status, how long ago, owner
123
- // and memories. The id is in the tooltip (and the search) only. A session
124
- // that waits on the user (a question, an approval, a card with actions)
125
- // wears the warning colour and says what for.
116
+ // A card's two lines: its title (one line) and the line under it. The
117
+ // title is +title+ (cardTitle); an untitled session (its title is only its
118
+ // first message: cardTitleSource "preview", not a chain link inheriting its
119
+ // chain's title, and not "—") reads "Untitled" over that message. The line
120
+ // under it is the recap (unless the title is the recap's first sentence,
121
+ // D5), else the first message when the title isn't it (and it isn't a
122
+ // chain link's opener). untitled: whether the title is the "Untitled"
123
+ // placeholder.
124
+ function cardLines(s, title) {
125
+ if (cardTitleSource(s) === "preview" && title !== "\u2014" && title === previewForCard(s)) {
126
+ return { title: "Untitled", untitled: true, preview: title, previewTip: s.first_preview || s.last_prompt || title };
127
+ }
128
+ if (s.recap && cardTitleSource(s) !== "recap") return { title, untitled: false, preview: s.recap, previewTip: s.recap };
129
+ const first = (s.first_preview || "").trim();
130
+ const shown = first && !(s.continues && isOpener(first)) ? previewForCard({ first_preview: first }) : "";
131
+ return shown && shown !== title && shown !== "\u2014"
132
+ ? { title, untitled: false, preview: shown, previewTip: first }
133
+ : { title, untitled: false, preview: null, previewTip: null };
134
+ }
135
+
136
+ // A card: its title, the line under it (cardLines), then how long ago,
137
+ // owner and memories. The id is in the tooltip (and the search) only. One
138
+ // mark per state, no status dot: a session that waits on the user (a
139
+ // question, an approval, a card with actions) wears the warning colour and
140
+ // says what for (and drops its "live" owner badge: waiting means a worker);
141
+ // an errored one wears an `error` badge; idle, completed and stopped are
142
+ // unmarked.
126
143
  // deletable / archivable: the all-sessions view's cards get a delete and an
127
144
  // archive (unarchive, on an archived card) button. selectable: select mode's
128
145
  // checkbox instead (picked: checked), and skipReason: why a batch skipped it.
@@ -133,7 +150,7 @@ export function cardAnimStyle({ enterMs = null, now = null } = {}, period = DOT_
133
150
  // members. A family in a session chain (chains.js foldChains) carries its
134
151
  // `#N` chip, which opens the other links in a popover. entering: a card
135
152
  // new to its list (enteringCards: it fades in); anim: its inline animation
136
- // phases (cardAnimStyle).
153
+ // fade (cardAnimStyle).
137
154
  // +ctx+: title (cardTitle), projectName (the page's scope), activeId (the
138
155
  // open session), chainOpen (its chain's popover is open), parent (the
139
156
  // delegating session, or null).
@@ -157,7 +174,7 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
157
174
  // the user (the waiting badge wins: the stop is behind the need).
158
175
  const stopped = waiting ? null : stoppedBadge(s);
159
176
  const stoppedT = stopped ? html`<span class="stopped-badge" title=${stopped.title}>${stopped.text}</span>` : nothing;
160
- const dotWord = waiting ? waiting.title : s.status;
177
+ const errorT = s.status === "error" ? html`<span class="error-badge" title=${s.status}>error</span>` : nothing;
161
178
  const pick = selectable ? html`<input type="checkbox" class="card-pick" aria-label=${`Select session ${s.short_id}`} ?checked=${picked}>` : nothing;
162
179
  const skip = skipReason ? html`<div class="skip-reason">skipped: ${skipReason}</div>` : nothing;
163
180
  const familyWaiting = !!family && familyWaits(family);
@@ -169,12 +186,13 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
169
186
  const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
170
187
  ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
171
188
  const cls = `card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}${entering ? " entering" : ""}`;
189
+ const lines = cardLines(s, title);
190
+ const titleT = html`<div class=${lines.untitled ? "preview untitled" : "preview"}>${lines.title}</div>`;
172
191
  const preview = renamable
173
- ? html`<div class="preview-row"><div class="preview">${title}</div><button type="button" class="card-rename" data-rename=${s.id} title="Rename this session" aria-label="Rename this session">\u270e</button></div>`
174
- : html`<div class="preview">${title}</div>`;
175
- // No recap line when the title is the recap's first sentence (D5).
176
- const recap = s.recap && cardTitleSource(s) !== "recap" ? html`<div class="recap" title=${s.recap}>${s.recap}</div>` : nothing;
177
- return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta"><span class=${`status dot ${s.status}${waiting ? " waiting" : ""}`} title=${dotWord} aria-label=${dotWord}></span>${waitingT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
192
+ ? html`<div class="preview-row">${titleT}<button type="button" class="card-rename" data-rename=${s.id} title="Rename this session" aria-label="Rename this session">\u270e</button></div>`
193
+ : titleT;
194
+ const recap = lines.preview ? html`<div class="recap" title=${lines.previewTip}>${lines.preview}</div>` : nothing;
195
+ return html`<div class=${cls} data-id=${s.id} data-members=${memberIds.length ? memberIds.join(" ") : nothing} style=${anim || nothing} title=${`${tip}${memTitle ? ` · ${memTitle}` : ""}`}>${preview}${recap}<div class="meta">${waitingT}${errorT}${stoppedT}${time}${ctxT}${folderT}${parentChipTemplate(s, parent)}${chain}${kids}${waiting ? nothing : ownerBadgeTemplate(s)}${archived}</div>${famRows}${skip}${pick}${selectable ? nothing : cardToolsTemplate(s, { deletable, archivable })}</div>`;
178
196
  }
179
197
 
180
198
  function cardToolsTemplate(s, { deletable, archivable }) {
@@ -208,9 +226,10 @@ export function stripTileTemplate(count, { entering = false, anim = "" } = {}) {
208
226
  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>`;
209
227
  }
210
228
 
211
- // The hidden strip's status pill: "5 sessions · ● 1 live · ● 1 waiting"
212
- // (strip.js stripStatusParts). Not a toggle any more (the grip hides and
213
- // shows the strip): a click opens the all-sessions view, as the tile does.
229
+ // The sessions pill: "5 sessions · ● 1 live · ● 1 waiting" (strip.js
230
+ // stripStatusParts), shown while the strip is hidden and in the all view.
231
+ // The all view's toggle (the grip hides and shows the strip): a click
232
+ // opens the view, as the tile does, and closes it again (pressed there).
214
233
  // The count (and the dot after it) in its own span: a phone's narrow top
215
234
  // bar drops it when live or waiting shows (index.html).
216
235
  export function stripPillTemplate({ count, live, waiting }) {
@@ -232,24 +251,29 @@ export function createListsView(deps) {
232
251
  const {
233
252
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
234
253
  confirmDelete, setArchived, archiveOne, updateSessionTitle, showToast,
235
- allViewOpen, openAllView, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
254
+ allViewOpen, openAllView, closeAllView = () => {}, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
236
255
  } = deps;
237
256
  const $ = (s) => doc.querySelector(s);
238
257
 
239
- // The hidden strip's status pill opens the all-sessions view (as the tile
240
- // does), saving the step of showing the strip first. Bound once: the pill
241
- // element stays, only its content is drawn again (draw()).
242
- $("#stripStatus").addEventListener("click", openAllView);
258
+ // The sessions pill opens the all-sessions view (as the tile does), saving
259
+ // the step of showing the strip first, and closes it again (as Esc does):
260
+ // it is the view's own button there. Bound once: the pill element stays,
261
+ // only its content is drawn again (draw()).
262
+ $("#stripStatus").addEventListener("click", () => (allViewOpen() ? closeAllView() : openAllView()));
243
263
 
244
- // The strip, its count and the hidden strip's pill leave archived sessions
245
- // out; the all view shows them with "include archived". sessions() keeps
246
- // them all (parent chips, the open session).
264
+ // The strip, its count and the sessions pill leave archived sessions out;
265
+ // the all view shows them with its "archived" toggle pressed. sessions()
266
+ // keeps them all (parent chips, the open session).
247
267
  function shownSessions() {
248
268
  return listedSessions(sessions(), false);
249
269
  }
250
270
 
271
+ function archivedOn() {
272
+ return $("#includeArchived").getAttribute("aria-pressed") === "true";
273
+ }
274
+
251
275
  function allViewSessions() {
252
- return listedSessions(sessions(), $("#includeArchived").checked);
276
+ return listedSessions(sessions(), archivedOn());
253
277
  }
254
278
 
255
279
  function filterQuery() {
@@ -333,7 +357,7 @@ export function createListsView(deps) {
333
357
  const now = win.performance?.now() ?? Date.now();
334
358
  const { drawn, entering } = enteringCards(lastDrawn.get(el), ids, now);
335
359
  lastDrawn.set(el, drawn);
336
- return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id), now }) });
360
+ return (id) => ({ entering: entering.has(id), anim: cardAnimStyle({ enterMs: entering.get(id) }) });
337
361
  }
338
362
 
339
363
  // The strip's cards: stripOrder, held under the pointer, as many as fit.
@@ -373,6 +397,7 @@ export function createListsView(deps) {
373
397
  let chainFrom = null; // the list element the open popover's chip is in
374
398
  const chainPop = createFamilyPop({
375
399
  id: "chainPop",
400
+ label: "Chain links",
376
401
  doc,
377
402
  win,
378
403
  rowsFor: (id) => {
@@ -565,7 +590,7 @@ export function createListsView(deps) {
565
590
  const listed = allViewSessions();
566
591
  const q = filterQuery();
567
592
  const { all, shown, delegates } = allViewFamilies(listed, sessions(), {
568
- query: q, includeArchived: $("#includeArchived").checked, matches: (s) => matchesFilter(s, q, cardTitle(s)),
593
+ query: q, includeArchived: archivedOn(), matches: (s) => matchesFilter(s, q, cardTitle(s)),
569
594
  });
570
595
  noteChainFamilies(all);
571
596
  const fams = listOrder(allListEl, shown, familyId);
@@ -684,6 +709,7 @@ export function createListsView(deps) {
684
709
 
685
710
  function updateSelectBar() {
686
711
  $("#allView").classList.toggle("selecting", selecting.on);
712
+ $("#allHead").classList.toggle("selecting", selecting.on); // the header is in the top bar
687
713
  $("#selectBtn").hidden = selecting.on;
688
714
  const bar = $("#selectBar");
689
715
  bar.hidden = !selecting.on;
@@ -770,7 +796,8 @@ export function createListsView(deps) {
770
796
  if (selecting.on && e.shiftKey) e.preventDefault();
771
797
  });
772
798
 
773
- $("#includeArchived").addEventListener("change", () => {
799
+ $("#includeArchived").addEventListener("click", (e) => {
800
+ e.currentTarget.setAttribute("aria-pressed", String(!archivedOn()));
774
801
  if (allViewOpen()) renderAll();
775
802
  });
776
803
  $("#filter").addEventListener("input", () => {
@@ -78,13 +78,13 @@ export function rowModel(grant, now) {
78
78
  };
79
79
  }
80
80
 
81
- // The chip: "invite" with nobody yet, else the active participants' count
82
- // and a dot while one is here.
81
+ // The chip: "participants · invite" with nobody yet, else the active
82
+ // participants' count and a dot while one is here.
83
83
  export function chipModel(grants, now) {
84
84
  if (!Array.isArray(grants)) return null;
85
85
  const active = grants.map((g) => rowModel(g, now)).filter((row) => row.active);
86
86
  if (active.length === 0) {
87
- return { text: "invite", count: 0, present: false, title: "Invite a participant to review this session" };
87
+ return { text: "participants · invite", count: 0, present: false, title: "Invite a participant to review this session" };
88
88
  }
89
89
  const present = active.filter((row) => row.present).map((row) => row.name);
90
90
  const names = active.map((row) => row.name).join(", ");
@@ -99,7 +99,7 @@ export function chipModel(grants, now) {
99
99
  export function chipTemplate(chip) {
100
100
  if (!chip) return nothing;
101
101
  return html`<button type="button" class=${`model participants-chip${chip.count ? "" : " none"}${chip.present ? " present" : ""}`}
102
- title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
102
+ title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog" aria-expanded="false"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
103
103
  }
104
104
 
105
105
  // A cap field's dollars (a leading $ allowed): {value}, {value: null} when
@@ -0,0 +1,57 @@
1
+ // What every popover does alike (chip_popover.js' shell, and the ones that
2
+ // place themselves: context_chips.js, families_view.js' family and chain
3
+ // lists): a modal dialog (modalDialog: aria-modal, Tab and Shift+Tab wrap
4
+ // inside it, a Tab from outside goes in), its opener's aria-expanded, Esc
5
+ // closes it and focus goes back to the opener (focusBack). Their :hover
6
+ // tints are gated on (hover:hover) in index.html, so a tap on a phone
7
+ // leaves none behind. A module of its own: its exports are new (see
8
+ // docs/internals/web-frontend.md, "New exports go in new modules").
9
+ //
10
+ // Not popovers in this sense, so not modal: model_picker.js and
11
+ // command_list.js (a combobox's listbox: the focus stays in its input),
12
+ // ref_menu.js (a menu: arrow keys, Tab leaves it), update_sheet.js (a
13
+ // native <dialog> shown modal: the browser holds the focus).
14
+
15
+ const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
16
+
17
+ // The elements Tab reaches in +root+, in order (none hidden or disabled).
18
+ export function tabStops(root) {
19
+ return [...root.querySelectorAll(FOCUSABLE)].filter((e) => !e.disabled && !e.closest("[hidden]") && e.tabIndex >= 0);
20
+ }
21
+
22
+ // Tab past +root+'s last stop goes to its first, Shift+Tab before the first
23
+ // to the last; a Tab from outside (the opener kept the focus, or a redraw
24
+ // dropped it) goes in. +e+: the Tab's keydown.
25
+ export function holdFocus(e, root, doc = document) {
26
+ const stops = tabStops(root);
27
+ if (!stops.length) return;
28
+ const first = stops[0];
29
+ const last = stops[stops.length - 1];
30
+ const at = doc.activeElement;
31
+ let to = null;
32
+ if (!root.contains(at)) to = e.shiftKey ? last : first;
33
+ else if (e.shiftKey && at === first) to = last;
34
+ else if (!e.shiftKey && at === last) to = first;
35
+ if (!to) return;
36
+ e.preventDefault();
37
+ to.focus();
38
+ }
39
+
40
+ // +pop+ as every popover is: a dialog (named +label+) that holds the focus
41
+ // while +isOpen()+ and on the page (aria-modal; Tab and Shift+Tab wrap inside it).
42
+ export function modalDialog(pop, { label = null, isOpen, doc = document }) {
43
+ pop.setAttribute("role", "dialog");
44
+ pop.setAttribute("aria-modal", "true");
45
+ if (label) pop.setAttribute("aria-label", label);
46
+ doc.addEventListener("keydown", (e) => {
47
+ if (e.key === "Tab" && isOpen() && pop.isConnected) holdFocus(e, pop, doc);
48
+ });
49
+ }
50
+
51
+ // Called before +pop+ closes: whether the focus was in it (or +refocus+:
52
+ // an Esc), and so goes back(to) its opener once it has closed.
53
+ // @return (to) => focuses +to+ when it should and still can
54
+ export function focusBack(pop, { refocus = false, doc = document } = {}) {
55
+ const had = refocus || pop.contains(doc.activeElement);
56
+ return (to) => { if (had && to?.isConnected) to.focus?.(); };
57
+ }
@@ -11,7 +11,9 @@
11
11
  // verdict cards, the recap. A time-gap divider between two folded stubs
12
12
  // folds into the later stub (it shows the divider's words);
13
13
  // - the top answer gets a version chip, "v3 · 2 earlier ▾", listing the
14
- // versions by their prompts; picking one reveals its stub;
14
+ // versions by their prompts; picking one reveals its stub; and a ‹ 3/3 ›
15
+ // switcher before it shows an earlier version's answer in the top
16
+ // answer's place (revise_switch.js; view only, not stored);
15
17
  // - the final answer of an ended turn (verify_target.js's rule, a delegate
16
18
  // report's reply excluded), unstacked or a stack's top, and not a Verify
17
19
  // turn's, gets a Revise button: it arms the composer (app.js).
@@ -28,6 +30,7 @@ import { bubbleCopyText } from "./copy.js";
28
30
  import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
29
31
  import { clip } from "./format.js";
30
32
  import { NOTE_BOUNDARY } from "./turn_range.js";
33
+ import { SWAPPED_CLASS, VIEW_CLASS, stepVersion, switcherTemplate, viewOf } from "./revise_switch.js";
31
34
 
32
35
  // The classes and attributes this module puts on the history.
33
36
  export const HIDDEN_CLASS = "stack-hidden";
@@ -146,7 +149,7 @@ export function historyTurns(historyEl) {
146
149
  while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
147
150
  };
148
151
  for (const el of historyEl.children) {
149
- if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
152
+ if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS) || el.classList.contains(VIEW_CLASS)) continue;
150
153
  if (el.classList.contains(FOLD_ROW) && el.nextElementSibling?.matches(OPENING)) continue;
151
154
  const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
152
155
  if (opening || el.matches(NOTE_BOUNDARY)) {
@@ -187,6 +190,10 @@ export function installReviseStacks({
187
190
  const rows = new Map();
188
191
  // Each row's answer bubble (its label at a click).
189
192
  const rowAnswer = new WeakMap();
193
+ // The switcher: each stack's version on view (0-based, by its top's id;
194
+ // none: the latest), and the copy shown for it ({el, source, hidden}).
195
+ const shown = new Map();
196
+ const views = new Map();
190
197
  let observer = null;
191
198
 
192
199
  function stubFor(id, opening, stored) {
@@ -234,7 +241,8 @@ export function installReviseStacks({
234
241
  : nothing;
235
242
  const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
236
243
  row.classList.toggle("stacked", !!chain);
237
- render(html`${versions}${button}`, row);
244
+ const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
245
+ render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
238
246
  rowAnswer.set(row, answer);
239
247
  }
240
248
 
@@ -287,6 +295,7 @@ export function installReviseStacks({
287
295
  keepRows.add(turn.id);
288
296
  drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
289
297
  }
298
+ syncViews(tops, keepRows, new Map(turns.filter((t) => t.id).map((t) => [t.id, t.answer])));
290
299
  // A divider between two folded stubs folds into the later one.
291
300
  for (const turn of turns) {
292
301
  if (!turn.stub) continue;
@@ -307,13 +316,67 @@ export function installReviseStacks({
307
316
  for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
308
317
  for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
309
318
  // A stale row left by a re-render (its map entry gone with the nodes).
310
- for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
311
- if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
319
+ const viewEls = new Set([...views.values()].map((v) => v.el));
320
+ for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}, :scope > .${VIEW_CLASS}`)) {
321
+ if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el && !viewEls.has(el)) el.remove();
312
322
  }
313
323
  // What we changed isn't news.
314
324
  observer?.takeRecords();
315
325
  }
316
326
 
327
+ // The switcher's copies: a stack on an earlier version (its top's row
328
+ // drawn, that version's answer there) shows a copy of that answer just
329
+ // before the top answer and its fold row, which hide; any other stack's
330
+ // copy goes and its top shows again.
331
+ function syncViews(tops, keepRows, answers) {
332
+ const wanted = (id) => {
333
+ const chain = tops.get(id);
334
+ const k = shown.get(id);
335
+ if (!chain || k === undefined || k >= chain.length - 1 || !keepRows.has(id)) return null;
336
+ const source = answers.get(chain[k]);
337
+ const top = answers.get(id);
338
+ return source && top ? { source, top, version: k + 1 } : null;
339
+ };
340
+ for (const [id, view] of views) {
341
+ const want = wanted(id);
342
+ if (want && view.source === want.source && view.top === want.top && view.el.isConnected) continue;
343
+ view.el.remove();
344
+ for (const el of view.hidden) el.classList.remove(SWAPPED_CLASS);
345
+ views.delete(id);
346
+ }
347
+ for (const id of [...shown.keys()]) {
348
+ const want = wanted(id);
349
+ if (!want) {
350
+ shown.delete(id);
351
+ continue;
352
+ }
353
+ let view = views.get(id);
354
+ if (!view) {
355
+ view = { el: viewOf(want.source, want.version), source: want.source, top: want.top, hidden: [] };
356
+ views.set(id, view);
357
+ }
358
+ const head = want.top.previousElementSibling?.classList.contains(FOLD_ROW) ? want.top.previousElementSibling : null;
359
+ for (const el of view.hidden) if (el !== want.top && el !== head) el.classList.remove(SWAPPED_CLASS);
360
+ view.hidden = head ? [want.top, head] : [want.top];
361
+ for (const el of view.hidden) el.classList.add(SWAPPED_CLASS);
362
+ const before = head || want.top;
363
+ if (before.previousElementSibling !== view.el) historyEl.insertBefore(view.el, before);
364
+ }
365
+ }
366
+
367
+ // The switcher's ‹ or › on its row's stack.
368
+ function step(row, delta) {
369
+ const id = row.dataset.reviseFor;
370
+ const count = row.querySelectorAll("[data-stack-pick]").length;
371
+ if (!id || count < 2) return;
372
+ const was = shown.get(id) ?? count - 1;
373
+ const k = stepVersion(was, count, delta);
374
+ if (k === was) return;
375
+ if (k === count - 1) shown.delete(id);
376
+ else shown.set(id, k);
377
+ keepRowInView(historyEl, row, decorate);
378
+ }
379
+
317
380
  function setOpen(stub, open, { save }) {
318
381
  keepRowInView(historyEl, stub, () => {
319
382
  stub.classList.toggle("open", open);
@@ -326,7 +389,7 @@ export function installReviseStacks({
326
389
  // answer's key or its end (its class).
327
390
  observer = new MutationObserver((records) => {
328
391
  const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
329
- && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
392
+ && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && ![STUB_CLASS, ROW_CLASS, VIEW_CLASS].some((c) => n.classList.contains(c)))
330
393
  : r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
331
394
  if (news) decorate();
332
395
  });
@@ -340,6 +403,13 @@ export function installReviseStacks({
340
403
  setOpen(stub, !stub.classList.contains("open"), { save: true });
341
404
  return;
342
405
  }
406
+ const stepper = e.target.closest?.("[data-stack-step]");
407
+ if (stepper) {
408
+ e.preventDefault();
409
+ const row = stepper.closest(`.${ROW_CLASS}`);
410
+ if (row && stepper.getAttribute("aria-disabled") !== "true") step(row, Number(stepper.dataset.stackStep));
411
+ return;
412
+ }
343
413
  const pick = e.target.closest?.("[data-stack-pick]");
344
414
  if (pick) {
345
415
  e.preventDefault();
@@ -0,0 +1,55 @@
1
+ // The ‹ N/M › switcher on a Revise stack's top answer (revise_stack.js draws
2
+ // it in the top answer's row, before the version chip): the owner reads an
3
+ // earlier version's answer in place of the top one. View only: no server
4
+ // call, nothing stored (a reload shows the latest again).
5
+ //
6
+ // An earlier version is shown as a copy of its answer bubble (the bubble
7
+ // itself stays hidden in its superseded turn): the copy goes just before the
8
+ // top answer (its fold row, answer_fold.js, kept with it), and the top answer
9
+ // and its fold row are hidden by a class while it shows. The copy keeps its
10
+ // kept source (data-copy-source) and its copy buttons, so Copy copies the
11
+ // version on view; it has no answer key (data-answer-turn), so no fold row,
12
+ // claim key or Revise row is drawn for it, and the top answer stays its
13
+ // turn's last answer (verify_target.js, cards_place.js).
14
+ //
15
+ // A module of its own: static JS is cached at unversioned URLs, and a new
16
+ // export on an old module would blank the page.
17
+ import { html } from "./vendor/lit-html.js";
18
+
19
+ // The copy of an earlier answer on view, and what hides the top one.
20
+ export const VIEW_CLASS = "stack-view";
21
+ export const SWAPPED_CLASS = "stack-swapped";
22
+
23
+ // The switcher's words for version +k+ (0-based) of +count+: the counter
24
+ // ("2/3") and its spoken label ("version 2 of 3").
25
+ export function switchText(k, count) {
26
+ return { text: `${k + 1}/${count}`, label: `version ${k + 1} of ${count}` };
27
+ }
28
+
29
+ // Version +k+ moved by +delta+, kept in 0…count-1.
30
+ export function stepVersion(k, count, delta) {
31
+ return Math.min(Math.max(k + delta, 0), Math.max(count - 1, 0));
32
+ }
33
+
34
+ // The switcher for the stack +id+ (its top turn's id) showing version +k+
35
+ // of +count+. The buttons stay focusable at either end (aria-disabled, not
36
+ // disabled: a press there keeps the focus where it is).
37
+ export function switcherTemplate(id, k, count) {
38
+ const { text, label } = switchText(k, count);
39
+ const first = k <= 0;
40
+ const last = k >= count - 1;
41
+ return html`<span class="stack-switch" role="group" aria-label="Answer versions" data-stack-switch=${id}><button type="button" class="stack-switch-btn" data-stack-step="-1" aria-label="Previous version" title="Read the previous version" aria-disabled=${first ? "true" : "false"}>‹</button><span class="stack-switch-n" title=${label}><span aria-hidden="true">${text}</span><span class="stack-switch-sr" aria-live="polite">${label}</span></span><button type="button" class="stack-switch-btn" data-stack-step="1" aria-label="Next version" title="Read the next version" aria-disabled=${last ? "true" : "false"}>›</button></span>`;
42
+ }
43
+
44
+ // A copy of +answer+ (an earlier version's answer bubble) to show in the top
45
+ // answer's place, titled as version +version+: no answer key,
46
+ // none of the history's state classes.
47
+ export function viewOf(answer, version) {
48
+ const view = answer.cloneNode(true);
49
+ for (const attr of ["id", "data-answer-turn", "data-reply-key", "data-stack-of", "data-line-key"]) view.removeAttribute(attr);
50
+ view.classList.remove("stack-hidden", "folded", SWAPPED_CLASS);
51
+ view.classList.add(VIEW_CLASS);
52
+ view.dataset.stackView = String(version);
53
+ for (const btn of view.querySelectorAll(".copy-btn[data-copied]")) delete btn.dataset.copied;
54
+ return view;
55
+ }
@@ -50,13 +50,13 @@ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "" }
50
50
  const at = where();
51
51
  const allHref = () => allScopeHref(where().hash, scope.dir);
52
52
  const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
53
- if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
54
- 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}`;
53
+ if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" aria-expanded="false" title=${error}>folder not found</button>${all}`;
54
+ if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
55
55
  const dir = scope.dir || scope.serverDir;
56
56
  const backHref = () => projectScopeHref(scope.backDir, where().hash);
57
57
  const back = scope.backName
58
58
  ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
59
59
  : nothing;
60
60
  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;
61
- return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
61
+ return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title="Sessions of every project">all projects</button>${back}${newSession}`;
62
62
  }