shadok-ai 0.1.199 → 0.1.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/index.html +333 -147
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.1.199",
3
+ "version": "0.1.201",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts .claude/skills/shadok-ai-agents/test/*.test.mjs",
package/public/index.html CHANGED
@@ -426,20 +426,6 @@
426
426
  dépasse (quand une session tourne, le transcript remplit exactement → inerte). */
427
427
  .chat-pane { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow-y: auto; }
428
428
 
429
- /* Start screen */
430
- #setup {
431
- margin: auto;
432
- width: min(520px, 92vw);
433
- border: 1px solid var(--line);
434
- border-radius: 8px;
435
- background: var(--bg-raised);
436
- padding: 28px;
437
- display: flex;
438
- flex-direction: column;
439
- gap: 18px;
440
- }
441
- #setup h1 { font-family: var(--mono); font-size: 16px; color: var(--amber); font-weight: 500; }
442
- #setup p.hint { color: var(--text-dim); font-size: 13px; line-height: 1.5; }
443
429
  .field { display: flex; flex-direction: column; gap: 6px; }
444
430
  input[type="text"] {
445
431
  font-family: var(--mono);
@@ -852,7 +838,6 @@
852
838
  /* Interactive terminal (xterm.js), per channel, filling the main area. Toggled
853
839
  by ⌨️; when on, .chat-pane gets .terminal-mode → chat hidden, terminal shown. */
854
840
  #terminalView { flex: 1; min-height: 0; display: none; flex-direction: column; }
855
- .chat-pane.terminal-mode #setup,
856
841
  .chat-pane.terminal-mode #transcripts,
857
842
  .chat-pane.terminal-mode #thinking,
858
843
  .chat-pane.terminal-mode #composer { display: none !important; }
@@ -870,8 +855,26 @@
870
855
  #chanBar button.icon { padding: 4px 8px; font-size: 14px; line-height: 1; letter-spacing: 0; }
871
856
  #chanBar button.icon.stop:hover { border-color: var(--err); color: var(--err); }
872
857
  #chanBar button.icon.on { border-color: var(--amber); color: var(--amber); background: rgba(240, 168, 72, 0.15); }
858
+ /* Status / Directory / Session / Branch : inline & compact, so the channel
859
+ header stays a thin single line. */
860
+ #chanBar .gauge { flex-direction: row; align-items: baseline; gap: 4px; }
861
+ #chanBar .gauge .label { font-size: 10px; }
862
+ #chanBar #cwdView { display: inline-block; max-width: 200px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
873
863
  #xtermHost { flex: 1; min-height: 0; overflow: auto; padding: 8px 10px; }
874
864
  #xtermHost .xterm { height: 100%; }
865
+ /* Aucun agent ouvert. Le panneau central n'affiche plus un formulaire à demi
866
+ rempli d'avance : il invite à agir, et rien d'autre. */
867
+ #emptyState {
868
+ flex: 1; min-height: 0; display: none;
869
+ flex-direction: column; align-items: center; justify-content: center;
870
+ gap: 12px; padding: 32px; text-align: center;
871
+ }
872
+ #emptyState.visible { display: flex; }
873
+ #emptyState .es-title {
874
+ font-family: var(--mono); font-size: 12px;
875
+ letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-dim);
876
+ }
877
+ #emptyState .es-hint { color: var(--text-dim); font-size: 13px; line-height: 1.5; max-width: 44ch; }
875
878
  .keys {
876
879
  display: flex;
877
880
  gap: 6px;
@@ -978,8 +981,78 @@
978
981
  @media (max-width: 900px) {
979
982
  #machine, #diffpanel { position: fixed; inset: 0; width: auto; max-width: none; z-index: 10; border-left: none; }
980
983
  }
981
- #secretsOverlay, #profilesOverlay, #telegramOverlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; z-index: 50; }
982
- #secretsPanel, .sec-panel { background: var(--panel, #1b1d22); color: var(--fg, #e8e8ea); width: min(520px, 92vw); max-height: 80vh; overflow: auto; border-radius: 10px; padding: 16px 18px; box-shadow: 0 10px 40px rgba(0,0,0,.5); }
984
+ /* ── Popins ───────────────────────────────────────────────
985
+ UNE CLASSE, pas une liste d'ids. La règle nommait
986
+ #secretsOverlay/#profilesOverlay/#telegramOverlay ; le panneau des crons,
987
+ ajouté après, n'y figurait pas — il s'affichait donc dans le flux de la page
988
+ au lieu de se centrer. Toute popin hérite maintenant du comportement en
989
+ portant `.overlay`, il n'y a plus de liste à ne pas oublier. */
990
+ .overlay {
991
+ position: fixed; inset: 0; z-index: 50;
992
+ display: flex; align-items: center; justify-content: center;
993
+ padding: 24px;
994
+ background: rgba(8, 9, 13, 0.62);
995
+ backdrop-filter: blur(2px);
996
+ }
997
+ .overlay[hidden] { display: none; }
998
+ /* Le panneau était hors palette : #1b1d22 au lieu de --bg-raised, #e8e8ea au
999
+ lieu de --text, des bordures rgba(255,255,255,.15) et un rayon de 10px quand
1000
+ tout le cockpit est à 6 — soit un gris et un blanc qui n'existaient nulle
1001
+ part ailleurs. Le filet ambre en haut reprend la grammaire des instruments
1002
+ (bord de l'onglet actif, ⌨️ allumé) : ce panneau est au premier plan. */
1003
+ #secretsPanel, .sec-panel {
1004
+ background: var(--bg-raised); color: var(--text);
1005
+ width: min(560px, 100%); max-height: min(80vh, 760px); overflow: auto;
1006
+ border: 1px solid var(--line); border-top: 1px solid var(--amber);
1007
+ border-radius: 6px; padding: 14px 16px;
1008
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
1009
+ }
1010
+ /* Squelette entête / corps / pied. Seul le CORPS défile, donc l'action
1011
+ principale reste atteignable : le formulaire de création dépasse l'écran sur
1012
+ un portable, et son bouton « Start » se trouvait en bas d'une page à
1013
+ dérouler. Réservé aux popins qui ont un pied — les autres gardent leur
1014
+ rembourrage simple. */
1015
+ .sec-panel.modal { padding: 0; overflow: hidden; display: flex; flex-direction: column; }
1016
+ .modal > .sec-head {
1017
+ flex: 0 0 auto; margin: 0; padding: 11px 16px;
1018
+ border-bottom: 1px solid var(--line);
1019
+ }
1020
+ /* Même traitement que les libellés du cockpit (.label, barre du canal) : la
1021
+ popin appartient visiblement à la même famille. */
1022
+ .modal > .sec-head strong {
1023
+ font-family: var(--mono); font-size: 11px; font-weight: normal;
1024
+ letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-dim);
1025
+ }
1026
+ .modal > .sec-head strong .obj { color: var(--amber); }
1027
+ .modal-body { flex: 1 1 auto; overflow: auto; padding: 14px 16px; }
1028
+ .modal-foot {
1029
+ flex: 0 0 auto; display: flex; align-items: center; justify-content: flex-end;
1030
+ gap: 8px; padding: 11px 16px;
1031
+ border-top: 1px solid var(--line); background: rgba(0, 0, 0, 0.18);
1032
+ }
1033
+ /* Pousse ce qui suit à droite : un pied peut porter une note à gauche. */
1034
+ .modal-foot .foot-note { margin-right: auto; font-size: 11px; color: var(--text-dim); }
1035
+ .sec-panel :focus-visible { outline: 2px solid var(--amber); outline-offset: 1px; }
1036
+ /* Les <select> et les champs numériques n'étaient couverts par aucune règle :
1037
+ ils tombaient sur le rendu par défaut du navigateur — clair — au milieu d'un
1038
+ panneau sombre. */
1039
+ .sec-panel select, .sec-panel input[type=number], .sec-panel input[type=time] {
1040
+ padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line);
1041
+ background: rgba(0, 0, 0, 0.25); color: inherit; font-family: inherit; font-size: inherit;
1042
+ }
1043
+ /* Une précision dans un libellé ne SE CRIE PAS : `.label` met en majuscules et
1044
+ espace les lettres, ce qui rendait une phrase entière illisible. */
1045
+ .label .check-hint { text-transform: none; letter-spacing: normal; font-weight: normal; }
1046
+ #cronList { list-style: none; padding: 0; margin: 0 0 14px; }
1047
+ #cronList li { padding: 6px 8px; border-radius: 6px; }
1048
+ #cronList li:nth-child(odd) { background: rgba(255, 255, 255, 0.04); }
1049
+ .cron-form { border-top: 1px solid var(--line); padding-top: 12px; }
1050
+ .cron-form .field { margin: 12px 0; }
1051
+ .cron-form .field:last-child { margin-bottom: 0; }
1052
+ /* `.field` est en colonne : sans redresser la direction ici, les trois champs
1053
+ s'empilaient — et `align-items: center` les centrait au lieu de les aligner. */
1054
+ .cron-when { flex-direction: row; align-items: center; gap: 8px; flex-wrap: wrap; }
1055
+ .cron-when input[type=number] { width: 76px; }
983
1056
  .sec-panel .field { margin: 8px 0; }
