shadok-ai 0.15.52 → 0.15.54

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 +193 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.52",
3
+ "version": "0.15.54",
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",
package/public/index.html CHANGED
@@ -2306,6 +2306,71 @@
2306
2306
  #composer { background: transparent; padding: 8px 20px calc(16px + env(safe-area-inset-bottom, 0px)); }
2307
2307
  #composer textarea { background: var(--bg-raised); border: 1px solid var(--line); border-bottom: 2px solid var(--amber); border-radius: 6px; }
2308
2308
  #composer textarea:focus-visible { box-shadow: none; border-color: var(--line); border-bottom-color: var(--amber); }
2309
+
2310
+ /* ── Motion: switching views, and what loading looks like ──────────────
2311
+ Short on purpose (≤ 220ms, staggers capped): a transition that makes you
2312
+ wait for the list you just asked for is worse than none. Everything here
2313
+ is decoration — the content is in place from the first frame, the
2314
+ animation only eases it in — and all of it is off under reduced motion. */
2315
+ :root { --ease-out: cubic-bezier(.2, .8, .2, 1); }
2316
+ @keyframes view-in-down { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
2317
+ @keyframes view-in-up { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
2318
+ @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
2319
+ @keyframes chat-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
2320
+ /* Picking a group: its agents slide in from the side the group sits on in
2321
+ the rail (below → up, above → down), one after the other. */
2322
+ #tabbar.view-in-down :is(#ungrouped, #groups) .tab { animation: view-in-down 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
2323
+ #tabbar.view-in-up :is(#ungrouped, #groups) .tab { animation: view-in-up 200ms var(--ease-out) both; animation-delay: calc(var(--i, 0) * 22ms); }
2324
+ #tabbar.view-in-down .side-label, #tabbar.view-in-up .side-label,
2325
+ #tabbar.view-in-down #viewEmpty, #tabbar.view-in-up #viewEmpty { animation: fade-in 160ms ease-out both; }
2326
+ /* Picking an agent: its conversation eases in instead of snapping. */
2327
+ .transcript.chat-in { animation: chat-in 170ms var(--ease-out) both; }
2328
+ /* The rail's selection is ONE pill that glides to the picked group, instead
2329
+ of a highlight that blinks from one row to the next. */
2330
+ #railList { position: relative; }
2331
+ #railList .rail-item { z-index: 1; transition: color 160ms ease; }
2332
+ #railList.has-pill .rail-item.active { background: transparent; border-color: transparent; }
2333
+ .rail-pill {
2334
+ position: absolute; left: 0; right: 0; top: 0; z-index: 0; pointer-events: none;
2335
+ background: var(--bg-raised); border: 1px solid var(--line); border-radius: 6px;
2336
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .12);
2337
+ transition: transform 200ms var(--ease-out), height 200ms var(--ease-out);
2338
+ }
2339
+ .rail-pill.no-anim { transition: none; }
2340
+
2341
+ /* Skeletons: the SHAPE of what is coming, shimmering, in place of a bare
2342
+ "loading…" — the layout does not jump when the real content lands. */
2343
+ @keyframes skel-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
2344
+ .skel {
2345
+ display: block; border-radius: 6px; height: 12px;
2346
+ /* Fallback first: a browser without color-mix() drops the gradient below
2347
+ and would otherwise draw no skeleton at all. */
2348
+ background: linear-gradient(90deg, rgba(128,128,128,.13) 25%, rgba(128,128,128,.26) 50%, rgba(128,128,128,.13) 75%);
2349
+ background: linear-gradient(90deg,
2350
+ color-mix(in srgb, var(--text-dim) 12%, transparent) 25%,
2351
+ color-mix(in srgb, var(--text-dim) 24%, transparent) 50%,
2352
+ color-mix(in srgb, var(--text-dim) 12%, transparent) 75%);
2353
+ background-size: 300% 100%;
2354
+ animation: skel-shimmer 1.3s linear infinite;
2355
+ }
2356
+ .skel-chat { display: flex; flex-direction: column; gap: 18px; padding-top: 4px; animation: fade-in 200ms ease-out both; }
2357
+ .skel-turn { display: flex; flex-direction: column; gap: 7px; max-width: 70%; }
2358
+ .skel-turn.mine { align-self: flex-end; align-items: flex-end; width: 42%; }
2359
+ .skel-turn .skel-label { width: 64px; height: 9px; }
2360
+ .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; }
2361
+ .skel-turn .skel-bubble .skel { height: 10px; }
2362
+ .skel-row { display: flex; flex-direction: column; gap: 7px; padding: 10px 8px; border-bottom: 1px solid var(--line); }
2363
+ .skel-row:last-child { border-bottom: none; }
2364
+ .skel-card { height: 92px; border-radius: 8px; }
2365
+ .skel-diff { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
2366
+ .skel-diff .skel { height: 10px; }
2367
+
2368
+ @media (prefers-reduced-motion: reduce) {
2369
+ #tabbar.view-in-down .tab, #tabbar.view-in-up .tab, #tabbar .side-label, #tabbar #viewEmpty,
2370
+ .transcript.chat-in, .skel-chat { animation: none !important; }
2371
+ .rail-pill { transition: none; }
2372
+ .skel { animation: none; }
2373
+ }
2309
2374
  </style>
2310
2375
  <!-- Light/dark mode is a per-browser choice (localStorage). Set it BEFORE the
2311
2376
  body paints — here in <head> — so a light-preferring viewer never sees a
@@ -2548,7 +2613,7 @@
2548
2613
  <span class="label">Which agent?</span>
2549
2614
  <button type="button" class="linkish" id="editProfilesLink" title="Create or edit agent profiles">✎ edit profiles</button>
2550
2615
  </div>
2551
- <div id="profileGrid" role="radiogroup" aria-label="Agent profile"></div>
2616
+ <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
2617
  <span class="check-hint" id="profileNaNote" hidden>Applies to new sessions only.</span>
2553
2618
  </div>
2554
2619
  <!-- The model this ONE agent runs on. Its default sends nothing at all,
@@ -3387,6 +3452,37 @@
3387
3452
  if (label.textContent !== text) label.textContent = text;
3388
3453
  }
