shadok-ai 0.15.53 → 0.15.55

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/README.md CHANGED
@@ -204,6 +204,13 @@ Enter to skip; you can add it later from the web UI).
204
204
  browser, since shell mode sits outside the agent's guardrails. The command and
205
205
  its output stay in the chat, reload included — as does one you typed yourself
206
206
  in the terminal view.
207
+ - **Provide the variable an agent asks for** — when an agent names a missing
208
+ credential in code (`` `HUBSPOT_TOKEN missing from the environment` ``), a
209
+ **+** sits right after it: one click opens the secret vault with the name
210
+ filled in and the agent's profile ticked, so you only paste the value (then
211
+ reload the agent when offered — a value reaches a process at spawn). Only
212
+ names that look like a credential or a connection setting get one (`…_TOKEN`,
213
+ `…_KEY`, `…_URL`, `…_ID`…), never `NODE_ENV` nor shadok's own `SHADOK_*`.
207
214
  - **Files you hand an agent** — paste or drop a file into the composer, or pick
208
215
  one with the 📎 button beside it (an image, a PDF, a CSV…), and it becomes a **chip** above the text box: a
209
216
  thumbnail for an image, a named tag for anything else, each with a ✕ to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.53",
3
+ "version": "0.15.55",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test --test-timeout=120000 test/*.ts context/*/test/*.test.mjs",
@@ -0,0 +1,57 @@
1
+ // Which environment variables an agent's message asks the human to provide.
2
+ //
3
+ // Loaded as-is by the browser (ESM, bridged to window.envVarAsks) and imported
4
+ // by the tests, like public/bang.js. The agent answers "the CLI stops on
5
+ // `HUBSPOT_TOKEN missing`" and the human then has to open the vault, type the
6
+ // name back, tick the profile… The "+" this feeds turns that into one click.
7
+ //
8
+ // Kept narrow on purpose: a "+" on every SCREAMING_CASE word an agent writes
9
+ // (`NODE_ENV`, `PATH`, `SHADOK_SESSION_ID`) would be noise, so a name must be
10
+ // marked as code by the agent AND look like a credential or a connection
11
+ // setting.
12
+
13
+ /** At most this many "+" under one message. More is a wall, not a help. */
14
+ export const MAX_ENV_ASKS_PER_MESSAGE = 6;
15
+
16
+ /** An env var name as a shell would export it, with at least one underscore. */
17
+ const ENV_NAME = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g;
18
+
19
+ /** The segments that make a name something you HAND an agent, not a setting it owns. */
20
+ const CREDENTIAL_PART = new Set([
21
+ "TOKEN", "TOKENS", "KEY", "KEYS", "APIKEY", "SECRET", "SECRETS", "PASSWORD", "PASSWD",
22
+ "PASS", "PWD", "AUTH", "CREDENTIAL", "CREDENTIALS", "CREDS", "DSN", "URL", "URI",
23
+ "HOST", "USER", "USERNAME", "LOGIN", "ACCOUNT", "ID", "CLIENT", "WEBHOOK", "JSON",
24
+ ]);
25
+
26
+ /** Names the cockpit itself sets: asking the human for one of these is never the fix. */
27
+ const OWN_PREFIX = /^(SHADOK|CLAUDE|CLAUDECODE|ANTHROPIC|AI_AGENT|NODE|NPM|GIT|XDG|TMUX)_/;
28
+
29
+ /** Pure: is this an env var name worth offering a "+" for? */
30
+ export function isEnvAsk(name) {
31
+ const n = String(name || "");
32
+ if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(n) || OWN_PREFIX.test(n)) return false;
33
+ return n.split("_").some((p) => CREDENTIAL_PART.has(p));
34
+ }
35
+
36
+ /**
37
+ * Pure: the env var names a message offers to add, in order, de-duplicated.
38
+ *
39
+ * Only names inside inline code or a fenced block count — that is how an agent
40
+ * writes a variable it means (`HUBSPOT_TOKEN`, `` `HUBSPOT_TOKEN missing from
41
+ * the environment` ``, `export MATOMO_TOKEN=…`). A capitalised word in prose is
42
+ * not an ask.
43
+ */
44
+ export function envVarAsks(text) {
45
+ const out = [];
46
+ const src = String(text || "");
47
+ // One pass, fenced blocks first in the alternation so their backticks are not
48
+ // read as inline spans — and the names come out in the order they were written.
49
+ for (const span of src.matchAll(/```[^\n]*\n([\s\S]*?)```|`([^`\n]+)`/g)) {
50
+ const s = span[1] ?? span[2];
51
+ for (const m of s.matchAll(ENV_NAME)) {
52
+ const n = m[0];
53
+ if (isEnvAsk(n) && !out.includes(n) && out.length < MAX_ENV_ASKS_PER_MESSAGE) out.push(n);
54
+ }
55
+ }
56
+ return out;
57
+ }
package/public/index.html CHANGED
@@ -943,6 +943,12 @@
943
943
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left;
944
944
  }
945
945
  .bang-btn:disabled { opacity: .5; cursor: default; }
946
+ .env-plus {
947
+ font: inherit; font-size: 11px; line-height: 1; cursor: pointer; vertical-align: 1px;
948
+ margin-left: 3px; padding: 2px 5px; border-radius: 5px;
949
+ border: 1px solid var(--amber); background: var(--amber-soft); color: var(--text);
950
+ }
951
+ .env-plus:hover, .env-plus:focus-visible { background: var(--amber); color: var(--bg); }
946
952
  .bang-card { font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; margin: 0; }
947
953
  .bang-card .bang-cmd { color: var(--amber); }
948
954
  .bang-card .bang-out.err { color: var(--err); }
@@ -2306,6 +2312,71 @@
2306
2312
  #composer { background: transparent; padding: 8px 20px calc(16px + env(safe-area-inset-bottom, 0px)); }
2307
2313
  #composer textarea { background: var(--bg-raised); border: 1px solid var(--line); border-bottom: 2px solid var(--amber); border-radius: 6px; }
2308
2314
  #composer textarea:focus-visible { box-shadow: none; border-color: var(--line); border-bottom-color: var(--amber); }
2315
+
2316
+ /* ── Motion: switching views, and what loading looks like ──────────────
2317
+ Short on purpose (≤ 220ms, staggers capped): a transition that makes you
2318
+ wait for the list you just asked for is worse than none. Everything here
2319
+ is decoration — the content is in place from the first frame, the
2320
+ animation only eases it in — and all of it is off under reduced motion. */
2321
+ :root { --ease-out: cubic-bezier(.2, .8, .2, 1); }
2322
+ @keyframes view-in-down { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
2323
+ @keyframes view-in-up { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
2324
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
2325
+ @keyframes chat-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
2326
+ /* Picking a group: its agents slide in from the side the group sits on in
2327
+ the rail (below → up, above → down), one after the other. */
2328
+ #tabbar.view-in-down :is(#ungrouped, #groups) .tab { animation: view-in-down 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
2329
+ #tabbar.view-in-up :is(#ungrouped, #groups) .tab { animation: view-in-up 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
2330
+ #tabbar.view-in-down .side-label, #tabbar.view-in-up .side-label,
2331
+ #tabbar.view-in-down #viewEmpty, #tabbar.view-in-up #viewEmpty { animation: fade-in 160ms ease-out both; }
2332
+ /* Picking an agent: its conversation eases in instead of snapping. */
2333
+ .transcript.chat-in { animation: chat-in 170ms var(--ease-out) both; }
2334
+ /* The rail's selection is ONE pill that glides to the picked group, instead
2335
+ of a highlight that blinks from one row to the next. */
2336
+ #railList { position: relative; }
2337
+ #railList .rail-item { z-index: 1; transition: color 160ms ease; }
2338
+ #railList.has-pill .rail-item.active { background: transparent; border-color: transparent; }
2339
+ .rail-pill {
2340
+ position: absolute; left: 0; right: 0; top: 0; z-index: 0; pointer-events: none;
2341
+ background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px;
2342
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
2343
+ transition: transform 200ms var(--ease-out), height 200ms var(--ease-out);
2344
+ }
2345
+ .rail-pill.no-anim { transition: none; }
2346
+
2347
+ /* Skeletons: the SHAPE of what is coming, shimmering, in place of a bare
2348
+ "loading…" — the layout does not jump when the real content lands. */
2349
+ @keyframes skel-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
2350
+ .skel {
2351
+ display: block; border-radius: 6px; height: 12px;
2352
+ /* Fallback first: a browser without color-mix() drops the gradient below
2353
+ and would otherwise draw no skeleton at all. */
2354
+ background: linear-gradient(90deg, rgba(128,128,128,.13) 25%, rgba(128,128,128,.26) 50%, rgba(128,128,128,.13) 75%);
2355
+ background: linear-gradient(90deg,
2356
+ color-mix(in srgb, var(--text-dim) 12%, transparent) 25%,
2357
+ color-mix(in srgb, var(--text-dim) 24%, transparent) 50%,
2358
+ color-mix(in srgb, var(--text-dim) 12%, transparent) 75%);
2359
+ background-size: 300% 100%;
2360
+ animation: skel-shimmer 1.3s linear infinite;
2361
+ }
2362
+ .skel-chat { display: flex; flex-direction: column; gap: 18px; padding-top: 4px; animation: fade-in 200ms ease-out both; }
2363
+ .skel-turn { display: flex; flex-direction: column; gap: 7px; max-width: 70%; }
2364
+ .skel-turn.mine { align-self: flex-end; align-items: flex-end; width: 42%; }
2365
+ .skel-turn .skel-label { width: 64px; height: 9px; }
2366
+ .skel-turn .skel-bubble { width: 100%; height: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: rgba(128,128,128,.07); background: color-mix(in srgb, var(--text-dim) 7%, transparent); animation: none; border-radius: 10px; }
2367
+ .skel-turn .skel-bubble .skel { height: 10px; }
2368
+ .skel-row { display: flex; flex-direction: column; gap: 7px; padding: 10px 8px; border-bottom: 1px solid var(--line); }
2369
+ .skel-row:last-child { border-bottom: none; }
2370
+ .skel-card { height: 92px; border-radius: 8px; }
2371
+ .skel-diff { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
2372
+ .skel-diff .skel { height: 10px; }
2373
+
2374
+ @media (prefers-reduced-motion: reduce) {
2375
+ #tabbar.view-in-down .tab, #tabbar.view-in-up .tab, #tabbar .side-label, #tabbar #viewEmpty,
2376
+ .transcript.chat-in, .skel-chat { animation: none !important; }
2377
+ .rail-pill { transition: none; }
2378
+ .skel { animation: none; }
2379
+ }
2309
2380
  </style>
2310
2381
  <!-- Light/dark mode is a per-browser choice (localStorage). Set it BEFORE the
2311
2382
  body paints — here in <head> — so a light-preferring viewer never sees a
@@ -2548,7 +2619,7 @@
2548
2619
  <span class="label">Which agent?</span>
2549
2620
  <button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
2550
2621
  </div>
2551
- <div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
2622
+ <div id="profileGrid" role="radiogroup" aria-label="Agent profile" aria-busy="true"><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span><span class="skel skel-card"></span></div>
2552
2623
  <span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
2553
2624
  </div>
2554
2625
  <!-- The model this ONE agent runs on. Its default sends nothing at all,
@@ -3090,6 +3161,8 @@
3090
3161
  import { bangCommands, blockCommand } from "/bang.js?v=__ASSET_V__";
3091
3162
  window.bangCommands = bangCommands;
3092
3163
  window.blockCommand = blockCommand;
3164
+ import { envVarAsks } from "/env-ask.js?v=__ASSET_V__";
3165
+ window.envVarAsks = envVarAsks;
3093
3166
  import { claudeUpdateState } from "/update-ready.js?v=__ASSET_V__";
3094
3167
  window.claudeUpdateState = claudeUpdateState;
3095
3168
  import { buildMessageLink, parseMessageLink, samePrompt } from "/deep-link.js?v=__ASSET_V__";
@@ -3218,6 +3291,8 @@
3218
3291
  bangCommands: () => [],
3219
3292
  // No module → no block is runnable, so no inline run button (copy only).
3220
3293
  blockCommand: () => null,
3294
+ // No module → no "+" next to an env var name; the vault is still one click away.
3295
+ envVarAsks: () => [],
3221
3296
  // Boot-critical (runs in restore before the module might land): a fulfilled
3222
3297
  // /channels wins even when empty; only a failed fetch uses the cache.
3223
3298
  pickChannelSource: (settled, cached) =>
@@ -3387,6 +3462,37 @@
3387
3462
  if (label.textContent !== text) label.textContent = text;
3388
3463
  }
3389
3464
 
3465
+ // ── Skeletons: the shape of what is loading ─────────────
3466
+ // Static markup only (no user data), so innerHTML is safe here.
3467
+ const skelLine = (w) => `<span class="skel" style="width:${w}%"></span>`;
3468
+ function skeletonHTML(kind, n = 3) {
3469
+ if (kind === "chat") {
3470
+ const turn = (mine, widths) =>
3471
+ `<div class="skel-turn${mine ? " mine" : ""}"><span class="skel skel-label"></span>` +
3472
+ `<div class="skel skel-bubble">${widths.map(skelLine).join("")}</div></div>`;
3473
+ return `<div class="skel-chat" aria-busy="true" aria-label="Loading the conversation">` +
3474
+ turn(true, [80]) + turn(false, [92, 78, 54]) + turn(true, [64]) + turn(false, [88, 60]) + `</div>`;
3475
+ }
3476
+ if (kind === "cards") return Array.from({ length: n }, () => `<span class="skel skel-card"></span>`).join("");
3477
+ if (kind === "diff") {
3478
+ const w = [38, 72, 64, 85, 30, 58, 77, 46, 69, 52];
3479
+ return `<div class="skel-diff" aria-busy="true">${w.map(skelLine).join("")}</div>`;
3480
+ }
3481
+ // "rows": a list of two-line entries (sessions, ledger).
3482
+ return Array.from({ length: n }, (_, i) =>
3483
+ `<div class="skel-row" aria-busy="true">${skelLine(30 + (i * 17) % 25)}${skelLine(60 + (i * 23) % 35)}</div>`).join("");
3484
+ }
3485
+ /** A tab waiting for its history shows a chat skeleton — only when it has
3486
+ * nothing at all yet (a reconnect keeps what is already on screen). */
3487
+ function showChatSkeleton(t) {
3488
+ if (t.transcriptEl.childElementCount) return;
3489
+ t.transcriptEl.innerHTML = skeletonHTML("chat");
3490
+ }
3491
+ function clearChatSkeleton(t) {
3492
+ const s = t.transcriptEl.querySelector(":scope > .skel-chat");
3493
+ if (s) s.remove();
3494
+ }
3495
+
3390
3496
  function createTab(groupId, { focus = true } = {}) {
3391
3497
  seq++;
3392
3498
  const el = document.createElement("div");
@@ -4594,10 +4700,29 @@
4594
4700
  const tabsInView = (v) =>
4595
4701
  tabs.filter((t) => !t.btnEl.closest("#tweakSlot") && viewOfTab(t) === v);
4596
4702
 
4703
+ /** Eases the newly shown agents in, from the side the picked group sits on
4704
+ * in the rail. Purely cosmetic: the list is already correct underneath. */
4705
+ let viewAnimTimer = 0;
4706
+ function animateViewSwitch(from, to) {
4707
+ if (from === to || !railShown()) return;
4708
+ const order = [...railList.querySelectorAll(".rail-item")].map((i) => i.dataset.view);
4709
+ const dir = order.indexOf(to) >= order.indexOf(from) ? "view-in-down" : "view-in-up";
4710
+ const shown = [...tabbar.querySelectorAll("#ungrouped .tab, #groups .group.in-view .tab")];
4711
+ // Cap the stagger: past ~8 agents the tail would only make you wait.
4712
+ shown.forEach((el, i) => el.style.setProperty("--i", Math.min(i, 8)));
4713
+ tabbar.classList.remove("view-in-down", "view-in-up");
4714
+ void tabbar.offsetWidth; // restart the animation on a quick re-click
4715
+ tabbar.classList.add(dir);
4716
+ clearTimeout(viewAnimTimer);
4717
+ viewAnimTimer = setTimeout(() => tabbar.classList.remove("view-in-down", "view-in-up"), 420);
4718
+ }
4719
+
4597
4720
  function setView(v, { follow = true } = {}) {
4721
+ const from = currentView();
4598
4722
  groupView = String(v);
4599
4723
  localStorage.setItem(VIEW_KEY(), groupView);
4600
4724
  applyView();
4725
+ animateViewSwitch(from, currentView());
4601
4726
  // Picking a group shows one of ITS agents: staying on an agent that is no
4602
4727
  // longer in the list would leave the chat and the list disagreeing.
4603
4728
  if (follow && active && !active.btnEl.closest("#tweakSlot") && viewOfTab(active) !== currentView()) {
@@ -4753,6 +4878,33 @@
4753
4878
  return item;
4754
4879
  }
4755
4880
 
4881
+ // The selection pill: one element that GLIDES to the active group (the rows
4882
+ // themselves are rebuilt on every change, so they cannot transition).
4883
+ const railPill = document.createElement("div");
4884
+ railPill.className = "rail-pill";
4885
+ railPill.setAttribute("aria-hidden", "true");
4886
+ let pillView = null;
4887
+ function placeRailPill({ animate = true } = {}) {
4888
+ const item = railList.querySelector(".rail-item.active");
4889
+ if (!item || !item.offsetHeight) {
4890
+ railList.classList.remove("has-pill");
4891
+ railPill.hidden = true;
4892
+ pillView = null;
4893
+ return;
4894
+ }
4895
+ // First placement (or a layout change) jumps; only a change of group glides.
4896
+ const glide = animate && pillView !== null && pillView !== item.dataset.view;
4897
+ railPill.classList.toggle("no-anim", !glide);
4898
+ railPill.hidden = false;
4899
+ railPill.style.height = item.offsetHeight + "px";
4900
+ railPill.style.transform = `translateY(${item.offsetTop}px)`;
4901
+ railList.classList.add("has-pill");
4902
+ pillView = item.dataset.view;
4903
+ }
4904
+ // Collapsing the rail, resizing the window, or a group renamed to a longer
4905
+ // name moves the rows: follow without gliding.
4906
+ if (window.ResizeObserver) new ResizeObserver(() => placeRailPill({ animate: false })).observe(railList);
4907
+
4756
4908
  let railFrame = 0;
4757
4909
  let railSig = "";
4758
4910
  function renderRail() {
@@ -4781,8 +4933,9 @@
4781
4933
  return;
4782
4934
  }
4783
4935
  railSig = sig;
4784
- railList.replaceChildren(...entries.map(railItem));
4936
+ railList.replaceChildren(...entries.map(railItem), railPill);
4785
4937
  }
4938
+ placeRailPill();
4786
4939
  if (groupIconsMinted && !restoring) {
4787
4940
  groupIconsMinted = false;
4788
4941
  persistGroups();
@@ -5121,6 +5274,7 @@
5121
5274
  }
5122
5275
 
5123
5276
  function activate(tab) {
5277
+ const prev = active;
5124
5278
  if (active && active !== tab) {
5125
5279
  active.draft = promptInput.value;
5126
5280
  persistDraft(active);
@@ -5132,10 +5286,19 @@
5132
5286
  if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
5133
5287
  // Viewing the channel clears its "unread response" highlight.
5134
5288
  if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
5289
+ const switched = !!prev && prev !== tab && !restoring;
5135
5290
  tabs.forEach((t) => {
5136
5291
  t.btnEl.classList.toggle("active", t === tab);
5137
5292
  t.transcriptEl.hidden = t !== tab;
5138
5293
  });
5294
+ // Ease the new conversation in rather than snapping (only on a real switch).
5295
+ if (switched) {
5296
+ const el = tab.transcriptEl;
5297
+ el.classList.remove("chat-in");
5298
+ void el.offsetWidth;
5299
+ el.classList.add("chat-in");
5300
+ el.addEventListener("animationend", () => el.classList.remove("chat-in"), { once: true });
5301
+ }
5139
5302
  promptInput.value = tab.draft;
5140
5303
  autosizePrompt();
5141
5304
  renderAttachments(tab); // each tab keeps its own pending chips
@@ -5993,6 +6156,7 @@
5993
6156
  window.addEventListener("shadok-bridge-ready", resideAllPrompts);
5994
6157
 
5995
6158
  function addTurn(tab, role, who, text, extraClass, at) {
6159
+ clearChatSkeleton(tab);
5996
6160
  // Time of the last message in the channel list, relative ("3m ago"). Every
5997
6161
  // message (replayed history or live) carries `at`; the last one wins.
5998
6162
  if (Number.isFinite(at)) {
@@ -6234,6 +6398,43 @@
6234
6398
  bubble.appendChild(row);
6235
6399
  }
6236
6400
 
6401
+ /**
6402
+ * Put a "+" next to each env var an agent asks for (`HUBSPOT_TOKEN missing…`),
6403
+ * so providing it is one click instead of a trip to the vault and a retyped
6404
+ * name. Placed right after the inline code that names it; a name with no
6405
+ * inline span of its own (a fenced `export X=…`) falls back to a row below.
6406
+ */
6407
+ function decorateEnvAsks(tab, bubble, text) {
6408
+ const names = window.envVarAsks(text);
6409
+ if (!names.length || !bubble) return;
6410
+ const placed = new Set();
6411
+ const plus = (name, cls, label) => {
6412
+ const b = document.createElement("button");
6413
+ b.type = "button";
6414
+ b.className = cls;
6415
+ b.textContent = label;
6416
+ b.title = "Add " + name + " to the secret vault" + (tab.profile ? " and inject it into " + tab.profile : "");
6417
+ // never onclick=: the CSP nonce does not cover inline handlers
6418
+ b.addEventListener("click", (e) => { e.stopPropagation(); openSecretFor(tab, name); });
6419
+ return b;
6420
+ };
6421
+ for (const code of bubble.querySelectorAll("code")) {
6422
+ if (code.closest("pre")) continue;
6423
+ for (const name of names) {
6424
+ if (placed.has(name) || !new RegExp("\\b" + name + "\\b").test(code.textContent)) continue;
6425
+ code.after(plus(name, "env-plus", "+"));
6426
+ placed.add(name);
6427
+ break;
6428
+ }
6429
+ }
6430
+ const rest = names.filter((n) => !placed.has(n));
6431
+ if (!rest.length) return;
6432
+ const row = document.createElement("div");
6433
+ row.className = "bang-row";
6434
+ for (const name of rest) row.appendChild(plus(name, "bang-btn env-btn", "+ " + name));
6435
+ bubble.appendChild(row);
6436
+ }
6437
+
6237
6438
  /** A shell-mode exchange: the command, then its output once it arrives. */
6238
6439
  function addBashCard(tab, h, at) {
6239
6440
  if (h.command !== undefined || !tab.lastBashCard) {
@@ -6782,6 +6983,7 @@
6782
6983
  // Reattach to the SAME session once we know its id; a brand-new tab still
6783
6984
  // starts fresh (no sessionId yet).
6784
6985
  const start = t.sessionId ? { ...t.launchMsg, resume: t.sessionId } : t.launchMsg;
6986
+ if (t.sessionId) showChatSkeleton(t); // a resume has history on its way
6785
6987
  ws.send(JSON.stringify({ type: "start", origin: "web", ...start }));
6786
6988
  });
6787
6989
  ws.addEventListener("close", () => {
@@ -7465,6 +7667,9 @@
7465
7667
  }
7466
7668
 
7467
7669
  function handleMessage(t, msg) {
7670
+ // `history` comes BEFORE `ready` when there is one, so by `ready` (or any
7671
+ // ending) the skeleton has nothing left to stand in for.
7672
+ if (["ready", "error", "exited", "gone", "stopped"].includes(msg.type)) clearChatSkeleton(t);
7468
7673
  switch (msg.type) {
7469
7674
  case "version":
7470
7675
  handleVersion(msg);
@@ -7500,6 +7705,7 @@
7500
7705
  } else {
7501
7706
  const b = addTurn(t, "claude", "claude", h.text, "hist", h.at);
7502
7707
  decorateBang(t, b, h.text);
7708
+ decorateEnvAsks(t, b, h.text);
7503
7709
  stampMsgIds(t, b.parentElement, h.ids);
7504
7710
  // A hidden prompt (cron / notification) ran before this turn: keep
7505
7711
  // its own label instead of merging it onto the previous answer
@@ -7690,6 +7896,7 @@
7690
7896
  // spares `.after-gap`).
7691
7897
  if (msg.afterInternal) b.parentElement.classList.add("after-gap");
7692
7898
  decorateBang(t, b, msg.text);
7899
+ decorateEnvAsks(t, b, msg.text);
7693
7900
  if (msg.msgId) stampMsgIds(t, b.parentElement, [msg.msgId]);
7694
7901
  markUnreadBoundary(t, b.parentElement);
7695
7902
  }
@@ -7899,7 +8106,7 @@
7899
8106
  /** Lists the directory's resumable sessions; a click fills the id field. */
7900
8107
  async function refreshSessionList() {
7901
8108
  const listEl = $("sessionList");
7902
- listEl.innerHTML = '<div class="empty">loading…</div>';
8109
+ listEl.innerHTML = skeletonHTML("rows", 4);
7903
8110
  let sessions = [];
7904
8111
  try {
7905
8112
  const cwd = $("cwdInput").value.trim();
@@ -8605,7 +8812,7 @@
8605
8812
  if (!t || !t.sessionId) return;
8606
8813
  $("diffBranch").textContent = t.branch || "working tree";
8607
8814
  $("diffStatus").textContent = "loading…";
8608
- $("diffBody").innerHTML = "";
8815
+ $("diffBody").innerHTML = skeletonHTML("diff");
8609
8816
  try {
8610
8817
  const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
8611
8818
  $("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
@@ -8822,6 +9029,26 @@
8822
9029
  $("secretsOverlay").hidden = false;
8823
9030
  $("secretKey").focus();
8824
9031
  });
9032
+ /** The vault, opened on ONE name an agent asked for: the name filled in (or the
9033
+ * existing secret in edit mode), the asking agent's profile pre-ticked so the
9034
+ * value actually reaches it, the cursor on the value. Saving then goes through
9035
+ * the ordinary submit — and its reload offer, since a value lands at spawn. */
9036
+ async function openSecretFor(tab, name) {
9037
+ await Promise.all([loadVault(), loadProfilesInto()]);
9038
+ renderSecrets();
9039
+ $("secretsReload").hidden = true;
9040
+ if (vaultNames.includes(name)) startEditSecret(name);
9041
+ else {
9042
+ resetSecretForm();
9043
+ $("secretKey").value = name;
9044
+ }
9045
+ const cb = tab && tab.profile
9046
+ ? [...$("secretProfiles").querySelectorAll("input")].find((c) => c.value === tab.profile)
9047
+ : null;
9048
+ if (cb) cb.checked = true;
9049
+ $("secretsOverlay").hidden = false;
9050
+ $("secretVal").focus();
9051
+ }
8825
9052
  $("secretEditCancel").addEventListener("click", resetSecretForm);
8826
9053
  // The missing link: from the secrets, go and attach them to a profile.
8827
9054
  $("secretsToProfiles").addEventListener("click", () => {
@@ -8961,6 +9188,11 @@
8961
9188
  applyLedgerFilters();
8962
9189
  });
8963
9190
  $("ledgerBtn").addEventListener("click", async () => {
9191
+ // Open at once on a skeleton: the table can be hundreds of rows, and a
9192
+ // click that does nothing until it lands reads as a dead button.
9193
+ $("ledgerList").innerHTML = skeletonHTML("rows", 6);
9194
+ $("ledgerCount").textContent = "";
9195
+ $("ledgerOverlay").hidden = false;
8964
9196
  try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
8965
9197
  // The option lists come from the module, so the control and the filter it
8966
9198
  // drives cannot drift apart.
@@ -9237,6 +9469,7 @@
9237
9469
  * keyboard) lives here only, in a single copy. */
9238
9470
  function renderProfileCards(container, selected, onPick, failed) {
9239
9471
  container.innerHTML = "";
9472
+ container.removeAttribute("aria-busy"); // the boot skeleton, if still there
9240
9473
  if (failed) {
9241
9474
  const note = document.createElement("div");
9242
9475
  note.className = "grid-note";