984
1057
  .sec-panel textarea, .sec-panel input[type=text], .sec-panel input:not([type]) { width: 100%; box-sizing: border-box; padding: 6px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,.15); background: rgba(0,0,0,.25); color: inherit; font-family: inherit; }
985
1058
  #profilesList { list-style: none; padding: 0; margin: 0 0 8px; }
@@ -1009,22 +1082,6 @@
1009
1082
 
1010
1083
  <header>
1011
1084
  <span class="brand">shadok-ai <span class="ver" id="verView" title="running version"></span></span>
1012
- <div class="gauge">
1013
- <span class="label">Status</span>
1014
- <span class="value"><span id="led" class="led"></span><span id="state">—</span></span>
1015
- </div>
1016
- <div class="gauge">
1017
- <span class="label">Directory</span>
1018
- <span class="value" id="cwdView">—</span>
1019
- </div>
1020
- <div class="gauge">
1021
- <span class="label">Session</span>
1022
- <span class="value" id="sessionView">—</span>
1023
- </div>
1024
- <div class="gauge" id="branchGauge" hidden>
1025
- <span class="label">Branch</span>
1026
- <span class="value" id="branchView">—</span>
1027
- </div>
1028
1085
  <span class="spacer"></span>
1029
1086
  <div class="gauge quota" id="quota5h" title="5-hour rolling limit">