3389
3454
 
3455
+ // ── Skeletons: the shape of what is loading ─────────────
3456
+ // Static markup only (no user data), so innerHTML is safe here.
3457
+ const skelLine = (w) => `<span class="skel" style="width:${w}%"></span>`;
3458
+ function skeletonHTML(kind, n = 3) {
3459
+ if (kind === "chat") {
3460
+ const turn = (mine, widths) =>
3461
+ `<div class="skel-turn${mine ? " mine" : ""}"><span class="skel skel-label"></span>` +
3462
+ `<div class="skel skel-bubble">${widths.map(skelLine).join("")}</div></div>`;
3463
+ return `<div class="skel-chat" aria-busy="true" aria-label="Loading the conversation">` +
3464
+ turn(true, [80]) + turn(false, [92, 78, 54]) + turn(true, [64]) + turn(false, [88, 60]) + `</div>`;
3465
+ }
3466
+ if (kind === "cards") return Array.from({ length: n }, () => `<span class="skel skel-card"></span>`).join("");
3467
+ if (kind === "diff") {
3468
+ const w = [38, 72, 64, 85, 30, 58, 77, 46, 69, 52];
3469
+ return `<div class="skel-diff" aria-busy="true">${w.map(skelLine).join("")}</div>`;
3470
+ }
3471
+ // "rows": a list of two-line entries (sessions, ledger).
3472
+ return Array.from({ length: n }, (_, i) =>
3473
+ `<div class="skel-row" aria-busy="true">${skelLine(30 + (i * 17) % 25)}${skelLine(60 + (i * 23) % 35)}</div>`).join("");
3474
+ }
3475
+ /** A tab waiting for its history shows a chat skeleton — only when it has
3476
+ * nothing at all yet (a reconnect keeps what is already on screen). */
3477
+ function showChatSkeleton(t) {
3478
+ if (t.transcriptEl.childElementCount) return;
3479
+ t.transcriptEl.innerHTML = skeletonHTML("chat");
3480
+ }
3481
+ function clearChatSkeleton(t) {
3482
+ const s = t.transcriptEl.querySelector(":scope > .skel-chat");
3483
+ if (s) s.remove();
3484
+ }
3485
+
3390
3486
  function createTab(groupId, { focus = true } = {}) {
3391
3487
  seq++;
3392
3488
  const el = document.createElement("div");
@@ -4594,10 +4690,29 @@
4594
4690
  const tabsInView = (v) =>
4595
4691
  tabs.filter((t) => !t.btnEl.closest("#tweakSlot") && viewOfTab(t) === v);
4596
4692
 
4693
+ /** Eases the newly shown agents in, from the side the picked group sits on
4694
+ * in the rail. Purely cosmetic: the list is already correct underneath. */
4695
+ let viewAnimTimer = 0;
4696
+ function animateViewSwitch(from, to) {
4697
+ if (from === to || !railShown()) return;
4698
+ const order = [...railList.querySelectorAll(".rail-item")].map((i) => i.dataset.view);
4699
+ const dir = order.indexOf(to) >= order.indexOf(from) ? "view-in-down" : "view-in-up";
4700
+ const shown = [...tabbar.querySelectorAll("#ungrouped .tab, #groups .group.in-view .tab")];
4701
+ // Cap the stagger: past ~8 agents the tail would only make you wait.
4702
+ shown.forEach((el, i) => el.style.setProperty("--i", Math.min(i, 8)));
4703
+ tabbar.classList.remove("view-in-down", "view-in-up");
4704
+ void tabbar.offsetWidth; // restart the animation on a quick re-click
4705
+ tabbar.classList.add(dir);
4706
+ clearTimeout(viewAnimTimer);
4707
+ viewAnimTimer = setTimeout(() => tabbar.classList.remove("view-in-down", "view-in-up"), 420);
4708
+ }
4709
+
4597
4710
  function setView(v, { follow = true } = {}) {
4711
+ const from = currentView();
4598
4712
  groupView = String(v);
4599
4713
  localStorage.setItem(VIEW_KEY(), groupView);
4600
4714
  applyView();
4715
+ animateViewSwitch(from, currentView());
4601
4716
  // Picking a group shows one of ITS agents: staying on an agent that is no
4602
4717
  // longer in the list would leave the chat and the list disagreeing.
4603
4718
  if (follow && active && !active.btnEl.closest("#tweakSlot") && viewOfTab(active) !== currentView()) {
@@ -4633,12 +4748,18 @@
4633
4748
 
4634
4749
  // A single click on a group's icon SELECTS the group (when the rail is
4635
4750
  // collapsed to icons the icon is almost the whole row, so this is the primary
4636
- // action); a double-click opens the icon picker. The stamp has to live out
4637
- // here, not on the element: selecting a non-active group rebuilds the rail
4638
- // (renderRail keys on the active view) and replaces the very icon between the
4639
- // two clicks, so a native `dblclick` never fires and an element-local timer
4640
- // would be thrown away with it.
4641
- let lastIconClick = { view: null, t: 0 };
4751
+ // action); a double-click opens the icon picker, and a double-click on the
4752
+ // name renames it. Neither may rely on a native `dblclick` or on state kept on
4753
+ // the element: the rail is REBUILT under the pointer — by the first click
4754
+ // itself (selecting a group changes the active view) and, with live agents,
4755
+ // by any mood change in between — and a dblclick whose two clicks land on two
4756
+ // different nodes never fires. The browser's own click count (`e.detail`)
4757
+ // follows the pointer, not the node, and the OS double-click speed with it.
4758
+ const isSecondClick = (e) => e.detail >= 2;
4759
+ // While a click is under way, a rebuild that only repaints moods waits: it
4760
+ // would swap the node between mousedown and mouseup and swallow the click.
4761
+ let railHoldUntil = 0;
4762
+ railList.addEventListener("pointerdown", () => { railHoldUntil = Date.now() + 700; }, true);
4642
4763
 
4643
4764
  function railItem({ view, icon, name, group }) {
4644
4765
  const members = tabsInView(view);
@@ -4688,16 +4809,13 @@
4688
4809
  // and so the document-level close-on-click doesn't shut the picker we
4689
4810
  // may be about to open with this very click.
4690
4811
  e.stopPropagation();
4691
- const now = Date.now();
4692
- if (lastIconClick.view === view && now - lastIconClick.t < 350) {
4693
- lastIconClick = { view: null, t: 0 };
4694
- openIconPicker(group, ic);
4695
- } else {
4696
- lastIconClick = { view, t: now };
4697
- setView(view);
4698
- }
4812
+ if (isSecondClick(e)) openIconPicker(group, ic);
4813
+ else setView(view);
4699
4814
  });
4700
- nm.addEventListener("dblclick", (e) => {
4815
+ nm.addEventListener("click", (e) => {
4816
+ if (!isSecondClick(e)) return; // the item's own click selects the group
4817
+ // Not to the item: its setView could move the focus to the composer
4818
+ // and blur (= close) the field we are opening.
4701
4819
  e.stopPropagation();
4702
4820
  inlineRename(item, nm, (v) => {
4703
4821
  group.nameEl.textContent = v;
@@ -4750,6 +4868,33 @@
4750
4868
  return item;
4751
4869
  }
4752
4870
 
4871
+ // The selection pill: one element that GLIDES to the active group (the rows
4872
+ // themselves are rebuilt on every change, so they cannot transition).
4873
+ const railPill = document.createElement("div");
4874
+ railPill.className = "rail-pill";
4875
+ railPill.setAttribute("aria-hidden", "true");
4876
+ let pillView = null;
4877
+ function placeRailPill({ animate = true } = {}) {
4878
+ const item = railList.querySelector(".rail-item.active");
4879
+ if (!item || !item.offsetHeight) {
4880
+ railList.classList.remove("has-pill");
4881
+ railPill.hidden = true;
4882
+ pillView = null;
4883
+ return;
4884
+ }
4885
+ // First placement (or a layout change) jumps; only a change of group glides.
4886
+ const glide = animate && pillView !== null && pillView !== item.dataset.view;
4887
+ railPill.classList.toggle("no-anim", !glide);
4888
+ railPill.hidden = false;
4889
+ railPill.style.height = item.offsetHeight + "px";
4890
+ railPill.style.transform = `translateY(${item.offsetTop}px)`;
4891
+ railList.classList.add("has-pill");
4892
+ pillView = item.dataset.view;
4893
+ }
4894
+ // Collapsing the rail, resizing the window, or a group renamed to a longer
4895
+ // name moves the rows: follow without gliding.
4896
+ if (window.ResizeObserver) new ResizeObserver(() => placeRailPill({ animate: false })).observe(railList);
4897
+
4753
4898
  let railFrame = 0;
4754
4899
  let railSig = "";
4755
4900
  function renderRail() {
@@ -4770,9 +4915,17 @@
4770
4915
  return [e.view, e.icon, e.name, members.length, members.map(moodOf).join(",")].join("/");
4771
4916
  }).join("|");
4772
4917
  if (sig !== railSig) {
4918
+ // Same view → nothing the user did; it can wait for the click to finish.
4919
+ const wait = railHoldUntil - Date.now();
4920
+ if (wait > 0 && railSig.startsWith(v + "|")) {
4921
+ clearTimeout(renderRail.retry);
4922
+ renderRail.retry = setTimeout(renderRail, wait);
4923
+ return;
4924
+ }
4773
4925
  railSig = sig;
4774
- railList.replaceChildren(...entries.map(railItem));
4926
+ railList.replaceChildren(...entries.map(railItem), railPill);
4775
4927
  }
4928
+ placeRailPill();
4776
4929
  if (groupIconsMinted && !restoring) {
4777
4930
  groupIconsMinted = false;
4778
4931
  persistGroups();
@@ -5111,6 +5264,7 @@
5111
5264
  }
5112
5265
 
5113
5266
  function activate(tab) {
5267
+ const prev = active;
5114
5268
  if (active && active !== tab) {
5115
5269
  active.draft = promptInput.value;
5116
5270
  persistDraft(active);
@@ -5122,10 +5276,19 @@
5122
5276
  if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
5123
5277
  // Viewing the channel clears its "unread response" highlight.
5124
5278
  if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
5279
+ const switched = !!prev && prev !== tab && !restoring;
5125
5280
  tabs.forEach((t) => {
5126
5281
  t.btnEl.classList.toggle("active", t === tab);
5127
5282
  t.transcriptEl.hidden = t !== tab;
5128
5283
  });
5284
+ // Ease the new conversation in rather than snapping (only on a real switch).
5285
+ if (switched) {
5286
+ const el = tab.transcriptEl;
5287
+ el.classList.remove("chat-in");
5288
+ void el.offsetWidth;
5289
+ el.classList.add("chat-in");
5290
+ el.addEventListener("animationend", () => el.classList.remove("chat-in"), { once: true });
5291
+ }
5129
5292
  promptInput.value = tab.draft;
5130
5293
  autosizePrompt();
5131
5294
  renderAttachments(tab); // each tab keeps its own pending chips
@@ -5983,6 +6146,7 @@
5983
6146
  window.addEventListener("shadok-bridge-ready", resideAllPrompts);
5984
6147
 
5985
6148
  function addTurn(tab, role, who, text, extraClass, at) {
6149
+ clearChatSkeleton(tab);
5986
6150
  // Time of the last message in the channel list, relative ("3m ago"). Every
5987
6151
  // message (replayed history or live) carries `at`; the last one wins.
5988
6152
  if (Number.isFinite(at)) {
@@ -6772,6 +6936,7 @@
6772
6936
  // Reattach to the SAME session once we know its id; a brand-new tab still
6773
6937
  // starts fresh (no sessionId yet).
6774
6938
  const start = t.sessionId ? { ...t.launchMsg, resume: t.sessionId } : t.launchMsg;
6939
+ if (t.sessionId) showChatSkeleton(t); // a resume has history on its way
6775
6940
  ws.send(JSON.stringify({ type: "start", origin: "web", ...start }));
6776
6941
  });
6777
6942
  ws.addEventListener("close", () => {
@@ -7455,6 +7620,9 @@
7455
7620
  }
7456
7621
 
7457
7622
  function handleMessage(t, msg) {
7623
+ // `history` comes BEFORE `ready` when there is one, so by `ready` (or any
7624
+ // ending) the skeleton has nothing left to stand in for.
7625
+ if (["ready", "error", "exited", "gone", "stopped"].includes(msg.type)) clearChatSkeleton(t);
7458
7626
  switch (msg.type) {
7459
7627
  case "version":
7460
7628
  handleVersion(msg);
@@ -7889,7 +8057,7 @@
7889
8057
  /** Lists the directory's resumable sessions; a click fills the id field. */
7890
8058
  async function refreshSessionList() {
7891
8059
  const listEl = $("sessionList");
7892
- listEl.innerHTML = '<div class="empty">loading…</div>';
8060
+ listEl.innerHTML = skeletonHTML("rows", 4);
7893
8061
  let sessions = [];
7894
8062
  try {
7895
8063
  const cwd = $("cwdInput").value.trim();
@@ -8595,7 +8763,7 @@
8595
8763
  if (!t || !t.sessionId) return;
8596
8764
  $("diffBranch").textContent = t.branch || "working tree";
8597
8765
  $("diffStatus").textContent = "loading…";
8598
- $("diffBody").innerHTML = "";
8766
+ $("diffBody").innerHTML = skeletonHTML("diff");
8599
8767
  try {
8600
8768
  const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
8601
8769
  $("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
@@ -8951,6 +9119,11 @@
8951
9119
  applyLedgerFilters();
8952
9120
  });
8953
9121
  $("ledgerBtn").addEventListener("click", async () => {
9122
+ // Open at once on a skeleton: the table can be hundreds of rows, and a
9123
+ // click that does nothing until it lands reads as a dead button.
9124
+ $("ledgerList").innerHTML = skeletonHTML("rows", 6);
9125
+ $("ledgerCount").textContent = "";
9126
+ $("ledgerOverlay").hidden = false;
8954
9127
  try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
8955
9128
  // The option lists come from the module, so the control and the filter it
8956
9129
  // drives cannot drift apart.
@@ -9227,6 +9400,7 @@
9227
9400
  * keyboard) lives here only, in a single copy. */
9228
9401
  function renderProfileCards(container, selected, onPick, failed) {
9229
9402
  container.innerHTML = "";
9403
+ container.removeAttribute("aria-busy"); // the boot skeleton, if still there
9230
9404
  if (failed) {
9231
9405
  const note = document.createElement("div");
9232
9406
  note.className = "grid-note";