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