1030
1087
  <span class="label">5h</span>
@@ -1059,7 +1116,7 @@
1059
1116
  <div class="vm-hint" id="vmModeHint"></div>
1060
1117
  </div>
1061
1118
 
1062
- <div id="secretsOverlay" hidden>
1119
+ <div id="secretsOverlay" class="overlay" hidden>
1063
1120
  <div id="secretsPanel">
1064
1121
  <div class="sec-head">
1065
1122
  <strong>Secret vault</strong>
@@ -1075,36 +1132,106 @@
1075
1132
  </div>
1076
1133
  </div>
1077
1134
 
1078
- <div id="cronOverlay" hidden>
1079
- <div id="secretsPanel">
1135
+ <div id="cronOverlay" class="overlay" hidden>
1136
+ <!-- Le panneau portait id="secretsPanel" — un id en DOUBLON de la vraie popin
1137
+ des secrets, donc $("secretsPanel") pouvait tomber sur le mauvais. -->
1138
+ <div id="cronPanel" class="sec-panel modal">
1080
1139
  <div class="sec-head">
1081
- <strong>⏰ Schedule — <span id="cronChanName">this agent</span></strong>
1140
+ <strong>⏰ Schedule — <span class="obj" id="cronChanName">this agent</span></strong>
1082
1141
  <button id="cronClose" title="Close">✕</button>
1083
1142
  </div>
1084
- <p class="sec-note">Scheduled prompts sent to <b>this agent</b> on a timer (monitoring, reporting…). The server fires them even when nobody's watching; results land here and in Telegram. Times are server-local. <b>Tip:</b> just ask the agent (« surveille X chaque matin et alerte si Y ») — it writes the check and schedules it. Or set one manually:</p>
1085
- <ul id="cronList"></ul>
1086
- <form id="cronAdd">
1087
- <textarea id="cronPrompt" rows="2" placeholder="Prompt to run when there's news, e.g. « Rédige une alerte à partir de l'anomalie détectée »" style="width:100%;box-sizing:border-box"></textarea>
1088
- <input id="cronCheck" placeholder="Check command (optional) — silent = nothing to report → 0 tokens" autocomplete="off" spellcheck="false" style="width:100%;box-sizing:border-box;margin-top:6px" />
1089
- <div class="field" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:6px">
1090
- <select id="cronKind">
1091
- <option value="interval">Every…</option>
1092
- <option value="daily">Daily at…</option>
1093
- </select>
1094
- <span id="cronIntervalFields">
1095
- <input id="cronEvery" type="number" min="1" value="60" style="width:70px">
1096
- <select id="cronUnit"><option value="1">minutes</option><option value="60">hours</option></select>
1097
- </span>
1098
- <span id="cronDailyFields" hidden>
1099
- <input id="cronTime" type="time" value="09:00">
1100
- </span>
1101
- <button type="submit" class="primary">Add schedule</button>
1102
- </div>
1103
- </form>
1143
+ <div class="modal-body">
1144
+ <p class="sec-note">Prompts sent to this agent on a timer — monitoring, reporting. The server fires them even when nobody's watching; answers land here and in Telegram.</p>
1145
+ <p class="sec-note"><b>Simplest way:</b> just ask the agent — « surveille X chaque matin et alerte-moi si Y ». It writes the check and schedules it itself.</p>
1146
+ <ul id="cronList"></ul>
1147
+ <form id="cronAdd" class="cron-form">
1148
+ <div class="field">
1149
+ <span class="label">Prompt to run</span>
1150
+ <textarea id="cronPrompt" rows="2" placeholder="e.g. « Rédige une alerte à partir de l'anomalie détectée »"></textarea>
1151
+ </div>
1152
+ <div class="field">
1153
+ <span class="label">Check command <span class="check-hint">— optional; prints nothing = nothing to report, and the agent stays asleep (0 tokens)</span></span>
1154
+ <input id="cronCheck" placeholder="e.g. ~/scripts/check-budget.sh" autocomplete="off" spellcheck="false" />
1155
+ </div>
1156
+ <div class="field cron-when">
1157
+ <select id="cronKind" aria-label="How often">
1158
+ <option value="interval">Every…</option>
1159
+ <option value="daily">Daily at…</option>
1160
+ </select>
1161
+ <span id="cronIntervalFields">
1162
+ <input id="cronEvery" type="number" min="1" value="60" aria-label="Interval">
1163
+ <select id="cronUnit" aria-label="Unit"><option value="1">minutes</option><option value="60">hours</option></select>
1164
+ </span>
1165
+ <span id="cronDailyFields" hidden>
1166
+ <input id="cronTime" type="time" value="09:00" aria-label="Time of day">
1167
+ </span>
1168
+ </div>
1169
+ </form>
1170
+ </div>
1171
+ <div class="modal-foot">
1172
+ <span class="foot-note" id="cronTzNote"></span>
1173
+ <button type="submit" form="cronAdd" class="primary">Add schedule</button>
1174
+ </div>
1104
1175
  </div>
