@double_geste/agent-standup 1.25.6 → 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,13 +522,14 @@ 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;
522
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
@@ -531,7 +544,14 @@ function short(t, max = 230) {
531
544
  return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
532
545
  }
533
546
  // make code-ish text pronounceable
534
- 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, ' ')
535
555
  .replace(/\s*[—–]\s*/g, ', ').replace(/\(([^)]{0,40})\)/g, ', $1,').replace(/\s+/g, ' ').trim();
536
556
 
537
557
  // ---------- translation (local claude CLI) ----------
@@ -619,7 +639,7 @@ const AUD = {
619
639
  const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3; const g = c.createGain();
620
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);
621
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); }
622
- 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;
623
643
  },
624
644
  stopAll() { for (const s of this.live) { try { s.stop(); } catch {} } this.live.clear(); },
625
645
  };
@@ -712,9 +732,9 @@ function rankVoices(list) { // list of names → best distinct voices first
712
732
  return out.slice(0, 5);
713
733
  }
714
734
  const TTS = {
715
- 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,
716
736
  async init() {
717
- 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 = [];
718
738
  try {
719
739
  if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
720
740
  const k = await (await fetch('/api/voices?engine=kokoro')).json();
@@ -725,22 +745,48 @@ const TTS = {
725
745
  },
726
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; },
727
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; },
728
- buf(text, voice, rate) {
729
- 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
730
752
  if (!this.cache.has(key)) {
731
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)));
732
754
  if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
733
- 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
734
757
  }
735
758
  return this.cache.get(key);
736
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
+ },
737
764
  };
738
- function warmTTS(i) { // synthesize the next few lines ahead of time
739
- if (!TTS.server || !AUD.ctx || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
740
- let n = 0; for (let k = i; k < S.events.length && n < 4; k++) {
741
- const e = S.events[k]; if (quiet(e)) continue; n++;
742
- 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;
743
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
744
790
  }
745
791
 
746
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.
@@ -768,7 +814,7 @@ async function speakBabble(text, p, gain, token) {
768
814
  const base = p.lead ? 150 : p.id === 'user' ? 190 : 200 + ((p.vi >>> 2) % 9) * 30, an = c.createAnalyser(); an.fftSize = 256;
769
815
  const g = c.createGain(); g.gain.value = gain * (sims ? 0.4 : 0.6); an.connect(g); g.connect(AUD.voice);
770
816
  const clauses = clean.match(/[^.!?;:]+[.!?;:]*/g) || [clean], nodes = []; let t = c.currentTime + 0.03; const end = t + dur;
771
- 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
772
818
  const vowelGroups = (w) => w.match(new RegExp(`[^${VOW}]*[${VOW}]+(?:[^${VOW}]+(?![${VOW}]))?`, 'g')) || [w];
773
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));
774
820
  const letterStep = Math.max(0.045, Math.min(0.085, dur / Math.max(8, clean.length)));
@@ -803,7 +849,8 @@ async function speakBabble(text, p, gain, token) {
803
849
  t += unit * (sims ? 1.2 : 2.4);
804
850
  }
805
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; };
806
- 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
807
854
  nodes.forEach((o) => AUD.live.delete(o)); delete S.level[p.id];
808
855
  }
809
856
  async function speakServer(text, p, gain, token, fr = FR()) {
@@ -816,22 +863,24 @@ async function speakServer(text, p, gain, token, fr = FR()) {
816
863
  const data = new Uint8Array(an.fftSize);
817
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
818
865
  AUD.live.add(src);
819
- captionRun(p.id, text, buf.duration * 1000); await new Promise((res) => { src.onended = res; setTimeout(res, buf.duration * 1000 + 800); src.start(); });
820
- 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 {} });
821
869
  }
822
870
  function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
823
871
  return new Promise((resolve) => {
824
872
  const vs = synth.getVoices().filter((v) => v.lang.toLowerCase().startsWith(fr ? 'fr' : 'en')); if (!vs.length) return resolve(false);
825
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];
826
- 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);
827
875
  const fin = (ok) => { if (!done) { done = true; resolve(ok); } };
