@double_geste/agent-standup 1.25.5 → 1.25.7

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.
package/public/index.html CHANGED
@@ -19,7 +19,7 @@
19
19
  header { height:48px; flex:none; display:flex; align-items:center; gap:10px; padding:0 12px; border-bottom:1px solid var(--line); background:var(--bg) }
20
20
  .brand { display:flex; align-items:center; gap:9px; min-width:0; overflow:hidden } .brand .logo { flex:none } .brand > div { min-width:0 } .brand b { overflow:hidden; text-overflow:ellipsis }
21
21
  header > * { flex-shrink:0 } header .brand { flex-shrink:100 } header #board { flex-shrink:50 } .tok, .ctxp { flex-shrink:2; min-width:0; overflow:hidden; text-overflow:ellipsis } #pill { flex-shrink:.05; min-width:0; overflow:hidden; text-overflow:ellipsis } /* in a narrow window the buttons (the gear above all) stay whole: the name gives way first, then the counters */
22
- @media (max-width:480px) { header { gap:6px; padding:0 8px } .ctxp { display:none !important } } @media (max-width:420px) { .tok { display:none !important } }
22
+ @media (max-width:720px) { .ctxp { display:none !important } } @media (max-width:620px) { .tok { display:none !important } } @media (max-width:560px) { #bCoffee { display:none !important } } @media (max-width:480px) { header { gap:6px; padding-left:8px; padding-right:8px } } /* narrow: the coffee link stays in ⚙ › Tools */
23
23
  .logo { width:26px; height:26px; border-radius:8px; background:#d97757; display:grid; place-items:center; font-size:15px; flex:none }
24
24
  .brand b { font:700 14px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif; white-space:nowrap }
25
25
  .brand small { display:block; color:var(--dim); font-size:11px; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
@@ -31,9 +31,13 @@
31
31
  .tile.quiet { opacity:.72 }
32
32
  .tag .tk { color:#aab2c5; font-size:10px } .tag .tk:empty { display:none }
33
33
  .keys { padding:6px 10px 4px; color:var(--dim); font-size:10.5px; line-height:1.5 }
34
- #pill { font-size:11px; font-weight:600; letter-spacing:.04em; padding:3px 9px; border-radius:99px; background:var(--panel2); color:var(--dim); white-space:nowrap }
35
- #pill.live { color:#fff; background:#c0392b } #pill.live::before { content:'● '; animation:pulse 1.4s infinite }
36
- #pill.wait { color:var(--ok) } #pill.wait::before { content:'● '; opacity:.7 }
34
+ /* the status of the session: a pill in the header of a wide window, a line of its own under the header from 1080 px down (#sline), where it stays readable in the narrowest side panel. Two lines at most, the whole text in the tooltip */
35
+ #pill, #sline { font-weight:600; background:var(--panel2); color:var(--dim); cursor:default; overflow:hidden; -webkit-box-orient:vertical; -webkit-line-clamp:2 }
36
+ #pill { display:-webkit-box; font-size:11px; letter-spacing:.04em; line-height:1.3; padding:3px 9px; border-radius:12px; max-width:min(34vw,440px) } #sline { display:none; flex:none; font-size:12px; line-height:1.4; padding:5px 12px; border-bottom:1px solid var(--line) }
37
+ :is(#pill, #sline).go { cursor:pointer } :is(#pill, #sline).go:hover { filter:brightness(1.15) } :is(#pill, #sline).off { color:#fff; background:#b3261e; box-shadow:inset 0 0 0 1.5px #ff8a80 } :is(#pill, #sline).blocked { color:#fff; background:#c0392b } :is(#pill, #sline).stalled { color:#1b1d27; background:#ffb347 }
38
+ :is(#pill, #sline).working { color:var(--gold) } :is(#pill, #sline).turn { color:#0b1020; background:var(--accent) } #pill:is(.working, .blocked, .stalled, .turn, .idle) { letter-spacing:0 } :is(#pill, #sline):focus-visible { outline:2px solid var(--accent); outline-offset:-2px }
39
+ @media (max-width:1080px) { #pill { display:none } #sline { display:-webkit-box } } @media (max-width:760px) { #sline::after { content:attr(data-sub); font-weight:400; opacity:.8 } } @media (max-width:400px) { #sline { -webkit-line-clamp:3 } }
40
+ @media (min-width:1081px) { header .brand { min-width:150px } #pill { flex-shrink:20 } .tok, .ctxp { flex-shrink:0 } } /* wide: a long status gives way before the name and the counters */
37
41
  @keyframes pulse { 50% { opacity:.3 } }
38
42
  .seg { display:flex; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:2px }
39
43
  .seg button { padding:4px 11px; border-radius:6px; color:var(--dim); font-size:12.5px }
@@ -57,7 +61,7 @@
57
61
  .rr .st { font-size:11.5px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
58
62
  .rr.k-working { border-left-color:var(--gold) } .rr.k-working .st { color:var(--gold) } .rr.k-waiting { border-left-color:#8fd3ff } .rr.k-waiting .st { color:#8fd3ff }
59
63
  .rr.k-blocked { border-left-color:#ff5252; background:rgba(192,57,43,.28) } .rr.k-blocked .st { color:#fff; font-weight:700 } .rr.k-stalled { border-left-color:#ffb347 } .rr.k-stalled .st { color:#ffb347; font-weight:700 }
60
- .rr.k-done { opacity:.82 } .rr.k-done .st { color:var(--ok) } .rr.k-idle .st { color:var(--dim) } .rr.speaking { box-shadow:0 0 0 2px rgba(124,156,255,.5) }
64
+ .rr.k-done { opacity:.82 } .rr.k-done .st { color:var(--ok) } .rr.turn { opacity:1; border-left-color:var(--accent) } .rr.turn .st { color:var(--accent); font-weight:700 } .rr.k-idle .st { color:var(--dim) } .rr.speaking { box-shadow:0 0 0 2px rgba(124,156,255,.5) }
61
65
  #boardv { flex:1; overflow:auto; padding:16px 18px; display:none }
62
66
  .bsum { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; font-size:12.5px } .bsum span { padding:3px 10px; border-radius:99px; background:var(--panel2); border:1px solid var(--line) }
63
67
  .bgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(270px,1fr)); gap:10px }
@@ -108,8 +112,8 @@
108
112
  .status { max-width:84%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
109
113
  #pbadge { margin-left:auto; flex:none; background:rgba(240,160,32,.94); color:#201400; font-weight:600; font-size:11px; padding:1px 10px; border-radius:99px; white-space:nowrap }
110
114
  #present.has-todo #pcam { right:270px } #pcam { position:absolute; right:28px; bottom:22px; width:158px; height:118px; z-index:2; cursor:default; box-shadow:0 8px 24px rgba(0,0,0,.6) }
111
- #pcap { display:none; position:absolute; left:24px; right:200px; bottom:20px; z-index:2; background:rgba(10,11,16,.84); backdrop-filter:blur(6px); border-radius:10px; padding:6px 12px; font-size:13px } #pcap:not(:empty) { display:block }
112
- body.narrow #present { height:clamp(150px,36vh,260px) } body.narrow #pcam { width:110px; height:82px } body.narrow #pcap { right:150px }
115
+ #pcap { display:none; position:absolute; left:24px; right:200px; bottom:20px; z-index:2; background:rgba(10,11,16,.84); backdrop-filter:blur(6px); border-radius:10px; padding:6px 12px; font-size:13px; max-height:calc(100% - 34px); overflow-y:auto; scrollbar-width:thin } #pcap:not(:empty) { display:block }
116
+ body.narrow #present { height:clamp(150px,36vh,260px) } body.narrow #pcam { width:110px; height:82px } body.narrow #pcap { left:14px; right:146px; font-size:12.5px }
113
117
  body.narrow #ptodo { display:none } body.narrow #present.has-todo #pcam { right:28px } /* too narrow for a terminal and a task list side by side: the terminal keeps the room */
114
118
  #gantt { display:none; flex:none; width:100%; background:#0f1118; border-top:1px solid var(--line); cursor:pointer }
115
119
  body.replay #gantt { display:block }
@@ -123,10 +127,11 @@
123
127
  .row .txt { color:#c3c6d4; word-break:break-word } .row.order .who { color:var(--gold) } .row.report .who { color:var(--ok) }
124
128
  footer { display:none; height:44px; flex:none; align-items:center; gap:12px; padding:0 14px; border-top:1px solid var(--line) }
125
129
  body.replay footer { display:flex }
126
- input[type=range] { accent-color:var(--accent) } #seek { flex:1 }
127
- @media (max-width:760px) { aside { position:absolute; right:0; top:0; bottom:0; z-index:6; box-shadow:-10px 0 30px rgba(0,0,0,.5) } .brand small, #viewSeg { display:none } }
128
- @media (max-width:540px) { .brand b { display:none } }
129
- @media (max-width:360px) { .logo { display:none } }
130
+ input[type=range] { accent-color:var(--accent) } #seek { flex:1; min-width:40px } #pos, footer label.dim { white-space:nowrap; flex:none } @media (max-width:520px) { footer { gap:8px; padding:0 8px } footer label.dim { display:none } } /* narrow: the 1× 2× 4× 8× buttons are the speed control */
131
+ @media (max-width:760px) { aside { position:absolute; right:0; top:0; bottom:0; z-index:6; box-shadow:-10px 0 30px rgba(0,0,0,.5) } .brand small { display:none } }
132
+ @media (max-width:720px) { .brand b { display:none } }
133
+ @media (max-width:480px) { #viewSeg button { font-size:0; padding:4px 6px } #viewSeg button::before { content:attr(data-ic); font-size:14px } } /* Call / Tree / Sessions stay one click away in a side panel, as icons */
134
+ @media (max-width:360px) { .brand { display:none } header { gap:4px } }
130
135
  @media (max-height:640px) { #gantt { display:none !important } }
131
136
 
132
137
  /* ---------- tiles (paper cut-out) ---------- */
@@ -142,7 +147,7 @@
142
147
  .tag b, .tag span { background:rgba(14,15,24,.78); backdrop-filter:blur(6px); padding:2px 9px; border-radius:99px; font-size:11.5px; white-space:nowrap }
143
148
  .tag span { color:#e3e6f0; font-size:10.5px; overflow:hidden; text-overflow:ellipsis; min-width:0 }
144
149
  .status { position:absolute; top:6px; right:6px; font-size:11.5px; font-weight:600; padding:2px 9px; border-radius:99px; background:rgba(14,15,24,.8); backdrop-filter:blur(6px) }
145
- .status.working { color:var(--gold) } .status.done { color:var(--ok) }
150
+ .status.working { color:var(--gold) } .status.done { color:var(--ok) } .status.turn { color:#0b1020; background:var(--accent) }
146
151
  .status.waiting { color:#8fd3ff } .status.blocked { color:#fff; background:#c0392b; font-weight:700 } .status.stalled { color:#1b1d27; background:#ffb347; font-weight:700 }
147
152
  .tile.waiting { border-color:rgba(110,190,255,.85) } .tile.blocked { border-color:#ff5252 !important; animation:slowp 1.2s ease-in-out infinite } .tile.stalled { border-color:#ffb347 !important }
148
153
  /* the header's status chips are styled by id: "board" is also the class the Sessions view puts on <body>, and a rule on .board used to hide the whole page in a narrow window */
@@ -150,8 +155,12 @@
150
155
  #board .working { color:var(--gold) } #board .waiting { color:#8fd3ff } #board .blocked { color:#fff; background:#c0392b; border-color:#c0392b } #board .stalled { color:#1b1d27; background:#ffb347; border-color:#ffb347 } #board .done { color:var(--ok) }
151
156
  @media (max-width:1180px) { #board em { display:none } .meet { display:none } }
152
157
  @media (max-width:640px) { #board { display:none } }
153
- .bubble { display:none; position:absolute; top:6px; left:6px; right:62px; background:#fff; color:#1b1d27; border-radius:11px; padding:6px 10px; font-size:12.5px; line-height:1.35; box-shadow:0 4px 14px rgba(0,0,0,.35); max-height:58%; overflow:hidden }
154
- .tile.bye .bubble { display:block; font-size:13.5px; animation:fadein .25s } .tile.speaking .bubble { display:block; font-size:14.5px; max-height:50% } .bubble small { display:block; color:#6a6f85; font-size:10.5px }
158
+ /* what is said sits above everything else on the tile (badge, prop, ticker) and is never cut: a line longer than its tile grows over the tiles below */
159
+ .bubble { display:none; position:absolute; z-index:6; top:6px; left:6px; width:max-content; max-width:calc(100% - 12px); background:#fff; color:#1b1d27; border-radius:11px; padding:6px 10px; font-size:12.5px; line-height:1.35; box-shadow:0 4px 14px rgba(0,0,0,.35); overflow-wrap:anywhere }
160
+ .tile.speaking, .tile.bye { overflow:visible; z-index:4 } .tile > svg:first-child { border-radius:13px } .tile.speaking .prop { left:auto; right:8px; top:52px }
161
+ .tile.focus.speaking:has(.status:not(:empty)) .bubble { top:32px } /* the spotlight has room: there the badge stays visible above the bubble */
162
+ body:not(.tiny) #center:has(#pcap:not(:empty)) .tile.presenter:not(.bye) .bubble { display:none } /* the presenter's line is the caption of the shared screen: not said twice */
163
+ .tile.bye .bubble { display:block; font-size:13.5px; animation:fadein .25s } .tile.speaking .bubble { display:block; font-size:13px } .tile.focus.speaking .bubble { font-size:14.5px } .bubble small { display:block; color:#6a6f85; font-size:10.5px }
155
164
  .ticker { display:none; position:absolute; left:6px; right:6px; bottom:30px; font:10.5px/1.3 ui-monospace,Menlo,monospace; color:#fff; background:rgba(15,20,35,.8); border-radius:5px; padding:2px 7px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
156
165
  .ticker.on { display:block } .ticker i { font-style:normal; color:#ffd24a } .ticker.flash { animation:flash .25s } @keyframes flash { from { background:#3b5bdb } }
157
166
  /* character animation: all state-driven from the tile's classes */
@@ -182,32 +191,33 @@
182
191
 
183
192
  /* ---------- overlays ---------- */
184
193
  #gate, #empty { position:absolute; inset:0; z-index:4; display:none; flex-direction:column; align-items:center; justify-content:center; gap:14px; text-align:center; padding:24px; background:rgba(18,19,26,.82); backdrop-filter:blur(3px) }
185
- #gate.on, #empty.on { display:flex } #empty { background:var(--bg) }
186
- #gate h2, #empty h2 { margin:0; font:800 22px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif } #gate p, #empty p { margin:0; color:var(--dim); max-width:380px }
187
- .cta { background:var(--accent); color:#0b1020; font-weight:700; padding:10px 22px; border-radius:99px; font-size:14px } .cta:hover { filter:brightness(1.1) }
194
+ #gate.on, #empty.on { display:flex } #empty { background:var(--bg) } #empty.on ~ #gate { display:none } /* no session at all: nothing to join, the empty state and its demo button are what there is to see */
195
+ #gate h2, #empty h2 { margin:0; font:800 22px "Comic Sans MS","Chalkboard SE",system-ui,sans-serif } #gate p, #empty p { margin:0; color:var(--dim); max-width:380px } #eP b { color:var(--text) }
196
+ .cta { background:var(--accent); color:#0b1020; font-weight:700; padding:10px 22px; border-radius:99px; font-size:14px } .cta:hover { filter:brightness(1.1) } .cta:empty { display:none }
188
197
  .btn { background:var(--panel2); border:1px solid var(--line); padding:6px 12px; border-radius:8px } .btn:hover { border-color:var(--accent) }
189
198
  #menu { position:absolute; top:52px; right:10px; z-index:12; width:280px; background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow:0 14px 40px rgba(0,0,0,.55); padding:6px; display:none; max-height:calc(100vh - 62px); max-height:calc(100dvh - 62px); overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin } /* never taller than the window: it scrolls instead of being cut off */
190
199
  .coffee-btn { color:#ffd24a !important; background:rgba(255,210,74,.12); box-shadow:inset 0 0 0 1px rgba(255,210,74,.35) } .coffee-btn:hover { background:rgba(255,210,74,.22) !important }
191
200
  .glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
192
201
  #mCoffee { background:rgba(255,210,74,.10); box-shadow:inset 0 0 0 1px rgba(255,210,74,.3); margin-top:4px } #mCoffee:hover { background:rgba(255,210,74,.2) }
193
- #menuQ { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:6px 9px; font:inherit; font-size:12.5px; outline:none } #menuQ:focus { border-color:var(--accent) }
202
+ #menuQ, #logQ { background:var(--bg); color:var(--text); border:1px solid var(--line); border-radius:8px; padding:6px 9px; font:inherit; font-size:12.5px; outline:none } #menuQ:focus, #logQ:focus { border-color:var(--accent) }
194
203
  #switcher { position:fixed; inset:0; z-index:20; background:rgba(5,6,12,.7); display:flex; align-items:flex-start; justify-content:center; padding-top:12vh } #switcher .sbox { width:min(560px,92vw); background:var(--panel); border:1px solid var(--line); border-radius:14px; box-shadow:0 20px 60px rgba(0,0,0,.6); overflow:hidden }
195
204
  #swq { width:100%; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--text); font:inherit; font-size:15px; padding:14px 16px; outline:none } #swl { max-height:50vh; overflow:auto } .swr { display:grid; grid-template-columns:auto 1fr; gap:2px 10px; padding:9px 14px; cursor:pointer; align-items:center } .swr.on, .swr:hover { background:var(--panel2) } .swr b { font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap } .swr i { grid-column:2; font-style:normal; font-size:11px; color:var(--dim) }
196
- #sps { display:none; gap:2px } body.replay #sps { display:inline-flex } #sps button { background:var(--panel2); color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:1px 6px; font:inherit; font-size:11px; cursor:pointer } #sps button.on { color:var(--text); border-color:var(--accent) } body.compact #sps { display:none !important }
205
+ #sps { display:none; gap:2px; flex:none } body.replay #sps { display:inline-flex } #sps button { background:var(--panel2); color:var(--dim); border:1px solid var(--line); border-radius:6px; padding:1px 6px; font:inherit; font-size:11px; cursor:pointer } #sps button.on { color:var(--text); border-color:var(--accent) }
197
206
  #menu .mfold, #menu .mhide { display:none !important } #menu h4.fold { cursor:pointer; user-select:none } #menu h4.fold::before { content:'▾ ' } #menu h4.fold.closed::before { content:'▸ ' }
198
- /* compact: a very narrow panel (the VS Code side bar) shows one row per agent instead of tiles */
199
- body.compact #stage { display:flex; flex-direction:column; gap:6px; padding:8px; overflow:auto }
200
- body.compact .tile, body.compact .tile.focus { height:54px !important; flex:none; border-radius:10px; display:flex; align-items:center; overflow:visible; animation:none; order:0 } body.compact .tile.lead { order:-2 }
201
- body.compact .tile > svg:first-child { width:54px; height:54px; flex:none; border-radius:9px 0 0 9px }
202
- body.compact .tile .bubble, body.compact .tile .thought, body.compact .tile .ticker, body.compact .tile .tbadge { display:none !important }
203
- body.compact .tile .tag { position:static; flex:1; min-width:0; padding:0 8px; flex-direction:column; gap:2px; align-items:flex-start } body.compact .tile .tag b, body.compact .tile .tag span { background:none; backdrop-filter:none; padding:0; max-width:100%; overflow:hidden; text-overflow:ellipsis }
204
- body.compact .tile .tag span:not(.tk):not(.cx) { display:none }
205
- body.compact .tile .status { position:static; max-width:none; margin-right:6px; font-size:10.5px; padding:2px 7px; flex:none; max-width:46% }
206
- body.compact .tile .prop { top:2px; left:34px; font-size:16px }
207
- body.compact .tile .status:empty { display:none }
208
- body.compact .tile .tag { order:1 } body.compact .tile .status { order:2 } body.compact .tile .prop { order:3 }
209
- body.compact footer { gap:6px; padding-left:8px; padding-right:8px } body.compact footer label.dim { display:none } body.compact #speed { width:70px; min-width:0 }
210
- body.compact #present { display:none !important } body.compact #hl { display:none } body.compact #gantt { display:none } body.compact #roster { display:none }
207
+ /* compact: a narrow panel (the VS Code side bar, under 480 px) shows one row per agent instead of tiles: who needs you comes first, and what is said shows under the row of who says it */
208
+ body.compact:not(.tree):not(.board) #stage { display:flex; flex-direction:column; gap:6px; padding:8px; overflow:auto }
209
+ body.compact #stage .tile, body.compact #stage .tile.focus { height:auto !important; min-height:54px; flex:none; flex-wrap:wrap; border-radius:10px; display:flex; align-items:center; overflow:visible; animation:none; order:0 } body.compact #stage .tile.lead { order:-2 } body.compact #stage .tile.stalled { order:-3 } body.compact #stage .tile.blocked { order:-4 }
210
+ body.compact #stage .tile > svg:first-child { width:54px; height:54px; flex:none; border-radius:9px 0 0 9px }
211
+ body.compact #stage .tile .thought, body.compact #stage .tile .ticker, body.compact #stage .tile .tbadge { display:none !important }
212
+ body.compact #stage .tile .bubble { position:static; order:9; flex:1 0 calc(100% - 12px); width:auto; max-width:none; margin:0 6px 6px; font-size:13px !important; box-shadow:none }
213
+ body.compact #stage .tile .tag { position:static; flex:1; min-width:0; padding:0 8px; flex-direction:column; gap:2px; align-items:flex-start } body.compact #stage .tile .tag b, body.compact #stage .tile .tag span { background:none; backdrop-filter:none; padding:0; max-width:100%; overflow:hidden; text-overflow:ellipsis }
214
+ body.compact #stage .tile .tag span:not(.tk):not(.cx) { display:none }
215
+ body.compact #stage .tile .status { position:static; max-width:none; margin-right:6px; font-size:10.5px; padding:2px 7px; flex:none; max-width:46% }
216
+ body.compact #stage .tile .prop { top:2px; left:34px; right:auto; font-size:16px }
217
+ body.compact #stage .tile .status:empty { display:none }
218
+ body.compact #stage .tile .tag { order:1 } body.compact #stage .tile .status { order:2 } body.compact #stage .tile .prop { order:3 }
219
+ @media (max-width:899px) { #menu .mi[data-t=roster] { display:none } } /* the agents list only exists from 900 px: no switch for it under that */
220
+ body.tiny #present { display:none !important } body.tiny #hl { display:none } body.tiny #gantt { display:none } /* under 300 px: the rows only */
211
221
  #menu.on { display:block } #menu h4 { margin:8px 10px 4px; font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim); font-weight:600 }
212
222
  .mi { display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%; text-align:left; padding:8px 10px; border-radius:8px } .mi:hover { background:var(--panel2) }
213
223
  .sw { width:30px; height:17px; border-radius:99px; background:#3a3e50; position:relative; flex:none; transition:background .15s } .sw::after { content:''; position:absolute; top:2px; left:2px; width:13px; height:13px; border-radius:50%; background:#fff; transition:transform .15s }
@@ -245,16 +255,18 @@
245
255
  <div class="brand"><div class="logo">🎥</div><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
246
256
  <span id="pill">REPLAY</span><button id="tok" class="tok" style="display:none"></button><button id="ctxp" class="ctxp" style="display:none"></button><span id="board" aria-live="polite"></span><span id="meet" class="meet"></span>
247
257
  <div class="sp"></div>
248
- <div class="seg" id="viewSeg"><button data-v="call" class="on" data-fr="Appel" data-en="Call">Appel</button><button data-v="tree" data-fr="Arbre" data-en="Tree">Arbre</button><button data-v="board" data-fr="Sessions" data-en="Sessions">Sessions</button></div>
258
+ <div class="seg" id="viewSeg"><button data-v="call" class="on" data-ic="👥" data-fr="Appel" data-en="Call">Appel</button><button data-v="tree" data-ic="🌳" data-fr="Arbre" data-en="Tree">Arbre</button><button data-v="board" data-ic="🗂" data-fr="Sessions" data-en="Sessions">Sessions</button></div>
249
259
  <button class="ic" id="bPlay" title="Pause / Lecture (espace)">⏸</button>
250
260
  <button class="ic" id="bLog" title="Transcript">☰</button>
251
261
  <button class="ic" id="bMute" title="Son (M)">🔊</button>
252
262
  <button class="ic coffee-btn" id="bCoffee" style="display:none">☕</button>
253
263
  <button class="ic" id="bMenu" title="Réglages">⚙</button>
254
264
  </header>
265
+ <div id="sline"></div>
255
266
 
256
267
  <div id="menu">
257
268
  <input id="menuQ" type="search" placeholder="Search settings…" aria-label="Search settings" style="margin:4px 8px 2px;width:calc(100% - 16px)">
269
+ <div class="hint" id="menuNone" style="display:none;padding-top:6px;color:var(--dim)" data-fr="Aucun réglage trouvé" data-en="No setting found">Aucun réglage trouvé</div>
258
270
  <h4 data-fr="Langue" data-en="Language">Langue</h4>
259
271
  <div class="box"><select id="langSel" aria-label="Language"></select></div>
260
272
  <div class="hint" id="trHint" style="display:none"></div>
@@ -265,7 +277,7 @@
265
277
  <button class="mi" data-t="notify"><span data-fr="Me prévenir quand les agents ont fini" data-en="Notify me when agents finish">Me prévenir quand les agents ont fini</span><i class="sw"></i></button>
266
278
  <button class="mi" data-t="music"><span data-fr="Musique d’ambiance (radio FIP)" data-en="Office music (FIP radio)">Musique d’ambiance (radio FIP)</span><i class="sw"></i></button>
267
279
  <div class="box" id="musicBox" style="display:none"><select id="station"></select><input id="musicVol" type="range" min="0" max="1" step="0.05" style="width:100%" aria-label="Music volume"></div>
268
- <div class="box" id="vstyleBox"><select id="vstyle"></select></div>
280
+ <div class="box" id="vstyleBox"><label class="lbl" for="vstyle" data-fr="Style des voix" data-en="Voice style">Style des voix</label><select id="vstyle"></select></div>
269
281
  <div class="box" id="kokoroBox" style="display:none"><button class="btn" id="kokoroInstall" style="width:100%"></button><div class="hint" id="kokoroHint" style="padding:2px 0 6px"></div></div>
270
282
  <h4 data-fr="Affichage" data-en="Display">Affichage</h4>
271
283
  <button class="mi" data-t="roster"><span data-fr="Liste des agents (à gauche)" data-en="Agents list (left)">Liste des agents (à gauche)</span><i class="sw"></i></button>
@@ -303,10 +315,10 @@
303
315
  <canvas id="gantt" height="90"></canvas>
304
316
  <div id="toasts" role="status" aria-live="polite"></div>
305
317
  <div id="empty"><h2 id="eT"></h2><p id="eP"></p><button class="cta" id="eDemo"></button></div>
306
- <div id="gate"><h2 id="gT"></h2><p id="gP"></p><button class="cta" id="gB"></button><button class="glink" id="gCoffee" style="display:none"></button></div>
318
+ <div id="gate"><h2 id="gT"></h2><p id="gP"></p><button class="cta" id="gB"></button></div>
307
319
  <div id="detail"><div class="hd"><b id="dname"></b><button class="ic" id="dclose">✕</button></div><div id="dbody"></div></div>
308
320
  </section>
309
- <aside><h2 id="logH">Transcript</h2><input id="logQ" type="search" placeholder="Search…" aria-label="Search the transcript" style="margin:0 10px 8px;width:calc(100% - 20px)"><div id="log"></div></aside>
321
+ <aside><h2 id="logH">Transcript</h2><input id="logQ" type="search" placeholder="Search…" aria-label="Search the transcript" style="margin:8px 10px;width:calc(100% - 20px)"><div id="log"></div></aside>
310
322
  </main>
311
323
  <footer>
312
324
  <input id="seek" type="range" min="0" max="0" value="0"><span class="dim" id="pos">0 / 0</span>
@@ -510,15 +522,17 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
510
522
  // =====================================================================
511
523
  const S = { owner: null, errRun: {}, exQ: [], agents: {}, events: [], idx: 0, playing: false, token: 0, status: {}, tc: {}, cur: null, live: true, userPaused: false, boost: 1, stopAt: 0,
512
524
  speakers: new Set(), say: {}, lastTool: {}, level: {}, detail: null, collapsed: new Set(), title: '', idle: false, locked: true, logTo: 0, trOk: true,
513
- clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false };
525
+ clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false, late: [], byK: new Map(), pos: 0 };
514
526
  const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
515
527
  // Live: how far the call is behind the real session, and what to do about it (speak faster, say less, skip decoration) so it keeps the rhythm of the real conversation
516
528
  const lagMs = (i = S.idx) => { // how much newer the newest event of the session is than the one being played: 0 when the call is up to date, however long the session has been idle
517
- if (!S.live || !S.events.length) return 0; const e = S.events[Math.max(0, Math.min(i, S.events.length - 1))]; let last = S.events.length - 1; while (last > 0 && !S.events[last].ts) last--;
518
- return e && e.ts && S.events[last].ts ? Math.max(0, S.events[last].ts - e.ts) : 0;
529
+ if (!S.live || !S.events.length) return 0; const e = S.events[Math.max(0, Math.min(i, S.events.length - 1))];
530
+ return Math.max(0, S.events[S.events.length - 1]._t - e._t);
519
531
  };
532
+ function stampT(from) { for (let k = from; k < S.events.length; k++) S.events[k]._t = Math.max(S.events[k].ts || 0, k ? S.events[k - 1]._t : 0); } // the time of an event as the call got it: one that arrives late with an older stamp (an order typed while the lead was busy) is not "far behind"
520
533
  const lagFactor = () => { const l = lagMs() / 1000; return l < 6 ? 1 : l < 15 ? 1.15 : l < 30 ? 1.3 : 1.5; }; // steps, so the cached audio of a line is reused
521
534
  const caughtUp = () => !S.live || lagMs() < 6000;
535
+ const nearLive = () => !S.live || lagMs() < 30000; // an answer to what was just said (a word after an order, a thank-you after a report) still fits here: reading the line took longer than 6 s and the session went on meanwhile
522
536
  const spd = () => (S.live ? 1.12 * lagFactor() : +$('#speed').value) * (S.boost || 1);
523
537
  const nameOf = (id) => (S.agents[id] || {}).name || id;
524
538
 
@@ -530,7 +544,14 @@ function short(t, max = 230) {
530
544
  return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
531
545
  }
532
546
  // make code-ish text pronounceable
533
- const speakable = (t) => t.replace(/(?:[\w.-]+\/)+([\w.-]+)/g, (m, f) => f.replace(/[._-]/g, ' ')).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_`*#<>{}\[\]|\\=~^]/g, ' ')
547
+ const PER = /^(?:s|sec|ms|min|h|hr|j|jour|d|day|mois|mo|month|an|yr|year)$/i;
548
+ function slashSay(m, pre, lead, body) { // word/word (pre: the character before it, put back as it is): only a file path is named by its last part; anything else keeps every word, so the voice says what the caption shows ("and/or", "client/server", "2/3")
549
+ const tail = (body.match(/[.-]+$/) || [''])[0], core = body.slice(0, body.length - tail.length), segs = core.split('/').filter(Boolean), last = segs[segs.length - 1] || '';
550
+ if (segs.every((s) => /^\d+(?:[.,]\d+)?$/.test(s))) return m; // a fraction, a date, 24/7: the voice reads those by itself
551
+ if (lead || segs.length > 2 || core.endsWith('/') || /\.[A-Za-z][A-Za-z0-9]{0,9}$/.test(last)) return pre + last.replace(/[._-]/g, ' ') + tail; // it looks like a path: a leading ./ ~/ or /, two slashes or more, a folder/, or an extension
552
+ return PER.test(last) ? m : pre + segs.join(' ') + tail; // km/h, req/s: a unit, left to the voice
553
+ }
554
+ const speakable = (t) => t.replace(/https?:\/\/(?:www\.)?([\w-]+(?:\.[\w-]+)*)\S*?(?=[.,;:!?)\]]*(?:\s|$))/g, '$1').replace(/(^|[^\wÀ-ÿ/.~@+-])((?:~|\.\.?|[A-Za-z]:)?\/)?((?:[\wÀ-ÿ.@+-]+\/)+[\wÀ-ÿ.+-]*)/g, slashSay).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_`*#<>{}\[\]|\\=~^]/g, ' ')
534
555
  .replace(/\s*[—–]\s*/g, ', ').replace(/\(([^)]{0,40})\)/g, ', $1,').replace(/\s+/g, ' ').trim();
535
556
 
536
557
  // ---------- translation (local claude CLI) ----------
@@ -618,7 +639,7 @@ const AUD = {
618
639
  const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3; const g = c.createGain();
619
640
  g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.08, t + 0.05); g.gain.setValueAtTime(0.08, t + dur - 0.1); g.gain.linearRampToValueAtTime(0, t + dur);
620
641
  const base = 85 + pitch * 70; for (let x = 0; x < dur; x += 0.11) { o.frequency.setValueAtTime(base * (0.8 + Math.random() * 0.55), t + x); bp.frequency.setValueAtTime(500 + Math.random() * 900, t + x); }
621
- o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o);
642
+ o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o); return o;
622
643
  },
623
644
  stopAll() { for (const s of this.live) { try { s.stop(); } catch {} } this.live.clear(); },
624
645
  };
@@ -711,9 +732,9 @@ function rankVoices(list) { // list of names → best distinct voices first
711
732
  return out.slice(0, 5);
712
733
  }
713
734
  const TTS = {
714
- server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
735
+ server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(), made: new Map(), q: [], busy: false, now: 0,
715
736
  async init() {
716
- this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
737
+ this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear(); this.made.clear(); this.q = [];
717
738
  try {
718
739
  if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
719
740
  const k = await (await fetch('/api/voices?engine=kokoro')).json();
@@ -724,22 +745,48 @@ const TTS = {
724
745
  },
725
746
  voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
726
747
  rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 * lagFactor() * (S.boost > 1 ? 1.25 : 1) : +$('#speed').value))) / 10) * 10; },
727
- buf(text, voice, rate) {
728
- const key = this.engine + '|' + voice + '|' + rate + '|' + text;
748
+ rateAt(k, boost) { const i = S.idx, b = S.boost; S.idx = k; S.boost = boost; const r = this.rate(); S.idx = i; S.boost = b; return r; }, // the rate rate() will give once playback is at event k
749
+ buf(text, voice, rate, ahead) { // ahead: made in advance (see warmTTS); otherwise the line is wanted now
750
+ const vt = this.engine + '|' + voice + '|' + text, K = (r) => this.engine + '|' + voice + '|' + r + '|' + text, key = K(rate), got = this.made.get(vt) || []; // got: the rates at which this text is ready in this voice
751
+ if (!ahead && S.live && !got.includes(rate)) { const r = got.find((x) => Math.abs(x - rate) <= rate * 0.2 && this.cache.has(K(x))); if (r) return this.cache.get(K(r)); } // made ahead at the rate of a moment ago (the call fell behind or caught up since): played as it is, a second of silence is worse than a slightly different pace
729
752
  if (!this.cache.has(key)) {
730
753
  this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
731
754
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
732
- this.cache.get(key).catch(() => this.cache.delete(key));
755
+ this.cache.get(key).then(() => { this.made.set(vt, [...(this.made.get(vt) || []), rate]); if (this.made.size > 120) this.made.delete(this.made.keys().next().value); }, () => this.cache.delete(key));
756
+ if (!ahead) { this.now++; const end = () => { this.now--; this.pump(); }; this.cache.get(key).then(end, end); } // a line wanted now never waits behind the ones made ahead: they stop being started until it is ready
733
757
  }
734
758
  return this.cache.get(key);
735
759
  },
760
+ pump() { // the lines made ahead: one at a time, in the order they will be said
761
+ if (this.busy || this.now || !this.q.length) return; const j = this.q.shift(); this.busy = true;
762
+ const end = () => { this.busy = false; this.pump(); }; this.buf(j.text, j.voice, j.rate, true).then(end, end);
763
+ },
736
764
  };
737
- function warmTTS(i) { // synthesize the next few lines ahead of time
738
- if (!TTS.server || !AUD.ctx || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
739
- let n = 0; for (let k = i; k < S.events.length && n < 4; k++) {
740
- const e = S.events[k]; if (quiet(e)) continue; n++;
741
- const p = S.agents[e.agent], txt = speakable(spoken(e)), v = p && TTS.voiceFor(p, frVoice(txt)); if (v) TTS.buf(txt, v, TTS.rate()).catch(() => {});
765
+ // The lines to come are synthesized ahead of time, so that a voice starts with its line and not a second later. What is made must be what will be asked for: the same text (the lead-in, the part
766
+ // that is read when the call is behind), the same voice and the same rate. So this follows what run() will do with the next events if nothing else arrives before then: how it groups them
767
+ // (sub-agents back to back, orders in a row) and how it sets the pace. Keep the two in step: a difference costs nothing but the benefit (the line is then made when it is said).
768
+ function paceAt(k) {
769
+ if (!S.live) return { pace: 0, boost: 1 };
770
+ const q = aheadSpeakable(k, 14), lg = lagMs(k) / 1000, hurry = q > 12 || lg > 45; // hurry: the line is shown without a voice
771
+ return { hurry, pace: Math.min(q > 7 ? 70 : q > 3 ? 130 : 260, lg > 30 ? 70 : lg > 15 ? 130 : lg > 6 ? 200 : 260), boost: hurry || S.events.length - k > 60 ? 3 : 1 };
772
+ }
773
+ function warmTTS(i) { // called by run() before each line (i is that line) and when new events arrive (i is the first of them)
774
+ if (!TTS.server || !AUD.ctx || AUD.off() || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
775
+ const fresh = i <= S.idx, from = fresh ? i : S.idx + 1, len = S.events.length, jobs = []; let n = 0; if (!fresh && !S.playing) return; // new events while a line is being read: the lines after it are planned again, they may now be read shorter and faster
776
+ for (let k = from; k < Math.min(len, from + 400) && n < 4;) {
777
+ const e = S.events[k]; if (quiet(e)) { k++; continue; }
778
+ if (S.live && len - k > 300) break; // a backlog that run() skips
779
+ const group = [e]; let j = k + 1;
780
+ if (e.kind === 'order') { while (j < Math.min(len, k + 40) && group.length < 8) { const e2 = S.events[j]; if (e2.kind === 'usage' || e2.kind === 'compact') j++; else if (e2.kind === 'order' && e2.agent === e.agent) { group.push(e2); j++; } else break; } }
781
+ else if (isSub(e.agent)) { while (j < Math.min(len, k + 28) && group.length < 3) { const e2 = S.events[j]; if (quiet(e2)) j++; else if (e2.kind !== 'order' && isSub(e2.agent) && !group.some((g) => g.agent === e2.agent)) { group.push(e2); j++; } else break; } }
782
+ const pl = paceAt(k), many = e.kind === 'order' && group.length > 1, rate = TTS.rateAt(many || !S.live ? k : j - 1, pl.boost);
783
+ for (const g of many ? [e] : group) { // orders in a row: only the first is read out
784
+ n++; const p = S.agents[g.agent], bt = baseText(g); if (pl.hurry || !p || (FR() && !trMem.has(bt) && langOf(bt) !== 'fr')) continue; // not translated yet: what will be said is not known
785
+ const said = clipSpeech(talkLine(g), pl.pace), text = speakable(said), voice = TTS.voiceFor(p, sessFr(said, g)); if (text && voice) jobs.push({ k, text, voice, rate });
786
+ }
787
+ k = j;
742
788
  }
789
+ TTS.q = (fresh ? [] : TTS.q.filter((x) => x.k < from)).concat(jobs); TTS.pump(); // the lines of the group being read keep their place at the front
743
790
  }
744
791
 
745
792
  // ---- Cartoon voices: the same system voice, given a character by a small WebAudio chain (pitch, vibrato, robot, radio…), or syllable "babble" in the spirit of Animal Crossing.
@@ -767,7 +814,7 @@ async function speakBabble(text, p, gain, token) {
767
814
  const base = p.lead ? 150 : p.id === 'user' ? 190 : 200 + ((p.vi >>> 2) % 9) * 30, an = c.createAnalyser(); an.fftSize = 256;
768
815
  const g = c.createGain(); g.gain.value = gain * (sims ? 0.4 : 0.6); an.connect(g); g.connect(AUD.voice);
769
816
  const clauses = clean.match(/[^.!?;:]+[.!?;:]*/g) || [clean], nodes = []; let t = c.currentTime + 0.03; const end = t + dur;
770
- const track = (o) => { AUD.live.add(o); nodes.push(o); };
817
+ let tail = 0; const track = (o) => { AUD.live.add(o); nodes.push(o); tail = t; }; // tail: when the last sound starts
771
818
  const vowelGroups = (w) => w.match(new RegExp(`[^${VOW}]*[${VOW}]+(?:[^${VOW}]+(?![${VOW}]))?`, 'g')) || [w];
772
819
  const nSyl = clauses.reduce((n, cl) => n + (cl.match(/[a-zà-ÿ0-9]+/g) || []).reduce((m, w) => m + vowelGroups(w).length, 0) + 1.5, 0) || 1, unit = Math.max(0.08, Math.min(0.2, dur / nSyl));
773
820
  const letterStep = Math.max(0.045, Math.min(0.085, dur / Math.max(8, clean.length)));
@@ -802,7 +849,8 @@ async function speakBabble(text, p, gain, token) {
802
849
  t += unit * (sims ? 1.2 : 2.4);
803
850
  }
804
851
  const data = new Uint8Array(an.fftSize); S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length) * 2.2; };
805
- await new Promise((res) => setTimeout(res, Math.max(100, (Math.min(t, end) - c.currentTime) * 1000 + 80)));
852
+ const lastN = nodes[nodes.length - 1]; if (lastN) captionRun(p.id, text, (tail - c.currentTime) * 1000 + 150); // the subtitles follow the real length of the babble, not the estimate it was cut from
853
+ await new Promise((res) => { if (lastN) lastN.onended = res; setTimeout(res, lastN ? (t - c.currentTime) * 1000 + 600 : Math.max(100, (Math.min(t, end) - c.currentTime) * 1000 + 80)); }); // the line ends when its last sound does (a long last word used to go on after it), or when Pause stops it
806
854
  nodes.forEach((o) => AUD.live.delete(o)); delete S.level[p.id];
807
855
  }
808
856
  async function speakServer(text, p, gain, token, fr = FR()) {
@@ -815,22 +863,24 @@ async function speakServer(text, p, gain, token, fr = FR()) {
815
863
  const data = new Uint8Array(an.fftSize);
816
864
  S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
817
865
  AUD.live.add(src);
818
- captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
819
- AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
866
+ const ms = buf.duration * 1000 / (src.playbackRate.value || 1); // how long it really lasts: the character's effect plays the clip slower or faster than it was made
867
+ captionRun(p.id, text, ms); await new Promise((res) => { src.onended = res; setTimeout(res, ms + 1000); src.start(); }); // the line ends when its sound ends (or when Pause stops it); the timer only covers a sound that never starts
868
+ try { src.stop(); } catch {} AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
820
869
  }
821
870
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
822
871
  return new Promise((resolve) => {
823
872
  const vs = synth.getVoices().filter((v) => v.lang.toLowerCase().startsWith(fr ? 'fr' : 'en')); if (!vs.length) return resolve(false);
824
873
  const ranked = rankVoices(vs.map((v) => v.name)), voice = vs.find((v) => v.name === (p.id === 'main' ? ranked[0] : ranked[(p.vi >>> 2) % ranked.length])) || vs[0];
825
- const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false;
874
+ const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false, t0 = 0, said = 0; const all = parts.reduce((n, x) => n + x.length, 0);
826
875
  const fin = (ok) => { if (!done) { done = true; resolve(ok); } };
827
- setTimeout(() => { if (!started) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
828
- setTimeout(() => fin(true), 45000);
876
+ setTimeout(() => { if (!started && !done) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
877
+ setTimeout(() => { if (!done) { synth.cancel(); fin(true); } }, Math.max(45000, text.length * 160)); // a voice that never reports its end is cut, not left talking over the next line
829
878
  const next = () => {
830
879
  if (i >= parts.length || done || token !== S.token) return fin(true);
831
- const part = parts[i++].trim(); if (!part) return next();
880
+ if (t0 && said >= 20) { const el = performance.now() - t0; captionRun(p.id, text, el * all / said, el); } // the length of the audio is not known in advance here: the subtitles are set again on the pace the voice really has, after each sentence
881
+ said += parts[i].length; const part = parts[i++].trim(); if (!part) return next();
832
882
  const u = new SpeechSynthesisUtterance(part); u.lang = fr ? 'fr-FR' : 'en-US'; u.voice = voice; u.pitch = prefs.vstyle === 'natural' ? 0.95 + ((p.vi >>> 5) % 5) * 0.03 : p.id === 'main' ? 0.8 : [1.5, 0.6, 1.2, 1.7, 0.9, 0.5, 1.35][(p.vi >>> 3) % 7]; u.rate = Math.min(1.7, 0.97 * spd());
833
- u.onstart = () => (started = true); u.onend = () => setTimeout(next, 120); u.onerror = () => fin(started); synth.speak(u);
883
+ u.onstart = () => { started = true; if (!t0) { t0 = performance.now(); captionRun(p.id, text, estMs(text)); } }; u.onend = () => setTimeout(next, 120); u.onerror = () => fin(started); synth.speak(u);
834
884
  };
835
885
  next();
836
886
  });
@@ -853,13 +903,33 @@ function setCaption(id, text) {
853
903
  const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
854
904
  b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
855
905
  }
856
- function captionRun(id, text, totalMs) {
906
+ function captionRun(id, text, totalMs, from = 0) { // from: how far into the line the voice already is, when the timing is corrected along the way
857
907
  captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
858
- const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
859
- for (let i = 1; i < ps.length; i++) { const x = ps[i], when = at; timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
860
- capTimers.set(id, timers);
908
+ const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0, cur = ps[0];
909
+ for (const x of ps) { const when = at - from; if (when <= 0) cur = x; else timers.push(setTimeout(() => setCaption(id, x), when)); at += totalMs * x.length / n; }
910
+ setCaption(id, cur); capTimers.set(id, timers);
861
911
  }
862
912
  const frVoice = (t) => (langOf(t) ?? (FR() ? 'fr' : 'en')) === 'fr'; // a French line gets a French voice whatever the interface language, and the other way round
913
+ // A line of the session that is too short to tell ("OK.", a file name) does not follow the interface language: in a French session watched in English, the lead would change voice for that
914
+ // one line and change back. It follows, in this order: the whole line it is cut from, what the same character said just before (its voice goes on) or says next, the language of the session so far.
915
+ // The session's language is counted once per line as the events arrive; in French, a line counts for what is said, i.e. its translation once it is there.
916
+ const SL = { ev0: null, ui: null, n: 0, fr: 0, en: 0, pend: [] };
917
+ function sessLang() { // 'fr' or 'en': the majority among the lines of the session that langOf can decide; null when there is none
918
+ const ev = S.events, ui = FR(), add = (t) => { const lo = langOf(t); if (lo) SL[lo]++; };
919
+ if (ev[0] !== SL.ev0 || ui !== SL.ui || ev.length < SL.n) Object.assign(SL, { ev0: ev[0], ui, n: 0, fr: 0, en: 0, pend: [] }); // another session, or another interface language
920
+ for (; SL.n < ev.length; SL.n++) { const e = ev[SL.n]; if (quiet(e) || typeof e.text !== 'string') continue; const t = baseText(e); if (!ui) add(t); else if (trMem.has(t)) add(trMem.get(t)); else if (langOf(t) === 'fr') SL.fr++; else SL.pend.push(t); }
921
+ if (SL.pend.length) SL.pend = SL.pend.filter((t) => !trMem.has(t) || add(trMem.get(t))); // translations that have arrived since
922
+ return SL.fr > SL.en ? 'fr' : SL.en > SL.fr ? 'en' : null;
923
+ }
924
+ function sessFr(text, ev) { // for the lines that come from the session (ev: the event, when there is one); the little built-in lines keep frVoice, they are written in the interface language
925
+ let lo = langOf(text);
926
+ if (!lo && ev) {
927
+ const now = (e) => { const t = baseText(e); return langOf(FR() && trMem.has(t) ? trMem.get(t) : t); }, at = S.events.indexOf(ev, Math.max(0, S.idx - 60)); lo = now(ev);
928
+ for (let k = at - 1, n = 0; !lo && k >= 0 && k > at - 5000 && n < 6; k--) { const e = S.events[k]; if (e.agent === ev.agent && !quiet(e) && typeof e.text === 'string') { lo = now(e); n++; } }
929
+ for (let k = at + 1, n = 0; !lo && at >= 0 && k < S.events.length && k < at + 5000 && n < 6; k++) { const e = S.events[k]; if (e.agent === ev.agent && !quiet(e) && typeof e.text === 'string') { lo = now(e); n++; } } // its first words: what it says next, when that is already there
930
+ }
931
+ return (lo ?? sessLang() ?? (FR() ? 'fr' : 'en')) === 'fr';
932
+ }
863
933
  async function speakLine(text, p, primary, token, fr = frVoice(text)) {
864
934
  captionRun(p.id, text, estMs(text));
865
935
  if (AUD.off() || !prefs.voice) { if (!AUD.off() && prefs.office) AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text)); }
@@ -867,7 +937,9 @@ async function speakLine(text, p, primary, token, fr = frVoice(text)) {
867
937
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
868
938
  if (TTS.server && AUD.ctx) { try { await speakServer(text, p, primary ? 1 : 0.5, token, fr); return; } catch {} }
869
939
  if (primary && synth && !EMBED && (await speakBrowser(text, p, token, fr))) return;
870
- AUD.murmur(0.5 + (p.vi % 5) / 10, estMs(text) / 1000); return sleep(estMs(text));
940
+ if (token !== S.token) return; // paused while a voice was being tried: no mumble after the fact
941
+ const ms = estMs(text), o = AUD.murmur(0.5 + (p.vi % 5) / 10, ms / 1000); captionRun(p.id, text, ms);
942
+ return o ? new Promise((res) => { o.addEventListener('ended', res); setTimeout(res, ms + 500); }) : sleep(ms); // the mumble that stands in for a voice: the line ends with it, also when Pause stops it
871
943
  }
872
944
 
873
945
  // =====================================================================
@@ -895,19 +967,22 @@ const layout = () => {
895
967
  if (nar) { k = cols; fr = cols > 1 ? 2 : 1; } // side panel: small tiles, a banner-sized spotlight
896
968
  else if (best < 0) { k = Math.min(2, cols); fr = Math.min(2, maxRows); }
897
969
  st.style.setProperty('--fcols', k); st.style.setProperty('--fr', fr); st.classList.toggle('one', cols === 1);
898
- document.body.classList.toggle('narrow', $('#center').clientWidth < 480); document.body.classList.toggle('compact', $('#center').clientWidth < 300); drawGantt();
970
+ document.body.classList.toggle('narrow', $('#center').clientWidth <= 480); document.body.classList.toggle('compact', $('#center').clientWidth <= 480 && !$('#roster').offsetWidth); document.body.classList.toggle('tiny', $('#center').clientWidth < 300); drawGantt();
899
971
  };
900
972
  new ResizeObserver(layout).observe($('#center'));
901
973
 
974
+ function logRow(i) {
975
+ const e = S.events[i], arrow = e.to ? ` <span class="arrow">→</span> <span class="who">${esc(nameOf(e.to))}</span>` : '';
976
+ const body = e.kind === 'tools' ? e.tools.map((t) => t.name).join(', ') : spoken(e), k = { order: L('tâche', 'task'), report: L('rapport', 'report'), say: '', user: L('brief', 'brief'), tools: L('outils', 'tools') }[e.kind];
977
+ return `<div class="row ${e.kind}" data-i="${i}"><span class="who">${esc(nameOf(e.agent))}</span>${arrow} <span class="k">${k}</span><div class="txt">${esc(body)}</div></div>`;
978
+ }
902
979
  function renderLog(full) {
903
980
  const log = $('#log'), from = S.live ? Math.max(0, S.events.length - 300) : 0;
904
981
  if (full || S.logTo < from || S.logTo > S.events.length) { log.innerHTML = ''; S.logTo = from; }
905
982
  const html = [];
906
983
  for (let i = S.logTo; i < S.events.length; i++) {
907
984
  if (S.events[i].kind === 'results' || S.events[i].kind === 'usage' || S.events[i].kind === 'finish' || S.events[i].kind === 'compact') continue;
908
- const e = S.events[i], arrow = e.to ? ` <span class="arrow">→</span> <span class="who">${esc(nameOf(e.to))}</span>` : '';
909
- const body = e.kind === 'tools' ? e.tools.map((t) => t.name).join(', ') : spoken(e), k = { order: L('tâche', 'task'), report: L('rapport', 'report'), say: '', user: L('brief', 'brief'), tools: L('outils', 'tools') }[e.kind];
910
- html.push(`<div class="row ${e.kind}" data-i="${i}"><span class="who">${esc(nameOf(e.agent))}</span>${arrow} <span class="k">${k}</span><div class="txt">${esc(body)}</div></div>`);
985
+ html.push(logRow(i));
911
986
  }
912
987
  log.insertAdjacentHTML('beforeend', html.join('')); S.logTo = S.events.length;
913
988
  if (S.live) while (log.children.length > 320) log.firstChild.remove();
@@ -931,9 +1006,10 @@ const applyStatus = (ev) => {
931
1006
  if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
932
1007
  if (ev.kind === 'finish') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); if (S.status[ev.agent] !== 'done') { S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; } delete S.run[ev.agent]; const lr = S.run[ev.to]; if (lr && /^(Task|Agent)$/.test(lr.name) && !(S.waitOn[ev.to] && S.waitOn[ev.to].size)) delete S.run[ev.to]; }
933
1008
  if (ev.kind === 'report') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; delete S.run[ev.agent]; }
1009
+ if (ev.kind === 'user' || (ev.agent === 'main' && (ev.kind === 'say' || ev.kind === 'tools' || (ev.kind === 'order' && !ev.wf)))) S.turnOver = !!ev.end; // Claude Code marks the message that closes the lead's turn; anything after it opens the next one (not the agents a workflow launches by itself)
934
1010
  };
935
1011
  function recomputeStatus(upTo) {
936
- S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
1012
+ S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.turnOver = false; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
937
1013
  S.status = {}; S.tc = {}; S.doneAt = {}; S.gone.clear(); S.leaving.clear(); document.querySelectorAll('.tile.vanishing').forEach((t) => t.classList.remove('vanishing')); const doneIdx = {}; document.querySelectorAll('.ticker').forEach((x) => { x.classList.remove('on'); x.innerHTML = ''; });
938
1014
  for (let i = 0; i < upTo; i++) {
939
1015
  const e = S.events[i]; applyStatus(e); tally(e); if (e.kind === 'tools') S.tc[e.agent] = (S.tc[e.agent] || 0) + e.tools.length;
@@ -946,9 +1022,7 @@ function recomputeStatus(upTo) {
946
1022
  }
947
1023
  function syncUI() {
948
1024
  $('#bPlay').textContent = S.playing ? '⏸' : '▶';
949
- const pill = $('#pill'); pill.className = '';
950
- if (S.offline) { pill.textContent = L('HORS LIGNE', 'OFFLINE'); }
951
- else if (!S.live) { pill.textContent = 'REPLAY'; } else if (S.locked) { pill.textContent = L('PRÊT', 'READY'); } else if (!S.playing) { pill.textContent = L('PAUSE', 'PAUSED'); } else if (S.idle) { pill.textContent = L('EN ATTENTE', 'WAITING'); pill.className = 'wait'; } else { pill.textContent = 'LIVE'; pill.className = 'live'; }
1025
+ paintPill();
952
1026
  document.body.classList.toggle('replay', !S.live);
953
1027
  }
954
1028
 
@@ -975,6 +1049,7 @@ function tally(ev) { // numbers that pile up over a session: token usage, erro
975
1049
  if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
976
1050
  else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); noteTrouble(ev.agent, false); } else noteTrouble(ev.agent, true); });
977
1051
  else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
1052
+ if (ev.kind === 'results') S.lastAct[ev.agent] = ev.ts; else if (ev.kind === 'user') S.lastAct.main = ev.ts; // a result coming back and your own order are signs of life too: the lead is thinking about them
978
1053
  }
979
1054
  const tokTip = (u) => `${L('Jetons enregistrés par Claude Code (comptés une fois par message, ce n’est pas une facture)', 'Tokens recorded by Claude Code (counted once per message, not a bill)')}\n${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}`;
980
1055
  const ctxInfo = (id) => { const c = S.ctx[id]; if (!c) return null; const w = ctxWin(S.model[id], c), p = Math.round((c / w) * 100); return { c, w, p, lvl: p >= 88 ? 'hi' : p >= 70 ? 'mid' : 'lo' }; };
@@ -998,7 +1073,10 @@ const NEEDS_OK = /^(Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion|ExitPlanMo
998
1073
  const atTail = () => S.idx >= S.events.length - 3; // only trust wall-clock ages when playback has caught up with the live end
999
1074
  function agentState(id, now) {
1000
1075
  let s = S.status[id]; const r = S.run[id], w = S.waitOn[id], la = S.lastAct[id];
1001
- if (id === 'main' && !s) { if (r || (w && w.size) || (S.live && la && now - la < 25000)) s = 'working'; else if (S.live && la) return { k: 'done', txt: '✓ ' + L('à vous', 'your turn') }; }
1076
+ if (id === 'main' && !s) { // the lead is at work until the message that closes its turn, even while it thinks; a transcript without that mark falls back on "quiet for 25 s", and a turn cut short on 3 minutes of silence
1077
+ const open = S.live && la && (S.sawEnd ? !S.turnOver && now - la < 180000 : now - la < 25000);
1078
+ if (r || (w && w.size) || open) s = 'working'; else if (S.live && la) return { k: 'done', txt: '✓ ' + L('à vous', 'your turn') };
1079
+ }
1002
1080
  if (s === 'done') return { k: 'done', txt: '✓ ' + L('terminé', 'done') };
1003
1081
  if (!s) return { k: 'idle', txt: '' };
1004
1082
  if (S.live && atTail() && r && r.ts && r.name && NEEDS_OK.test(r.name)) { const age = now - r.ts; if (age > (ASKS.test(r.name) ? 4000 : 15000)) return { k: 'blocked', txt: '✋ ' + L('attend vous ? ', 'needs you? ') + fmtShort(age) }; }
@@ -1018,7 +1096,7 @@ function paintStates() {
1018
1096
  if (p.id === 'user') continue; const t = $('#t-' + p.id); if (!t) continue;
1019
1097
  const st = agentState(p.id, now), shown = t.style.display !== 'none'; if (shown && n[st.k] !== undefined && !S.gone.has(p.id)) { n[st.k]++; if (st.k === 'blocked' || st.k === 'stalled') attn.push(p.id); }
1020
1098
  STATE_CLASSES.forEach((c) => t.classList.toggle(c, st.k === c));
1021
- const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k;
1099
+ const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k + (isTurn(p.id, st) ? ' turn' : '');
1022
1100
  const pr = t.querySelector('.prop'), want = { blocked: '✋', stalled: '😰', waiting: '⌛' }[st.k] || '', was = S.prevState[p.id]; // a raised hand, a sweat drop, an hourglass: the call metaphor for "needs you / stuck / waiting"
1023
1101
  if (st.k === 'done' && was && was !== 'done' && was !== 'idle') { pr.textContent = '🎉'; pr.className = 'prop cheer'; setTimeout(() => { if (pr.classList.contains('cheer')) { pr.textContent = ''; pr.className = 'prop'; } }, 1200); }
1024
1102
  else if (!pr.classList.contains('cheer')) { if (pr.textContent !== want) pr.textContent = want; pr.className = 'prop ' + (want ? st.k : ''); }
@@ -1027,15 +1105,16 @@ function paintStates() {
1027
1105
  if (p.id === 'main') { if (st.k === 'working' || st.k === 'waiting') S.mainBusyAt = S.mainBusyAt || Date.now(); else if (st.k === 'done') S.mainBusyAt = 0; }
1028
1106
  S.prevState[p.id] = st.k; const tip = `${p.name} — ${p.role}${st.txt ? '\n' + st.txt : ''}${S.lastTool[p.id] ? '\n' + S.lastTool[p.id] : ''}`; if (t.title !== tip) t.title = tip;
1029
1107
  }
1108
+ paintPill();
1030
1109
  const b = $('#board'), parts = [['working', '⚙', L('au travail', 'working')], ['waiting', '🔗', L('en attente', 'waiting')], ['blocked', '✋', L('vous attendent', 'need you')], ['stalled', '⚠', L('bloqués', 'stalled')], ['done', '✓', L('terminés', 'done')]]
1031
- .filter(([k]) => n[k]).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
1110
+ .filter(([k]) => n[k] && !S.pillSess).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
1032
1111
  if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
1033
1112
  }
1034
1113
  const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
1035
1114
  function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
1036
1115
  const box = $('#roster'); if (!box || !document.body.classList.contains('rost')) return;
1037
1116
  ros.sort((a, b) => (a.p.id === 'main' ? -1 : b.p.id === 'main' ? 1 : (RORDER[a.st.k] - RORDER[b.st.k]) || (S.lastAct[b.p.id] || 0) - (S.lastAct[a.p.id] || 0)));
1038
- const rows = ros.slice(0, 60).map(({ p, st }) => `<div class="rr k-${st.k}${S.focus === p.id ? ' sel' : ''}${S.speakers.has(p.id) ? ' speaking' : ''}" data-id="${esc(p.id)}" tabindex="0" role="button" aria-label="${esc(p.name)}, ${esc(st.txt || '')}"><div class="n"><span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="ro">${esc(p.role)}</span></div><div class="st">${esc(st.txt || L('en veille', 'standing by'))}${S.loops[p.id] ? ' 🔁' : ''}</div></div>`).join('');
1117
+ const rows = ros.slice(0, 60).map(({ p, st }) => `<div class="rr k-${st.k}${isTurn(p.id, st) ? ' turn' : ''}${S.focus === p.id ? ' sel' : ''}${S.speakers.has(p.id) ? ' speaking' : ''}" data-id="${esc(p.id)}" tabindex="0" role="button" aria-label="${esc(p.name)}, ${esc(st.txt || '')}"><div class="n"><span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="ro">${esc(p.role)}</span></div><div class="st">${esc(st.txt || L('en veille', 'standing by'))}${S.loops[p.id] ? ' 🔁' : ''}</div></div>`).join('');
1039
1118
  const html = `<h3>${L('Agents', 'Agents')} · ${ros.length}</h3>${rows}${ros.length > 60 ? `<div class="ro" style="padding:4px 8px">+${ros.length - 60}</div>` : ''}`;
1040
1119
  if (box.dataset.h !== html) { box.dataset.h = html; box.innerHTML = html; }
1041
1120
  }
@@ -1076,6 +1155,7 @@ function setFav(color) {
1076
1155
  function updateTitle() {
1077
1156
  paintStates(); if (S.live && S.nBlocked && !S.wasBlocked && prefs.notify) { AUD.alert(); const msg = L('Un agent attend peut-être votre feu vert ✋', 'An agent may be waiting for you ✋'); if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*'); else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} } } S.wasBlocked = S.nBlocked > 0;
1078
1157
  let n = 0; for (const id in S.status) if (S.status[id] === 'working' && !S.gone.has(id)) n++;
1158
+ if (S.live && S.agents.main && /^(working|waiting)$/.test(agentState('main', Date.now()).k)) n++; // the lead counts too: a session without sub-agents also ends
1079
1159
  watchDone(n); setFav(S.live && S.nBlocked ? '#ff4d4d' : null);
1080
1160
  document.title = S.live && S.nBlocked ? `✋ ${S.nBlocked} ${L('à voir', 'need you')} · Agent Standup` : S.doneUntil > Date.now() && !n ? '✓ ' + L('Terminé', 'Done') + ' · Agent Standup' : S.live && S.playing && n ? `● ${n} ${L('au travail', 'working')} · Agent Standup` : 'Agent Standup';
1081
1161
  }
@@ -1118,7 +1198,7 @@ function setTicker(id, e) { // the strip on the tile: spinner while running, t
1118
1198
  tk.classList.add('on'); if (running) { tk.classList.remove('flash'); void tk.offsetWidth; tk.classList.add('flash'); }
1119
1199
  }
1120
1200
  function tickTools(ev) {
1121
- const t = $('#t-' + ev.agent); if (!t) return; t.style.display = '';
1201
+ ev._n = ev.tools.length; const t = $('#t-' + ev.agent); if (!t) return; t.style.display = '';
1122
1202
  ev.tools.forEach((tool) => pushTerm(ev.agent, tool));
1123
1203
  const buf = S.term[ev.agent], k = ev.tools[ev.tools.length - 1];
1124
1204
  S.tc[ev.agent] = (S.tc[ev.agent] || 0) + ev.tools.length; S.lastTool[ev.agent] = k.name + ' ' + (k.arg || '').slice(-38);
@@ -1350,9 +1430,8 @@ function openExternal(url) { if (EMBED) window.parent.postMessage({ agentStandup
1350
1430
  function renderCoffee() {
1351
1431
  const on = !!SUPPORT.url, b = $('#mCoffee'); b.style.display = on ? '' : 'none'; $('#mCoffeeT').textContent = L('☕ Offrir un café au développeur', '☕ Buy the (replaced) dev a coffee'); b.title = coffeeMsg();
1352
1432
  const h = $('#bCoffee'); h.style.display = on ? '' : 'none'; h.title = coffeeMsg(); h.setAttribute('aria-label', coffeeMsg());
1353
- const g = $('#gCoffee'); g.style.display = on ? '' : 'none'; g.textContent = L('☕ Ce développeur a été remplacé par ses agents — lui offrir un café', '☕ This dev was replaced by his agents — buy him a coffee');
1354
1433
  }
1355
- $('#bCoffee').onclick = $('#gCoffee').onclick = () => openExternal(SUPPORT.url);
1434
+ $('#bCoffee').onclick = () => openExternal(SUPPORT.url);
1356
1435
  $('#mCoffee').onclick = () => { $('#menu').classList.remove('on'); openExternal(SUPPORT.url); };
1357
1436
 
1358
1437
  // =====================================================================
@@ -1448,7 +1527,7 @@ function ackLine(ev) {
1448
1527
  // =====================================================================
1449
1528
  // conversation: short exchanges between agents at the right moments, built from what really happened in the session
1450
1529
  // =====================================================================
1451
- const CHAT = { quiet: 0.45, normal: 1, lively: 1.8 }, chatMul = () => CHAT[prefs.chat] || CHAT.quiet; // quiet unless chosen otherwise, so the call does not tire anyone
1530
+ const CHAT = { quiet: 0.45, normal: 1, lively: 1.8 }, chatMul = () => CHAT[prefs.chat] || 1; // normal unless chosen otherwise
1452
1531
  const EXW = { en: { edit: ['edit', 'edits'], call: ['tool call', 'tool calls'], error: ['error', 'errors'] }, fr: { edit: ['modification', 'modifications'], call: ['appel d’outil', 'appels d’outils'], error: ['erreur', 'erreurs'] } };
1453
1532
  const P = (k, n) => { const w = EXW[FR() ? 'fr' : 'en'][k]; return `${n} ${n === 1 ? w[0] : w[1]}`; };
1454
1533
  const EXCH = {
@@ -1487,8 +1566,9 @@ function exchFacts(i, agent) { // what this agent did since it received its or
1487
1566
  f.what = f.edits ? P('edit', f.edits) : f.tools ? P('call', f.tools) : L('rien à signaler', 'nothing to report'); return f;
1488
1567
  }
1489
1568
  const exVisible = (id) => S.agents[id] && !S.gone.has(id) && $('#t-' + id) && $('#t-' + id).style.display !== 'none';
1569
+ async function sayLine(id, line, label, token) { startTalk(id, line, label); await speakLine(line, S.agents[id], true, token); if (token === S.token) stopTalk(id); } // a line cut short (pause, seek) must not close the bubble of whoever speaks next
1490
1570
  async function exSay(id, line, token) { // one line of an exchange, then a short human pause
1491
- if (!exVisible(id) || token !== S.token) return false; startTalk(id, line, '💬'); await speakLine(line, S.agents[id], true, token); stopTalk(id);
1571
+ if (!exVisible(id) || token !== S.token) return false; await sayLine(id, line, '💬', token);
1492
1572
  if (token !== S.token) return false; await sleep((150 + Math.random() * 250) / spd()); return token === S.token;
1493
1573
  }
1494
1574
  const exReady = () => prefs.banter && !S.hurry && S.boost <= 1 && !S.recomputing && !S.speakers.size && caughtUp() && performance.now() - (S.exAt || 0) > (S.live ? 22000 : 9000 / spd()) / chatMul();
@@ -1543,16 +1623,16 @@ function talkLine(g) {
1543
1623
  return t;
1544
1624
  }
1545
1625
  async function react(ev, token) {
1546
- if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !caughtUp()) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
1626
+ if ((!prefs.epi && !prefs.banter) || S.boost > 1 || S.hurry || !nearLive()) return; const seed = hash(String(ev.ts) + ev.agent), R = REACT[FR() ? 'fr' : 'en']; let who, line, thanks = false;
1547
1627
  if (ev.kind === 'order') { if (!prefs.banter && seed % 100 > 55) return; who = ev.to; line = prefs.banter ? ackLine(ev) : R.ack[seed % R.ack.length]; }
1548
1628
  else if (ev.kind === 'report') {
1549
1629
  const r = seed % 100, joke = prefs.epi ? 60 : prefs.chat === 'quiet' ? -1 : 35; // a remark from someone else, or (a little less often) a word of thanks from whoever receives the report
1550
- if (r <= joke) { const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1630
+ if (r <= joke) { if (!caughtUp()) return; const others = Object.keys(S.status).concat(['main']).filter((x) => x !== ev.agent && S.agents[x] && !S.gone.has(x)); who = others[(seed >>> 3) % others.length]; line = quipMem.get(baseText(ev, 160)) || R.done[seed % R.done.length]; }
1551
1631
  else if (prefs.banter && r <= joke + 25 && ev.to !== 'user' && S.agents[ev.to] && !S.gone.has(ev.to)) { who = ev.to; line = varied('thanks', seed >>> 3); thanks = true; }
1552
1632
  else return;
1553
1633
  }
1554
1634
  else return;
1555
- if (!S.agents[who]) return; startTalk(who, line, thanks || (ev.kind === 'order' && prefs.banter) ? '💬' : '🎬'); await speakLine(line, S.agents[who], true, token); stopTalk(who);
1635
+ if (!S.agents[who]) return; await sayLine(who, line, thanks || (ev.kind === 'order' && prefs.banter) ? '💬' : '🎬', token);
1556
1636
  }
1557
1637
  // Small talk: when nobody has said anything for a while, someone fills the silence the way people do in video calls. At most three per silence, further and further apart; any other line starts the count again.
1558
1638
  const FILL = {
@@ -1576,15 +1656,27 @@ const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? '
1576
1656
  const before = (i) => { let k = i - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; return S.events[k]; };
1577
1657
  async function ponder(i, token) { // replay: an agent that really took a long time (40 s or more) before this line or call says so first, about every other time
1578
1658
  const ev = S.events[i], p = before(i); if (!prefs.banter || prefs.chat === 'quiet' || S.boost > 1 || S.hurry || !p || !S.agents[ev.agent] || thinkerOf(p) !== ev.agent || ev.ts - p.ts < 40000 || hash('t' + ev.ts) % 100 > 55) return;
1579
- const line = varied('think', hash(String(ev.ts))); startTalk(ev.agent, line, '💭'); await speakLine(line, S.agents[ev.agent], true, token); stopTalk(ev.agent);
1659
+ const line = varied('think', hash(String(ev.ts))); await sayLine(ev.agent, line, '💭', token);
1660
+ }
1661
+ // Live: you gave an order and the lead starts on its tools without a word (about one time in three): it says what it is starting with, from the tool call itself, so you know it heard you and what it is on
1662
+ const LEAD = {
1663
+ en: { in: ['Got it.', 'Okay.', 'On it.', 'Sure.', 'Right.'], read: 'Let me look at ', edit: 'I’m editing ', write: 'I’m writing ', cmd: 'I’m running ', note: 'I’m running a command: ', grep: 'Let me search the code.', glob: 'Let me see which files we have.', todo: 'Let me lay out a plan.', web: 'Let me check the web.', question: 'I have a question for you.', any: 'Let me get started.', and: ' and ' },
1664
+ fr: { in: ['D’accord.', 'Compris.', 'Très bien.', 'Entendu.', 'Ça marche.'], read: 'Je regarde ', edit: 'Je modifie ', write: 'J’écris ', cmd: 'Je lance ', note: 'Je lance une commande : ', grep: 'Je cherche dans le code.', glob: 'Je regarde quels fichiers on a.', todo: 'Je fais mon plan.', web: 'Je vais voir sur le web.', question: 'J’ai une question pour vous.', any: 'Je m’y mets.', and: ' et ' },
1665
+ };
1666
+ async function leadAck(ev, token) {
1667
+ if (!prefs.banter || !S.agents.main) { S.owed = false; return; } if (S.hurry || S.boost > 1 || !nearLive() || S.speakers.size) return; S.owed = false; // reading your order took a while: the first tool is rarely less than 6 s old; if now is a bad moment, the next tool will do
1668
+ const W = LEAD[FR() ? 'fr' : 'en'], d = ev.tools[0].d || {}, k = d.kind === 'text' ? d.what : d.kind; let what = W[k] || W.any;
1669
+ if (k === 'read' || k === 'edit' || k === 'write') { const files = [...new Set(ev.tools.filter((t) => (t.d || {}).kind === k).map((t) => base(t.d.file)).filter(Boolean))].slice(0, 2); what = files.length ? what + files.join(W.and) + '.' : W.any; }
1670
+ else if (k === 'cmd') { const note = (d.note || '').trim().replace(/[.\s]+$/, ''), cmd = String(d.text || '').trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 26); what = note ? W.note + note + '.' : cmd ? what + cmd + '.' : W.any; }
1671
+ await sayLine('main', `${W.in[hash('k' + ev.ts) % W.in.length]} ${what}`, '💬', token);
1580
1672
  }
1581
1673
  async function wrapUp(token) { // live, once the lead is done: the part of its last message that the clipped reading left out, so you know what was done
1582
1674
  if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
1583
1675
  let k = S.events.length - 1; while (k >= 0 && !(S.events[k].agent === 'main' && S.events[k].kind === 'say')) k--; if (k < 0) return false; const e = S.events[k], m = S.mainSaid;
1584
- if (S.wrapped === e) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
1676
+ if (S.wrapped === e || e.ts <= (S.from || 0)) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
1585
1677
  const full = plain(e.text); let rest = m && m.ev === e ? full.slice(m.said).trim() : full; if (m && m.ev === e && m.full) return false; if (!m || m.ev !== e) rest = full;
1586
1678
  rest = rest.replace(/^[^\w\p{L}]+/u, ''); if (rest.length < 25) return false; const line = (m && m.ev === e ? L('Pour résumer : ', 'To sum up: ') : '') + short(rest, 520);
1587
- startTalk('main', line, '🎉'); await speakLine(line, S.agents.main, true, token); stopTalk('main'); return true;
1679
+ await sayLine('main', line, '🎉', token); return true;
1588
1680
  }
1589
1681
  async function smallTalk(token) {
1590
1682
  if (prefs.chat === 'quiet') return; // quiet: nothing to fill the silences, only the real exchanges
@@ -1609,10 +1701,11 @@ const SPK = new Set(['say', 'report', 'order', 'user']);
1609
1701
  function aheadSpeakable(i, limit) { let n = 0; for (let k = i, m = Math.min(S.events.length, i + 800); k < m; k++) if (SPK.has(S.events[k].kind) && ++n > limit) break; return n; }
1610
1702
  const isSub = (id) => id !== 'main' && id !== 'user';
1611
1703
  async function run(i) {
1612
- const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.wrap = false; S.mainSaid = null; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
1704
+ const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.wrap = false; S.mainSaid = null; S.owed = false; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
1613
1705
  if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1614
1706
  while (token === S.token) {
1615
- const lim = S.stopAt || S.events.length;
1707
+ const lim = S.stopAt || S.events.length; S.pos = i;
1708
+ if (S.live && S.late.length) { await converse(S.late.shift(), token); if (token !== S.token) return; continue; } // a line that turned out to be a report after it was read
1616
1709
  if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } if (await wrapUp(token)) continue; await smallTalk(token); await sleep(350); continue; } break; }
1617
1710
  if (S.idle) { S.idle = false; syncUI(); }
1618
1711
  if (S.live) { // way behind: skip ahead to stay current, but only count what is actually said: sub-agents produce thousands of silent tool events
@@ -1625,13 +1718,13 @@ async function run(i) {
1625
1718
  const q = S.live ? aheadSpeakable(i, 14) : 0; // lines waiting to be said: the more of them, the shorter each one is read, but it is always read
1626
1719
  const lg = lagMs(i) / 1000; S.pace = !S.live ? 0 : Math.min(q > 7 ? 70 : q > 3 ? 130 : 260, lg > 30 ? 70 : lg > 15 ? 130 : lg > 6 ? 200 : 260); S.hurry = q > 12 || lg > 45; // voices are only skipped when more than a dozen lines are queued
1627
1720
  S.boost = S.hurry || (S.live && S.events.length - i > 60) ? 3 : 1;
1628
- const ev = S.events[i]; S.idx = i; tally(ev);
1721
+ const ev = S.events[i]; S.idx = i; tally(ev); if (S.live && ev.ts > S.since) S.since = ev.ts; // said once: a reload or a return to this session does not read it again
1629
1722
  if (!S.live && (ev.kind === 'say' || ev.kind === 'report' || ev.kind === 'order' || ev.kind === 'tools')) { await ponder(i, token); if (token !== S.token) return; }
1630
1723
  if (ev.kind === 'usage' || ev.kind === 'compact') { if (ev.kind === 'compact' && S.live && prefs.banter) toast(`${nameOf(ev.agent)}: ${L('contexte compacté 🗜', 'context compacted 🗜')}`); i++; continue; }
1631
1724
  if (ev.kind === 'finish') { applyStatus(ev); paintStatus(); i++; continue; }
1632
1725
  const backlog = S.live ? S.events.length - i : 0, quick = S.live ? Math.min(1, 40 / Math.max(1, backlog)) : 1; // live: hundreds of tool events from parallel sub-agents must not take minutes to scroll past
1633
1726
  if (ev.kind === 'results') { applyResults(ev, S.boost > 1); await sleep((S.boost === 1 && S.present === ev.agent && ev.items.some((it) => it.img) ? (S.live ? 900 : 1800) : S.demo ? 900 : S.live ? 30 * quick : 80) / spd()); i++; continue; } // a picture gets a moment on screen
1634
- if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); if (quick === 1) await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 * quick : 250) / spd()); i++; continue; }
1727
+ if (ev.kind === 'tools') { applyStatus(ev); tickTools(ev); AUD.click(); OFFICE.kick(ev.tools[ev.tools.length - 1].name); paintStatus(); if (S.live && S.owed && ev.agent === 'main') { await leadAck(ev, token); if (token !== S.token) return; } if (quick === 1) await smallTalk(token); await sleep((S.demo ? 1100 : S.live ? 110 * quick : 250) / spd()); i++; continue; }
1635
1728
  const group = [ev]; let j = i + 1; // sub-agents talking back-to-back were running in parallel: they talk over each other
1636
1729
  if (ev.kind === 'order') { // several orders in a row (a parallel launch): the new agents all appear now, the first order is read, the others are named in one line
1637
1730
  while (j < Math.min(S.events.length, i + 40) && group.length < 8) { const e2 = S.events[j]; if (e2.kind === 'usage' || e2.kind === 'compact') { tally(e2); j++; continue; } if (e2.kind === 'order' && e2.agent === ev.agent) { group.push(e2); j++; } else break; }
@@ -1650,23 +1743,23 @@ async function run(i) {
1650
1743
  if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
1651
1744
  prefetchQuips(i); warmTTS(i);
1652
1745
  const playOne = (g) => {
1653
- applyStatus(g);
1746
+ applyStatus(g); g._as = g.kind; if (g.agent === 'user') S.owed = true; else if (g.agent === 'main') S.owed = false; // your order waits for a word from the lead
1654
1747
  const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = talkLine(g);
1655
1748
  startTalk(g.agent, line, label);
1656
1749
  if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1657
1750
  if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1658
1751
  const said = clipSpeech(line, S.pace); if (g.agent === 'main' && g.kind === 'say') { const full = plain(g.text), key = said.replace(/…$/, '').trim().slice(-30), pos = key ? full.indexOf(key) : -1, n = pos >= 0 ? pos + key.length : said.length; S.mainSaid = { ev: g, said: n, full: n >= full.length - 5 }; }
1659
- return speakLine(said, S.agents[g.agent], true, token, frVoice(said)).then(() => stopTalk(g.agent));
1752
+ return speakLine(said, S.agents[g.agent], true, token, sessFr(said, g)).then(() => { if (token === S.token) stopTalk(g.agent); });
1660
1753
  };
1661
1754
  let plays;
1662
1755
  if (ev.kind === 'order' && group.length > 1) { // the first order in full, then "Also Remy on X, Iris on Y"
1663
1756
  const rest = group.slice(1), line = `${L('Et aussi ', 'Also ')}${rest.map((o) => `${nameOf(o.to)}${L(' sur ', ' on ')}${(o.title || short(o.text, 60)).replace(/[.。]\s*$/, '')}`).join(', ')}.`;
1664
1757
  setTimeout(() => rest.forEach((o, k) => setTimeout(() => { fly(o.agent, o.to, '📨'); popTile(o.to); }, k * 120)), 30);
1665
- plays = [playOne(ev).then(() => { if (token !== S.token) return; startTalk(ev.agent, line, ''); return speakLine(clipSpeech(line, S.pace), S.agents[ev.agent], true, token).then(() => stopTalk(ev.agent)); })];
1758
+ plays = [playOne(ev).then(() => { if (token !== S.token) return; startTalk(ev.agent, line, ''); return speakLine(clipSpeech(line, S.pace), S.agents[ev.agent], true, token).then(() => { if (token === S.token) stopTalk(ev.agent); }); })];
1666
1759
  }
1667
1760
  else if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
1668
1761
  S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
1669
- for (const g of (ev.kind === 'order' && group.length > 1 ? [ev] : group)) { await converse(g, token); if (token !== S.token) return; }
1762
+ for (const g of (ev.kind === 'order' ? group.slice(0, 3) : group)) { await converse(g, token); if (token !== S.token) return; } // a parallel launch: the first three answer, as long as the call keeps up
1670
1763
  i = j;
1671
1764
  }
1672
1765
  if (token === S.token) {
@@ -1682,7 +1775,7 @@ function clipSpeech(t, cap) { // the bubble keeps the full text; the voice rea
1682
1775
  function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); $('#card').classList.remove('on'); syncUI(); }
1683
1776
  function togglePlay() {
1684
1777
  if (S.locked) return;
1685
- if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.idx); }
1778
+ if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.live ? S.pos : S.idx); } // live: from the first line not said yet (the one that was cut, if any)
1686
1779
  }
1687
1780
  function seekTo(i) {
1688
1781
  pause(); i = Math.max(0, Math.min(S.events.length - 1, i)); S.idx = i; recomputeStatus(i); paintStatus(); S.userPaused = false; run(i);
@@ -1706,7 +1799,7 @@ async function loadSession(proj, id) {
1706
1799
  const data = await (await fetch(`/api/session?proj=${encodeURIComponent(proj)}&id=${id}`)).json();
1707
1800
  if (mine !== S.loadN || data.error) return false;
1708
1801
  S.term = {}; S.camFor = null; S.focus = null; S.agents = {}; for (const k in nameBook) delete nameBook[k]; [{ id: 'user' }, ...data.agents].forEach((a) => (S.agents[a.id] = persona(a)));
1709
- S.events = data.events; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
1802
+ S.events = data.events; stampT(0); S.byK = new Map(data.events.map((e) => [e.k, e])); S.seq = data.seq || 0; S.ep = data.ep || ''; S.sawEnd = data.events.some((e) => e.end); S.late = []; S.pos = 0; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
1710
1803
  const opt = $('#sessions').selectedOptions[0]; S.title = (S.live ? niceProj(proj) : opt && opt.dataset.title) || '';
1711
1804
  if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
1712
1805
  $('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
@@ -1724,8 +1817,27 @@ async function followCurrent() {
1724
1817
  if (S.pin && cur.id !== S.pin) S.pin = ''; // the pinned session is gone: follow the latest one instead of staying silently pinned
1725
1818
  if (S.cur && S.cur.id === cur.id && S.cur.proj === cur.proj) return false;
1726
1819
  if (!(await loadSession(cur.proj, cur.id))) return false;
1727
- const start = Math.max(0, S.events.length - 16); recomputeStatus(start); S.idx = start; trNew(start); paintStatus();
1728
- if (!S.locked && !S.userPaused) run(start); return true;
1820
+ const start = liveStart(); if (!S.locked && !S.userPaused) run(start); return true;
1821
+ }
1822
+ // Live: a call starts when you join it. Only what happened since then, and was not said yet, is said (the last 16 events at most, when a session is picked up on the way);
1823
+ // the end of a session that was already over is on screen (states, transcript) but is not read again. Joining while the agents are at work, the last few seconds are said too, to know what they are on
1824
+ const JOIN_BACK_MS = 10000;
1825
+ function midWork() { // someone is in the middle of something: the session does not end on a message of the lead (its turn is over then, or about to be)
1826
+ let k = S.events.length - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; const e = S.events[k]; return !!e && !(e.agent === 'main' && e.kind === 'say');
1827
+ }
1828
+ function liveStart() {
1829
+ const n = S.events.length; if (!S.locked && !S.since) S.since = Date.now() - (midWork() ? JOIN_BACK_MS : 0); // S.since 0: joining now
1830
+ const since = S.locked ? Date.now() : S.since; let i = n; while (i > Math.max(0, n - 16) && S.events[i - 1].ts > since) i--;
1831
+ recomputeStatus(i); S.idx = S.pos = i; S.from = since; trNew(Math.max(0, n - 16)); paintStatus(); return i;
1832
+ }
1833
+ function amend(e, was) { // the server corrected an event the call already has: another tool call in the same message, a last line that turns out to be a report, the mark that closes the lead's turn
1834
+ const i = S.events.indexOf(e), row = $(`#log .row[data-i="${i}"]`); if (row) row.outerHTML = logRow(i);
1835
+ if (e.end) S.sawEnd = true; if (S.locked || S.recomputing || !S.live) return; const past = i < S.pos; // at the door everything is worked out again on Join
1836
+ if (e.kind === 'tools') { const done = e._n != null ? e._n : past ? was.n : null; if (done != null && e.tools.length > done) { const more = { ...e, tools: e.tools.slice(done) }; e._n = e.tools.length; applyStatus(more); tickTools(more); } }
1837
+ if (e.kind === 'report' && was.kind === 'say' && (e._as === 'say' || past)) { // it was read as an ordinary line: the report still counts (the agent is done, a ding, perhaps a word of thanks), without reading it again
1838
+ applyStatus(e); AUD.ding(); fly(e.agent, e.to, '✅'); popTile(e.agent); if (e._as === 'say') { e._as = 'report'; S.late.push(e); }
1839
+ }
1840
+ if (e.end && !was.end && (e._n != null || e._as || past)) { let k = S.events.length - 1; while (k > i && !(S.events[k].kind === 'user' || (S.events[k].agent === 'main' && /^(say|tools|order)$/.test(S.events[k].kind) && !S.events[k].wf))) k--; if (k === i) S.turnOver = true; } // only if nothing came after it
1729
1841
  }
1730
1842
  let polling = false;
1731
1843
  setInterval(async () => {
@@ -1733,12 +1845,16 @@ setInterval(async () => {
1733
1845
  try {
1734
1846
  const switched = await followCurrent(); if (S.offline) { S.offline = false; syncUI(); }
1735
1847
  if (switched || !S.cur) return;
1736
- const d = await (await fetch(`/api/session?proj=${encodeURIComponent(S.cur.proj)}&id=${S.cur.id}&after=${S.events.length}`)).json();
1737
- if (d.error) return; if (d.total < S.events.length) { S.cur = null; return; } // transcript was rewritten: reload next tick
1738
- if (!d.events || !d.events.length) return;
1848
+ const cur = S.cur, d = await (await fetch(`/api/session?proj=${encodeURIComponent(cur.proj)}&id=${cur.id}&after=${S.seq}&ep=${encodeURIComponent(S.ep)}`)).json();
1849
+ if (d.error || cur !== S.cur) return; if (d.resync) { S.cur = null; return; } // the server no longer has our place (restarted, transcript rewritten): reload next tick
1850
+ S.seq = d.seq; if (!d.events || !d.events.length) return;
1739
1851
  for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
1740
- const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false);
1741
- if (S.playing && !S.recomputing) for (const e of d.events) if (e.kind === 'order' && !S.status[e.to]) { S.status[e.to] = 'working'; S.orderTs[e.to] = e.ts || 0; S.gone.delete(e.to); } // a new sub-agent shows up now, while the previous line is still being read
1852
+ const from = S.events.length, fresh = [], changed = []; // the session is not written in order: what is new goes at the end (the order the call got it in), what the server corrected is corrected in place
1853
+ for (const e of d.events) { const old = S.byK.get(e.k); if (old) { changed.push([old, { kind: old.kind, n: old.tools ? old.tools.length : 0, end: old.end }]); Object.assign(old, e); } else { S.byK.set(e.k, e); fresh.push(e); } }
1854
+ if (fresh.some((e) => e.end)) S.sawEnd = true;
1855
+ if (fresh.length) { S.events = S.events.concat(fresh); stampT(from); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false); }
1856
+ if (S.playing && !S.recomputing) for (const e of fresh) if (e.kind === 'order' && !S.status[e.to]) { S.status[e.to] = 'working'; S.orderTs[e.to] = e.ts || 0; S.gone.delete(e.to); } // a new sub-agent shows up now, while the previous line is still being read
1857
+ for (const [e, was] of changed) amend(e, was);
1742
1858
  paintStatus();
1743
1859
  } catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
1744
1860
  }, 400);
@@ -1818,7 +1934,7 @@ async function setMode(m) {
1818
1934
  const live = m === 'live'; if (live === S.live && S.cur) return; pause(); S.live = live; S.userPaused = false;
1819
1935
  document.querySelectorAll('#modeSeg button').forEach((b) => b.classList.toggle('on', b.dataset.m === m)); $('#sessions').style.display = live ? 'none' : '';
1820
1936
  S.cur = null; syncUI();
1821
- if (live) { fillProjects(); await followCurrent(); return; }
1937
+ if (live) { S.since = 0; fillProjects(); await followCurrent(); return; }
1822
1938
  $('#liveProj').style.display = 'none';
1823
1939
  const rows = await (await fetch('/api/sessions')).json();
1824
1940
  $('#sessions').innerHTML = rows.map((r) => { const d = new Date(r.mtime).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
@@ -1919,10 +2035,11 @@ function applyLang() {
1919
2035
  renderCharInfo();
1920
2036
  document.documentElement.lang = (LANGS.find((l) => l[0] === prefs.lang) || LANGS[0])[2];
1921
2037
  document.querySelectorAll('[data-fr]').forEach((el) => (el.textContent = FR() ? el.dataset.fr : tr(el.dataset.en)));
2038
+ document.querySelectorAll('#viewSeg button').forEach((b) => { b.title = b.textContent; }); // in a narrow window they are icons: the name stays as a tooltip
1922
2039
  const sel = $('#langSel'); if (!sel.options.length) sel.innerHTML = LANGS.map(([c, n]) => `<option value="${c}">${n}</option>`).join(''); sel.value = prefs.lang;
1923
2040
  [['#bPlay', L('Pause / Lecture (espace)', 'Pause / Play (space)')], ['#bLog', 'Transcript'], ['#bMute', L('Son (touche M)', 'Sound (M key)')], ['#bMenu', L('Réglages', 'Settings')], ['#dclose', L('Fermer', 'Close')], ['#officeVol', L('Volume de l’ambiance', 'Ambience volume')], ['#musicVol', L('Volume de la musique', 'Music volume')], ['#liveProj', L('Projet à suivre', 'Project to follow')], ['#reactBar', L('Réactions', 'Reactions')], ['#logQ', L('Rechercher dans la transcription', 'Search the transcript')], ['#roster', L('Agents', 'Agents')]].forEach(([q, t]) => { const el = $(q); if (!el) return; el.title = t; el.setAttribute('aria-label', t); }); $('#logQ').placeholder = L('Rechercher…', 'Search…'); $('#menuQ').placeholder = L('Chercher un réglage…', 'Search settings…'); document.querySelectorAll('#reactBar button').forEach((b) => b.setAttribute('aria-label', b.dataset.e));
1924
2041
  $('#gT').textContent = L('Rejoindre l’appel', 'Join the call'); $('#gP').textContent = L('Cliquez pour activer le son et suivre votre session Claude Code en direct.', 'Click to enable sound and follow your Claude Code session live.'); $('#gB').textContent = L('▶ Rejoindre', '▶ Join');
1925
- $('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').textContent = L('Lancez Claude Code dans ce dossier : l’appel démarrera tout seul.', 'Start Claude Code in this folder — the call will start by itself.');
2042
+ $('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').innerHTML = watched() ? esc(L('Lancez Claude Code dans {0} : l’appel démarrera tout seul.', 'Start Claude Code in {0} — the call will start by itself.')).replace('{0}', `<b>${esc(watched())}</b>`) : esc(L('Lancez Claude Code dans ce dossier : l’appel démarrera tout seul.', 'Start Claude Code in this folder — the call will start by itself.'));
1926
2043
  for (const id in S.agents) { const p = persona(S.agents[id]); Object.assign(S.agents[id], { name: p.name, role: p.role }); const t = $('#t-' + id); if (t) { t.querySelector('.tag span').textContent = p.role; t.querySelector('.tag b').textContent = p.name; if (id === 'main' || id === 'user') t.querySelector('.tag span').textContent = p.role; } }
1927
2044
  document.querySelectorAll('#menu .mi[data-t]').forEach((b) => b.querySelector('.sw').classList.toggle('on', !!prefs[b.dataset.t])); renderCoffee(); renderTokens(); updateHint(); renderLog(true); syncUI();
1928
2045
  }
@@ -1961,6 +2078,7 @@ function menuInit() {
1961
2078
  for (const e of sc.items) { const hit = !q || e.textContent.toLowerCase().includes(q) || [...e.querySelectorAll('option')].some((o) => o.textContent.toLowerCase().includes(q)); e.classList.toggle('mhide', !hit); if (hit && q) any = true; if (q) e.classList.remove('mfold'); else e.classList.toggle('mfold', sc.h.classList.contains('closed')); }
1962
2079
  sc.h.classList.toggle('mhide', !!q && !any && !sc.h.textContent.toLowerCase().includes(q)); if (q) sc.h.classList.remove('closed'); }
1963
2080
  if (!q) secs.forEach((sc, i) => sc.h.classList.toggle('closed', (prefs.menuClosed || []).includes(i)));
2081
+ $('#menuNone').style.display = q && !secs.some((sc) => sc.items.some((e) => !e.classList.contains('mhide'))) ? '' : 'none';
1964
2082
  };
1965
2083
  }
1966
2084
  $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'customLaptop') { for (const k in imgCache) delete imgCache[k]; buildStage(); paintStatus(); } if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify') { prefs.notifyChosen = true; savePrefs(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
@@ -1968,7 +2086,7 @@ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b)
1968
2086
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1969
2087
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1970
2088
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1971
- $('#chatSel').value = ['quiet', 'normal', 'lively'].includes(prefs.chat) ? prefs.chat : 'quiet'; $('#chatSel').onchange = () => { prefs.chat = $('#chatSel').value; savePrefs(); };
2089
+ $('#chatSel').value = ['quiet', 'normal', 'lively'].includes(prefs.chat) ? prefs.chat : 'normal'; $('#chatSel').onchange = () => { prefs.chat = $('#chatSel').value; savePrefs(); };
1972
2090
  $('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
1973
2091
  // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1974
2092
  let kokoroTimer = null;
@@ -2000,11 +2118,11 @@ $('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo()
2000
2118
  $('#mClip').onclick = () => { $('#menu').classList.remove('on'); exportVideo(0, 0, 25000); };
2001
2119
  $('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
2002
2120
  $('#seek').oninput = (e) => seekTo(+e.target.value);
2003
- $('#eDemo').onclick = () => setMode('demo');
2121
+ $('#eDemo').onclick = async () => { await setMode('demo'); if (S.demo && !S.playing) togglePlay(); }; // "Watch the demo" plays it
2004
2122
  $('#gB').onclick = () => {
2005
2123
  claimAudio(); AUD.init(); AUD.apply(); S.locked = false; $('#gate').classList.remove('on'); syncUI();
2006
2124
  if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
2007
- if (S.live && S.events.length) run(S.idx);
2125
+ S.since = 0; if (S.live && S.events.length) run(liveStart()); // what arrived while the door was closed is past too
2008
2126
  };
2009
2127
  // J / K (or a click on the “need you” chip) jump to the agents that need you or are stalled, one after the other
2010
2128
  function jumpAttn(dir = 1) {
@@ -2016,6 +2134,74 @@ function jumpAttn(dir = 1) {
2016
2134
  toast(`${nameOf(id)} — ${agentState(id, Date.now()).txt.replace(/^\S+\s/, '')}`);
2017
2135
  }
2018
2136
  $('#board').onclick = (e) => { if (e.target.closest('.blocked, .stalled')) jumpAttn(1); };
2137
+ // ---------- the header status: one line that tells the state of the SESSION (not of the playback), most urgent first ----------
2138
+ // offline › an agent needs you (who, since when) › stalled › agents at work (and who the lead waits on) › your turn (with the lead's last sentence) › idle.
2139
+ // It reads the same states as the tiles (agentState); a click goes to the agent it is about. Live, it replaces the count chips, which would say the same thing twice.
2140
+ function isTurn(id, st) { return id === 'main' && st.k === 'done' && S.status.main !== 'done'; } // the lead is done and waits for you: not a sub-agent's "done"
2141
+ function watched() { return niceProj(Q.get('proj') || prefs.proj || ''); } // the folder the live view follows, when it follows one
2142
+ function lastWord() { // the last sentence the lead said: a closing question ("Do you want me to commit?") stays on screen while it is your turn
2143
+ const key = (S.cur ? S.cur.id : '') + '|' + S.idx + '|' + S.events.length; if (lastWord.key === key) return lastWord.v;
2144
+ let e = null; for (let i = Math.min(S.idx, S.events.length - 1); i >= 0 && !e; i--) if (S.events[i].agent === 'main' && S.events[i].kind === 'say') e = S.events[i];
2145
+ lastWord.key = key; return (lastWord.v = e ? pieces(plain(e.text)).pop() || '' : '');
2146
+ }
2147
+ function paintPill() {
2148
+ const pill = $('#pill'), now = Date.now(); let cls = '', txt = '', to = '', tip = '', sess = false;
2149
+ if (S.offline) { cls = 'off'; txt = '⚠ ' + L('HORS LIGNE', 'OFFLINE'); }
2150
+ else if (!S.live) txt = 'REPLAY';
2151
+ else if (!S.cur) txt = L('En attente d’une session', 'Waiting for a session');
2152
+ else if (S.locked) txt = L('PRÊT', 'READY');
2153
+ else if (!S.playing) txt = L('PAUSE', 'PAUSED');
2154
+ else {
2155
+ sess = true; const st = {}, by = { working: [], waiting: [], blocked: [], stalled: [], done: [], idle: [] };
2156
+ for (const id in S.agents) if (id !== 'user' && !S.gone.has(id) && (id === 'main' || S.status[id])) { st[id] = agentState(id, now); by[st[id].k].push(id); }
2157
+ const what = (id) => `${nameOf(id)} — ${st[id].txt.replace(/^\S+\s/, '')}`, more = (a) => (a.length > 1 ? ` +${a.length - 1}` : ''), lead = st.main || { k: 'idle' };
2158
+ if (by.blocked.length) { cls = 'blocked'; to = by.blocked[0]; txt = '✋ ' + what(to) + more(by.blocked); }
2159
+ else if (by.stalled.length) { cls = 'stalled'; to = by.stalled[0]; txt = '⚠ ' + what(to) + more(by.stalled); }
2160
+ else if (by.working.length || by.waiting.length) { // "2 working · Claude — waiting on Ken, Mira", or what the only one at work is doing
2161
+ const w = by.working, wt = lead.k === 'waiting' ? 'main' : w.length ? '' : by.waiting[0]; cls = 'working'; to = wt || (w.length === 1 ? w[0] : 'main');
2162
+ txt = (w.length === 1 && !wt ? '⚙ ' + what(w[0]) : w.length ? `⚙ ${w.length} ${L('au travail', 'working')}` : '') + (wt ? (w.length ? ' · ' : '🔗 ') + what(wt) : '');
2163
+ }
2164
+ else if (isTurn('main', lead)) { const last = lastWord(); cls = 'turn'; to = 'main'; txt = '👉 ' + L('à vous', 'your turn') + (last ? ' · ' + (last.length > 110 ? last.slice(0, 108).replace(/\s+\S*$/, '') + '…' : last) : ''); tip = last; }
2165
+ else { cls = 'idle'; to = 'main'; txt = '💤 ' + L('inactif', 'idle'); }
2166
+ }
2167
+ const c = cls + (to ? ' go' : ''), sub = !sess && S.cur && S.title ? ' · ' + S.title : '';
2168
+ for (const el of [pill, $('#sline')]) { // the same status, in the header (wide) and on its own line (narrow)
2169
+ if (el.className !== c) el.className = c; if (el.textContent !== txt) el.textContent = txt; if (el.title !== (tip || txt)) el.title = tip || txt; if (el.dataset.sub !== sub) el.dataset.sub = sub;
2170
+ if (to && !el.hasAttribute('tabindex')) { el.tabIndex = 0; el.setAttribute('role', 'button'); } else if (!to && el.hasAttribute('tabindex')) { el.removeAttribute('tabindex'); el.removeAttribute('role'); }
2171
+ }
2172
+ S.pillTo = to; S.pillSess = sess;
2173
+ }
2174
+ function pillGo() { // to the agent the status is about; several need you: one after the other (as J does)
2175
+ const id = S.pillTo, t = id && $('#t-' + id); if (!t) return; if ((S.attn || []).includes(id)) { jumpAttn(1); return; }
2176
+ if (document.body.classList.contains('tree') || document.body.classList.contains('board')) setView('call');
2177
+ S.focusLock = Date.now() + 25000; setFocus(id); paintStates(); t.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); popTile(id);
2178
+ }
2179
+ $('#pill').onclick = $('#sline').onclick = pillGo; $('#pill').onkeydown = $('#sline').onkeydown = (e) => { if (S.pillTo && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); pillGo(); } };
2180
+ // ---------- keyboard: the door has the focus (Enter / Space joins, or starts the demo when there is no session); the ⚙ switches say their state; Escape closes and gives the focus back ----------
2181
+ function focusDoor() { // never taken from a field or a menu the person is in
2182
+ const b = $('#empty').classList.contains('on') ? $('#eDemo') : $('#gate').classList.contains('on') ? $('#gB') : null, a = document.activeElement;
2183
+ if (b && a !== b && (!a || a === document.body || a === $('#gB') || a === $('#eDemo'))) b.focus({ preventScroll: true });
2184
+ }
2185
+ for (const q of ['#gate', '#empty']) new MutationObserver(focusDoor).observe($(q), { attributes: true, attributeFilter: ['class'] });
2186
+ function syncSwitches() { document.querySelectorAll('#menu .mi[data-t]').forEach((b) => { b.setAttribute('role', 'switch'); b.setAttribute('aria-checked', String(!!prefs[b.dataset.t])); }); }
2187
+ new MutationObserver(() => { // the menu opens under the header and the status line, not over them
2188
+ const m = $('#menu'), on = m.classList.contains('on'), h = Math.round($('main').getBoundingClientRect().top); $('#bMenu').setAttribute('aria-expanded', String(on)); if (!on) return;
2189
+ syncSwitches(); m.style.top = h + 3 + 'px'; m.style.maxHeight = `calc(100dvh - ${h + 13}px)`;
2190
+ }).observe($('#menu'), { attributes: true, attributeFilter: ['class'] });
2191
+ $('#menu').addEventListener('click', () => setTimeout(syncSwitches)); $('#bMenu').setAttribute('aria-expanded', 'false'); syncSwitches();
2192
+ addEventListener('keydown', (e) => { // on the way down: Escape also works from the search box and the selects of the menu, which the shortcuts below ignore
2193
+ if (e.key !== 'Escape' || $('#switcher')) return;
2194
+ if ($('#menu').classList.contains('on')) { $('#menu').classList.remove('on'); $('#bMenu').focus(); }
2195
+ else if ($('#detail').classList.contains('on') && !$('#recap').classList.contains('on')) { const t = $('#t-' + S.detail); $('#dclose').click(); if (t && t.offsetParent) t.focus(); }
2196
+ }, true);
2197
+ // ---------- a bubble that is not whole on screen (last row of tiles, a row of the compact list below the fold) brings the stage to it ----------
2198
+ new MutationObserver((ms) => {
2199
+ for (const m of ms) {
2200
+ const b = m.target; if (b.className !== 'bubble' || !b.offsetHeight) continue; const st = $('#stage'), r = b.getBoundingClientRect(), s = st.getBoundingClientRect();
2201
+ let room = r.top - s.top - 10; for (const o of st.querySelectorAll('.bubble')) { const q = o.getBoundingClientRect(); if (o !== b && q.height && q.top >= s.top && q.bottom <= s.bottom) room = Math.min(room, q.top - s.top - 10); } // never at the cost of another line that is on screen
2202
+ if (r.bottom > s.bottom - 6) { if (room > 0) st.scrollBy({ top: Math.min(r.bottom - s.bottom + 10, room), behavior: 'smooth' }); } else if (r.top < s.top + 4) st.scrollBy({ top: r.top - s.top - 10, behavior: 'smooth' });
2203
+ }
2204
+ }).observe($('#stage'), { subtree: true, childList: true });
2019
2205
  // Replay: arrow keys step through the call, N jumps between highlights, speed presets, and the position of each session is remembered
2020
2206
  const stepKind = (e) => !['usage', 'results', 'finish', 'compact'].includes(e.kind);
2021
2207
  function stepEvent(dir) {