1105
1176
  </div>
1106
1177
 
1107
- <div id="profilesOverlay" hidden>
1178
+ <div id="setupOverlay" class="overlay" hidden>
1179
+ <div id="setupPanel" class="sec-panel modal">
1180
+ <div class="sec-head">
1181
+ <strong>New <span class="obj">agent</span></strong>
1182
+ <button id="setupClose" title="Close">✕</button>
1183
+ </div>
1184
+ <div class="modal-body">
1185
+ <p class="sec-note">A real Claude Code session, driven inside a pseudo-terminal. Open as many as you want Claudes working in parallel.</p>
1186
+ <div class="field" id="profileField">
1187
+ <div class="field-head">
1188
+ <span class="label">Which agent?</span>
1189
+ <button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
1190
+ </div>
1191
+ <div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
1192
+ <span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
1193
+ </div>
1194
+ <div class="field">
1195
+ <span class="label">Working directory</span>
1196
+ <input type="text" id="cwdInput" placeholder="/path/to/project">
1197
+ </div>
1198
+ <label class="check" id="worktreeField">
1199
+ <input type="checkbox" id="worktreeInput" checked>
1200
+ <span>Isolate in a git worktree <span class="check-hint">— the agent edits a fresh branch; review &amp; merge from the Diff panel. Auto-removed on close if unused, kept if it has changes. (new sessions only)</span></span>
1201
+ </label>
1202
+ <details class="fold" id="advancedField">
1203
+ <summary>Advanced — resume an existing session</summary>
1204
+ <div class="field">
1205
+ <div class="radio-row">
1206
+ <label><input type="radio" name="mode" value="new" checked> new session</label>
1207
+ <label><input type="radio" name="mode" value="continue"> latest in directory</label>
1208
+ <label><input type="radio" name="mode" value="resume"> by id</label>
1209
+ </div>
1210
+ </div>
1211
+ <div class="field" id="resumeField" hidden>
1212
+ <span class="label">Session id</span>
1213
+ <input type="text" id="resumeInput" placeholder="5fe046dd-…">
1214
+ <span class="label">Sessions in this directory</span>
1215
+ <div class="session-list" id="sessionList"></div>
1216
+ </div>
1217
+ </details>
1218
+ <details class="fold" id="recoverField" hidden>
1219
+ <summary>Reopen a past session <span class="fold-count" id="recoverCount"></span></summary>
1220
+ <div class="recover-list" id="recoverList"></div>
1221
+ </details>
1222
+ <details class="fold" id="liveField" hidden>
1223
+ <summary>Agents running now <span class="fold-count" id="liveCount"></span></summary>
1224
+ <div class="recover-list" id="liveList"></div>
1225
+ </details>
1226
+ </div>
1227
+ <div class="modal-foot">
1228
+ <button id="setupCancel">Cancel</button>
1229
+ <button class="primary" id="startBtn">Start agent</button>
1230
+ </div>
1231
+ </div>
1232
+ </div>
1233
+
1234
+ <div id="profilesOverlay" class="overlay" hidden>
1108
1235
  <div id="profilesPanel" class="sec-panel">
1109
1236
  <div class="sec-head">
1110
1237
  <strong>Agent profiles</strong>
@@ -1127,7 +1254,7 @@
1127
1254
  </div>
1128
1255
  </div>
1129
1256
 
1130
- <div id="telegramOverlay" hidden>
1257
+ <div id="telegramOverlay" class="overlay" hidden>
1131
1258
  <div id="telegramPanel" class="sec-panel">
1132
1259
  <div class="sec-head">
1133
1260
  <strong>Telegram bot</strong>
@@ -1172,6 +1299,22 @@
1172
1299
  séparé, donc ses actions n'ont pas de raison d'apparaître avec lui. -->
1173
1300
  <div id="chanBar" hidden>
1174
1301
  <span class="label" id="chanBarName"></span>
1302
+ <div class="gauge">
1303
+ <span class="label">Status</span>
1304
+ <span class="value"><span id="led" class="led"></span><span id="state">—</span></span>
1305
+ </div>
1306
+ <div class="gauge">
1307
+ <span class="label">Directory</span>
1308
+ <span class="value" id="cwdView">—</span>
1309
+ </div>
1310
+ <div class="gauge">
1311
+ <span class="label">Session</span>
1312
+ <span class="value" id="sessionView">—</span>
1313
+ </div>
1314
+ <div class="gauge" id="branchGauge" hidden>
1315
+ <span class="label">Branch</span>
1316
+ <span class="value" id="branchView">—</span>
1317
+ </div>
1175
1318
  <span class="chan-actions">
1176
1319
  <button id="chanCron" class="icon" title="Scheduled prompts for this agent (monitoring / reporting)">⏰</button>
1177
1320
  <button id="chanStop" class="icon stop" title="End session">⏹️</button>
@@ -1179,52 +1322,13 @@
1179
1322
  </span>
1180
1323
  </div>
1181
1324
 