828
- setTimeout(() => { if (!started) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
829
- 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
830
878
  const next = () => {
831
879
  if (i >= parts.length || done || token !== S.token) return fin(true);
832
- 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();
833
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());
834
- 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);
835
884
  };
836
885
  next();
837
886
  });
@@ -854,13 +903,33 @@ function setCaption(id, text) {
854
903
  const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
855
904
  b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
856
905
  }
857
- 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
858
907
  captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
859
- const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0; setCaption(id, ps[0]); at += totalMs * ps[0].length / n;
860
- 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; }
861
- 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);
862
911
  }
863
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
+ }
864
933
  async function speakLine(text, p, primary, token, fr = frVoice(text)) {
865
934
  captionRun(p.id, text, estMs(text));
866
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)); }
@@ -868,7 +937,9 @@ async function speakLine(text, p, primary, token, fr = frVoice(text)) {
868
937
  if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
869
938
  if (TTS.server && AUD.ctx) { try { await speakServer(text, p, primary ? 1 : 0.5, token, fr); return; } catch {} }
870
939
  if (primary && synth && !EMBED && (await speakBrowser(text, p, token, fr))) return;
871
- 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
872
943
  }
873
944
 
874
945
  // =====================================================================
@@ -896,19 +967,22 @@ const layout = () => {
896
967
  if (nar) { k = cols; fr = cols > 1 ? 2 : 1; } // side panel: small tiles, a banner-sized spotlight
897
968
  else if (best < 0) { k = Math.min(2, cols); fr = Math.min(2, maxRows); }
898
969
  st.style.setProperty('--fcols', k); st.style.setProperty('--fr', fr); st.classList.toggle('one', cols === 1);
899
- 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();
900
971
  };
901
972
  new ResizeObserver(layout).observe($('#center'));
902
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
+ }
903
979
  function renderLog(full) {
904
980
  const log = $('#log'), from = S.live ? Math.max(0, S.events.length - 300) : 0;
905
981
  if (full || S.logTo < from || S.logTo > S.events.length) { log.innerHTML = ''; S.logTo = from; }
906
982
  const html = [];
907
983
  for (let i = S.logTo; i < S.events.length; i++) {
908
984
  if (S.events[i].kind === 'results' || S.events[i].kind === 'usage' || S.events[i].kind === 'finish' || S.events[i].kind === 'compact') continue;
909
- const e = S.events[i], arrow = e.to ? ` <span class="arrow">→</span> <span class="who">${esc(nameOf(e.to))}</span>` : '';
910
- 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];
911
- 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));
912
986
  }
913
987
  log.insertAdjacentHTML('beforeend', html.join('')); S.logTo = S.events.length;
914
988
  if (S.live) while (log.children.length > 320) log.firstChild.remove();
@@ -932,9 +1006,10 @@ const applyStatus = (ev) => {
932
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'); }
933
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]; }
934
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)
935
1010
  };
936
1011
  function recomputeStatus(upTo) {
937
- 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();
938
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 = ''; });
939
1014
  for (let i = 0; i < upTo; i++) {
940
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;
@@ -947,9 +1022,7 @@ function recomputeStatus(upTo) {
947
1022
  }
948
1023
  function syncUI() {
949
1024
  $('#bPlay').textContent = S.playing ? '⏸' : '▶';
950
- const pill = $('#pill'); pill.className = '';
951
- if (S.offline) { pill.textContent = L('HORS LIGNE', 'OFFLINE'); }
952
- 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();
953
1026
  document.body.classList.toggle('replay', !S.live);
954
1027
  }
955
1028
 
@@ -976,6 +1049,7 @@ function tally(ev) { // numbers that pile up over a session: token usage, erro
976
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); }
977
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); });
978
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
979
1053
  }
