shadok-ai 0.15.53 → 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 +168 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.15.53",
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()) {
@@ -4753,6 +4868,33 @@
4753
4868
  return item;
4754
4869
  }
4755
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
+
4756
4898
  let railFrame = 0;
4757
4899
  let railSig = "";
4758
4900
  function renderRail() {
@@ -4781,8 +4923,9 @@
4781
4923
  return;
4782
4924
  }
4783
4925
  railSig = sig;
4784
- railList.replaceChildren(...entries.map(railItem));
4926
+ railList.replaceChildren(...entries.map(railItem), railPill);
4785
4927
  }
4928
+ placeRailPill();
4786
4929
  if (groupIconsMinted && !restoring) {
4787
4930
  groupIconsMinted = false;
4788
4931
  persistGroups();
@@ -5121,6 +5264,7 @@
5121
5264
  }
5122
5265
 
5123
5266
  function activate(tab) {
5267
+ const prev = active;
5124
5268
  if (active && active !== tab) {
5125
5269
  active.draft = promptInput.value;
5126
5270
  persistDraft(active);
@@ -5132,10 +5276,19 @@
5132
5276
  if (!restoring && tab.sessionId) localStorage.setItem("cp.activeSession", tab.sessionId);
5133
5277
  // Viewing the channel clears its "unread response" highlight.
5134
5278
  if (tab.btnEl.classList.contains("unread")) setTabMood(tab, null);
5279
+ const switched = !!prev && prev !== tab && !restoring;
5135
5280
  tabs.forEach((t) => {
5136
5281
  t.btnEl.classList.toggle("active", t === tab);
5137
5282
  t.transcriptEl.hidden = t !== tab;
5138
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
+ }
5139
5292
  promptInput.value = tab.draft;
5140
5293
  autosizePrompt();
5141
5294
  renderAttachments(tab); // each tab keeps its own pending chips
@@ -5993,6 +6146,7 @@
5993
6146
  window.addEventListener("shadok-bridge-ready", resideAllPrompts);
5994
6147
 
5995
6148
  function addTurn(tab, role, who, text, extraClass, at) {
6149
+ clearChatSkeleton(tab);
5996
6150
  // Time of the last message in the channel list, relative ("3m ago"). Every
5997
6151
  // message (replayed history or live) carries `at`; the last one wins.
5998
6152
  if (Number.isFinite(at)) {
@@ -6782,6 +6936,7 @@
6782
6936
  // Reattach to the SAME session once we know its id; a brand-new tab still
6783
6937
  // starts fresh (no sessionId yet).
6784
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
6785
6940
  ws.send(JSON.stringify({ type: "start", origin: "web", ...start }));
6786
6941
  });
6787
6942
  ws.addEventListener("close", () => {
@@ -7465,6 +7620,9 @@
7465
7620
  }
7466
7621
 
7467
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);
7468
7626
  switch (msg.type) {
7469
7627
  case "version":
7470
7628
  handleVersion(msg);
@@ -7899,7 +8057,7 @@
7899
8057
  /** Lists the directory's resumable sessions; a click fills the id field. */
7900
8058
  async function refreshSessionList() {
7901
8059
  const listEl = $("sessionList");
7902
- listEl.innerHTML = '<div class="empty">loading…</div>';
8060
+ listEl.innerHTML = skeletonHTML("rows", 4);
7903
8061
  let sessions = [];
7904
8062
  try {
7905
8063
  const cwd = $("cwdInput").value.trim();
@@ -8605,7 +8763,7 @@
8605
8763
  if (!t || !t.sessionId) return;
8606
8764
  $("diffBranch").textContent = t.branch || "working tree";
8607
8765
  $("diffStatus").textContent = "loading…";
8608
- $("diffBody").innerHTML = "";
8766
+ $("diffBody").innerHTML = skeletonHTML("diff");
8609
8767
  try {
8610
8768
  const d = await (await fetch("/diff?session=" + encodeURIComponent(t.sessionId))).json();
8611
8769
  $("diffStatus").textContent = d.status || (d.diff ? "" : "no changes");
@@ -8961,6 +9119,11 @@
8961
9119
  applyLedgerFilters();
8962
9120
  });
8963
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;
8964
9127
  try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
8965
9128
  // The option lists come from the module, so the control and the filter it
8966
9129
  // drives cannot drift apart.
@@ -9237,6 +9400,7 @@
9237
9400
  * keyboard) lives here only, in a single copy. */
9238
9401
  function renderProfileCards(container, selected, onPick, failed) {
9239
9402
  container.innerHTML = "";
9403
+ container.removeAttribute("aria-busy"); // the boot skeleton, if still there
9240
9404
  if (failed) {
9241
9405
  const note = document.createElement("div");
9242
9406
  note.className = "grid-note";