1182
- <div id="setup" hidden>
1183
- <h1>New agent</h1>
1184
- <p class="hint">Starts a real Claude Code session, driven inside a
1185
- pseudo-terminal. Each agent is an independent session — open as many
1186
- agents as you want Claudes working in parallel.</p>
1187
- <div class="field" id="profileField">
1188
- <div class="field-head">
1189
- <span class="label">Which agent?</span>
1190
- <button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
1191
- </div>
1192
- <div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
1193
- <span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
1194
- </div>
1195
- <div class="field">
1196
- <span class="label">Working directory</span>
1197
- <input type="text" id="cwdInput" placeholder="/path/to/project">
1198
- </div>
1199
- <label class="check" id="worktreeField">
1200
- <input type="checkbox" id="worktreeInput" checked>
1201
- <span>Isolate in a git worktree <span class="check-hint">— the agent edits a fresh branch; review &amp; merge from the Diff panel. Auto-removed on close if unused, kept if it has changes. (new sessions only)</span></span>
1202
- </label>
1203
- <details class="fold" id="advancedField">
1204
- <summary>Advanced — resume an existing session</summary>
1205
- <div class="field">
1206
- <div class="radio-row">
1207
- <label><input type="radio" name="mode" value="new" checked> new session</label>
1208
- <label><input type="radio" name="mode" value="continue"> latest in directory</label>
1209
- <label><input type="radio" name="mode" value="resume"> by id</label>
1210
- </div>
1211
- </div>
1212
- <div class="field" id="resumeField" hidden>
1213
- <span class="label">Session id</span>
1214
- <input type="text" id="resumeInput" placeholder="5fe046dd-…">
1215
- <span class="label">Sessions in this directory</span>
1216
- <div class="session-list" id="sessionList"></div>
1217
- </div>
1218
- </details>
1219
- <details class="fold" id="recoverField" hidden>
1220
- <summary>Reopen a past session <span class="fold-count" id="recoverCount"></span></summary>
1221
- <div class="recover-list" id="recoverList"></div>
1222
- </details>
1223
- <details class="fold" id="liveField" hidden>
1224
- <summary>Agents running now <span class="fold-count" id="liveCount"></span></summary>
1225
- <div class="recover-list" id="liveList"></div>
1226
- </details>
1227
- <button class="primary" id="startBtn">Start agent</button>
1325
+ <!-- Aucun agent : une invitation, pas un formulaire pré-rempli. La création
1326
+ se fait dans une popin (#setupOverlay) et ne crée le canal qu'à la
1327
+ validation — ouvrir puis renoncer ne laisse plus d'onglet mort-né. -->
1328
+ <div id="emptyState">
1329
+ <span class="es-title">No agent open</span>
1330
+ <p class="es-hint">Each agent is its own Claude Code session, working on its own directory. Start one and it takes the controls.</p>
1331
+ <button class="primary" id="emptyNewBtn">+ new agent</button>
1228
1332
  </div>
1229
1333
 
1230
1334
  <div id="transcripts"></div>
@@ -1299,7 +1403,7 @@
1299
1403
  const $ = (id) => document.getElementById(id);
1300
1404
  const tabbar = $("tabbar"), newTabBtn = $("newTab"), newGroupBtn = $("newGroup");
1301
1405
  const ungroupedEl = $("ungrouped"), groupsEl = $("groups");
1302
- const setup = $("setup"), transcripts = $("transcripts"), thinking = $("thinking");
1406
+ const transcripts = $("transcripts"), thinking = $("thinking");
1303
1407
  const composer = $("composer"), promptInput = $("promptInput"), sendBtn = $("sendBtn");
1304
1408
  const machine = $("machine"), screenEl = $("screen");
1305
1409
 
@@ -1315,7 +1419,7 @@
1315
1419
  .then((r) => r.json())
1316
1420
  .then((d) => {
1317
1421
  serverCwd = d.cwd || "";
1318
- if (active && active.status === "setup" && !$("cwdInput").value) refreshChrome();
1422
+ if (!$("setupOverlay").hidden && !$("cwdInput").value) $("cwdInput").value = serverCwd || "";
1319
1423
  })
1320
1424
  .catch(() => {});
1321
1425
 
@@ -1915,7 +2019,8 @@
1915
2019
  refreshTitle();
1916
2020
  if (active === tab) {
1917
2021
  if (tabs.length) activate(tabs[tabs.length - 1]);
1918
- else createTab();
2022
+ // Plus aucun agent : l'invitation, pas un onglet « setup » fantôme.
2023
+ else { active = null; refreshChrome(); }
1919
2024
  }
1920
2025
  }
1921
2026
 
@@ -2126,9 +2231,38 @@
2126
2231
  refreshBadge();
2127
2232
  }
2128
2233
 
