@double_geste/agent-standup 1.25.6 → 1.25.8
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 +344 -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 +154 -86
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,14 @@
|
|
|
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.musing:not(.speaking):not(.bye) .bubble { display:block; top:auto; bottom:36px; max-width:min(80%, 320px); font-size:12px; font-style:italic; color:#40455c; background:rgba(255,255,255,.88); border-radius:14px; box-shadow:0 2px 8px rgba(0,0,0,.28); animation:fadein .5s } /* an inner thought: not said, so lower, lighter, and never over the status at the top */
|
|
164
|
+
.tile.focus.musing:not(.speaking):not(.bye) .bubble { bottom:46px; font-size:13px } .tile.musing:not(.speaking):not(.bye) .bubble small { display:inline; margin-right:5px; font-style:normal }
|
|
165
|
+
.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
166
|
.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
167
|
.ticker.on { display:block } .ticker i { font-style:normal; color:#ffd24a } .ticker.flash { animation:flash .25s } @keyframes flash { from { background:#3b5bdb } }
|
|
157
168
|
/* character animation: all state-driven from the tile's classes */
|
|
@@ -182,32 +193,33 @@
|
|
|
182
193
|
|
|
183
194
|
/* ---------- overlays ---------- */
|
|
184
195
|
#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) }
|
|
196
|
+
#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 */
|
|
197
|
+
#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) }
|
|
198
|
+
.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
199
|
.btn { background:var(--panel2); border:1px solid var(--line); padding:6px 12px; border-radius:8px } .btn:hover { border-color:var(--accent) }
|
|
189
200
|
#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
201
|
.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
202
|
.glink { color:var(--dim); font-size:12.5px; text-decoration:underline; text-underline-offset:3px } .glink:hover { color:#ffd24a }
|
|
192
203
|
#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) }
|
|
204
|
+
#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
205
|
#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
206
|
#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) }
|
|
207
|
+
#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
208
|
#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
|
-
|
|
209
|
+
/* 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 */
|
|
210
|
+
body.compact:not(.tree):not(.board) #stage { display:flex; flex-direction:column; gap:6px; padding:8px; overflow:auto }
|
|
211
|
+
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 }
|
|
212
|
+
body.compact #stage .tile > svg:first-child { width:54px; height:54px; flex:none; border-radius:9px 0 0 9px }
|
|
213
|
+
body.compact #stage .tile .thought, body.compact #stage .tile .ticker, body.compact #stage .tile .tbadge { display:none !important }
|
|
214
|
+
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 }
|
|
215
|
+
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 }
|
|
216
|
+
body.compact #stage .tile .tag span:not(.tk):not(.cx) { display:none }
|
|
217
|
+
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% }
|
|
218
|
+
body.compact #stage .tile .prop { top:2px; left:34px; right:auto; font-size:16px }
|
|
219
|
+
body.compact #stage .tile .status:empty { display:none }
|
|
220
|
+
body.compact #stage .tile .tag { order:1 } body.compact #stage .tile .status { order:2 } body.compact #stage .tile .prop { order:3 }
|
|
221
|
+
@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 */
|
|
222
|
+
body.tiny #present { display:none !important } body.tiny #hl { display:none } body.tiny #gantt { display:none } /* under 300 px: the rows only */
|
|
211
223
|
#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
224
|
.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
225
|
.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 +257,18 @@
|
|
|
245
257
|
<div class="brand"><div class="logo">🎥</div><div><b>Agent Standup</b><small id="subtitle"></small></div></div>
|
|
246
258
|
<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
259
|
<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>
|
|
260
|
+
<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
261
|
<button class="ic" id="bPlay" title="Pause / Lecture (espace)">⏸</button>
|
|
250
262
|
<button class="ic" id="bLog" title="Transcript">☰</button>
|
|
251
263
|
<button class="ic" id="bMute" title="Son (M)">🔊</button>
|
|
252
264
|
<button class="ic coffee-btn" id="bCoffee" style="display:none">☕</button>
|
|
253
265
|
<button class="ic" id="bMenu" title="Réglages">⚙</button>
|
|
254
266
|
</header>
|
|
267
|
+
<div id="sline"></div>
|
|
255
268
|
|
|
256
269
|
<div id="menu">
|
|
257
270
|
<input id="menuQ" type="search" placeholder="Search settings…" aria-label="Search settings" style="margin:4px 8px 2px;width:calc(100% - 16px)">
|
|
271
|
+
<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
272
|
<h4 data-fr="Langue" data-en="Language">Langue</h4>
|
|
259
273
|
<div class="box"><select id="langSel" aria-label="Language"></select></div>
|
|
260
274
|
<div class="hint" id="trHint" style="display:none"></div>
|
|
@@ -265,7 +279,7 @@
|
|
|
265
279
|
<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
280
|
<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
281
|
<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>
|
|
282
|
+
<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
283
|
<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
284
|
<h4 data-fr="Affichage" data-en="Display">Affichage</h4>
|
|
271
285
|
<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 +317,10 @@
|
|
|
303
317
|
<canvas id="gantt" height="90"></canvas>
|
|
304
318
|
<div id="toasts" role="status" aria-live="polite"></div>
|
|
305
319
|
<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
|
|
320
|
+
<div id="gate"><h2 id="gT"></h2><p id="gP"></p><button class="cta" id="gB"></button></div>
|
|
307
321
|
<div id="detail"><div class="hd"><b id="dname"></b><button class="ic" id="dclose">✕</button></div><div id="dbody"></div></div>
|
|
308
322
|
</section>
|
|
309
|
-
<aside><h2 id="logH">Transcript</h2><input id="logQ" type="search" placeholder="Search…" aria-label="Search the transcript" style="margin:
|
|
323
|
+
<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
324
|
</main>
|
|
311
325
|
<footer>
|
|
312
326
|
<input id="seek" type="range" min="0" max="0" value="0"><span class="dim" id="pos">0 / 0</span>
|
|
@@ -510,13 +524,14 @@ function avatarSVG(p, o, uid = '') { // uid keeps SVG ids unique per instance:
|
|
|
510
524
|
// =====================================================================
|
|
511
525
|
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
526
|
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 };
|
|
527
|
+
clock: 0, doneAt: {}, gone: new Set(), leaving: new Set(), demo: false, hold: false, late: [], byK: new Map(), pos: 0 };
|
|
514
528
|
const VANISH_MS = 120000; // a finished agent leaves the call after 2 minutes (playback time)
|
|
515
529
|
// 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
530
|
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
|
|
531
|
+
if (!S.live || !S.events.length) return 0; const e = S.events[Math.max(0, Math.min(i, S.events.length - 1))];
|
|
532
|
+
return Math.max(0, S.events[S.events.length - 1]._t - e._t);
|
|
519
533
|
};
|
|
534
|
+
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
535
|
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
536
|
const caughtUp = () => !S.live || lagMs() < 6000;
|
|
522
537
|
const nearLive = () => !S.live || lagMs() < 30000; // an answer to what was just said (a word after an order, a thank-you after a report) still fits here: reading the line took longer than 6 s and the session went on meanwhile
|
|
@@ -531,7 +546,14 @@ function short(t, max = 230) {
|
|
|
531
546
|
return end > 80 ? cut.slice(0, end + 1) : cut.replace(/\s+\S*$/, '') + '…';
|
|
532
547
|
}
|
|
533
548
|
// make code-ish text pronounceable
|
|
534
|
-
const
|
|
549
|
+
const PER = /^(?:s|sec|ms|min|h|hr|j|jour|d|day|mois|mo|month|an|yr|year)$/i;
|
|
550
|
+
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")
|
|
551
|
+
const tail = (body.match(/[.-]+$/) || [''])[0], core = body.slice(0, body.length - tail.length), segs = core.split('/').filter(Boolean), last = segs[segs.length - 1] || '';
|
|
552
|
+
if (segs.every((s) => /^\d+(?:[.,]\d+)?$/.test(s))) return m; // a fraction, a date, 24/7: the voice reads those by itself
|
|
553
|
+
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
|
|
554
|
+
return PER.test(last) ? m : pre + segs.join(' ') + tail; // km/h, req/s: a unit, left to the voice
|
|
555
|
+
}
|
|
556
|
+
const speakable = (t) => t.replace(/https?:\/\/(?:www\.)?([\w-]+(?:\.[\w-]+)*)\S*?(?=[.,;:!?)\]]*(?:\s|$))/g, '$1').replace(/(^|[^\wÀ-ÿ/.~@+-])((?:~|\.\.?|[A-Za-z]:)?\/)?((?:[\wÀ-ÿ.@+-]+\/)+[\wÀ-ÿ.+-]*)/g, slashSay).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_`*#<>{}\[\]|\\=~^]/g, ' ')
|
|
535
557
|
.replace(/\s*[—–]\s*/g, ', ').replace(/\(([^)]{0,40})\)/g, ', $1,').replace(/\s+/g, ' ').trim();
|
|
536
558
|
|
|
537
559
|
// ---------- translation (local claude CLI) ----------
|
|
@@ -614,12 +636,12 @@ const AUD = {
|
|
|
614
636
|
},
|
|
615
637
|
join() { this.tone(523, .14, 'triangle', .1); this.tone(784, .2, 'triangle', .1, .12); },
|
|
616
638
|
ding() { this.tone(988, .5, 'triangle', .14); this.tone(1318, .5, 'sine', .06, .02); },
|
|
617
|
-
murmur(pitch, dur) { // cartoon mumble, used when there is no real voice
|
|
639
|
+
murmur(pitch, dur, vol = 1) { // cartoon mumble, used when there is no real voice (vol: lower for a thought that escapes)
|
|
618
640
|
if (!this.ctx || !prefs.voice) return; const c = this.ctx, t = c.currentTime, o = c.createOscillator(); o.type = 'sawtooth';
|
|
619
641
|
const bp = c.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3; const g = c.createGain();
|
|
620
|
-
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);
|
|
642
|
+
g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(0.08 * vol, t + 0.05); g.gain.setValueAtTime(0.08 * vol, t + dur - 0.1); g.gain.linearRampToValueAtTime(0, t + dur);
|
|
621
643
|
const base = 85 + pitch * 70; for (let x = 0; x < dur; x += 0.11) { o.frequency.setValueAtTime(base * (0.8 + Math.random() * 0.55), t + x); bp.frequency.setValueAtTime(500 + Math.random() * 900, t + x); }
|
|
622
|
-
o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o);
|
|
644
|
+
o.connect(bp).connect(g).connect(this.voice); o.start(t); o.stop(t + dur + 0.05); this.live.add(o); o.onended = () => this.live.delete(o); return o;
|
|
623
645
|
},
|
|
624
646
|
stopAll() { for (const s of this.live) { try { s.stop(); } catch {} } this.live.clear(); },
|
|
625
647
|
};
|
|
@@ -712,9 +734,9 @@ function rankVoices(list) { // list of names → best distinct voices first
|
|
|
712
734
|
return out.slice(0, 5);
|
|
713
735
|
}
|
|
714
736
|
const TTS = {
|
|
715
|
-
server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(),
|
|
737
|
+
server: false, engine: 'system', platform: '', pool: { fr: [], en: [] }, cache: new Map(), made: new Map(), q: [], busy: false, now: 0,
|
|
716
738
|
async init() {
|
|
717
|
-
this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear();
|
|
739
|
+
this.engine = 'system'; this.server = false; this.pool = { fr: [], en: [] }; this.cache.clear(); this.made.clear(); this.q = [];
|
|
718
740
|
try {
|
|
719
741
|
if (prefs.vstyle === 'kokoro') { // natural voices installed on demand; until they are, the system voices stay in use
|
|
720
742
|
const k = await (await fetch('/api/voices?engine=kokoro')).json();
|
|
@@ -725,22 +747,48 @@ const TTS = {
|
|
|
725
747
|
},
|
|
726
748
|
voiceFor(p, fr = FR()) { const pool = this.pool[fr ? 'fr' : 'en']; return pool.length ? (p.id === 'main' ? pool[0] : pool[(p.vi >>> 2) % pool.length]) : null; },
|
|
727
749
|
rate() { return Math.round(Math.max(120, Math.min(330, 178 * (S.live ? 1.12 * lagFactor() * (S.boost > 1 ? 1.25 : 1) : +$('#speed').value))) / 10) * 10; },
|
|
728
|
-
|
|
729
|
-
|
|
750
|
+
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
|
|
751
|
+
buf(text, voice, rate, ahead) { // ahead: made in advance (see warmTTS); otherwise the line is wanted now
|
|
752
|
+
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
|
|
753
|
+
if (!ahead && S.live && !got.includes(rate)) { const r = got.find((x) => Math.abs(x - rate) <= rate * 0.2 && this.cache.has(K(x))); if (r) return this.cache.get(K(r)); } // made ahead at the rate of a moment ago (the call fell behind or caught up since): played as it is, a second of silence is worse than a slightly different pace
|
|
730
754
|
if (!this.cache.has(key)) {
|
|
731
755
|
this.cache.set(key, fetch(`/api/tts?engine=${this.engine}&voice=${encodeURIComponent(voice)}&rate=${rate}&text=${encodeURIComponent(text)}`).then((r) => { if (!r.ok) throw new Error('tts'); return r.arrayBuffer(); }).then((b) => AUD.ctx.decodeAudioData(b)));
|
|
732
756
|
if (this.cache.size > 60) this.cache.delete(this.cache.keys().next().value);
|
|
733
|
-
this.cache.get(key).
|
|
757
|
+
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));
|
|
758
|
+
if (!ahead) { this.now++; const end = () => { this.now--; this.pump(); }; this.cache.get(key).then(end, end); } // a line wanted now never waits behind the ones made ahead: they stop being started until it is ready
|
|
734
759
|
}
|
|
735
760
|
return this.cache.get(key);
|
|
736
761
|
},
|
|
762
|
+
pump() { // the lines made ahead: one at a time, in the order they will be said
|
|
763
|
+
if (this.busy || this.now || !this.q.length) return; const j = this.q.shift(); this.busy = true;
|
|
764
|
+
const end = () => { this.busy = false; this.pump(); }; this.buf(j.text, j.voice, j.rate, true).then(end, end);
|
|
765
|
+
},
|
|
737
766
|
};
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
767
|
+
// 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
|
|
768
|
+
// 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
|
|
769
|
+
// (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).
|
|
770
|
+
function paceAt(k) {
|
|
771
|
+
if (!S.live) return { pace: 0, boost: 1 };
|
|
772
|
+
const q = aheadSpeakable(k, 14), lg = lagMs(k) / 1000, hurry = q > 12 || lg > 45; // hurry: the line is shown without a voice
|
|
773
|
+
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 };
|
|
774
|
+
}
|
|
775
|
+
function warmTTS(i) { // called by run() before each line (i is that line) and when new events arrive (i is the first of them)
|
|
776
|
+
if (!TTS.server || !AUD.ctx || AUD.off() || !prefs.voice || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') return;
|
|
777
|
+
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
|
|
778
|
+
for (let k = from; k < Math.min(len, from + 400) && n < 4;) {
|
|
779
|
+
const e = S.events[k]; if (quiet(e)) { k++; continue; }
|
|
780
|
+
if (S.live && len - k > 300) break; // a backlog that run() skips
|
|
781
|
+
const group = [e]; let j = k + 1;
|
|
782
|
+
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; } }
|
|
783
|
+
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; } }
|
|
784
|
+
const pl = paceAt(k), many = e.kind === 'order' && group.length > 1, rate = TTS.rateAt(many || !S.live ? k : j - 1, pl.boost);
|
|
785
|
+
for (const g of many ? [e] : group) { // orders in a row: only the first is read out
|
|
786
|
+
n++; const p = S.agents[g.agent], bt = baseText(g); if ((pl.hurry && g.kind !== 'user') || !p || (FR() && !trMem.has(bt) && langOf(bt) !== 'fr')) continue; // not translated yet: what will be said is not known
|
|
787
|
+
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 });
|
|
788
|
+
}
|
|
789
|
+
k = j;
|
|
743
790
|
}
|
|
791
|
+
TTS.q = (fresh ? [] : TTS.q.filter((x) => x.k < from)).concat(jobs); TTS.pump(); // the lines of the group being read keep their place at the front
|
|
744
792
|
}
|
|
745
793
|
|
|
746
794
|
// ---- Cartoon voices: the same system voice, given a character by a small WebAudio chain (pitch, vibrato, robot, radio…), or syllable "babble" in the spirit of Animal Crossing.
|
|
@@ -768,7 +816,7 @@ async function speakBabble(text, p, gain, token) {
|
|
|
768
816
|
const base = p.lead ? 150 : p.id === 'user' ? 190 : 200 + ((p.vi >>> 2) % 9) * 30, an = c.createAnalyser(); an.fftSize = 256;
|
|
769
817
|
const g = c.createGain(); g.gain.value = gain * (sims ? 0.4 : 0.6); an.connect(g); g.connect(AUD.voice);
|
|
770
818
|
const clauses = clean.match(/[^.!?;:]+[.!?;:]*/g) || [clean], nodes = []; let t = c.currentTime + 0.03; const end = t + dur;
|
|
771
|
-
const track = (o) => { AUD.live.add(o); nodes.push(o); };
|
|
819
|
+
let tail = 0; const track = (o) => { AUD.live.add(o); nodes.push(o); tail = t; }; // tail: when the last sound starts
|
|
772
820
|
const vowelGroups = (w) => w.match(new RegExp(`[^${VOW}]*[${VOW}]+(?:[^${VOW}]+(?![${VOW}]))?`, 'g')) || [w];
|
|
773
821
|
const nSyl = clauses.reduce((n, cl) => n + (cl.match(/[a-zà-ÿ0-9]+/g) || []).reduce((m, w) => m + vowelGroups(w).length, 0) + 1.5, 0) || 1, unit = Math.max(0.08, Math.min(0.2, dur / nSyl));
|
|
774
822
|
const letterStep = Math.max(0.045, Math.min(0.085, dur / Math.max(8, clean.length)));
|
|
@@ -803,7 +851,8 @@ async function speakBabble(text, p, gain, token) {
|
|
|
803
851
|
t += unit * (sims ? 1.2 : 2.4);
|
|
804
852
|
}
|
|
805
853
|
const data = new Uint8Array(an.fftSize); S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length) * 2.2; };
|
|
806
|
-
|
|
854
|
+
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
|
|
855
|
+
await new Promise((res) => { if (lastN) lastN.onended = res; setTimeout(res, lastN ? (t - c.currentTime) * 1000 + 600 : Math.max(100, (Math.min(t, end) - c.currentTime) * 1000 + 80)); }); // the line ends when its last sound does (a long last word used to go on after it), or when Pause stops it
|
|
807
856
|
nodes.forEach((o) => AUD.live.delete(o)); delete S.level[p.id];
|
|
808
857
|
}
|
|
809
858
|
async function speakServer(text, p, gain, token, fr = FR()) {
|
|
@@ -816,22 +865,24 @@ async function speakServer(text, p, gain, token, fr = FR()) {
|
|
|
816
865
|
const data = new Uint8Array(an.fftSize);
|
|
817
866
|
S.level[p.id] = () => { an.getByteTimeDomainData(data); let s = 0; for (const v of data) { const x = (v - 128) / 128; s += x * x; } return Math.sqrt(s / data.length); }; // drives the mouth
|
|
818
867
|
AUD.live.add(src);
|
|
819
|
-
|
|
820
|
-
|
|
868
|
+
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
|
|
869
|
+
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
|
|
870
|
+
try { src.stop(); } catch {} AUD.live.delete(src); delete S.level[p.id]; stops.forEach((o) => { try { o.stop(); } catch {} });
|
|
821
871
|
}
|
|
822
872
|
function speakBrowser(text, p, token, fr = FR()) { // fallback where no server voice exists (Windows/Linux)
|
|
823
873
|
return new Promise((resolve) => {
|
|
824
874
|
const vs = synth.getVoices().filter((v) => v.lang.toLowerCase().startsWith(fr ? 'fr' : 'en')); if (!vs.length) return resolve(false);
|
|
825
875
|
const ranked = rankVoices(vs.map((v) => v.name)), voice = vs.find((v) => v.name === (p.id === 'main' ? ranked[0] : ranked[(p.vi >>> 2) % ranked.length])) || vs[0];
|
|
826
|
-
const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false;
|
|
876
|
+
const parts = speakable(text).match(/[^.!?;:]+[.!?;:]*/g) || [text]; synth.cancel(); let i = 0, done = false, started = false, t0 = 0, said = 0; const all = parts.reduce((n, x) => n + x.length, 0);
|
|
827
877
|
const fin = (ok) => { if (!done) { done = true; resolve(ok); } };
|
|
828
|
-
setTimeout(() => { if (!started) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
|
|
829
|
-
setTimeout(() => fin(true), 45000);
|
|
878
|
+
setTimeout(() => { if (!started && !done) { synth.cancel(); fin(false); } }, 2500); // engine never started (e.g. inside a webview)
|
|
879
|
+
setTimeout(() => { if (!done) { synth.cancel(); fin(true); } }, Math.max(45000, text.length * 160)); // a voice that never reports its end is cut, not left talking over the next line
|
|
830
880
|
const next = () => {
|
|
831
881
|
if (i >= parts.length || done || token !== S.token) return fin(true);
|
|
832
|
-
const
|
|
882
|
+
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
|
|
883
|
+
said += parts[i].length; const part = parts[i++].trim(); if (!part) return next();
|
|
833
884
|
const u = new SpeechSynthesisUtterance(part); u.lang = fr ? 'fr-FR' : 'en-US'; u.voice = voice; u.pitch = prefs.vstyle === 'natural' ? 0.95 + ((p.vi >>> 5) % 5) * 0.03 : p.id === 'main' ? 0.8 : [1.5, 0.6, 1.2, 1.7, 0.9, 0.5, 1.35][(p.vi >>> 3) % 7]; u.rate = Math.min(1.7, 0.97 * spd());
|
|
834
|
-
u.onstart = () =>
|
|
885
|
+
u.onstart = () => { started = true; if (!t0) { t0 = performance.now(); captionRun(p.id, text, estMs(text)); } }; u.onend = () => setTimeout(next, 120); u.onerror = () => fin(started); synth.speak(u);
|
|
835
886
|
};
|
|
836
887
|
next();
|
|
837
888
|
});
|
|
@@ -854,21 +905,43 @@ function setCaption(id, text) {
|
|
|
854
905
|
const t = $('#t-' + id), b = t && t.querySelector('.bubble'), say = S.say[id]; if (!b || !say) return;
|
|
855
906
|
b.innerHTML = (say.label ? `<small>${esc(say.label)}</small>` : '') + esc(text); if (S.present === id) $('#pcap').textContent = text;
|
|
856
907
|
}
|
|
857
|
-
function captionRun(id, text, totalMs) {
|
|
908
|
+
function captionRun(id, text, totalMs, from = 0) { // from: how far into the line the voice already is, when the timing is corrected along the way
|
|
858
909
|
captionClear(id); const ps = pieces(text); if (ps.length < 2 || !(totalMs > 0)) return;
|
|
859
|
-
const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0
|
|
860
|
-
for (
|
|
861
|
-
capTimers.set(id, timers);
|
|
910
|
+
const n = ps.reduce((a, x) => a + x.length, 0), timers = []; let at = 0, cur = ps[0];
|
|
911
|
+
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; }
|
|
912
|
+
setCaption(id, cur); capTimers.set(id, timers);
|
|
862
913
|
}
|
|
863
914
|
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
|
|
864
|
-
|
|
915
|
+
// 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
|
|
916
|
+
// 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.
|
|
917
|
+
// 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.
|
|
918
|
+
const SL = { ev0: null, ui: null, n: 0, fr: 0, en: 0, pend: [] };
|
|
919
|
+
function sessLang() { // 'fr' or 'en': the majority among the lines of the session that langOf can decide; null when there is none
|
|
920
|
+
const ev = S.events, ui = FR(), add = (t) => { const lo = langOf(t); if (lo) SL[lo]++; };
|
|
921
|
+
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
|
|
922
|
+
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); }
|
|
923
|
+
if (SL.pend.length) SL.pend = SL.pend.filter((t) => !trMem.has(t) || add(trMem.get(t))); // translations that have arrived since
|
|
924
|
+
return SL.fr > SL.en ? 'fr' : SL.en > SL.fr ? 'en' : null;
|
|
925
|
+
}
|
|
926
|
+
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
|
|
927
|
+
let lo = langOf(text);
|
|
928
|
+
if (!lo && ev) {
|
|
929
|
+
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);
|
|
930
|
+
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++; } }
|
|
931
|
+
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
|
|
932
|
+
}
|
|
933
|
+
return (lo ?? sessLang() ?? (FR() ? 'fr' : 'en')) === 'fr';
|
|
934
|
+
}
|
|
935
|
+
async function speakLine(text, p, primary, token, fr = frVoice(text), always = false) { // always: your own lines are voiced even when the call is behind
|
|
865
936
|
captionRun(p.id, text, estMs(text));
|
|
866
937
|
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
|
-
if (S.hurry) return sleep(estMs(text));
|
|
938
|
+
if (S.hurry && !always) return sleep(estMs(text));
|
|
868
939
|
if ((prefs.vstyle === 'animal' || prefs.vstyle === 'sims') && AUD.ctx) { try { await speakBabble(text, p, primary ? 1 : 0.5, token); return; } catch {} }
|
|
869
940
|
if (TTS.server && AUD.ctx) { try { await speakServer(text, p, primary ? 1 : 0.5, token, fr); return; } catch {} }
|
|
870
941
|
if (primary && synth && !EMBED && (await speakBrowser(text, p, token, fr))) return;
|
|
871
|
-
|
|
942
|
+
if (token !== S.token) return; // paused while a voice was being tried: no mumble after the fact
|
|
943
|
+
const ms = estMs(text), o = AUD.murmur(0.5 + (p.vi % 5) / 10, ms / 1000); captionRun(p.id, text, ms);
|
|
944
|
+
return o ? new Promise((res) => { o.addEventListener('ended', res); setTimeout(res, ms + 500); }) : sleep(ms); // the mumble that stands in for a voice: the line ends with it, also when Pause stops it
|
|
872
945
|
}
|
|
873
946
|
|
|
874
947
|
// =====================================================================
|
|
@@ -896,19 +969,22 @@ const layout = () => {
|
|
|
896
969
|
if (nar) { k = cols; fr = cols > 1 ? 2 : 1; } // side panel: small tiles, a banner-sized spotlight
|
|
897
970
|
else if (best < 0) { k = Math.min(2, cols); fr = Math.min(2, maxRows); }
|
|
898
971
|
st.style.setProperty('--fcols', k); st.style.setProperty('--fr', fr); st.classList.toggle('one', cols === 1);
|
|
899
|
-
document.body.classList.toggle('narrow', $('#center').clientWidth
|
|
972
|
+
document.body.classList.toggle('narrow', $('#center').clientWidth <= 480); document.body.classList.toggle('compact', $('#center').clientWidth <= 480 && !$('#roster').offsetWidth); document.body.classList.toggle('tiny', $('#center').clientWidth < 300); drawGantt();
|
|
900
973
|
};
|
|
901
974
|
new ResizeObserver(layout).observe($('#center'));
|
|
902
975
|
|
|
976
|
+
function logRow(i) {
|
|
977
|
+
const e = S.events[i], arrow = e.to ? ` <span class="arrow">→</span> <span class="who">${esc(nameOf(e.to))}</span>` : '';
|
|
978
|
+
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];
|
|
979
|
+
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>`;
|
|
980
|
+
}
|
|
903
981
|
function renderLog(full) {
|
|
904
982
|
const log = $('#log'), from = S.live ? Math.max(0, S.events.length - 300) : 0;
|
|
905
983
|
if (full || S.logTo < from || S.logTo > S.events.length) { log.innerHTML = ''; S.logTo = from; }
|
|
906
984
|
const html = [];
|
|
907
985
|
for (let i = S.logTo; i < S.events.length; i++) {
|
|
908
986
|
if (S.events[i].kind === 'results' || S.events[i].kind === 'usage' || S.events[i].kind === 'finish' || S.events[i].kind === 'compact') continue;
|
|
909
|
-
|
|
910
|
-
const body = e.kind === 'tools' ? e.tools.map((t) => t.name).join(', ') : spoken(e), k = { order: L('tâche', 'task'), report: L('rapport', 'report'), say: '', user: L('brief', 'brief'), tools: L('outils', 'tools') }[e.kind];
|
|
911
|
-
html.push(`<div class="row ${e.kind}" data-i="${i}"><span class="who">${esc(nameOf(e.agent))}</span>${arrow} <span class="k">${k}</span><div class="txt">${esc(body)}</div></div>`);
|
|
987
|
+
html.push(logRow(i));
|
|
912
988
|
}
|
|
913
989
|
log.insertAdjacentHTML('beforeend', html.join('')); S.logTo = S.events.length;
|
|
914
990
|
if (S.live) while (log.children.length > 320) log.firstChild.remove();
|
|
@@ -932,9 +1008,10 @@ const applyStatus = (ev) => {
|
|
|
932
1008
|
if (ev.kind === 'order') { (S.waitOn[ev.agent] = S.waitOn[ev.agent] || new Set()).add(ev.to); S.orderTs[ev.to] = ev.ts || 0; S.status[ev.to] = 'working'; delete S.doneAt[ev.to]; S.gone.delete(ev.to); S.leaving.delete(ev.to); if (!ev.bg && ev.toolId) S.run[ev.agent] = { id: ev.toolId, ts: ev.ts, start: performance.now(), name: 'Agent' }; const t = $('#t-' + ev.to); if (t) t.classList.remove('vanishing'); }
|
|
933
1009
|
if (ev.kind === 'finish') { if (S.waitOn[ev.to]) S.waitOn[ev.to].delete(ev.agent); if (S.status[ev.agent] !== 'done') { S.status[ev.agent] = 'done'; S.doneAt[ev.agent] = S.clock; } delete S.run[ev.agent]; const lr = S.run[ev.to]; if (lr && /^(Task|Agent)$/.test(lr.name) && !(S.waitOn[ev.to] && S.waitOn[ev.to].size)) delete S.run[ev.to]; }
|
|
934
1010
|
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]; }
|
|
1011
|
+
if (ev.kind === 'user' || (ev.agent === 'main' && (ev.kind === 'say' || ev.kind === 'tools' || (ev.kind === 'order' && !ev.wf)))) S.turnOver = !!ev.end; // Claude Code marks the message that closes the lead's turn; anything after it opens the next one (not the agents a workflow launches by itself)
|
|
935
1012
|
};
|
|
936
1013
|
function recomputeStatus(upTo) {
|
|
937
|
-
S.term = {}; S.todos = {}; S.run = {}; S.act = {}; S.usage = {}; S.errs = {}; S.lastAct = {}; S.waitOn = {}; S.orderTs = {}; S.prevState = {}; S.lastTool = {}; S.mainBusyAt = 0; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
|
|
1014
|
+
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.th = {}; S.ctx = {}; S.model = {}; S.compacts = {}; S.ctxWarned = {}; S.rep = {}; S.loops = {}; S.recomputing = true; document.querySelectorAll('.tile').forEach((t) => { clearTimeout(t._byeT); t.classList.remove('bye'); }); hidePresent();
|
|
938
1015
|
S.status = {}; S.tc = {}; S.doneAt = {}; S.gone.clear(); S.leaving.clear(); document.querySelectorAll('.tile.vanishing').forEach((t) => t.classList.remove('vanishing')); const doneIdx = {}; document.querySelectorAll('.ticker').forEach((x) => { x.classList.remove('on'); x.innerHTML = ''; });
|
|
939
1016
|
for (let i = 0; i < upTo; i++) {
|
|
940
1017
|
const e = S.events[i]; applyStatus(e); tally(e); if (e.kind === 'tools') S.tc[e.agent] = (S.tc[e.agent] || 0) + e.tools.length;
|
|
@@ -947,9 +1024,7 @@ function recomputeStatus(upTo) {
|
|
|
947
1024
|
}
|
|
948
1025
|
function syncUI() {
|
|
949
1026
|
$('#bPlay').textContent = S.playing ? '⏸' : '▶';
|
|
950
|
-
|
|
951
|
-
if (S.offline) { pill.textContent = L('HORS LIGNE', 'OFFLINE'); }
|
|
952
|
-
else if (!S.live) { pill.textContent = 'REPLAY'; } else if (S.locked) { pill.textContent = L('PRÊT', 'READY'); } else if (!S.playing) { pill.textContent = L('PAUSE', 'PAUSED'); } else if (S.idle) { pill.textContent = L('EN ATTENTE', 'WAITING'); pill.className = 'wait'; } else { pill.textContent = 'LIVE'; pill.className = 'live'; }
|
|
1027
|
+
paintPill();
|
|
953
1028
|
document.body.classList.toggle('replay', !S.live);
|
|
954
1029
|
}
|
|
955
1030
|
|
|
@@ -976,6 +1051,8 @@ function tally(ev) { // numbers that pile up over a session: token usage, erro
|
|
|
976
1051
|
if (ev.kind === 'usage') { const c = ev.u.i + ev.u.r + ev.u.w; if (c > 0 && ev.model !== '<synthetic>') { S.ctx[ev.agent] = c; S.model[ev.agent] = ev.model; later('tok', renderCtx, 300); } const u = (S.usage[ev.agent] = S.usage[ev.agent] || { i: 0, o: 0, r: 0, w: 0 }); u.i += ev.u.i; u.o += ev.u.o; u.r += ev.u.r; u.w += ev.u.w; later('tok', renderTokens, 300); }
|
|
977
1052
|
else if (ev.kind === 'results') ev.items.forEach((it) => { if (it.err) { S.errs[ev.agent] = (S.errs[ev.agent] || 0) + 1; later('badges', renderBadges, 300); oops(ev.agent); noteTrouble(ev.agent, false); } else noteTrouble(ev.agent, true); });
|
|
978
1053
|
else if (ev.kind === 'tools' || ev.kind === 'say') S.lastAct[ev.agent] = ev.ts;
|
|
1054
|
+
if (ev.th) S.th[ev.agent] = { text: ev.th, ts: ev.ts }; else if (ev.kind === 'user') delete S.th.main; // your order starts a new chain of thought: what the lead thought before is no longer what it is on
|
|
1055
|
+
if (ev.kind === 'results') S.lastAct[ev.agent] = ev.ts; else if (ev.kind === 'user') S.lastAct.main = ev.ts; // a result coming back and your own order are signs of life too: the lead is thinking about them
|
|
979
1056
|
}
|
|
980
1057
|
const tokTip = (u) => `${L('Jetons enregistrés par Claude Code (comptés une fois par message, ce n’est pas une facture)', 'Tokens recorded by Claude Code (counted once per message, not a bill)')}\n${L('entrée', 'input')} ${fmtTok(u.i)} · ${L('sortie', 'output')} ${fmtTok(u.o)} · ${L('écriture cache', 'cache write')} ${fmtTok(u.w)} · ${L('lecture cache', 'cache read')} ${fmtTok(u.r)}`;
|
|
981
1058
|
const ctxInfo = (id) => { const c = S.ctx[id]; if (!c) return null; const w = ctxWin(S.model[id], c), p = Math.round((c / w) * 100); return { c, w, p, lvl: p >= 88 ? 'hi' : p >= 70 ? 'mid' : 'lo' }; };
|
|
@@ -999,7 +1076,10 @@ const NEEDS_OK = /^(Edit|Write|MultiEdit|NotebookEdit|AskUserQuestion|ExitPlanMo
|
|
|
999
1076
|
const atTail = () => S.idx >= S.events.length - 3; // only trust wall-clock ages when playback has caught up with the live end
|
|
1000
1077
|
function agentState(id, now) {
|
|
1001
1078
|
let s = S.status[id]; const r = S.run[id], w = S.waitOn[id], la = S.lastAct[id];
|
|
1002
|
-
if (id === 'main' && !s) {
|
|
1079
|
+
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
|
|
1080
|
+
const open = S.live && la && (S.sawEnd ? !S.turnOver && now - la < 180000 : now - la < 25000);
|
|
1081
|
+
if (r || (w && w.size) || open) s = 'working'; else if (S.live && la) return { k: 'done', txt: '✓ ' + L('à vous', 'your turn') };
|
|
1082
|
+
}
|
|
1003
1083
|
if (s === 'done') return { k: 'done', txt: '✓ ' + L('terminé', 'done') };
|
|
1004
1084
|
if (!s) return { k: 'idle', txt: '' };
|
|
1005
1085
|
if (S.live && atTail() && r && r.ts && r.name && NEEDS_OK.test(r.name)) { const age = now - r.ts; if (age > (ASKS.test(r.name) ? 4000 : 15000)) return { k: 'blocked', txt: '✋ ' + L('attend vous ? ', 'needs you? ') + fmtShort(age) }; }
|
|
@@ -1019,7 +1099,7 @@ function paintStates() {
|
|
|
1019
1099
|
if (p.id === 'user') continue; const t = $('#t-' + p.id); if (!t) continue;
|
|
1020
1100
|
const st = agentState(p.id, now), shown = t.style.display !== 'none'; if (shown && n[st.k] !== undefined && !S.gone.has(p.id)) { n[st.k]++; if (st.k === 'blocked' || st.k === 'stalled') attn.push(p.id); }
|
|
1021
1101
|
STATE_CLASSES.forEach((c) => t.classList.toggle(c, st.k === c));
|
|
1022
|
-
const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k;
|
|
1102
|
+
const el = t.querySelector('.status'); if (el.textContent !== st.txt) el.textContent = st.txt; el.className = 'status ' + st.k + (isTurn(p.id, st) ? ' turn' : '');
|
|
1023
1103
|
const pr = t.querySelector('.prop'), want = { blocked: '✋', stalled: '😰', waiting: '⌛' }[st.k] || '', was = S.prevState[p.id]; // a raised hand, a sweat drop, an hourglass: the call metaphor for "needs you / stuck / waiting"
|
|
1024
1104
|
if (st.k === 'done' && was && was !== 'done' && was !== 'idle') { pr.textContent = '🎉'; pr.className = 'prop cheer'; setTimeout(() => { if (pr.classList.contains('cheer')) { pr.textContent = ''; pr.className = 'prop'; } }, 1200); }
|
|
1025
1105
|
else if (!pr.classList.contains('cheer')) { if (pr.textContent !== want) pr.textContent = want; pr.className = 'prop ' + (want ? st.k : ''); }
|
|
@@ -1028,15 +1108,16 @@ function paintStates() {
|
|
|
1028
1108
|
if (p.id === 'main') { if (st.k === 'working' || st.k === 'waiting') S.mainBusyAt = S.mainBusyAt || Date.now(); else if (st.k === 'done') S.mainBusyAt = 0; }
|
|
1029
1109
|
S.prevState[p.id] = st.k; const tip = `${p.name} — ${p.role}${st.txt ? '\n' + st.txt : ''}${S.lastTool[p.id] ? '\n' + S.lastTool[p.id] : ''}`; if (t.title !== tip) t.title = tip;
|
|
1030
1110
|
}
|
|
1111
|
+
paintPill();
|
|
1031
1112
|
const b = $('#board'), parts = [['working', '⚙', L('au travail', 'working')], ['waiting', '🔗', L('en attente', 'waiting')], ['blocked', '✋', L('vous attendent', 'need you')], ['stalled', '⚠', L('bloqués', 'stalled')], ['done', '✓', L('terminés', 'done')]]
|
|
1032
|
-
.filter(([k]) => n[k]).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
|
|
1113
|
+
.filter(([k]) => n[k] && !S.pillSess).map(([k, i, l]) => `<span class="${k}" title="${n[k]} ${l}">${i} ${n[k]}<em> ${l}</em></span>`).join('');
|
|
1033
1114
|
if (b.innerHTML !== parts) b.innerHTML = parts; S.nBlocked = n.blocked + n.stalled; S.attn = attn; paintRoster(ros); return n;
|
|
1034
1115
|
}
|
|
1035
1116
|
const RORDER = { blocked: 0, stalled: 1, working: 2, waiting: 3, idle: 4, done: 5 };
|
|
1036
1117
|
function paintRoster(ros) { // a fixed list beside the call: who is doing what, urgent first, readable even with dozens of agents
|
|
1037
1118
|
const box = $('#roster'); if (!box || !document.body.classList.contains('rost')) return;
|
|
1038
1119
|
ros.sort((a, b) => (a.p.id === 'main' ? -1 : b.p.id === 'main' ? 1 : (RORDER[a.st.k] - RORDER[b.st.k]) || (S.lastAct[b.p.id] || 0) - (S.lastAct[a.p.id] || 0)));
|
|
1039
|
-
const rows = ros.slice(0, 60).map(({ p, st }) => `<div class="rr k-${st.k}${S.focus === p.id ? ' sel' : ''}${S.speakers.has(p.id) ? ' speaking' : ''}" data-id="${esc(p.id)}" tabindex="0" role="button" aria-label="${esc(p.name)}, ${esc(st.txt || '')}"><div class="n"><span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="ro">${esc(p.role)}</span></div><div class="st">${esc(st.txt || L('en veille', 'standing by'))}${S.loops[p.id] ? ' 🔁' : ''}</div></div>`).join('');
|
|
1120
|
+
const rows = ros.slice(0, 60).map(({ p, st }) => `<div class="rr k-${st.k}${isTurn(p.id, st) ? ' turn' : ''}${S.focus === p.id ? ' sel' : ''}${S.speakers.has(p.id) ? ' speaking' : ''}" data-id="${esc(p.id)}" tabindex="0" role="button" aria-label="${esc(p.name)}, ${esc(st.txt || '')}"><div class="n"><span class="dot" style="background:${p.shirt}"></span><b>${esc(p.name)}</b><span class="ro">${esc(p.role)}</span></div><div class="st">${esc(st.txt || L('en veille', 'standing by'))}${S.loops[p.id] ? ' 🔁' : ''}</div></div>`).join('');
|
|
1040
1121
|
const html = `<h3>${L('Agents', 'Agents')} · ${ros.length}</h3>${rows}${ros.length > 60 ? `<div class="ro" style="padding:4px 8px">+${ros.length - 60}</div>` : ''}`;
|
|
1041
1122
|
if (box.dataset.h !== html) { box.dataset.h = html; box.innerHTML = html; }
|
|
1042
1123
|
}
|
|
@@ -1077,6 +1158,7 @@ function setFav(color) {
|
|
|
1077
1158
|
function updateTitle() {
|
|
1078
1159
|
paintStates(); if (S.live && S.nBlocked && !S.wasBlocked && prefs.notify) { AUD.alert(); const msg = L('Un agent attend peut-être votre feu vert ✋', 'An agent may be waiting for you ✋'); if (EMBED) window.parent.postMessage({ agentStandup: 'notify', text: msg }, '*'); else { try { if (document.hidden && 'Notification' in window && Notification.permission === 'granted') new Notification('Agent Standup', { body: msg }); } catch {} } } S.wasBlocked = S.nBlocked > 0;
|
|
1079
1160
|
let n = 0; for (const id in S.status) if (S.status[id] === 'working' && !S.gone.has(id)) n++;
|
|
1161
|
+
if (S.live && S.agents.main && /^(working|waiting)$/.test(agentState('main', Date.now()).k)) n++; // the lead counts too: a session without sub-agents also ends
|
|
1080
1162
|
watchDone(n); setFav(S.live && S.nBlocked ? '#ff4d4d' : null);
|
|
1081
1163
|
document.title = S.live && S.nBlocked ? `✋ ${S.nBlocked} ${L('à voir', 'need you')} · Agent Standup` : S.doneUntil > Date.now() && !n ? '✓ ' + L('Terminé', 'Done') + ' · Agent Standup' : S.live && S.playing && n ? `● ${n} ${L('au travail', 'working')} · Agent Standup` : 'Agent Standup';
|
|
1082
1164
|
}
|
|
@@ -1119,7 +1201,7 @@ function setTicker(id, e) { // the strip on the tile: spinner while running, t
|
|
|
1119
1201
|
tk.classList.add('on'); if (running) { tk.classList.remove('flash'); void tk.offsetWidth; tk.classList.add('flash'); }
|
|
1120
1202
|
}
|
|
1121
1203
|
function tickTools(ev) {
|
|
1122
|
-
const t = $('#t-' + ev.agent); if (!t) return; t.style.display = '';
|
|
1204
|
+
ev._n = ev.tools.length; const t = $('#t-' + ev.agent); if (!t) return; t.style.display = '';
|
|
1123
1205
|
ev.tools.forEach((tool) => pushTerm(ev.agent, tool));
|
|
1124
1206
|
const buf = S.term[ev.agent], k = ev.tools[ev.tools.length - 1];
|
|
1125
1207
|
S.tc[ev.agent] = (S.tc[ev.agent] || 0) + ev.tools.length; S.lastTool[ev.agent] = k.name + ' ' + (k.arg || '').slice(-38);
|
|
@@ -1243,7 +1325,7 @@ setInterval(() => {
|
|
|
1243
1325
|
// ---------- speaking tiles ----------
|
|
1244
1326
|
const mouths = new Map();
|
|
1245
1327
|
function startTalk(id, text, label) {
|
|
1246
|
-
const t = $('#t-' + id); if (!t) return;
|
|
1328
|
+
const t = $('#t-' + id); if (!t) return; endThought(id); stopMumble(); TH.next[id] = Date.now() + 8000; // a spoken line takes the bubble and the floor
|
|
1247
1329
|
if (S.present && S.present !== id) hidePresent();
|
|
1248
1330
|
S.spokeAt = performance.now(); S.fill = 0; S.mused = false;
|
|
1249
1331
|
if (!S.speakers.size && !(S.focusLock > Date.now())) setFocus(id); S.gone.delete(id); if (S.doneAt[id] != null) S.doneAt[id] = S.clock; t.style.display = ''; t.classList.add('speaking'); S.speakers.add(id); S.say[id] = { text, label }; AUD.duck(true);
|
|
@@ -1279,6 +1361,73 @@ function stopTalk(id) {
|
|
|
1279
1361
|
const pc = $('#pcam .cimg-talk'), pi = $('#pcam .cimg'); if (pc && pi && S.present === id) { pc.style.display = 'none'; pi.style.display = ''; }
|
|
1280
1362
|
}
|
|
1281
1363
|
const clearSpeaking = () => [...S.speakers].forEach(stopTalk);
|
|
1364
|
+
// =====================================================================
|
|
1365
|
+
// inner thoughts: an agent that has been at work for a while thinks to itself, in a bubble it does not say. What it thinks is real: its own reasoning when the session kept it
|
|
1366
|
+
// (rarely: Claude Code mostly stores a signature), otherwise what it is on right now (the command it waits for, the files it goes between, its to-do item, a failure, its task).
|
|
1367
|
+
// One time in five the thought escapes as a mumble: its own voice, low and muffled. Live only, never while it speaks, off with the little lines or in Quiet.
|
|
1368
|
+
// =====================================================================
|
|
1369
|
+
const TH = { since: {}, next: {}, said: {}, last: 0, voice: null };
|
|
1370
|
+
const THINK = {
|
|
1371
|
+
en: { run: ['Still running… {x}.', 'Come on… {x}.', '{x}. Any second now.'], err: ['Why did that fail?', 'That error again… what am I missing?', 'Not what I expected from “{x}”.'], two: ['{a}, then {b}… what links them?', '{a} and {b}. It has to be in one of them.'], one: ['Something in {a} does not add up.', '{a}… where was that part again?'], again: ['Back to {a}. Again.', '{a}, once more. I am missing something.'],
|
|
1372
|
+
todo: ['One thing at a time: {x}.', '{x}… then the next one.'], next: ['After this: {x}.'], grep: ['Where is “{x}” hiding?', '“{x}”… it has to be somewhere.'], web: ['“{x}”… let’s see what comes up.'], task: ['{x}. Stay on it.', '{x}… getting there, I think.'], many: ['{n} calls in. What do I know so far?'] },
|
|
1373
|
+
fr: { run: ['Toujours en cours… {x}.', 'Allez… {x}.', '{x}. Ça arrive.'], err: ['Pourquoi ça a planté ?', 'Encore cette erreur… qu’est-ce que je rate ?', 'Pas ce que j’attendais de « {x} ».'], two: ['{a}, puis {b}… quel est le lien ?', '{a} et {b}. C’est forcément dans l’un des deux.'], one: ['Il y a un truc dans {a} qui ne colle pas.', '{a}… c’était où, déjà ?'], again: ['Retour à {a}. Encore.', '{a}, une fois de plus. Je rate quelque chose.'],
|
|
1374
|
+
todo: ['Une chose à la fois : {x}.', '{x}… puis la suite.'], next: ['Après ça : {x}.'], grep: ['Où se cache « {x} » ?', '« {x} »… c’est forcément quelque part.'], web: ['« {x} »… voyons ce que ça donne.'], task: ['{x}. On reste dessus.', '{x}… j’y arrive, je crois.'], many: ['{n} appels. Qu’est-ce que je sais, à ce stade ?'] },
|
|
1375
|
+
};
|
|
1376
|
+
function thoughtFor(id) {
|
|
1377
|
+
const now = Date.now(), W = THINK[FR() ? 'fr' : 'en'], seen = (TH.said[id] = TH.said[id] || []), cut = (x, n) => { x = String(x || '').replace(/\s+/g, ' ').trim(); return (x.length > n ? x.slice(0, n).replace(/\s+\S*$/, '') + '…' : x).replace(/[.\s]+$/, ''); };
|
|
1378
|
+
const real = realThought(id); if (real) return real; // its own words first, always: the rest is only for when there are none (left)
|
|
1379
|
+
const buf = (S.term[id] || []).slice(-8), last = buf[buf.length - 1], r = S.run[id], hot = [], out = [], add = (to, k, v) => { for (const tpl of W[k]) to.push(tpl.replace(/\{(\w+)\}/g, (m, key) => v[key])); };
|
|
1380
|
+
const what = (e) => { const d = e.tool.d || {}; return cut(d.note || (d.kind === 'cmd' ? String(d.text || '').trim().split(/\s+/).slice(0, 3).join(' ') : base(d.file) || e.tool.name), 60); };
|
|
1381
|
+
if (last && last.k === 'cmd' && r && r.id === last.id && now - (r.ts || now) > 20000) add(hot, 'run', { x: what(last) }); // a command that takes its time
|
|
1382
|
+
if (last && last.err) add(hot, 'err', { x: what(last) });
|
|
1383
|
+
const files = buf.filter((e) => /^(read|edit|write)$/.test(e.k)).map((e) => base((e.tool.d || {}).file)).filter(Boolean), uniq = [...new Set(files)], f = uniq[uniq.length - 1];
|
|
1384
|
+
if (f && files.filter((x) => x === f).length >= 3) add(out, 'again', { a: f }); else if (uniq.length > 1) add(out, 'two', { a: uniq[uniq.length - 2], b: f }); else if (f) add(out, 'one', { a: f });
|
|
1385
|
+
const td = S.todos[id] || [], cur = td.find((x) => x.s === 'in_progress'), nx = td.find((x) => x.s === 'pending'); if (cur) add(out, 'todo', { x: cut(cur.c, 70) }); if (nx) add(out, 'next', { x: cut(nx.c, 70) });
|
|
1386
|
+
const g = buf.slice(-3).reverse().find((e) => e.k === 'grep' && (e.tool.d || {}).pattern); if (g) add(out, 'grep', { x: cut(g.tool.d.pattern, 30) });
|
|
1387
|
+
const w = buf.slice(-3).reverse().find((e) => e.k === 'web' && (e.tool.d || {}).query); if (w) add(out, 'web', { x: cut(w.tool.d.query, 50) });
|
|
1388
|
+
const p = S.agents[id]; if (isSub(id) && p && p.role && !/^agent$/i.test(p.role)) add(out, 'task', { x: cut(p.role, 60) });
|
|
1389
|
+
if ((S.tc[id] || 0) >= 15) add(out, 'many', { n: S.tc[id] });
|
|
1390
|
+
const fresh = (a) => a.filter((x) => !seen.includes(x)), h = fresh(hot), o = fresh(out), pool = h.length && (!o.length || Math.random() < 0.6) ? h : o; if (!pool.length) return null;
|
|
1391
|
+
const line = pool[Math.floor(Math.random() * pool.length)]; seen.push(line); if (seen.length > 6) seen.shift(); return line;
|
|
1392
|
+
}
|
|
1393
|
+
function realThought(id, peek) { // the next sentence of the agent's own reasoning for the work it is on (kept by the session for some of its steps); null when there is none, or when all of it has been shown
|
|
1394
|
+
const th = S.th[id]; if (!th || Date.now() - th.ts > 600000) return null;
|
|
1395
|
+
if (!th.ss) { th.ss = (th.text.match(/[^.!?]+(?:[.!?]+|$)/g) || []).map((x) => x.trim()).filter((x) => x.length >= 12).map((x) => (x.length > 150 ? x.slice(0, 150).replace(/\s+\S*$/, '') + '…' : x)); th.i = 0; }
|
|
1396
|
+
return th.i < th.ss.length ? (peek ? th.ss[th.i] : th.ss[th.i++]) : null;
|
|
1397
|
+
}
|
|
1398
|
+
function showThought(id, text) {
|
|
1399
|
+
const t = $('#t-' + id); if (!t || t.style.display === 'none' || S.speakers.has(id) || t.classList.contains('bye')) return false;
|
|
1400
|
+
t.querySelector('.bubble').innerHTML = `<small>💭</small>${esc(text)}`; t.classList.add('musing'); clearTimeout(t._thT); t._thT = setTimeout(() => endThought(id), Math.min(9000, 3500 + text.length * 55)); return true;
|
|
1401
|
+
}
|
|
1402
|
+
function endThought(id) {
|
|
1403
|
+
const t = $('#t-' + id); if (!t || !t.classList.contains('musing')) return; clearTimeout(t._thT); t.classList.remove('musing'); if (TH.voice && TH.voice.id === id) stopMumble();
|
|
1404
|
+
if (!S.speakers.has(id) && !t.classList.contains('bye')) t.querySelector('.bubble').innerHTML = '';
|
|
1405
|
+
}
|
|
1406
|
+
const clearThoughts = () => { document.querySelectorAll('.tile.musing').forEach((t) => endThought(t.id.slice(2))); stopMumble(); };
|
|
1407
|
+
function stopMumble() { const v = TH.voice; if (v) { TH.voice = null; try { v.src.stop(); } catch {} } }
|
|
1408
|
+
async function mumble(id, text) { // lips closed: the mouth does not move, the music does not make room, and anyone who starts to speak cuts it
|
|
1409
|
+
const p = S.agents[id], t = $('#t-' + id); if (!p || !t || !prefs.voice || AUD.off() || !AUD.ctx || S.speakers.size || TH.voice) return;
|
|
1410
|
+
if (!TTS.server || prefs.vstyle === 'animal' || prefs.vstyle === 'sims') { AUD.murmur(0.5 + (p.vi % 5) / 10, Math.min(2.2, estMs(text) / 1000), 0.4); return; }
|
|
1411
|
+
const voice = TTS.voiceFor(p, frVoice(text)), token = S.token; if (!voice) return; let buf; try { buf = await TTS.buf(speakable(text), voice, TTS.rate(), true); } catch { return; } // made like a line prepared ahead: it never delays a real one
|
|
1412
|
+
if (token !== S.token || S.speakers.size || TH.voice || !t.classList.contains('musing')) return; // someone spoke meanwhile, or the thought is gone
|
|
1413
|
+
const c = AUD.ctx, src = c.createBufferSource(), lp = c.createBiquadFilter(), g = c.createGain(); src.buffer = buf; lp.type = 'lowpass'; lp.frequency.value = 1100; g.gain.value = 0.2; let stops = [];
|
|
1414
|
+
if (prefs.vstyle === 'natural' || prefs.vstyle === 'kokoro') src.connect(lp); else { const ch = fxChain(c, src, fxFor(p)); stops = ch.stops; ch.node.connect(lp); }
|
|
1415
|
+
lp.connect(g); g.connect(AUD.voice); AUD.live.add(src); TH.voice = { id, src };
|
|
1416
|
+
src.onended = () => { AUD.live.delete(src); stops.forEach((o) => { try { o.stop(); } catch {} }); if (TH.voice && TH.voice.src === src) TH.voice = null; }; src.start();
|
|
1417
|
+
}
|
|
1418
|
+
setInterval(() => { // who gets a thought: someone at work for a while and silent; two at most on screen, a few seconds apart, each agent every half minute or so
|
|
1419
|
+
const now = Date.now(); if (!S.live || !S.playing || S.locked || S.recomputing || !prefs.banter || prefs.chat === 'quiet' || S.hurry || S.boost > 1 || (document.hidden && !S.exporting) || !atTail()) return;
|
|
1420
|
+
const ready = [];
|
|
1421
|
+
for (const p of Object.values(S.agents)) {
|
|
1422
|
+
const id = p.id, t = $('#t-' + id); if (id === 'user' || !t) continue;
|
|
1423
|
+
if (t.style.display === 'none' || S.gone.has(id) || agentState(id, now).k !== 'working') { delete TH.since[id]; continue; }
|
|
1424
|
+
TH.since[id] = TH.since[id] || now; if (!S.speakers.has(id) && !t.classList.contains('musing') && now - TH.since[id] > 15000 && now > (TH.next[id] || 0)) ready.push(id);
|
|
1425
|
+
}
|
|
1426
|
+
if (!ready.length || now - TH.last < 5000 || document.querySelectorAll('.tile.musing').length >= 2) return;
|
|
1427
|
+
const own = ready.filter((id) => realThought(id, true)), from = own.length ? own : ready; // whoever has reasoning of its own to show goes first
|
|
1428
|
+
const id = from[Math.floor(Math.random() * from.length)], text = thoughtFor(id); TH.next[id] = now + (text ? 28000 + Math.random() * 24000 : 8000) / chatMul(); if (!text || !showThought(id, text)) return;
|
|
1429
|
+
TH.last = now; if (Math.random() < 0.2) mumble(id, text);
|
|
1430
|
+
}, 1500);
|
|
1282
1431
|
|
|
1283
1432
|
// ---------- hierarchy tree ----------
|
|
1284
1433
|
function renderTree() {
|
|
@@ -1351,9 +1500,8 @@ function openExternal(url) { if (EMBED) window.parent.postMessage({ agentStandup
|
|
|
1351
1500
|
function renderCoffee() {
|
|
1352
1501
|
const on = !!SUPPORT.url, b = $('#mCoffee'); b.style.display = on ? '' : 'none'; $('#mCoffeeT').textContent = L('☕ Offrir un café au développeur', '☕ Buy the (replaced) dev a coffee'); b.title = coffeeMsg();
|
|
1353
1502
|
const h = $('#bCoffee'); h.style.display = on ? '' : 'none'; h.title = coffeeMsg(); h.setAttribute('aria-label', coffeeMsg());
|
|
1354
|
-
const g = $('#gCoffee'); g.style.display = on ? '' : 'none'; g.textContent = L('☕ Ce développeur a été remplacé par ses agents — lui offrir un café', '☕ This dev was replaced by his agents — buy him a coffee');
|
|
1355
1503
|
}
|
|
1356
|
-
$('#bCoffee').onclick =
|
|
1504
|
+
$('#bCoffee').onclick = () => openExternal(SUPPORT.url);
|
|
1357
1505
|
$('#mCoffee').onclick = () => { $('#menu').classList.remove('on'); openExternal(SUPPORT.url); };
|
|
1358
1506
|
|
|
1359
1507
|
// =====================================================================
|
|
@@ -1449,7 +1597,7 @@ function ackLine(ev) {
|
|
|
1449
1597
|
// =====================================================================
|
|
1450
1598
|
// conversation: short exchanges between agents at the right moments, built from what really happened in the session
|
|
1451
1599
|
// =====================================================================
|
|
1452
|
-
const CHAT = { quiet: 0.45, normal: 1, lively: 1.8 }, chatMul = () => CHAT[prefs.chat] ||
|
|
1600
|
+
const CHAT = { quiet: 0.45, normal: 1, lively: 1.8 }, chatMul = () => CHAT[prefs.chat] || 1; // normal unless chosen otherwise
|
|
1453
1601
|
const EXW = { en: { edit: ['edit', 'edits'], call: ['tool call', 'tool calls'], error: ['error', 'errors'] }, fr: { edit: ['modification', 'modifications'], call: ['appel d’outil', 'appels d’outils'], error: ['erreur', 'erreurs'] } };
|
|
1454
1602
|
const P = (k, n) => { const w = EXW[FR() ? 'fr' : 'en'][k]; return `${n} ${n === 1 ? w[0] : w[1]}`; };
|
|
1455
1603
|
const EXCH = {
|
|
@@ -1488,8 +1636,9 @@ function exchFacts(i, agent) { // what this agent did since it received its or
|
|
|
1488
1636
|
f.what = f.edits ? P('edit', f.edits) : f.tools ? P('call', f.tools) : L('rien à signaler', 'nothing to report'); return f;
|
|
1489
1637
|
}
|
|
1490
1638
|
const exVisible = (id) => S.agents[id] && !S.gone.has(id) && $('#t-' + id) && $('#t-' + id).style.display !== 'none';
|
|
1639
|
+
async function sayLine(id, line, label, token) { startTalk(id, line, label); await speakLine(line, S.agents[id], true, token); if (token === S.token) stopTalk(id); } // a line cut short (pause, seek) must not close the bubble of whoever speaks next
|
|
1491
1640
|
async function exSay(id, line, token) { // one line of an exchange, then a short human pause
|
|
1492
|
-
if (!exVisible(id) || token !== S.token) return false;
|
|
1641
|
+
if (!exVisible(id) || token !== S.token) return false; await sayLine(id, line, '💬', token);
|
|
1493
1642
|
if (token !== S.token) return false; await sleep((150 + Math.random() * 250) / spd()); return token === S.token;
|
|
1494
1643
|
}
|
|
1495
1644
|
const exReady = () => prefs.banter && !S.hurry && S.boost <= 1 && !S.recomputing && !S.speakers.size && caughtUp() && performance.now() - (S.exAt || 0) > (S.live ? 22000 : 9000 / spd()) / chatMul();
|
|
@@ -1553,7 +1702,7 @@ async function react(ev, token) {
|
|
|
1553
1702
|
else return;
|
|
1554
1703
|
}
|
|
1555
1704
|
else return;
|
|
1556
|
-
if (!S.agents[who]) return;
|
|
1705
|
+
if (!S.agents[who]) return; await sayLine(who, line, thanks || (ev.kind === 'order' && prefs.banter) ? '💬' : '🎬', token);
|
|
1557
1706
|
}
|
|
1558
1707
|
// Small talk: when nobody has said anything for a while, someone fills the silence the way people do in video calls. At most three per silence, further and further apart; any other line starts the count again.
|
|
1559
1708
|
const FILL = {
|
|
@@ -1577,7 +1726,7 @@ const thinkerOf = (e) => (e.kind === 'results' ? e.agent : e.kind === 'user' ? '
|
|
|
1577
1726
|
const before = (i) => { let k = i - 1; while (k >= 0 && (S.events[k].kind === 'usage' || S.events[k].kind === 'compact')) k--; return S.events[k]; };
|
|
1578
1727
|
async function ponder(i, token) { // replay: an agent that really took a long time (40 s or more) before this line or call says so first, about every other time
|
|
1579
1728
|
const ev = S.events[i], p = before(i); if (!prefs.banter || prefs.chat === 'quiet' || S.boost > 1 || S.hurry || !p || !S.agents[ev.agent] || thinkerOf(p) !== ev.agent || ev.ts - p.ts < 40000 || hash('t' + ev.ts) % 100 > 55) return;
|
|
1580
|
-
const line = varied('think', hash(String(ev.ts)));
|
|
1729
|
+
const line = varied('think', hash(String(ev.ts))); await sayLine(ev.agent, line, '💭', token);
|
|
1581
1730
|
}
|
|
1582
1731
|
// Live: you gave an order and the lead starts on its tools without a word (about one time in three): it says what it is starting with, from the tool call itself, so you know it heard you and what it is on
|
|
1583
1732
|
const LEAD = {
|
|
@@ -1585,19 +1734,19 @@ const LEAD = {
|
|
|
1585
1734
|
fr: { in: ['D’accord.', 'Compris.', 'Très bien.', 'Entendu.', 'Ça marche.'], read: 'Je regarde ', edit: 'Je modifie ', write: 'J’écris ', cmd: 'Je lance ', note: 'Je lance une commande : ', grep: 'Je cherche dans le code.', glob: 'Je regarde quels fichiers on a.', todo: 'Je fais mon plan.', web: 'Je vais voir sur le web.', question: 'J’ai une question pour vous.', any: 'Je m’y mets.', and: ' et ' },
|
|
1586
1735
|
};
|
|
1587
1736
|
async function leadAck(ev, token) {
|
|
1588
|
-
S.owed = false; if (
|
|
1737
|
+
if (!prefs.banter || !S.agents.main) { S.owed = false; return; } if (S.hurry || S.boost > 1 || !nearLive() || S.speakers.size) return; S.owed = false; // reading your order took a while: the first tool is rarely less than 6 s old; if now is a bad moment, the next tool will do
|
|
1589
1738
|
const W = LEAD[FR() ? 'fr' : 'en'], d = ev.tools[0].d || {}, k = d.kind === 'text' ? d.what : d.kind; let what = W[k] || W.any;
|
|
1590
1739
|
if (k === 'read' || k === 'edit' || k === 'write') { const files = [...new Set(ev.tools.filter((t) => (t.d || {}).kind === k).map((t) => base(t.d.file)).filter(Boolean))].slice(0, 2); what = files.length ? what + files.join(W.and) + '.' : W.any; }
|
|
1591
1740
|
else if (k === 'cmd') { const note = (d.note || '').trim().replace(/[.\s]+$/, ''), cmd = String(d.text || '').trim().split(/\s+/).slice(0, 2).join(' ').slice(0, 26); what = note ? W.note + note + '.' : cmd ? what + cmd + '.' : W.any; }
|
|
1592
|
-
|
|
1741
|
+
await sayLine('main', `${W.in[hash('k' + ev.ts) % W.in.length]} ${what}`, '💬', token);
|
|
1593
1742
|
}
|
|
1594
1743
|
async function wrapUp(token) { // live, once the lead is done: the part of its last message that the clipped reading left out, so you know what was done
|
|
1595
1744
|
if (!S.wrap || S.speakers.size || !prefs.voice && !prefs.banter) return false; S.wrap = false;
|
|
1596
1745
|
let k = S.events.length - 1; while (k >= 0 && !(S.events[k].agent === 'main' && S.events[k].kind === 'say')) k--; if (k < 0) return false; const e = S.events[k], m = S.mainSaid;
|
|
1597
|
-
if (S.wrapped === e) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
|
|
1746
|
+
if (S.wrapped === e || e.ts <= (S.from || 0)) return false; S.wrapped = e; // once per message: the lead's state can flip to done several times (a short command, a silence) without a new message
|
|
1598
1747
|
const full = plain(e.text); let rest = m && m.ev === e ? full.slice(m.said).trim() : full; if (m && m.ev === e && m.full) return false; if (!m || m.ev !== e) rest = full;
|
|
1599
1748
|
rest = rest.replace(/^[^\w\p{L}]+/u, ''); if (rest.length < 25) return false; const line = (m && m.ev === e ? L('Pour résumer : ', 'To sum up: ') : '') + short(rest, 520);
|
|
1600
|
-
|
|
1749
|
+
await sayLine('main', line, '🎉', token); return true;
|
|
1601
1750
|
}
|
|
1602
1751
|
async function smallTalk(token) {
|
|
1603
1752
|
if (prefs.chat === 'quiet') return; // quiet: nothing to fill the silences, only the real exchanges
|
|
@@ -1625,14 +1774,16 @@ async function run(i) {
|
|
|
1625
1774
|
const token = ++S.token; S.playing = true; S.idle = false; S.hurry = false; S.boost = 1; S.spokeAt = performance.now(); S.fill = 0; S.exQ = []; S.errRun = {}; S.wrap = false; S.mainSaid = null; S.owed = false; S.syncTs = (S.events[i] && S.events[i].ts) || Date.now(); syncUI(); AUD.init();
|
|
1626
1775
|
if (i === 0 && prefs.epi && !S.live) { await card(L('ÉPISODE 1', 'EPISODE 1'), S.title); if (token !== S.token) return; }
|
|
1627
1776
|
while (token === S.token) {
|
|
1628
|
-
const lim = S.stopAt || S.events.length;
|
|
1777
|
+
const lim = S.stopAt || S.events.length; S.pos = i;
|
|
1778
|
+
if (S.live && S.late.length) { await converse(S.late.shift(), token); if (token !== S.token) return; continue; } // a line that turned out to be a report after it was read
|
|
1629
1779
|
if (i >= lim) { if (S.live && !S.stopAt) { if (!S.idle) { S.idle = true; syncUI(); } if (await wrapUp(token)) continue; await smallTalk(token); await sleep(350); continue; } break; }
|
|
1630
1780
|
if (S.idle) { S.idle = false; syncUI(); }
|
|
1631
1781
|
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
|
|
1632
1782
|
const rem = S.events.length - i;
|
|
1633
1783
|
if (rem > 300 || aheadSpeakable(i, 16) > 16 || lagMs(i) > 60000) { // only a really big backlog is skipped; before that, lines are shortened instead
|
|
1634
1784
|
let k = S.events.length, back = 0; while (k > i && back < 8) { k--; if (SPK.has(S.events[k].kind)) back++; }
|
|
1635
|
-
|
|
1785
|
+
let to = rem > 300 ? Math.max(k, S.events.length - 14) : k; for (let u = i; u < to; u++) if (S.events[u].kind === 'user') { to = u; break; } // never over a line of yours: what you asked is always said, shortened if need be
|
|
1786
|
+
if (to > i) { i = to; recomputeStatus(i); paintStatus(); }
|
|
1636
1787
|
}
|
|
1637
1788
|
}
|
|
1638
1789
|
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
|
|
@@ -1663,19 +1814,19 @@ async function run(i) {
|
|
|
1663
1814
|
if (FR() && !S.hurry) { prefetchTr(i); await Promise.race([ensureTr(group.map((g) => baseText(g))), sleep(S.live ? 7000 : 14000)]); if (token !== S.token) return; }
|
|
1664
1815
|
prefetchQuips(i); warmTTS(i);
|
|
1665
1816
|
const playOne = (g) => {
|
|
1666
|
-
applyStatus(g); if (g.agent === 'user') S.owed = true; else if (g.agent === 'main') S.owed = false; // your order waits for a word from the lead
|
|
1817
|
+
applyStatus(g); g._as = g.kind; if (g.agent === 'user') S.owed = true; else if (g.agent === 'main') S.owed = false; // your order waits for a word from the lead
|
|
1667
1818
|
const label = g.kind === 'order' ? `${L('pour', 'to')} ${nameOf(g.to)}` : g.kind === 'report' ? `${L('rapport à', 'report to')} ${nameOf(g.to)}` : '', line = talkLine(g);
|
|
1668
1819
|
startTalk(g.agent, line, label);
|
|
1669
1820
|
if (g.kind === 'order') { S.status[g.to] = 'working'; AUD.join(); setTimeout(() => { paintStatus(); fly(g.agent, g.to, '📨'); popTile(g.to); }, 30); }
|
|
1670
1821
|
if (g.kind === 'report') { AUD.ding(); setTimeout(() => { fly(g.agent, g.to, '✅'); popTile(g.agent); }, 30); }
|
|
1671
1822
|
const said = clipSpeech(line, S.pace); if (g.agent === 'main' && g.kind === 'say') { const full = plain(g.text), key = said.replace(/…$/, '').trim().slice(-30), pos = key ? full.indexOf(key) : -1, n = pos >= 0 ? pos + key.length : said.length; S.mainSaid = { ev: g, said: n, full: n >= full.length - 5 }; }
|
|
1672
|
-
return speakLine(said, S.agents[g.agent], true, token,
|
|
1823
|
+
return speakLine(said, S.agents[g.agent], true, token, sessFr(said, g), g.kind === 'user').then(() => { if (token === S.token) stopTalk(g.agent); });
|
|
1673
1824
|
};
|
|
1674
1825
|
let plays;
|
|
1675
1826
|
if (ev.kind === 'order' && group.length > 1) { // the first order in full, then "Also Remy on X, Iris on Y"
|
|
1676
1827
|
const rest = group.slice(1), line = `${L('Et aussi ', 'Also ')}${rest.map((o) => `${nameOf(o.to)}${L(' sur ', ' on ')}${(o.title || short(o.text, 60)).replace(/[.。]\s*$/, '')}`).join(', ')}.`;
|
|
1677
1828
|
setTimeout(() => rest.forEach((o, k) => setTimeout(() => { fly(o.agent, o.to, '📨'); popTile(o.to); }, k * 120)), 30);
|
|
1678
|
-
plays = [playOne(ev).then(() => { if (token !== S.token) return; startTalk(ev.agent, line, ''); return speakLine(clipSpeech(line, S.pace), S.agents[ev.agent], true, token).then(() => stopTalk(ev.agent)); })];
|
|
1829
|
+
plays = [playOne(ev).then(() => { if (token !== S.token) return; startTalk(ev.agent, line, ''); return speakLine(clipSpeech(line, S.pace), S.agents[ev.agent], true, token).then(() => { if (token === S.token) stopTalk(ev.agent); }); })];
|
|
1679
1830
|
}
|
|
1680
1831
|
else if (S.live) { plays = []; let chain = Promise.resolve(); for (const g of group) { chain = chain.then(() => (token === S.token ? playOne(g) : null)); plays.push(chain); } } else plays = group.map(playOne); // live: one voice at a time so each can be heard; replay keeps the overlapping chatter
|
|
1681
1832
|
S.idx = j - 1; paintStatus(); await Promise.all(plays); if (token !== S.token) return;
|
|
@@ -1692,10 +1843,10 @@ function clipSpeech(t, cap) { // the bubble keeps the full text; the voice rea
|
|
|
1692
1843
|
const m = t.slice(0, cap + 40).match(/^.{20,}?[.!?](?=\s|$)/); if (m && m[0].length <= cap + 30) return m[0];
|
|
1693
1844
|
return t.slice(0, cap).replace(/\s+\S*$/, '') + '…';
|
|
1694
1845
|
}
|
|
1695
|
-
function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); $('#card').classList.remove('on'); syncUI(); }
|
|
1846
|
+
function pause() { S.playing = false; S.hurry = false; S.boost = 1; S.token++; synth && synth.cancel(); AUD.stopAll(); clearSpeaking(); clearThoughts(); $('#card').classList.remove('on'); syncUI(); }
|
|
1696
1847
|
function togglePlay() {
|
|
1697
1848
|
if (S.locked) return;
|
|
1698
|
-
if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.idx); }
|
|
1849
|
+
if (S.playing) { S.userPaused = true; pause(); } else { claimAudio(); S.userPaused = false; run(S.idx >= S.events.length - 1 && !S.live ? (recomputeStatus(0), 0) : S.live ? S.pos : S.idx); } // live: from the first line not said yet (the one that was cut, if any)
|
|
1699
1850
|
}
|
|
1700
1851
|
function seekTo(i) {
|
|
1701
1852
|
pause(); i = Math.max(0, Math.min(S.events.length - 1, i)); S.idx = i; recomputeStatus(i); paintStatus(); S.userPaused = false; run(i);
|
|
@@ -1719,7 +1870,7 @@ async function loadSession(proj, id) {
|
|
|
1719
1870
|
const data = await (await fetch(`/api/session?proj=${encodeURIComponent(proj)}&id=${id}`)).json();
|
|
1720
1871
|
if (mine !== S.loadN || data.error) return false;
|
|
1721
1872
|
S.term = {}; S.camFor = null; S.focus = null; S.agents = {}; for (const k in nameBook) delete nameBook[k]; [{ id: 'user' }, ...data.agents].forEach((a) => (S.agents[a.id] = persona(a)));
|
|
1722
|
-
S.events = data.events; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
|
|
1873
|
+
clearThoughts(); Object.assign(TH, { since: {}, next: {}, said: {}, last: 0 }); S.events = data.events; stampT(0); S.byK = new Map(data.events.map((e) => [e.k, e])); S.seq = data.seq || 0; S.ep = data.ep || ''; S.sawEnd = data.events.some((e) => e.end); S.late = []; S.pos = 0; S.joinAt = Date.now(); S.idx = 0; S.status = {}; S.tc = {}; S.cur = { proj, id }; S.logTo = 0; S.detail = null; $('#detail').classList.remove('on');
|
|
1723
1874
|
const opt = $('#sessions').selectedOptions[0]; S.title = (S.live ? niceProj(proj) : opt && opt.dataset.title) || '';
|
|
1724
1875
|
if (proj === '_demo') S.events.forEach((e) => { if (e.fr) trMem.set(baseText(e), e.fr); }); // the demo ships its own French
|
|
1725
1876
|
$('#subtitle').textContent = S.title; recomputeStatus(0); buildStage(); renderHighlights(); setFocus('main'); renderLog(true); computeGantt(); layout(); paintStatus(); prefetchTr(0); return true;
|
|
@@ -1748,7 +1899,16 @@ function midWork() { // someone is in the middle of something: the session doe
|
|
|
1748
1899
|
function liveStart() {
|
|
1749
1900
|
const n = S.events.length; if (!S.locked && !S.since) S.since = Date.now() - (midWork() ? JOIN_BACK_MS : 0); // S.since 0: joining now
|
|
1750
1901
|
const since = S.locked ? Date.now() : S.since; let i = n; while (i > Math.max(0, n - 16) && S.events[i - 1].ts > since) i--;
|
|
1751
|
-
recomputeStatus(i); S.idx = i; trNew(Math.max(0, n - 16)); paintStatus(); return i;
|
|
1902
|
+
recomputeStatus(i); S.idx = S.pos = i; S.from = since; trNew(Math.max(0, n - 16)); paintStatus(); return i;
|
|
1903
|
+
}
|
|
1904
|
+
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
|
|
1905
|
+
const i = S.events.indexOf(e), row = $(`#log .row[data-i="${i}"]`); if (row) row.outerHTML = logRow(i);
|
|
1906
|
+
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
|
|
1907
|
+
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); } }
|
|
1908
|
+
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
|
|
1909
|
+
applyStatus(e); AUD.ding(); fly(e.agent, e.to, '✅'); popTile(e.agent); if (e._as === 'say') { e._as = 'report'; S.late.push(e); }
|
|
1910
|
+
}
|
|
1911
|
+
if (e.end && !was.end && (e._n != null || e._as || past)) { let k = S.events.length - 1; while (k > i && !(S.events[k].kind === 'user' || (S.events[k].agent === 'main' && /^(say|tools|order)$/.test(S.events[k].kind) && !S.events[k].wf))) k--; if (k === i) S.turnOver = true; } // only if nothing came after it
|
|
1752
1912
|
}
|
|
1753
1913
|
let polling = false;
|
|
1754
1914
|
setInterval(async () => {
|
|
@@ -1756,12 +1916,16 @@ setInterval(async () => {
|
|
|
1756
1916
|
try {
|
|
1757
1917
|
const switched = await followCurrent(); if (S.offline) { S.offline = false; syncUI(); }
|
|
1758
1918
|
if (switched || !S.cur) return;
|
|
1759
|
-
const d = await (await fetch(`/api/session?proj=${encodeURIComponent(
|
|
1760
|
-
if (d.error) return; if (d.
|
|
1761
|
-
if (!d.events || !d.events.length) return;
|
|
1919
|
+
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();
|
|
1920
|
+
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
|
|
1921
|
+
S.seq = d.seq; if (!d.events || !d.events.length) return;
|
|
1762
1922
|
for (const a of d.agents) if (!S.agents[a.id]) { S.agents[a.id] = persona(a); addTile(S.agents[a.id]); }
|
|
1763
|
-
const from = S.events.length
|
|
1764
|
-
|
|
1923
|
+
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
|
|
1924
|
+
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); } }
|
|
1925
|
+
if (fresh.some((e) => e.end)) S.sawEnd = true;
|
|
1926
|
+
if (fresh.length) { S.events = S.events.concat(fresh); stampT(from); $('#seek').max = S.events.length - 1; trNew(from); warmTTS(from); renderLog(false); }
|
|
1927
|
+
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
|
|
1928
|
+
for (const [e, was] of changed) amend(e, was);
|
|
1765
1929
|
paintStatus();
|
|
1766
1930
|
} catch { if (!S.offline) { S.offline = true; syncUI(); } } finally { polling = false; }
|
|
1767
1931
|
}, 400);
|
|
@@ -1942,10 +2106,11 @@ function applyLang() {
|
|
|
1942
2106
|
renderCharInfo();
|
|
1943
2107
|
document.documentElement.lang = (LANGS.find((l) => l[0] === prefs.lang) || LANGS[0])[2];
|
|
1944
2108
|
document.querySelectorAll('[data-fr]').forEach((el) => (el.textContent = FR() ? el.dataset.fr : tr(el.dataset.en)));
|
|
2109
|
+
document.querySelectorAll('#viewSeg button').forEach((b) => { b.title = b.textContent; }); // in a narrow window they are icons: the name stays as a tooltip
|
|
1945
2110
|
const sel = $('#langSel'); if (!sel.options.length) sel.innerHTML = LANGS.map(([c, n]) => `<option value="${c}">${n}</option>`).join(''); sel.value = prefs.lang;
|
|
1946
2111
|
[['#bPlay', L('Pause / Lecture (espace)', 'Pause / Play (space)')], ['#bLog', 'Transcript'], ['#bMute', L('Son (touche M)', 'Sound (M key)')], ['#bMenu', L('Réglages', 'Settings')], ['#dclose', L('Fermer', 'Close')], ['#officeVol', L('Volume de l’ambiance', 'Ambience volume')], ['#musicVol', L('Volume de la musique', 'Music volume')], ['#liveProj', L('Projet à suivre', 'Project to follow')], ['#reactBar', L('Réactions', 'Reactions')], ['#logQ', L('Rechercher dans la transcription', 'Search the transcript')], ['#roster', L('Agents', 'Agents')]].forEach(([q, t]) => { const el = $(q); if (!el) return; el.title = t; el.setAttribute('aria-label', t); }); $('#logQ').placeholder = L('Rechercher…', 'Search…'); $('#menuQ').placeholder = L('Chercher un réglage…', 'Search settings…'); document.querySelectorAll('#reactBar button').forEach((b) => b.setAttribute('aria-label', b.dataset.e));
|
|
1947
2112
|
$('#gT').textContent = L('Rejoindre l’appel', 'Join the call'); $('#gP').textContent = L('Cliquez pour activer le son et suivre votre session Claude Code en direct.', 'Click to enable sound and follow your Claude Code session live.'); $('#gB').textContent = L('▶ Rejoindre', '▶ Join');
|
|
1948
|
-
$('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').
|
|
2113
|
+
$('#eDemo').textContent = L('▶ Voir la démo', '▶ Watch the demo'); $('#eT').textContent = L('En attente d’une session', 'Waiting for a session'); $('#eP').innerHTML = watched() ? esc(L('Lancez Claude Code dans {0} : l’appel démarrera tout seul.', 'Start Claude Code in {0} — the call will start by itself.')).replace('{0}', `<b>${esc(watched())}</b>`) : esc(L('Lancez Claude Code dans ce dossier : l’appel démarrera tout seul.', 'Start Claude Code in this folder — the call will start by itself.'));
|
|
1949
2114
|
for (const id in S.agents) { const p = persona(S.agents[id]); Object.assign(S.agents[id], { name: p.name, role: p.role }); const t = $('#t-' + id); if (t) { t.querySelector('.tag span').textContent = p.role; t.querySelector('.tag b').textContent = p.name; if (id === 'main' || id === 'user') t.querySelector('.tag span').textContent = p.role; } }
|
|
1950
2115
|
document.querySelectorAll('#menu .mi[data-t]').forEach((b) => b.querySelector('.sw').classList.toggle('on', !!prefs[b.dataset.t])); renderCoffee(); renderTokens(); updateHint(); renderLog(true); syncUI();
|
|
1951
2116
|
}
|
|
@@ -1984,6 +2149,7 @@ function menuInit() {
|
|
|
1984
2149
|
for (const e of sc.items) { const hit = !q || e.textContent.toLowerCase().includes(q) || [...e.querySelectorAll('option')].some((o) => o.textContent.toLowerCase().includes(q)); e.classList.toggle('mhide', !hit); if (hit && q) any = true; if (q) e.classList.remove('mfold'); else e.classList.toggle('mfold', sc.h.classList.contains('closed')); }
|
|
1985
2150
|
sc.h.classList.toggle('mhide', !!q && !any && !sc.h.textContent.toLowerCase().includes(q)); if (q) sc.h.classList.remove('closed'); }
|
|
1986
2151
|
if (!q) secs.forEach((sc, i) => sc.h.classList.toggle('closed', (prefs.menuClosed || []).includes(i)));
|
|
2152
|
+
$('#menuNone').style.display = q && !secs.some((sc) => sc.items.some((e) => !e.classList.contains('mhide'))) ? '' : 'none';
|
|
1987
2153
|
};
|
|
1988
2154
|
}
|
|
1989
2155
|
$('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b) return; prefs[b.dataset.t] = !prefs[b.dataset.t]; savePrefs(); if (prefs[b.dataset.t] && ['voice', 'office', 'music', 'notify'].includes(b.dataset.t)) claimAudio(); b.querySelector('.sw').classList.toggle('on', prefs[b.dataset.t]); AUD.apply(); if (b.dataset.t === 'office') $('#officeBox').style.display = prefs.office ? '' : 'none'; if (b.dataset.t === 'customLaptop') { for (const k in imgCache) delete imgCache[k]; buildStage(); paintStatus(); } if (b.dataset.t === 'reactions') document.body.classList.toggle('react', prefs.reactions); if (b.dataset.t === 'roster') { document.body.classList.toggle('rost', prefs.roster); paintStates(); } if (b.dataset.t === 'notify') { prefs.notifyChosen = true; savePrefs(); } if (b.dataset.t === 'notify' && prefs.notify) askNotify(); if (b.dataset.t === 'vanish' && !prefs.vanish) { S.gone.clear(); paintStatus(); }
|
|
@@ -1991,7 +2157,7 @@ $('#menu').onclick = (e) => { const b = e.target.closest('.mi[data-t]'); if (!b)
|
|
|
1991
2157
|
$('#station').innerHTML = STATIONS.map((s) => `<option value="${s.id}">📻 ${s.name}</option>`).join('');
|
|
1992
2158
|
$('#station').value = prefs.station; $('#musicVol').value = prefs.musicVol; $('#musicBox').style.display = prefs.music ? '' : 'none';
|
|
1993
2159
|
$('#station').onchange = (e) => { prefs.station = e.target.value; savePrefs(); AUD.init(); RADIO.sync(); };
|
|
1994
|
-
$('#chatSel').value = ['quiet', 'normal', 'lively'].includes(prefs.chat) ? prefs.chat : '
|
|
2160
|
+
$('#chatSel').value = ['quiet', 'normal', 'lively'].includes(prefs.chat) ? prefs.chat : 'normal'; $('#chatSel').onchange = () => { prefs.chat = $('#chatSel').value; savePrefs(); };
|
|
1995
2161
|
$('#vstyle').innerHTML = [['animal', 'Animal Crossing (babillage)', 'Animal Crossing (babble)'], ['sims', 'Simlish (babillage)', 'Simlish (babble)'], ['cartoon', 'Voix cartoon (voix réelle)', 'Cartoon voices (real voice)'], ['natural', 'Voix naturelles', 'Natural voices'], ['kokoro', 'Voix très naturelles (à installer)', 'Very natural voices (installs on demand)']].map(([v, fr, en]) => `<option value="${v}" data-fr="${fr}" data-en="${en}">${L(fr, en)}</option>`).join(''); $('#vstyle').value = prefs.vstyle; $('#vstyle').onchange = () => { prefs.vstyle = $('#vstyle').value; prefs.vstyleChosen = true; savePrefs(); TTS.init().then(kokoroUI); kokoroUI(); };
|
|
1996
2162
|
// ---- ⚙ → very natural voices (Kokoro): install box and status, polled while installing
|
|
1997
2163
|
let kokoroTimer = null;
|
|
@@ -2023,7 +2189,7 @@ $('#mExport').onclick = () => { $('#menu').classList.remove('on'); exportVideo()
|
|
|
2023
2189
|
$('#mClip').onclick = () => { $('#menu').classList.remove('on'); exportVideo(0, 0, 25000); };
|
|
2024
2190
|
$('#mBrowser').onclick = () => { $('#menu').classList.remove('on'); window.parent.postMessage({ agentStandup: 'openBrowser' }, '*'); };
|
|
2025
2191
|
$('#seek').oninput = (e) => seekTo(+e.target.value);
|
|
2026
|
-
$('#eDemo').onclick = () => setMode('demo');
|
|
2192
|
+
$('#eDemo').onclick = async () => { await setMode('demo'); if (S.demo && !S.playing) togglePlay(); }; // "Watch the demo" plays it
|
|
2027
2193
|
$('#gB').onclick = () => {
|
|
2028
2194
|
claimAudio(); AUD.init(); AUD.apply(); S.locked = false; $('#gate').classList.remove('on'); syncUI();
|
|
2029
2195
|
if (synth && !EMBED) { try { synth.speak(new SpeechSynthesisUtterance('')); } catch {} } // unlock browser speech
|
|
@@ -2039,6 +2205,74 @@ function jumpAttn(dir = 1) {
|
|
|
2039
2205
|
toast(`${nameOf(id)} — ${agentState(id, Date.now()).txt.replace(/^\S+\s/, '')}`);
|
|
2040
2206
|
}
|
|
2041
2207
|
$('#board').onclick = (e) => { if (e.target.closest('.blocked, .stalled')) jumpAttn(1); };
|
|
2208
|
+
// ---------- the header status: one line that tells the state of the SESSION (not of the playback), most urgent first ----------
|
|
2209
|
+
// 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.
|
|
2210
|
+
// 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.
|
|
2211
|
+
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"
|
|
2212
|
+
function watched() { return niceProj(Q.get('proj') || prefs.proj || ''); } // the folder the live view follows, when it follows one
|
|
2213
|
+
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
|
|
2214
|
+
const key = (S.cur ? S.cur.id : '') + '|' + S.idx + '|' + S.events.length; if (lastWord.key === key) return lastWord.v;
|
|
2215
|
+
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];
|
|
2216
|
+
lastWord.key = key; return (lastWord.v = e ? pieces(plain(e.text)).pop() || '' : '');
|
|
2217
|
+
}
|
|
2218
|
+
function paintPill() {
|
|
2219
|
+
const pill = $('#pill'), now = Date.now(); let cls = '', txt = '', to = '', tip = '', sess = false;
|
|
2220
|
+
if (S.offline) { cls = 'off'; txt = '⚠ ' + L('HORS LIGNE', 'OFFLINE'); }
|
|
2221
|
+
else if (!S.live) txt = 'REPLAY';
|
|
2222
|
+
else if (!S.cur) txt = L('En attente d’une session', 'Waiting for a session');
|
|
2223
|
+
else if (S.locked) txt = L('PRÊT', 'READY');
|
|
2224
|
+
else if (!S.playing) txt = L('PAUSE', 'PAUSED');
|
|
2225
|
+
else {
|
|
2226
|
+
sess = true; const st = {}, by = { working: [], waiting: [], blocked: [], stalled: [], done: [], idle: [] };
|
|
2227
|
+
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); }
|
|
2228
|
+
const what = (id) => `${nameOf(id)} — ${st[id].txt.replace(/^\S+\s/, '')}`, more = (a) => (a.length > 1 ? ` +${a.length - 1}` : ''), lead = st.main || { k: 'idle' };
|
|
2229
|
+
if (by.blocked.length) { cls = 'blocked'; to = by.blocked[0]; txt = '✋ ' + what(to) + more(by.blocked); }
|
|
2230
|
+
else if (by.stalled.length) { cls = 'stalled'; to = by.stalled[0]; txt = '⚠ ' + what(to) + more(by.stalled); }
|
|
2231
|
+
else if (by.working.length || by.waiting.length) { // "2 working · Claude — waiting on Ken, Mira", or what the only one at work is doing
|
|
2232
|
+
const w = by.working, wt = lead.k === 'waiting' ? 'main' : w.length ? '' : by.waiting[0]; cls = 'working'; to = wt || (w.length === 1 ? w[0] : 'main');
|
|
2233
|
+
txt = (w.length === 1 && !wt ? '⚙ ' + what(w[0]) : w.length ? `⚙ ${w.length} ${L('au travail', 'working')}` : '') + (wt ? (w.length ? ' · ' : '🔗 ') + what(wt) : '');
|
|
2234
|
+
}
|
|
2235
|
+
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; }
|
|
2236
|
+
else { cls = 'idle'; to = 'main'; txt = '💤 ' + L('inactif', 'idle'); }
|
|
2237
|
+
}
|
|
2238
|
+
const c = cls + (to ? ' go' : ''), sub = !sess && S.cur && S.title ? ' · ' + S.title : '';
|
|
2239
|
+
for (const el of [pill, $('#sline')]) { // the same status, in the header (wide) and on its own line (narrow)
|
|
2240
|
+
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;
|
|
2241
|
+
if (to && !el.hasAttribute('tabindex')) { el.tabIndex = 0; el.setAttribute('role', 'button'); } else if (!to && el.hasAttribute('tabindex')) { el.removeAttribute('tabindex'); el.removeAttribute('role'); }
|
|
2242
|
+
}
|
|
2243
|
+
S.pillTo = to; S.pillSess = sess;
|
|
2244
|
+
}
|
|
2245
|
+
function pillGo() { // to the agent the status is about; several need you: one after the other (as J does)
|
|
2246
|
+
const id = S.pillTo, t = id && $('#t-' + id); if (!t) return; if ((S.attn || []).includes(id)) { jumpAttn(1); return; }
|
|
2247
|
+
if (document.body.classList.contains('tree') || document.body.classList.contains('board')) setView('call');
|
|
2248
|
+
S.focusLock = Date.now() + 25000; setFocus(id); paintStates(); t.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); popTile(id);
|
|
2249
|
+
}
|
|
2250
|
+
$('#pill').onclick = $('#sline').onclick = pillGo; $('#pill').onkeydown = $('#sline').onkeydown = (e) => { if (S.pillTo && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); pillGo(); } };
|
|
2251
|
+
// ---------- 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 ----------
|
|
2252
|
+
function focusDoor() { // never taken from a field or a menu the person is in
|
|
2253
|
+
const b = $('#empty').classList.contains('on') ? $('#eDemo') : $('#gate').classList.contains('on') ? $('#gB') : null, a = document.activeElement;
|
|
2254
|
+
if (b && a !== b && (!a || a === document.body || a === $('#gB') || a === $('#eDemo'))) b.focus({ preventScroll: true });
|
|
2255
|
+
}
|
|
2256
|
+
for (const q of ['#gate', '#empty']) new MutationObserver(focusDoor).observe($(q), { attributes: true, attributeFilter: ['class'] });
|
|
2257
|
+
function syncSwitches() { document.querySelectorAll('#menu .mi[data-t]').forEach((b) => { b.setAttribute('role', 'switch'); b.setAttribute('aria-checked', String(!!prefs[b.dataset.t])); }); }
|
|
2258
|
+
new MutationObserver(() => { // the menu opens under the header and the status line, not over them
|
|
2259
|
+
const m = $('#menu'), on = m.classList.contains('on'), h = Math.round($('main').getBoundingClientRect().top); $('#bMenu').setAttribute('aria-expanded', String(on)); if (!on) return;
|
|
2260
|
+
syncSwitches(); m.style.top = h + 3 + 'px'; m.style.maxHeight = `calc(100dvh - ${h + 13}px)`;
|
|
2261
|
+
}).observe($('#menu'), { attributes: true, attributeFilter: ['class'] });
|
|
2262
|
+
$('#menu').addEventListener('click', () => setTimeout(syncSwitches)); $('#bMenu').setAttribute('aria-expanded', 'false'); syncSwitches();
|
|
2263
|
+
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
|
|
2264
|
+
if (e.key !== 'Escape' || $('#switcher')) return;
|
|
2265
|
+
if ($('#menu').classList.contains('on')) { $('#menu').classList.remove('on'); $('#bMenu').focus(); }
|
|
2266
|
+
else if ($('#detail').classList.contains('on') && !$('#recap').classList.contains('on')) { const t = $('#t-' + S.detail); $('#dclose').click(); if (t && t.offsetParent) t.focus(); }
|
|
2267
|
+
}, true);
|
|
2268
|
+
// ---------- 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 ----------
|
|
2269
|
+
new MutationObserver((ms) => {
|
|
2270
|
+
for (const m of ms) {
|
|
2271
|
+
const b = m.target; if (b.className !== 'bubble' || !b.offsetHeight || b.parentNode.classList.contains('musing')) continue; const st = $('#stage'), r = b.getBoundingClientRect(), s = st.getBoundingClientRect(); // (a thought does not move the stage)
|
|
2272
|
+
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
|
|
2273
|
+
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' });
|
|
2274
|
+
}
|
|
2275
|
+
}).observe($('#stage'), { subtree: true, childList: true });
|
|
2042
2276
|
// Replay: arrow keys step through the call, N jumps between highlights, speed presets, and the position of each session is remembered
|
|
2043
2277
|
const stepKind = (e) => !['usage', 'results', 'finish', 'compact'].includes(e.kind);
|
|
2044
2278
|
function stepEvent(dir) {
|