980
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)}`;
981
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' }; };
@@ -999,7 +1073,10 @@ const NEEDS_OK = /^(Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion|ExitPlanMo
999
1073
  const atTail = () => S.idx >= S.events.length - 3; // only trust wall-clock ages when playback has caught up with the live end
1000
1074
  function agentState(id, now) {
1001
1075
  let s = S.status[id]; const r = S.run[id], w = S.waitOn[id], la = S.lastAct[id];
1002
- 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
+ }
1003
1080
  if (s === 'done') return { k: 'done', txt: '✓ ' + L('terminé', 'done') };
1004
1081
  if (!s) return { k: 'idle', txt: '' };
1005
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) }; }
@@ -1019,7 +1096,7 @@ function paintStates() {
1019
1096
  if (p.id === 'user') continue; const t = $('#t-' + p.id); if (!t) continue;
1020
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); }
1021
1098
  STATE_CLASSES.forEach((c) => t.classList.toggle(c, st.k === c));
1022
- 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' : '');
1023
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"
1024
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); }
1025
1102
  else if (!pr.classList.contains('cheer')) { if (pr.textContent !== want) pr.textContent = want; pr.className = 'prop ' + (want ? st.k : ''); }
@@ -1028,15 +1105,16 @@ function paintStates() {
1028
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; }
1029
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;
1030
1107
  }
1108
+ paintPill();
1031
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')]]
1032
- .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('');
1033
1111
  if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
1034
1112
  }
1035
1113
  const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
1036
1114
  function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
1037
1115
  const box = $('#roster'); if (!box || !document.body.classList.contains('rost')) return;
1038
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)));
1039
- 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('');
1040
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>` : ''}`;
1041
1119
  if (box.dataset.h !== html) { box.dataset.h = html; box.innerHTML = html; }
1042
1120
  }
@@ -1077,6 +1155,7 @@ function setFav(color) {
1077
1155
  function updateTitle() {
1078
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;
1079
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
1080
1159
  watchDone(n); setFav(S.live && S.nBlocked ? '#ff4d4d' : null);
1081
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';
1082
1161
  }
@@ -1119,7 +1198,7 @@ function setTicker(id, e) { // the strip on the tile: spinner while running, t
1119
1198
  tk.classList.add('on'); if (running) { tk.classList.remove('flash'); void tk.offsetWidth; tk.classList.add('flash'); }
1120
1199
  }
1121
1200
  function tickTools(ev) {
1122
- 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 = '';
1123
1202
  ev.tools.forEach((tool) => pushTerm(ev.agent, tool));
1124
1203
  const buf = S.term[ev.agent], k = ev.tools[ev.tools.length - 1];
1125
1204
  S.tc[ev.agent] = (S.tc[ev.agent] || 0) + ev.tools.length; S.lastTool[ev.agent] = k.name + ' ' + (k.arg || '').slice(-38);
@@ -1351,9 +1430,8 @@ function openExternal(url) { if (EMBED) window.parent.postMessage({ agentStandup
1351
1430
  function renderCoffee() {
1352
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();
1353
1432
  const h = $('#bCoffee'); h.style.display = on ? '' : 'none'; h.title = coffeeMsg(); h.setAttribute('aria-label', coffeeMsg());
1354
- 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');
1355
1433
  }
1356
- $('#bCoffee').onclick = $('#gCoffee').onclick = () => openExternal(SUPPORT.url);
1434
+ $('#bCoffee').onclick = () => openExternal(SUPPORT.url);
1357
1435
  $('#mCoffee').onclick = () => { $('#menu').classList.remove('on'); openExternal(SUPPORT.url); };
1358
1436
 
1359
1437
  // =====================================================================
@@ -1449,7 +1527,7 @@ function ackLine(ev) {
1449
1527
  // =====================================================================
1450
1528
  // conversation: short exchanges between agents at the right moments, built from what really happened in the session
1451
1529
  // =====================================================================
1452
- 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
1453
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'] } };
1454
1532
  const P = (k, n) => { const w = EXW[FR() ? 'fr' : 'en'][k]; return `${n} ${n === 1 ? w[0] : w[1]}`; };
1455
1533
  const EXCH = {
@@ -1488,8 +1566,9 @@ function exchFacts(i, agent) { // what this agent did since it received its or
1488
1566
  f.what = f.edits ? P('edit', f.edits) : f.tools ? P('call', f.tools) : L('rien à signaler', 'nothing to report'); return f;
1489
1567
  }
1490
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
1491
1570
  async function exSay(id, line, token) { // one line of an exchange, then a short human pause
1492
- 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);
1493
1572
  if (token !== S.token) return false; await sleep((150 + Math.random() * 250) / spd()); return token === S.token;
1494
1573
  }
1495
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();
@@ -1553,7 +1632,7 @@ async function react(ev, token) {
1553
1632
  else return;
1554
1633
  }
1555
1634
  else return;
1556
- 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);
1557
1636
  }
1558
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.
1559
1638
  const FILL = {
@@ -1577,7 +1656,7 @@ const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? '
1577
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]; };
1578
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
1579
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;
1580
- 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);
1581
1660
  }
1582
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
1583
1662
  const LEAD = {
@@ -1585,19 +1664,19 @@ const LEAD = {
1585
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 ' },
1586
1665
  };
1587
1666
  async function leadAck(ev, token) {
1588
- S.owed = false; if (!prefs.banter || S.hurry || S.boost > 1 || !caughtUp() || S.speakers.size || !S.agents.main) return;
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
1589
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;
1590
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; }
1591
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; }
1592
- const line = `${W.in[hash('k' + ev.ts) % W.in.length]} ${what}`; startTalk('main', line, '💬'); await speakLine(line, S.agents.main, true, token); stopTalk('main');
1671
+ await sayLine('main', `${W.in[hash('k' + ev.ts) % W.in.length]} ${what}`, '💬', token);
1593
1672
  }
1594
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
1595
1674
  if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
1596
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;
1597
- 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
1598
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;
1599
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);
1600
- startTalk('main', line, '🎉'); await speakLine(line, S.agents.main, true, token); stopTalk('main'); return true;
1679
+ await sayLine('main', line, '🎉', token); return true;
1601
1680
  }
1602
1681
  async function smallTalk(token) {
1603
1682
  if (prefs.chat === 'quiet') return; // quiet: nothing to fill the silences, only the real exchanges
@@ -1625,7 +1704,8 @@ async function run(i) {
1625
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();
1626
1705
  if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
1627
1706
  while (token === S.token) {
1628
- 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
1629
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; }
1630
1710
  if (S.idle) { S.idle = false; syncUI(); }
1631
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
@@ -1663,19 +1743,19 @@ async function run(i) {
1663
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; }
1664
1744
  prefetchQuips(i); warmTTS(i);
1665
1745
  const playOne = (g) => {
1666
- applyStatus(g); if (g.agent === 'user') S.owed = true; else if (g.agent === 'main') S.owed = false; // your order waits for a word from the lead
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
1667
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);
1668
1748
  startTalk(g.agent, line, label);
1669
1749
  if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
1670
1750
  if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
1671
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 }; }
1672
- 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); });
1673
1753
  };
1674
1754
  let plays;
1675
1755
  if (ev.kind === 'order' && group.length > 1) { // the first order in full, then "Also Remy on X, Iris on Y"
1676
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(', ')}.`;
1677
1757
  setTimeout(() => rest.forEach((o, k) => setTimeout(() => { fly(o.agent, o.to, '📨'); popTile(o.to); }, k * 120)), 30);