2129
- /** Keeps header, setup, composer and engine room in sync with the active channel. */
2234
+ /**
2235
+ * Keeps header, composer, channel bar and engine room in sync with the active
2236
+ * channel — ou avec l'ABSENCE de canal.
2237
+ *
2238
+ * `active` peut désormais être nul : la création vit dans une popin et ne crée
2239
+ * plus d'onglet « setup » d'avance, donc fermer le dernier agent ne laisse plus
2240
+ * un formulaire à la place. Tout ce qui décrit un canal retombe alors sur « — »
2241
+ * et le panneau central montre l'invitation.
2242
+ */
2130
2243
  function refreshChrome() {
2131
2244
  const t = active;
2245
+ if (!t) {
2246
+ $("led").className = "led ";
2247
+ $("state").textContent = "no agent";
2248
+ $("cwdView").textContent = "—";
2249
+ $("sessionView").textContent = "—";
2250
+ $("sessionView").title = "";
2251
+ $("branchGauge").hidden = true;
2252
+ composer.hidden = true;
2253
+ $("chanBar").hidden = true;
2254
+ thinking.classList.remove("visible");
2255
+ screenEl.textContent = "";
2256
+ // Programmer ou arrêter n'a pas de cible : mieux vaut des boutons éteints
2257
+ // qu'une popin qui parle d'un agent inexistant.
2258
+ $("cronBtn").disabled = true;
2259
+ $("stopBtn").disabled = true;
2260
+ $("emptyState").classList.add("visible");
2261
+ return;
2262
+ }
2263
+ $("emptyState").classList.remove("visible");
2264
+ $("cronBtn").disabled = !t.sessionId;
2265
+ $("stopBtn").disabled = false;
2132
2266
  $("led").className = "led " +
2133
2267
  ({ setup: "", connecting: "busy", ready: "on", busy: "busy", dead: "off" }[t.status]);
2134
2268
  $("state").textContent = t.stateText;
@@ -2139,33 +2273,14 @@
2139
2273
  $("sessionView").title = t.sessionId || "";
2140
2274
  $("branchGauge").hidden = !t.branch;
2141
2275
  $("branchView").textContent = t.branch || "—";
2142
- setup.hidden = t.status !== "setup";
2143
- composer.hidden = t.status === "setup";
2144
- // La barre du canal accompagne les deux vues, mais pas le formulaire « New
2145
- // agent » : il n'y a pas encore de canal à programmer, arrêter ou ouvrir en
2146
- // terminal. Le nom vit ici (et non dans syncTerm) pour qu'un renommage ou un
2147
- // changement de canal le mette à jour même terminal fermé.
2148
- $("chanBar").hidden = t.status === "setup";
2276
+ composer.hidden = false;
2277
+ // La barre du canal accompagne les deux vues du canal.
2278
+ $("chanBar").hidden = false;
2149
2279
  $("chanBarName").textContent = t.nameEl ? t.nameEl.textContent : "";
2150
2280
  $("chanBarName").title = t.cwd || "";
2151
2281
  sendBtn.disabled = t.status !== "ready" || !!t.pendingChoices;
2152
2282
  thinking.classList.toggle("visible", t.status === "busy");
2153
2283
  screenEl.textContent = t.screenText;
2154
- if (t.status === "setup") {
2155
- // Only pre-fill when empty — never clobber what the user is typing.
2156
- if (!$("cwdInput").value) {
2157
- $("cwdInput").value =
2158
- t.cwd || localStorage.getItem("cp.cwd") || serverCwd || "";
2159
- }
2160
- const last = localStorage.getItem("cp.session");
2161
- if (last && !$("resumeInput").value) $("resumeInput").value = last;
2162
- $("startBtn").disabled = false;
2163
- // refreshChrome tourne souvent : on ne rappelle la mémoire qu'au premier
2164
- // passage sur CET onglet, sinon on écraserait le choix de l'utilisateur.
2165
- if (profileTab !== t) { profileTab = t; profileTouched = false; applyRememberedProfile(); }
2166
- refreshRecoverList();
2167
- refreshLiveList();
2168
- }
2169
2284
  }
2170
2285
 
2171
2286
  /** Paints a channel's context-window usage bar + tiny number in its tab. */
@@ -2205,12 +2320,32 @@
2205
2320
  return el;
2206
2321
  }
2207
2322
 
2323
+ /** Temps relatif compact : "now" / "3m ago" / "2h ago" / "5d ago". */
2324
+ function relTime(ts) {
2325
+ const s = Math.max(0, (Date.now() - ts) / 1000);
2326
+ if (s < 45) return "now";
2327
+ const m = Math.round(s / 60);
2328
+ if (m < 60) return m + "m ago";
2329
+ const h = Math.round(m / 60);
2330
+ if (h < 24) return h + "h ago";
2331
+ return Math.round(h / 24) + "d ago";
2332
+ }
2333
+ /** (Re)pose l'heure relative du dernier message sur l'onglet. */
2334
+ function renderLastAt(tab) {
2335
+ if (!tab.lastAtEl) return;
2336
+ if (!Number.isFinite(tab.lastMsgAt)) { tab.lastAtEl.textContent = ""; return; }
2337
+ tab.lastAtEl.textContent = relTime(tab.lastMsgAt);
2338
+ tab.lastAtEl.title = new Date(tab.lastMsgAt).toLocaleString();
2339
+ }
2340
+ // Le relatif vieillit tout seul : on rafraîchit tous les onglets périodiquement.
2341
+ setInterval(() => { for (const t of tabs) renderLastAt(t); }, 30000);
2342
+
2208
2343
  function addTurn(tab, role, who, text, extraClass, at) {
2209
- // Heure du dernier message dans la liste des canaux (HH:MM, discret). Chaque
2210
- // message (historique rejoué ou live) porte `at` ; le dernier gagne.
2211
- if (Number.isFinite(at) && tab.lastAtEl) {
2344
+ // Heure du dernier message dans la liste des canaux, en relatif (« 3m ago »).
2345
+ // Chaque message (historique rejoué ou live) porte `at` ; le dernier gagne.
2346
+ if (Number.isFinite(at)) {
2212
2347
  tab.lastMsgAt = at;
2213
- tab.lastAtEl.textContent = new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
2348
+ renderLastAt(tab);
2214
2349
  }
2215
2350
  const turn = document.createElement("div");
2216
2351
  turn.className = "turn " + role + (extraClass ? " " + extraClass : "");
@@ -2778,9 +2913,33 @@
2778
2913
  }
2779
2914
  }
