shadok-ai 0.12.5 → 0.12.7

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 +91 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -897,6 +897,22 @@
897
897
  overflow-x: auto;
898
898
  }
899
899
  .bubble.md pre code { background: none; padding: 0; }
900
+ /* Copy on a fenced code block. The button sits on a WRAPPER around the <pre>,
901
+ not inside it: the <pre> scrolls sideways, and a button within would scroll
902
+ away. Always visible (a phone has no hover), small, top-right — the <pre>'s
903
+ own top padding keeps the first line clear of it. */
904
+ .bubble.md .md-code { position: relative; }
905
+ .bubble.md .md-code-copy {
906
+ position: absolute; top: 6px; right: 6px;
907
+ padding: 2px 7px; font-family: var(--mono); font-size: 10px; line-height: 1.4;
908
+ cursor: pointer; background: var(--bg-inset); border: 1px solid var(--line);
909
+ border-radius: 6px; color: var(--text-dim); opacity: 0.75;
910
+ transition: color 0.12s, border-color 0.12s, opacity 0.12s;
911
+ }
912
+ .bubble.md .md-code-copy:hover, .bubble.md .md-code-copy:focus-visible {
913
+ opacity: 1; color: var(--amber); border-color: var(--amber);
914
+ }
915
+ .bubble.md .md-code-copy.done { opacity: 1; color: var(--ok, #7ac77a); border-color: currentColor; }
900
916
  /* The horizontal scroll for a wide table lives on this wrapper (added in JS
901
917
  after sanitising), NOT on the <table>: `display:block` on a table breaks its
902
918
  own layout — thead and tbody stop sharing column widths, so the header row
@@ -2259,7 +2275,12 @@
2259
2275
  // every upload settles. `updateSendDisabled` re-derives the button state.
2260
2276
  let pendingUploads = 0;
2261
2277
  function updateSendDisabled() {
2262
- sendBtn.disabled = !active || active.status !== "ready" || !!active.pendingChoices || pendingUploads > 0;
2278
+ // Sending is also allowed while a turn is RUNNING (`busy`): the message then
2279
+ // interrupts the turn and is sent the moment it settles, mirroring Telegram
2280
+ // (see preemptTurn). A pending dialog still blocks — you answer it, you don't
2281
+ // free-type over it — and so does an in-flight upload.
2282
+ const canType = active && (active.status === "ready" || active.status === "busy");
2283
+ sendBtn.disabled = !canType || !!active.pendingChoices || pendingUploads > 0;
2263
2284
  }
2264
2285
  const machine = $("machine"), screenEl = $("screen");
2265
2286
 
@@ -4139,6 +4160,30 @@
4139
4160
  t.replaceWith(wrap);
4140
4161
  wrap.appendChild(t);
4141
4162
  }
4163
+ // A copy button on every fenced code block — the thing you most often want
4164
+ // to lift out of an answer. It goes on a wrapper AROUND the <pre> (the
4165
+ // <pre> scrolls; a button inside would scroll off), and copies the code's
4166
+ // own text, not the whole message.
4167
+ for (const pre of bubble.querySelectorAll("pre")) {
4168
+ const code = pre.querySelector("code") || pre;
4169
+ const wrap = document.createElement("div");
4170
+ wrap.className = "md-code";
4171
+ pre.replaceWith(wrap);
4172
+ wrap.appendChild(pre);
4173
+ const btn = document.createElement("button");
4174
+ btn.type = "button";
4175
+ btn.className = "md-code-copy";
4176
+ btn.textContent = "⧉ copy";
4177
+ btn.title = "Copy this code";
4178
+ btn.addEventListener("click", async (e) => {
4179
+ e.stopPropagation();
4180
+ const ok = await copyText(code.textContent.replace(/\n$/, ""));
4181
+ btn.textContent = ok ? "✓ copied" : "⚠ failed";
4182
+ btn.classList.toggle("done", ok);
4183
+ setTimeout(() => { btn.textContent = "⧉ copy"; btn.classList.remove("done"); }, 1500);
4184
+ });
4185
+ wrap.appendChild(btn);
4186
+ }
4142
4187
  } else {
4143
4188
  bubble.textContent = text;
4144
4189
  }
@@ -5194,6 +5239,16 @@
5194
5239
  persistChannels();
5195
5240
  }
5196
5241
  stopTurnTimer(t);
5242
+ // A message was typed during this turn (a takeover): the turn has just
5243
+ // settled, so send it now. Mirrors the Telegram bridge's preempt.
5244
+ if (t.preempting) {
5245
+ const queued = t.preempting;
5246
+ t.preempting = null;
5247
+ clearTimeout(t.preemptTimer);
5248
+ setTabState(t, "ready", "ready");
5249
+ submitPrompt(t, queued);
5250
+ break;
5251
+ }
5197
5252
  // "unread" only when the turn produced visible text. An empty turn (e.g.
5198
5253
  // a cron concluding NOTHING TO SHOW) asks for no reading at all, neither
5199
5254
  // on the tab nor on the favicon.
@@ -5406,6 +5461,36 @@
5406
5461
  if (!$("setupOverlay").hidden) refreshLiveList();
5407
5462
  }, 5000);
5408
5463
 
5464
+ // If the interrupt never lands (a wedged agent whose turn won't settle), don't
5465
+ // lose the message: put it back in the box and say so, rather than loop.
5466
+ const PREEMPT_TIMEOUT_MS = 8000;
5467
+ /**
5468
+ * Send a message WHILE a turn is running: interrupt the turn (Escape, exactly
5469
+ * the on-screen ⎋ button), stash the text, and submit it from the `turn-done`
5470
+ * that follows. This mirrors the Telegram bridge, which does the same on a
5471
+ * `busy` refusal — the web used to just disable Send instead.
5472
+ *
5473
+ * Nothing is rendered optimistically: the queued message appears as an
5474
+ * ordinary prompt once the interrupted turn has settled, so the transcript
5475
+ * stays in order (interrupted output, then your message, then its answer).
5476
+ */
5477
+ function preemptTurn(t, text) {
5478
+ // Socket down: leave the text in the box, like submitPrompt's own guard.
5479
+ if (!sendTo(t, { type: "key", key: "escape" })) return;
5480
+ t.preempting = text;
5481
+ t.transcriptEl._stickBottom = true;
5482
+ if (t === active) { promptInput.value = ""; promptInput.style.height = "auto"; }
5483
+ dropDraft(t);
5484
+ setTabState(t, "busy", "interrupting…");
5485
+ clearTimeout(t.preemptTimer);
5486
+ t.preemptTimer = setTimeout(() => {
5487
+ if (t.preempting !== text) return; // already sent, or replaced by a newer one
5488
+ t.preempting = null;
5489
+ if (t === active && !promptInput.value) { promptInput.value = text; active.draft = text; }
5490
+ addTurn(t, "system", "", "Couldn't interrupt the running turn — your message is back in the box. Try again, or use ⎋.");
5491
+ }, PREEMPT_TIMEOUT_MS);
5492
+ }
5493
+
5409
5494
  function submitPrompt(t, text, force) {
5410
5495
  // Send BEFORE touching the UI. Everything below is optimistic — it shows the
5411
5496
  // turn, marks the tab busy, clears the composer and drops the draft — so on
@@ -5505,7 +5590,11 @@
5505
5590
  e.preventDefault();
5506
5591
  const t = active;
5507
5592
  const text = promptInput.value.trim();
5508
- if (!text || t.status !== "ready" || t.pendingChoices || pendingUploads > 0 || !t.ws) return;
5593
+ if (!text || !t || t.pendingChoices || pendingUploads > 0 || !t.ws) return;
5594
+ // Sending during a running turn is a takeover: interrupt it, then send the
5595
+ // message once the turn settles. Same behaviour as Telegram.
5596
+ if (t.status === "busy") return preemptTurn(t, text);
5597
+ if (t.status !== "ready") return;
5509
5598
  // Checked here so nothing is optimistically rendered before the server
5510
5599
  // refuses. The server re-checks anyway — it is the authority.
5511
5600
  if (paceBlocked) return renderPaceConfirm(t, text, paceReason);