1678
- 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); }); })];
1679
1759
  }
1680
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
1681
1761
  S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
@@ -1695,7 +1775,7 @@ function clipSpeech(t, cap) { // the bubble keeps the full text; the voice rea
1695
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(); }
1696
1776
  function togglePlay() {
1697
1777
  if (S.locked) return;
1698
- 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)
1699
1779
  }
1700
1780
  function seekTo(i) {
1701
1781
  pause(); i = Math.max(0, Math.min(S.events.length - 1, i)); S.idx = i; recomputeStatus(i); paintStatus(); S.userPaused = false; run(i);
@@ -1719,7 +1799,7 @@ async function loadSession(proj, id) {
1719
1799
  const data = await (await fetch(`/api/session?proj=${encodeURIComponent(proj)}&id=${id}`)).json();
1720
1800
  if (mine !== S.loadN || data.error) return false;
1721
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)));
1722
- 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');
1723
1803
  const opt = $('#sessions').selectedOptions[0]; S.title = (S.live ? niceProj(proj) : opt && opt.dataset.title) || '';
1724
1804
  if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
1725
1805
  $('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
@@ -1748,7 +1828,16 @@ function midWork() { // someone is in the middle of something: the session doe
1748
1828
  function liveStart() {
1749
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
1750
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--;
1751
- recomputeStatus(i); S.idx = i; trNew(Math.max(0, n - 16)); paintStatus(); return 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
1752
1841
  }
1753
1842
  let polling = false;
1754
1843
  setInterval(async () => {
@@ -1756,12 +1845,16 @@ setInterval(async () => {
1756
1845
  try {
1757
1846
  const switched = await followCurrent(); if (S.offline) { S.offline = false; syncUI(); }
1758
1847
  if (switched || !S.cur) return;
1759
- const d = await (await fetch(`/api/session?proj=${encodeURIComponent(S.cur.proj)}&id=${S.cur.id}&after=${S.events.length}`)).json();
1760
- if (d.error) return; if (d.total < S.events.length) { S.cur = null; return; } // transcript was rewritten: reload next tick
1761
- 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;
1762
1851
  for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
1763
- const from = S.events.length; S.events = S.events.concat(d.events); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false);
1764
- 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);
1765
1858
  paintStatus();
1766
1859
  } catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
1767
1860
  }, 400);
@@ -1942,10 +2035,11 @@ function applyLang() {
1942
2035
  renderCharInfo();
1943
2036
  document.documentElement.lang = (LANGS.find((l) => l[0] === prefs.lang) || LANGS[0])[2];
1944
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
1945
2039
  const sel = $('#langSel'); if (!sel.options.length) sel.innerHTML = LANGS.map(([c, n]) => `<option value="${c}">${n}</option>`).join(''); sel.value = prefs.lang;
1946
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));
1947
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');
1948
- $('#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.'));
1949
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; } }
1950
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();
1951
2045
  }