2780
2915
 
2781
- function startActiveTab() {
2782
- const t = active;
2783
- if (t.status !== "setup") return;
2916
+ /**
2917
+ * Ouvre la popin de création. Elle ne crée RIEN : l'onglet naît au « Start
2918
+ * agent » (cf. startNewAgent). Ouvrir puis renoncer ne laissait auparavant pas
2919
+ * seulement un onglet mort-né — il devenait le canal actif, avec son formulaire
2920
+ * à la place de la conversation qu'on regardait.
2921
+ */
2922
+ function openSetup() {
2923
+ // Ne jamais écraser ce qui est déjà tapé (la popin peut se rouvrir).
2924
+ if (!$("cwdInput").value) {
2925
+ $("cwdInput").value = (active && active.cwd) || localStorage.getItem("cp.cwd") || serverCwd || "";
2926
+ }
2927
+ const last = localStorage.getItem("cp.session");
2928
+ if (last && !$("resumeInput").value) $("resumeInput").value = last;
2929
+ $("startBtn").disabled = false;
2930
+ // Le profil retenu n'est rappelé qu'à l'OUVERTURE : rappelé en continu, il
2931
+ // écraserait le choix en cours de saisie.
2932
+ profileTouched = false;
2933
+ applyRememberedProfile();
2934
+ refreshRecoverList();
2935
+ refreshLiveList();
2936
+ $("setupOverlay").hidden = false;
2937
+ $("cwdInput").focus();
2938
+ }
2939
+ function closeSetup() { $("setupOverlay").hidden = true; }
2940
+
2941
+ /** Valide le formulaire → C'EST ICI que le canal est créé, et pas avant. */
2942
+ function startNewAgent() {
2784
2943
  const cwd = $("cwdInput").value.trim();
2785
2944
  const mode = document.querySelector('input[name="mode"]:checked').value;
2786
2945
  const msg = { cwd };
@@ -2797,7 +2956,9 @@
2797
2956
  if (cwd) localStorage.setItem("cp.profile:" + cwd, selectedProfile);
2798
2957
  }
2799
2958
  $("startBtn").disabled = true;
2800
- launchTab(t, msg);
2959
+ closeSetup();
2960
+ // createTab active l'onglet créé ; launchTab ouvre le lien.
2961
+ launchTab(createTab(), msg);
2801
2962
  }
2802
2963
 
2803
2964
  // ── Version display + auto-reload ─────────────────────────────────────────
@@ -3189,13 +3350,17 @@
3189
3350
  }
3190
3351
 
3191
3352
  // ── Controls ────────────────────────────────────────────
3192
- $("startBtn").addEventListener("click", startActiveTab);
3353
+ $("startBtn").addEventListener("click", startNewAgent);
3354
+ $("setupClose").addEventListener("click", closeSetup);
3355
+ $("setupCancel").addEventListener("click", closeSetup);
3356
+ $("setupOverlay").addEventListener("click", (e) => { if (e.target === $("setupOverlay")) closeSetup(); });
3357
+ $("emptyNewBtn").addEventListener("click", openSetup);
3193
3358
  document.querySelectorAll('input[name="mode"]').forEach((r) =>
3194
3359
  r.addEventListener("change", () => {
3195
3360
  const mode = document.querySelector('input[name="mode"]:checked').value;
3196
3361
  const isNew = mode === "new";
3197
3362
  $("resumeField").hidden = mode !== "resume";
3198
- // Profil et worktree ne valent que pour une session neuve : startActiveTab
3363
+ // Profil et worktree ne valent que pour une session neuve : startNewAgent
3199
3364
  // les ignore déjà en resume/continue, l'UI cesse simplement de le cacher.
3200
3365
  $("profileField").classList.toggle("na", !isNew);
3201
3366
  $("profileNaNote").hidden = isNew;
@@ -3213,7 +3378,7 @@
3213
3378
  // Agents can appear while the setup screen sits open (pilotctl spawns one,
3214
3379
  // another tab starts one) — keep the list current without a reload.
3215
3380
  setInterval(() => {
3216
- if (active && active.status === "setup") refreshLiveList();
3381
+ if (!$("setupOverlay").hidden) refreshLiveList();
3217
3382
  }, 5000);
3218
3383
 
3219
3384
  function submitPrompt(t, text, force) {
@@ -3323,6 +3488,7 @@
3323
3488
  }
3324
3489
  promptInput.addEventListener("input", () => {
3325
3490
  autosizePrompt();
3491
+ if (!active) return; // aucun agent : le composer est masqué, mais pas de crash gratuit
3326
3492
  active.draft = promptInput.value;
3327
3493
  persistDraft(active);
3328
3494
  });
@@ -3452,7 +3618,7 @@
3452
3618
  $("refreshDiff").addEventListener("click", loadDiff);
3453
3619
  document.querySelectorAll(".keys button[data-key]").forEach((b) =>
3454
3620
  b.addEventListener("click", () => {
3455
- if (active.ws) active.ws.send(JSON.stringify({ type: "key", key: b.dataset.key }));
3621
+ if (active && active.ws) active.ws.send(JSON.stringify({ type: "key", key: b.dataset.key }));
3456
3622
  })
3457
3623
  );
3458
3624
  // La touche Échap envoie esc à la session active (comme le bouton Esc de
@@ -3469,6 +3635,9 @@
3469
3635
  // 1. Overlays / panneaux ouverts → fermer, pas d'esc.
3470
3636
  if (!$("secretsOverlay").hidden) { $("secretsOverlay").hidden = true; e.preventDefault(); return; }
3471
3637
  if (!$("profilesOverlay").hidden) { closeProfilesPanel(); e.preventDefault(); return; }
3638
+ if (!$("setupOverlay").hidden) { closeSetup(); e.preventDefault(); return; }
3639
+ if (!$("cronOverlay").hidden) { $("cronOverlay").hidden = true; e.preventDefault(); return; }
3640
+ if (!$("telegramOverlay").hidden) { $("telegramOverlay").hidden = true; e.preventDefault(); return; }
3472
3641
  if ($("diffpanel").classList.contains("open")) { $("diffpanel").classList.remove("open"); e.preventDefault(); return; }
3473
3642
  // 2. Dialogue clickable en attente de réponse → ne rien faire.
3474
3643
  if (active && active.dialogBubble) return;
@@ -3488,11 +3657,11 @@
3488
3657
  }
3489
3658
  });