@@ -1984,6 +2078,7 @@ function menuInit() {
1984
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')); }
1985
2079
  sc.h.classList.toggle('mhide', !!q && !any && !sc.h.textContent.toLowerCase().includes(q)); if (q) sc.h.classList.remove('closed'); }
1986
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';
1987
2082
  };
1988
2083
  }
1989
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(); }
@@ -1991,7 +2086,7 @@ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b)
1991
2086
  $('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
1992
2087
  $('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
1993
2088
  $('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
1994
- $('#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(); };
1995
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(); };
1996
2091
  // ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
1997
2092
  let kokoroTimer = null;
@@ -2023,7 +2118,7 @@ $('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo()
2023
2118
  $('#mClip').onclick = () => { $('#menu').classList.remove('on'); exportVideo(0, 0, 25000); };
2024
2119
  $('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
2025
2120
  $('#seek').oninput = (e) => seekTo(+e.target.value);
2026
- $('#eDemo').onclick = () => setMode('demo');
2121
+ $('#eDemo').onclick = async () => { await setMode('demo'); if (S.demo && !S.playing) togglePlay(); }; // "Watch the demo" plays it
2027
2122
  $('#gB').onclick = () => {
2028
2123
  claimAudio(); AUD.init(); AUD.apply(); S.locked = false; $('#gate').classList.remove('on'); syncUI();
2029
2124
  if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
@@ -2039,6 +2134,74 @@ function jumpAttn(dir = 1) {
2039
2134
  toast(`${nameOf(id)} — ${agentState(id, Date.now()).txt.replace(/^\S+\s/, '')}`);
2040
2135
  }
2041
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 });
2042
2205
  // Replay: arrow keys step through the call, N jumps between highlights, speed presets, and the position of each session is remembered
2043
2206
  const stepKind = (e) => !['usage', 'results', 'finish', 'compact'].includes(e.kind);
2044
2207
  function stepEvent(dir) {