3490
3659
  $("settleBtn").addEventListener("click", () => {
3491
- if (active.ws && active.status === "ready")
3660
+ if (active && active.ws && active.status === "ready")
3492
3661
  active.ws.send(JSON.stringify({ type: "settle" }));
3493
3662
  });
3494
3663
  $("stopBtn").addEventListener("click", () => {
3495
- if (active.ws) active.ws.send(JSON.stringify({ type: "stop" }));
3664
+ if (active && active.ws) active.ws.send(JSON.stringify({ type: "stop" }));
3496
3665
  });
3497
3666
 
3498
3667
  // ── Secret vault panel (central, global; profiles pick which to inject) ──
@@ -3577,8 +3746,23 @@
3577
3746
  if (s.kind === "interval") return s.everyMin % 60 === 0 ? "every " + (s.everyMin / 60) + "h" : "every " + s.everyMin + "m";
3578
3747
  return "daily at " + String(s.hour).padStart(2, "0") + ":" + String(s.minute).padStart(2, "0");
3579
3748
  }
3749
+ /**
3750
+ * Fuseau réellement appliqué aux horaires quotidiens, dans le pied de la popin.
3751
+ * Le panneau annonçait « Times are server-local », ce qui ne dit pas 9h OÙ —
3752
+ * or le serveur peut tourner dans un autre fuseau que le navigateur.
3753
+ */
3754
+ async function showCronTz() {
3755
+ const el = $("cronTzNote");
3756
+ el.textContent = "";
3757
+ try {
3758
+ const tz = await (await fetch("/timezone")).json();
3759
+ const zone = tz.timezone || tz.system;
3760
+ if (zone) el.textContent = "Daily times run in " + zone;
3761
+ } catch { /* la popin marche très bien sans cette note */ }
3762
+ }
3580
3763
  async function renderCrons() {
3581
3764
  const ul = $("cronList"); ul.innerHTML = "";
3765
+ showCronTz();
3582
3766
  $("cronChanName").textContent = (active && (active.name || (active.sessionId || "").slice(0, 8))) || "no agent";
3583
3767
  if (!active || !active.sessionId) { ul.innerHTML = '<li><span style="opacity:.6">Open an agent to schedule prompts.</span></li>'; return; }
3584
3768
  let all = [];
@@ -3937,7 +4121,7 @@
3937
4121
  el.value = "";
3938
4122
  });
3939
4123
 
3940
- newTabBtn.addEventListener("click", () => createTab());
4124
+ newTabBtn.addEventListener("click", openSetup);
3941
4125
  newGroupBtn.addEventListener("click", () => {
3942
4126
  createGroup();
3943
4127
  persistGroups();
@@ -4086,7 +4270,9 @@
4086
4270
  if (i !== targetIdx) launchTab(pending[i].t, pending[i].msg);
4087
4271
  }
4088
4272
  } else {
4089
- createTab();
4273
+ // Rien à restaurer : l'invitation. Le formulaire ne s'ouvre pas de force —
4274
+ // on peut aussi arriver là pour regarder, ou lancer depuis Telegram.
4275
+ refreshChrome();
4090
4276
  // Nothing to restore: enable persistence for user actions, but do NOT
4091
4277
  // push a blank list (a failed /channels fetch must never wipe the store).
4092
4278
  restoring = false;